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 fmt
Приводит .tf-файлы к единому стилю отступов
По желанию, для чистоты
terraform validate
Проверяет синтаксис и внутреннюю согласованность без обращения к облаку
Перед plan, особенно после ручных правок
terraform plan
«Сухой прогон»: показывает, что будет создано/изменено/удалено — ничего не трогает
Перед каждым apply, чтобы не удивляться
terraform apply
Приводит реальное облако к тому, что описано в файлах
Когда план проверен и устраивает
terraform destroy
Удаляет ровно то, что создал этот же state
Когда стенд/инстанс больше не нужен
Как читать вывод plan
Каждый ресурс в выводе помечен знаком: + — будет создан, - — будет удалён, ~ (или -/+) — будет пересоздан/изменён. Первый apply в пустом облаке — это список из одних +. Если увидишь неожиданный - рядом с oci_core_volume.pgdata — стоп, это тот самый том с базой данных, его destroy необратим (см. §7 «Гочи»).
Переменные и секреты — как они попадают внутрь
Три файла образуют цепочку:
variables.tf — объявляет какие переменные существуют, их тип и (не обязательно) значение по умолчанию. Ничего секретного здесь нет — только описания.
terraform.tfvars (реальный, у тебя локально, в git не попадает — см. .gitignore) — здесь ты подставляешь настоящие значения: пароль БД, токен туннеля и т.д.
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/…) этот модуль не трогает.
Все входы: регион, размеры инстанса/томов, пароль БД, токены — см. таблицу словаря выше
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" { … } # диск с базой — переживает пересоздание VMresource "oci_core_volume_attachment" "pgdata" { … } # их «провод» друг к другу
Если завтра захочешь поменять размер VM, образ ОС или сам compose-файл — Terraform иногда вынужден пересоздать инстанс (destroy + create), а не обновить на месте. Если бы Postgres хранил данные на диске самой VM (boot-диске), пересоздание стирало бы базу целиком — включая bots.token_hash (невосстановим) и рейтинги. Вынеся данные на отдельный том, который инстанс только подключает, а не владеет его содержимым, пересоздание VM становится безопасной операцией. cloud-init при загрузке проверяет: если том уже размечен (blkid находит файловую систему) — просто монтирует; форматирует (mkfs.ext4) только абсолютно пустой новый том.
Компактная проекция завершённых партий (лидерборд, рейтинг-батч)
Да
games
JSONB-снапшоты партий (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 и раздел «Архитектура» выше.
Cloudflare Dashboard → Zero Trust → Networks → Tunnels → Create a tunnel.
Тип — Cloudflared. Имя, например, dicechess-play-cloud.
Скопировать показанный токен — он понадобится в terraform.tfvars на шаге 2.
Публичный hostname пока не добавлять — это отдельно, на шаге cutover (§6).
Шаг 2 — заполнить terraform.tfvars
cd dicechess-infra/play-cloudcp terraform.tfvars.example terraform.tfvars
Открой terraform.tfvars и заполни (файл гитигнорится, реальные секреты сюда — нормально):
Переменная
Откуда взять
compartment_ocid
Тот же, что использовался для hackathon-stand/bot-fleet (tenancy root подходит)
Те же значения, что уже стоят в .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_tag
latest, или конкретный vX.Y.Z, если хочешь пин
Шаг 3 — init → plan → apply
terraform init # один раз: качает провайдер ociterraform 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 минуты.
Шаг 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 растёт во время дампа — если дампить на живую, счётчики после переноса разъедутся на несколько строк (это не потеря данных, а нормальный эффект живой системы, но раздражает при сверке). Проще сначала остановить источник:
Остановить толькоapi-контейнер play-api на aurora — его tunnel не трогать, если он же обслуживает другие домашние hostname:
ssh aurora 'docker stop dicechess-play-api-api-1'
С этого момента play.game_results на aurora гарантированно не растёт.
Перенести все три таблицы одним пайпом — напрямую 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
Свериться по счётчикам (обязаны совпасть один-в-один — источник заморожен ДО дампа):
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.txtIDS=$(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 уже не работает — это нормально, если ты шёл по документу по порядку.
Проверить через временный hostname на новом туннеле сначала: в дашборде туннеля dicechess-play-cloud добавить public hostname, например play-cloud-test.jc.id.lv → HTTP → api:8080. Снаружи: curl https://play-cloud-test.jc.id.lv/health, открыть лидерборд (должны быть видны перенесённые боты/рейтинги), сыграть тестовую партию.
В туннеле dicechess-play-cloud добавить public hostname play-api.jc.id.lv → api:8080. Cloudflare предупредит про существующую DNS-запись → Overwrite.
Сразу после Overwrite — подожди ~20-30 секунд error code: 1016 (Origin DNS error) — это не поломка стека, а задержка распространения переключения внутри Cloudflare. Не чинить, просто подождать полминуты и повторить curl.
Первые запросы после переключения могут получить ошибку самого Cloudflare
Убрать play-api.jc.id.lv из туннеля aurora (и временный play-cloud-test.jc.id.lv, если создавал) — оба через Public Hostname → Delete в соответствующем туннеле.
Проверить: 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, а не public — pg_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, ни у самого приложения). Два слоя, оба нужно менять вместе:
Живой .env на инстансе — то, что реально действует сейчас:
ssh ubuntu@<public_ip>sudo nano /opt/play-cloud/.envcd /opt/play-cloud && sudo docker compose up -d --force-recreate api
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)
Деплой занял один присест, с семью находками, которых не было в исходном плане (разобраны выше, в «Гочи»):
✅ terraform apply — с первого раза, 8 ресурсов (5 сетевых + инстанс + том + сцепка), ~90 секунд.
⚠️→✅ Первый docker compose up внутри cloud-init упал — Postgres 18 mount-путь. Пофиксили docker-compose.cloud.yaml и на живом инстансе, и в репозитории; второй docker compose up -d поднялся с первого раза, все три сервиса healthy.
⚠️→✅ ingest_url дважды указывал не туда (сначала Syncthing, потом Access-закрытый хост) — решилось отдельным hostname ingest.jc.id.lv на домашнем туннеле, без Access.
✅ Перенос данных: bots 6/6, bot_webhooks 2/2, game_results 11080/11080 — точное совпадение после остановки источника перед финальной сверкой (см. Шаг 5).
✅ Cutover: Overwrite DNS → кратковременный error 1016 (~20-30 сек распространения) → стабильно 200 OK, включая /leaderboard с перенесёнными Glicko-рейтингами вживую на боевом домене (прямое доказательство успеха переноса данных).
✅ Постфактум (после cutover) обнаружено: play_bot_tokens остался пустым при первом деплое — статический бот на rpi4 (dicechess-reference-bot) падал 401 Unauthorized (визуально выглядело как «контейнер перезапускается», хотя реально просто ретраил без падения). Поймано по жалобе «там что-то перезапускается», не по мониторингу — стоит завести на это алерт. Пофикшено переносом значения из aurora .env в .env облака + в terraform.tfvars.
✅ Отдельно от самой миграции: домашний Cloudflare-туннель (обслуживал не только play-api, но и syncthing/status/dca/ingest) вынесен из dicechess-play-api/docker-compose.yaml в собственную папку ~/cloudflare-tunnel/ на aurora — раньше docker compose down там мог случайно погасить и всё остальное. Сделано без даунтайма (два коннектора одного туннеля временно работали параллельно).
⚠️→✅ Самая крупная по влиянию находка, обнаружена не сразу: 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 начал расти на глазах.
⚠️→✅ Третий пропуск той же категории: 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) выполнить уже после, не блокируя ею переключение трафика.