☁️ Play-API — переезд в облако на Terraform

О чём это

dicechess-play-api переехал с домашнего aurora (см. dicechess-analytics-na-aurora-deploy-i-sizing) на постоянный (не одноразовый) инстанс Oracle Always Free A1. Аналитика осталась дома — облачный play-api шлёт ей завершённые партии по тому же ingest-эндпоинту, что и раньше. Этот документ — учебник Terraform с нуля плюс пошаговая инструкция, уже сверенная с реальным деплоем.

Код модуля — dicechess-infra/play-cloud/ (приватный репо, рядом с hackathon-stand/ и bot-fleet/).

Статус: ЗАВЕРШЕНО 2026-07-21

play-api.jc.id.lv полностью обслуживается из облака (публичный IP инстанса 92.5.120.48, eu-frankfurt-1). Данные перенесены без потерь: bots 6/6, bot_webhooks 2/2, game_results 11080/11080 (точное совпадение). По пути нашлись 7 реальных проблем, которых не было в исходном плане — см. §7 «Гочи» и раздел «Как прошло на практике» в конце документа.

Чем это отличается от hackathon-stand

hackathon-stand — одноразовый демо-стенд: in-memory, без БД, поднял-показал-снёс. play-cloudпостоянный прод: Postgres на отдельном томе, доставка партий в аналитику, тот же Cloudflare-домен play-api.jc.id.lv, что и сейчас. Concept тот же (Terraform + cloud-init + compose), но здесь важна сохранность данных между запусками — см. §5.


🎓 Terraform с нуля — на пальцах

Что это вообще такое

Terraform — декларативный инструмент Infrastructure-as-Code. Ты не пишешь «шаги» (создай VM, потом создай сеть, потом подключи…) — ты описываешь желаемое конечное состояние в файлах .tf, а Terraform сам считает, что нужно создать/изменить/удалить, чтобы облако стало таким, как в файлах.

Аналогия: это не рецепт («сделай так, потом так»), а чертёж («вот что должно существовать»). Строитель (Terraform) сам решает, что достроить, а что снести, если чертёж изменился.

Словарь на минимальном наборе понятий

ТерминЧто этоПример из play-cloud/
ProviderПлагин, который умеет говорить с конкретным облаком (API-вызовы)oci — провайдер Oracle Cloud, объявлен в providers.tf
ResourceШтука, которую Terraform создаёт и которой владеет (может удалить)oci_core_instance, oci_core_volume в compute.tf
Data sourceШтука, которую Terraform только читает (не создаёт и не удаляет)data "oci_core_images" "ubuntu_arm" — просто спрашивает «какой образ Ubuntu сейчас самый свежий»
VariableВход конфигурации (параметр)variable "instance_ocpus" в variables.tf
LocalПромежуточное вычисляемое значение (как переменная внутри функции)local.env_file — собирает .env из переменных
OutputЧто Terraform печатает после примененияpublic_ip, ssh_command в outputs.tf
StateФайл-карта «мой код ↔ реальные ID ресурсов в облаке»terraform.tfstate (создаётся автоматически, в git не идёт)

Про state и идемпотентность

После apply Terraform запоминает в terraform.tfstate, какие реальные ресурсы (с их OCID) он создал. Поэтому повторный apply не плодит дубликаты — Terraform видит «этот ресурс уже существует, менять нечего» и трогает только то, что реально изменилось в .tf-файлах. State может содержать чувствительные значения → никогда не коммитится (см. .gitignore в репо). Для соло-проекта локального terraform.tfstate достаточно, ничего дополнительно настраивать не надо.

Пять команд, которые закрывают весь жизненный цикл

КомандаЧто делаетКогда запускать
terraform initСкачивает провайдер (oci), готовит рабочую директориюОдин раз при первом запуске (и после смены версии провайдера)
terraform fmtПриводит .tf-файлы к единому стилю отступовПо желанию, для чистоты
terraform validateПроверяет синтаксис и внутреннюю согласованность без обращения к облакуПеред plan, особенно после ручных правок
terraform plan«Сухой прогон»: показывает, что будет создано/изменено/удалено — ничего не трогаетПеред каждым apply, чтобы не удивляться
terraform applyПриводит реальное облако к тому, что описано в файлахКогда план проверен и устраивает
terraform destroyУдаляет ровно то, что создал этот же stateКогда стенд/инстанс больше не нужен

Как читать вывод plan

Каждый ресурс в выводе помечен знаком: + — будет создан, - — будет удалён, ~ (или -/+) — будет пересоздан/изменён. Первый apply в пустом облаке — это список из одних +. Если увидишь неожиданный - рядом с oci_core_volume.pgdataстоп, это тот самый том с базой данных, его destroy необратим (см. §7 «Гочи»).

Переменные и секреты — как они попадают внутрь

Три файла образуют цепочку:

  1. variables.tf — объявляет какие переменные существуют, их тип и (не обязательно) значение по умолчанию. Ничего секретного здесь нет — только описания.
  2. terraform.tfvars (реальный, у тебя локально, в git не попадает — см. .gitignore) — здесь ты подставляешь настоящие значения: пароль БД, токен туннеля и т.д.
  3. terraform.tfvars.example — шаблон с плейсхолдерами, который коммитится, чтобы было видно, какие поля вообще нужно заполнить.

Переменные, помеченные в variables.tf как sensitive = true (пароль БД, токен туннеля, ingest-токен), Terraform не печатает в выводе plan/apply — вместо значения увидишь (sensitive value).

Гоча (уже ловили на hackathon-stand): terraform.tfvars побеждает TF_VAR_*

Если значение задано и в terraform.tfvars, и в переменной окружения TF_VAR_имя, побеждает файл. На репетиции стенда это один раз привело к тому, что в tfvars остался плейсхолдер, а реальный токен лежал в окружении — cloud-init тихо получил пустышку. Правило: если используешь terraform.tfvars, вписывай туда настоящие значения (файл и так не в git), а не смешивай с env-переменными.


Архитектура

flowchart TD
    subgraph Internet["Интернет"]
        Bot["Боты / браузер игрока"]
    end
    subgraph CF["Cloudflare"]
        T["Отдельный туннель play-cloud<br/>(НЕ aurora-токен)"]
    end
    subgraph VM["Oracle A1.Flex · aarch64 · только SSH inbound"]
        Tun["cloudflared"]
        Api["api :8080<br/>(play-api образ)"]
        Db[("db · Postgres 18<br/>/var/lib/postgresql")]
        Vol[("block volume pgdata<br/>/mnt/pgdata — переживает пересоздание VM")]
        Tun --> Api
        Api --> Db
        Db -.montirovano.-> Vol
    end
    Bot -->|HTTPS/WSS| T --> Tun
    Api -->|"POST /api/games (outbox)"| Home["Аналитика дома (aurora)<br/>INGEST_URL"]

Ключевое отличие от домашней схемы: входящих портов на инстансе нет вообще (кроме SSH) — публикация идёт только через исходящее соединение cloudflared. Это даже безопаснее домашней схемы, потому что весь трафик проходит через отдельный, специально созданный туннель — общий домашний туннель aurora (тот, что несёт syncthing/status/uptime/dca/…) этот модуль не трогает.


Файлы модуля — что каждый делает

ФайлРоль
versions.tfПины версий: Terraform ≥1.6, провайдер oracle/oci ≥5.30
providers.tfКак Terraform логинится в OCI (session-token, тот же профиль DEFAULT, что и у oci CLI — см. Oracle-Cloud-zhurnal-nastroyki-CLI-avtorizatsiya-set)
variables.tfВсе входы: регион, размеры инстанса/томов, пароль БД, токены — см. таблицу словаря выше
network.tfСвоя VCN + subnet + шлюз + security list (только SSH входящий)
compute.tfСам инстанс (oci_core_instance) + отдельный блок-том для БД (oci_core_volume) + их сцепка (oci_core_volume_attachment)
cloud-init.yamlШаблон: что происходит внутри свежесозданной VM при первом старте (см. §4)
outputs.tfЧто напечатать после apply: публичный IP, готовая SSH-команда, чек-лист следующих шагов
terraform.tfvars.exampleШаблон значений — копируется в terraform.tfvars и заполняется
docker-compose.cloud.yamlСам стек (api + db + tunnel) — этот файл заворачивается в cloud-init и разворачивается на VM
.env.exampleШаблон .env для ручного локального запуска compose (без Terraform вообще)

Почему том БД отдельно от инстанса

Это единственное решение в модуле, которое стоит понимать отдельно. compute.tf создаёт два ресурса вместо одного:

resource "oci_core_instance" "play" { … }        # сама VM — можно пересоздать
resource "oci_core_volume" "pgdata" { … }          # диск с базой — переживает пересоздание VM
resource "oci_core_volume_attachment" "pgdata" { … } # их «провод» друг к другу

Если завтра захочешь поменять размер VM, образ ОС или сам compose-файл — Terraform иногда вынужден пересоздать инстанс (destroy + create), а не обновить на месте. Если бы Postgres хранил данные на диске самой VM (boot-диске), пересоздание стирало бы базу целиком — включая bots.token_hash (невосстановим) и рейтинги. Вынеся данные на отдельный том, который инстанс только подключает, а не владеет его содержимым, пересоздание VM становится безопасной операцией. cloud-init при загрузке проверяет: если том уже размечен (blkid находит файловую систему) — просто монтирует; форматирует (mkfs.ext4) только абсолютно пустой новый том.


Что храним в БД play (напоминание)

ТаблицаЧтоПереносить при переезде?
botstoken_hash (SHA-256 токена бота) + Glicko-рейтингиДа — токен заново не выдать
bot_webhooksURL + HMAC-секрет вебхукаДа
game_resultsКомпактная проекция завершённых партий (лидерборд, рейтинг-батч)Да
gamesJSONB-снапшоты партий (crash-recovery)Нет — активные партии всё равно оборвутся при cutover
outboxОчередь доставки в аналитикуНет — новая база начнёт копить свою очередь с нуля

Перенесено 2026-07-21: bots 6/6, bot_webhooks 2/2, game_results 11080/11080 — точное совпадение (источник был остановлен перед финальной сверкой, см. Шаг 5).


Пошаговая инструкция

Шаг 0 — предпосылки (обычно уже выполнены)

  • OCI CLI авторизован. Если oci session authenticate не запускался давно — токен живёт ~1 час, обнови через oci session refresh или заново oci session authenticate (регион eu-frankfurt-1, профиль DEFAULT). Подробности — Oracle-Cloud-zhurnal-nastroyki-CLI-avtorizatsiya-set.
  • Terraform установлен. В корне dicechess-infra есть mise.toml с terraform = "latest"mise install в корне репо поставит его, если ещё нет.
  • SSH-ключ есть. Тот же, что использовался для hackathon-stand/bot-fleet подойдёт — нужно его содержимое (cat ~/.ssh/id_ed25519.pub), не путь.

Шаг 1 — создать новый Cloudflare-туннель (в дашборде, до apply)

Нужен новый, отдельный от aurora токен — см. предупреждение в docker-compose.cloud.yaml и раздел «Архитектура» выше.

  1. Cloudflare Dashboard → Zero Trust → Networks → Tunnels → Create a tunnel.
  2. Тип — Cloudflared. Имя, например, dicechess-play-cloud.
  3. Скопировать показанный токен — он понадобится в terraform.tfvars на шаге 2.
  4. Публичный hostname пока не добавлять — это отдельно, на шаге cutover (§6).

Шаг 2 — заполнить terraform.tfvars

cd dicechess-infra/play-cloud
cp terraform.tfvars.example terraform.tfvars

Открой terraform.tfvars и заполни (файл гитигнорится, реальные секреты сюда — нормально):

ПеременнаяОткуда взять
compartment_ocidТот же, что использовался для hackathon-stand/bot-fleet (tenancy root подходит)
ssh_public_keyСодержимое ~/.ssh/id_ed25519.pub
play_db_passwordПридумать длинный случайный пароль (например openssl rand -base64 24)
ingest_url / ingest_tokenТе же значения, что уже стоят в .env домашнего play-api сейчас (аналитика остаётся дома, эндпоинт не меняется)
cf_tunnel_tokenТокен из Шага 1 (НЕ токен aurora)
play_bot_tokens⚠️ НЕ оставлять пустым, если дома есть статические боты — скопировать один-в-один grep PLAY_BOT_TOKENS ~/dicechess-play-api/.env на aurora. Это отдельный от таблицы bots механизм авторизации (см. «Гочи»); при первом деплое (2026-07-21) оставили пустым — статические боты падали 401 Unauthorized
api_taglatest, или конкретный vX.Y.Z, если хочешь пин

Шаг 3 — initplanapply

terraform init      # один раз: качает провайдер oci
terraform plan       # сухой прогон — прочитай, что будет создано (ожидаются только "+")
terraform apply      # подтвердить "yes" — создаёт сеть + VM + том (~1-2 минуты)

После apply Terraform напечатает outputs — среди них next_steps с готовыми командами для следующего шага.

Шаг 4 — дождаться cloud-init и проверить

cloud-init внутри VM делает по порядку: монтирует том → ставит Docker → поднимает compose (Postgres → Flyway-миграции → play-api → cloudflared). Обычно 2-3 минуты.

ssh ubuntu@<public_ip> 'sudo tail -f /var/log/cloud-init-output.log'   # смотреть прогресс, Ctrl+C когда стихнет
ssh ubuntu@<public_ip> 'curl -fsS localhost:8040/health'                 # {"status":"ok",...}

Шаг 5 — перенести данные (исправлено по факту реального деплоя)

Исходный план этого шага не совпал с реальностью

Ниже — версия, которая реально сработала 2026-07-21. Отличия от первого черновика: не отдельный контейнер play с пользователем play, а общий с analytics контейнер/пользователь; таблицы лежат в схеме play, а не public. Разбор — в «Гочи» ниже.

Реальная топология источника на aurora: таблицы play живут внутри контейнера dicechess-analytics-db-1 (тот же Postgres, что держит и dicechess_analytics — один инстанс на двоих ради экономии RAM), под пользователем dicechess_user, в схеме play (не public). Из этого — два обязательных нюанса: дамп идёт через docker exec (а не -h localhost), и имена таблиц квалифицируются схемой — play.bots, а не просто bots (иначе pg_dump тихо вернёт no matching tables were found: он не ищет вне search_path текущего пользователя).

Порядок — сначала заморозить источник, потом дампить. aurora продолжает принимать игры до самого cutover, так что game_results растёт во время дампа — если дампить на живую, счётчики после переноса разъедутся на несколько строк (это не потеря данных, а нормальный эффект живой системы, но раздражает при сверке). Проще сначала остановить источник:

  1. Остановить только api-контейнер play-api на aurora — его tunnel не трогать, если он же обслуживает другие домашние hostname:

    ssh aurora 'docker stop dicechess-play-api-api-1'

    С этого момента play.game_results на aurora гарантированно не растёт.

  2. Перенести все три таблицы одним пайпом — напрямую aurora → облако, без промежуточных файлов на диске (в bot_webhooks есть HMAC-секрет вебхука в открытом виде, незачем ему оседать в ~/*.sql где бы то ни было):

    CLOUD="ubuntu@<public_ip>"
    for TABLE in bots bot_webhooks game_results; do
      ssh aurora "docker exec dicechess-analytics-db-1 pg_dump -U dicechess_user -d play -t play.$TABLE --data-only --column-inserts" \
        | ssh "$CLOUD" "sudo docker exec -i dicechess-play-cloud-db-1 psql -U play -d play -v ON_ERROR_STOP=1"
    done
  3. Свериться по счётчикам (обязаны совпасть один-в-один — источник заморожен ДО дампа):

    ssh aurora "docker exec dicechess-analytics-db-1 psql -U dicechess_user -d play -t -c \"select count(*) from play.game_results;\""
    ssh "$CLOUD" "sudo docker exec dicechess-play-cloud-db-1 psql -U play -d play -t -c \"select count(*) from game_results;\""

Если всё-таки дампил ДО остановки источника, и счётчики разъехались

Не передампливать таблицу целиком — построчный INSERT-дамп упадёт на конфликте первичных ключей по уже перенесённым строкам. Найти именно недостающие game_id и перенести точечно через COPY (COPY, в отличие от INSERT, тут просто добавляет строки без конфликта, если список id подобран верно):

comm -23 <(ssh aurora "docker exec dicechess-analytics-db-1 psql -U dicechess_user -d play -t -c 'select game_id from play.game_results;'" | sort) \
         <(ssh "$CLOUD" "sudo docker exec dicechess-play-cloud-db-1 psql -U play -d play -t -c 'select game_id from game_results;'" | sort) \
         > missing.txt
IDS=$(paste -sd, missing.txt)
ssh aurora "docker exec dicechess-analytics-db-1 psql -U dicechess_user -d play -c \"\\copy (select * from play.game_results where game_id = any(string_to_array('$IDS', ',')::uuid[])) TO STDOUT\"" \
  | ssh "$CLOUD" "sudo docker exec -i dicechess-play-cloud-db-1 psql -U play -d play -c '\\copy game_results FROM STDIN'"

Шаг 6 — публикация и cutover

play-api дома уже остановлен — см. Шаг 5

В отличие от исходного плана, api на aurora останавливается в Шаге 5 (чтобы заморозить game_results перед точным переносом), а не здесь. К этому шагу домашний play-api уже не работает — это нормально, если ты шёл по документу по порядку.

  1. Проверить через временный hostname на новом туннеле сначала: в дашборде туннеля dicechess-play-cloud добавить public hostname, например play-cloud-test.jc.id.lv → HTTP → api:8080. Снаружи: curl https://play-cloud-test.jc.id.lv/health, открыть лидерборд (должны быть видны перенесённые боты/рейтинги), сыграть тестовую партию.

  2. В туннеле dicechess-play-cloud добавить public hostname play-api.jc.id.lvapi:8080. Cloudflare предупредит про существующую DNS-запись → Overwrite.

    Сразу после Overwrite — подожди ~20-30 секунд error code: 1016 (Origin DNS error) — это не поломка стека, а задержка распространения переключения внутри Cloudflare. Не чинить, просто подождать полминуты и повторить curl.

    Первые запросы после переключения могут получить ошибку самого Cloudflare

  3. Убрать play-api.jc.id.lv из туннеля aurora (и временный play-cloud-test.jc.id.lv, если создавал) — оба через Public Hostname → Delete в соответствующем туннеле.

  4. Проверить: curl https://play-api.jc.id.lv/health, /leaderboard (боты и рейтинги видны — прямое доказательство, что перенос данных сработал), что outbox.delivered_at заполняется (партии реально доходят домой).


Гочи

Первые шесть — подтверждённые находки реального деплоя 2026-07-21 (не гипотезы); остальные — предусмотренные заранее.

Postgres 18 сменил ожидаемую точку монтирования — самая болезненная находка деплоя

Официальный образ postgres:18-alpine (и вообще 18+) ожидает volume на /var/lib/postgresql (родительский каталог), а НЕ на /var/lib/postgresql/data, как было во всех версиях до 18-й. Смонтировав по-старому, на первом же старте получаешь: Error: in 18+, these Docker images are configured to store database data in a format... — Postgres отказывается стартовать, db уходит в рестарт-луп, а api/tunnel не запускаются следом (depends_on: db condition: service_healthy их держит). docker-compose.cloud.yaml уже исправлен на правильный путь — если увидишь этот текст в docker compose logs db на будущем передеплое, вот причина (например, если кто-то откатит файл к старой версии).

INGEST_URL — не угадывать по созвучию имени хоста

sync.jc.id.lv — это Syncthing (файловая синхронизация), не аналитика; dca.jc.id.lv закрыт Cloudflare Access (SSO-редирект на весь хост, включая любой путь) — оба не годятся для автоматического POST от play-api. Реальное значение пришлось смотреть напрямую в окружении живого контейнера на aurora (docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' dicechess-play-api-api-1), а для публикации завести отдельный hostname (ingest.jc.id.lv) на существующем домашнем туннеле — без Cloudflare Access поверх: /api/games и так закрыт своим Bearer-токеном, второй слой SSO просто блокирует машинные вызовы редиректом на логин.

pg_dump -t <table> не видит таблицы вне search_path пользователя

На aurora таблицы play лежат в схеме play, а не publicpg_dump -t bots (без указания схемы) тихо возвращает no matching tables were found. Нужно квалифицировать явно: -t play.bots. Заодно контейнер и пользователь оказались не тем, что подсказывает интуиция (не отдельный play-инстанс, а общий с analytics dicechess-analytics-db-1 / dicechess_user) — см. Шаг 5.

WEBHOOK_TIMEOUT_SECONDS тоже забыт — та же категория, третий раз подряд

Обнаружено не по мониторингу, а по жалобе «у зрителя на сайте один из ботов не отвечает на свой ход, часы просто тикают» — конкретно rabestro/aggressive-2 (вебхук на dicechess-bot-aggressive.azurewebsites.net, сам Azure-сервис был жив и ни при чём). Без WEBHOOK_TIMEOUT_SECONDS весь механизм server→bot webhook push выключен целиком — зарегистрированные webhook-боты (таблица bot_webhooks) никогда не узнают, что наступил их ход, никакой ошибки нигде не появляется. После третьего повтора этой категории пропуска сделали системную сверку: grep -rhoE '"[A-Z][A-Z0-9_]{3,}"' src/main/scala в репозитории play-api вытаскивает ВСЕ 13 env-переменных, которые код где-либо читает — этим способом и нашли RATING_BATCH_SIZE/WEBHOOK_TIMEOUT_SECONDS, которых не было даже в кратком списке AGENTS.md. Урок закреплён: при следующем переносе окружения сверять этим grep’ом, а не полагаться на документированный список (он неполный).

Ladder- и rating-планировщики не были перенесены вообще — самая незаметная находка

docker-compose.cloud.yaml изначально не пробрасывал LADDER_INTERVAL_SECONDS/LADDER_MAX_CONCURRENT_PAIRS/RATING_INTERVAL_SECONDS вообще (их не было ни в environment: блоке api, ни в списке Terraform-переменных) — сервер стартовал чисто, /health отвечал 200 OK, но ни одна новая партия никогда не создавалась: GET /games пустой, счётчики на /leaderboard статичны навсегда. Никакой ошибки в логах — только тихое [play][ladder] LADDER_INTERVAL_SECONDS unset: no automatic ladder pairings, которое легко принять за нормальное «фича не включена» и пройти мимо (что и произошло при первом деплое). Обнаружено только когда специально спросили «боты вообще играют?» и свериди /games//leaderboard до и после. Добавлены переменные ladder_interval_seconds/ladder_max_concurrent_pairs/rating_interval_seconds в variables.tf+compute.tf+docker-compose.cloud.yaml — значения брать из домашнего .env (aurora: 60/4/60). Урок: при переносе env-конфигурации между окружениями сверять ВЕСЬ список опциональных фич из AGENTS.md/README, а не только те, что показались очевидными.

terraform.tfvars.example не упоминает, что play_bot_tokens нужно РЕАЛЬНО заполнить — при первом деплое оставили пустым

Статические боты (house|greedy, house|oracle-1/2/3 — team не совпадает с DB-зарегистрированными oracle/rabestro ботами, это два независимых механизма авторизации: PLAY_BOT_TOKENS env-роспись vs таблица bots) аутентифицируются ТОЛЬКО через PLAY_BOT_TOKENS на сервере — перенос таблицы bots их не покрывает. Забыв перенести это значение из aurora в terraform.tfvars/.env облака, получаешь: сервер отвечает 200 OK на /health, но любой такой бот падает 401 Unauthorized на попытке создать seek. Симптом на клиенте может выглядеть как «контейнер перезапускается» (если у бота нет retry и он падает с необработанным исключением на 401), а может — как тихий “retrying next tick” в логе, вообще без падения контейнера (как оказалось при разборе — контейнер dicechess-reference-bot-bot-1 на самом деле не падал, просто логировал неудачные попытки). Значение — то же самое, что уже стоит в домашнем .env (grep PLAY_BOT_TOKENS ~/dicechess-play-api/.env на aurora), просто скопировать один в один.

terraform destroy удаляет том с базой

Блок-том pgdata — тоже ресурс, которым владеет Terraform. destroy снесёт и его, а вместе с ним — токены ботов и рейтинги, которые больше нигде не хранятся. Перед destroy — обязательно pg_dump (команды из Шага 5, в обратную сторону).

docker compose down -v — та же ловушка на уровне compose

Даже без Terraform: -v удаляет volume/точку монтирования данных Postgres. Обычный down (без -v) — безопасен.

Один Cloudflare-токен = один туннель = все его hostname

Домашний туннель aurora держит 10+ hostname (syncthing, status, uptime, dca, …). Использование его токена в облаке заставит Cloudflare балансировать все эти hostname между домом и облаком — большинство из них в облаке не существует → массовые 502. Только отдельный новый токен.

Out of host capacity на A1

Изредка Oracle Free-инстансы A1 временно кончаются в конкретном availability domain. Если apply падает с этой ошибкой — подождать и повторить, или (реже нужно) сменить availability domain/регион. См. также Oracle-Cloud-rannbuk §2 про фолбэк-шейп E5 (платный, x86) — для этого модуля не нужен, но как общий контекст.


Как менять переменные окружения (обслуживание)

Только CLI/SSH — веб-интерфейса для конфигурации play-api не существует (ни у OCI, ни у самого приложения). Два слоя, оба нужно менять вместе:

  1. Живой .env на инстансе — то, что реально действует сейчас:
    ssh ubuntu@<public_ip>
    sudo nano /opt/play-cloud/.env
    cd /opt/play-cloud && sudo docker compose up -d --force-recreate api
  2. terraform.tfvars локально (dicechess-infra/play-cloud/) — источник истины на будущее.

Поменять только .env — недостаточно, само по себе не «прилипнет»

Это ровно та ошибка, из-за которой в деплое 2026-07-21 потерялись PLAY_BOT_TOKENS/LADDER_*/WEBHOOK_TIMEOUT_SECONDS (см. «Гочи» выше) — только наоборот: тогда забыли перенести из aurora, теперь легко забыть перенести ОБРАТНО в tfvars после ручной правки на инстансе. Если инстанс когда-нибудь пересоздастся (terraform apply/destroy+apply), возьмётся значение из tfvars, а ручная правка .env бесследно потеряется. Менять — всегда в обоих местах, terraform validate после правки tfvars — бесплатная проверка, что ничего не сломано синтаксически.

Пример (2026-07-22): LADDER_INTERVAL_SECONDS 60 → 30, чтобы ladder-планировщик подбирал пары вдвое чаще:

# на инстансе:
ssh ubuntu@<public_ip> "sudo sed -i '/^LADDER_INTERVAL_SECONDS=/d' /opt/play-cloud/.env && echo 'LADDER_INTERVAL_SECONDS=30' | sudo tee -a /opt/play-cloud/.env"
ssh ubuntu@<public_ip> 'cd /opt/play-cloud && sudo docker compose up -d --force-recreate api'
# локально — правишь ladder_interval_seconds = "30" в terraform.tfvars, затем:
terraform validate

Ручной доступ к БД (обслуживание)

Прямого подключения обычным Postgres-клиентом снаружи нет — порт 5432 сознательно не опубликован (у сервиса db в docker-compose.cloud.yaml вообще нет ports:, только внутренняя сеть compose). Единственный путь — SSH на инстанс, затем docker exec внутрь контейнера:

ssh ubuntu@<public_ip>
sudo docker exec -it dicechess-play-cloud-db-1 psql -U play -d play

Выход: \q (из psql), затем exit (из SSH-сессии).

Схема — public, не play

В отличие от домашней aurora (там таблицы play неожиданно лежат в отдельной схеме play — см. Шаг 5 и «Гочи» выше), здесь — отдельная выделенная база, Flyway создал таблицы в дефолтной схеме public. Схему указывать не нужно: select * from bots; работает как есть, без play. спереди.

bot_webhooks каскадно удаляется вместе с bots

FOREIGN KEY (team, name) REFERENCES bots (team, name) ON DELETE CASCADE — удаление строки из bots автоматически стирает и вебхук-регистрацию этого бота (URL + HMAC-секрет). Обратно — только через POST /bot/register + верификацию заново, не восстанавливается автоматически. game_results не задет: там external_id — обычное текстовое поле, не внешний ключ на bots, история сыгранных партий остаётся.

Перед DELETE/UPDATE на живых ботах — проверить активные партии

curl https://play-api.jc.id.lv/games — если бот прямо сейчас играет, дальнейшие ходы всё равно требуют его аутентификации по токену из bots; удалив строку посреди партии, обрываешь её для живого оппонента.

Открытые решения

  • Размер инстанса — сейчас дефолт 2 OCPU / 12 GB (в пределах Always Free 4 OCPU/24 GB на всю арендную запись A1). Поднять, если станет тесно при большом числе одновременных партий.
  • Автоматизация туннеля — создание Cloudflare-туннеля и public hostname всё ещё ручное (провайдер oci не управляет Cloudflare, а Cloudflare API/дашборд недоступны напрямую из агентской сессии); можно позже добавить провайдер cloudflare/cloudflare в Terraform, если захочется полностью декларативно.
  • Когда именно переезжать — решено: выполнено 2026-07-21.

🧹 Как прошло на практике (2026-07-21)

Деплой занял один присест, с семью находками, которых не было в исходном плане (разобраны выше, в «Гочи»):

  1. terraform apply — с первого раза, 8 ресурсов (5 сетевых + инстанс + том + сцепка), ~90 секунд.
  2. ⚠️→✅ Первый docker compose up внутри cloud-init упал — Postgres 18 mount-путь. Пофиксили docker-compose.cloud.yaml и на живом инстансе, и в репозитории; второй docker compose up -d поднялся с первого раза, все три сервиса healthy.
  3. ⚠️→✅ ingest_url дважды указывал не туда (сначала Syncthing, потом Access-закрытый хост) — решилось отдельным hostname ingest.jc.id.lv на домашнем туннеле, без Access.
  4. ✅ Перенос данных: bots 6/6, bot_webhooks 2/2, game_results 11080/11080 — точное совпадение после остановки источника перед финальной сверкой (см. Шаг 5).
  5. ✅ Cutover: Overwrite DNS → кратковременный error 1016 (~20-30 сек распространения) → стабильно 200 OK, включая /leaderboard с перенесёнными Glicko-рейтингами вживую на боевом домене (прямое доказательство успеха переноса данных).
  6. ✅ Постфактум (после cutover) обнаружено: play_bot_tokens остался пустым при первом деплое — статический бот на rpi4 (dicechess-reference-bot) падал 401 Unauthorized (визуально выглядело как «контейнер перезапускается», хотя реально просто ретраил без падения). Поймано по жалобе «там что-то перезапускается», не по мониторингу — стоит завести на это алерт. Пофикшено переносом значения из aurora .env в .env облака + в terraform.tfvars.
  7. ✅ Отдельно от самой миграции: домашний Cloudflare-туннель (обслуживал не только play-api, но и syncthing/status/dca/ingest) вынесен из dicechess-play-api/docker-compose.yaml в собственную папку ~/cloudflare-tunnel/ на aurora — раньше docker compose down там мог случайно погасить и всё остальное. Сделано без даунтайма (два коннектора одного туннеля временно работали параллельно).
  8. ⚠️→✅ Самая крупная по влиянию находка, обнаружена не сразу: LADDER_INTERVAL_SECONDS/LADDER_MAX_CONCURRENT_PAIRS/RATING_INTERVAL_SECONDS не были перенесены вообще — ни в переменные Terraform, ни в compose. Сервер стартовал чисто, health/leaderboard отвечали 200 — но GET /games был пустым, а счётчики партий статичны навсегда: без ladder-планировщика партии просто никогда не создаются. Обнаружено только когда специально проверили «а боты реально играют?» и сверили /games до/после. После добавления значений (60/4/60, как на aurora) — новые партии появились за ~30 секунд, /leaderboard начал расти на глазах.
  9. ⚠️→✅ Третий пропуск той же категории: WEBHOOK_TIMEOUT_SECONDS тоже забыт — обнаружено по жалобе «зритель видит, что один конкретный бот (rabestro/aggressive-2, вебхук на Azure) не отвечает на ход, часы просто тикают» (Azure-сервис бота был жив, дело не в нём). Без этой переменной весь механизм server→bot webhook push выключен целиком, без единой ошибки где-либо. После третьего повтора сделали системную сверку: grep -rhoE '"[A-Z][A-Z0-9_]{3,}"' src/main/scala в репозитории play-api — вытаскивает ВСЕ 13 env-переменных, которые код где-либо читает, включая RATING_BATCH_SIZE/WEBHOOK_TIMEOUT_SECONDS, которых даже нет в кратком списке AGENTS.md. После фикса — rabestro/aggressive-2 +8 партий за ~90 секунд.

Простой play-api — от остановки api на aurora (ради заморозки game_results перед точной сверкой) до успешного Overwrite DNS. Если в следующий раз важно минимизировать простой сильнее — можно сделать Overwrite сразу после остановки api, а сверку game_results (Шаг 5, шаг 3) выполнить уже после, не блокируя ею переключение трафика.

Связанные заметки