Одна Azure-ВМ, два независимых бота, переключаемых профилями Compose:
azure/onnx3-k48 (05.08.2026) — опросный, играет на живых партиях то, что парная дуэль измерила в лаборатории: +28.3 elo у K=48 против K=24 на той же модели.
azure/hunter-book (07.08.2026) — вебхук, Hunter с книгой дебютов (dicechess-hunter#26), ingress через Cloudflare Tunnel.
Terraform-модуль — dicechess-infra/azure-bot (PR #14 и #15).
named tunnel dicechess-bot-azure, контейнер cloudflared рядом в compose
Книга
HUNTER_BOOK_PATH=/opt/hunter-book/opening_book.tsv, bind-mount с хоста; 335 записей (экспорт minGames=100 minRating=2000)
Веса
env-переменные (EvalWeights), файла модели у Hunter нет вовсе
HUNTER_CANDIDATE_LIMIT
не задан → Hunter’овский дефолт 48
Capacity
4 одновременных партии
Почему один бокс на двоих. Боты не конкурируют: разные порты, независимые контейнеры, и подселение второго не стоит ничего дополнительно — машина всё равно работает под первого. Своя resource group, не общая с azure-arena: арена одноразовая и сносится по завершении замера, а боты должны пережить этот снос.
Почему у hunter-book ingress через туннель, а не открытый порт.cloudflared дозванивается до края Cloudflare исходящим соединением, поэтому NSG остаётся SSH-only. Тот же приём, что у bot-cloud на Oracle. Публичный адрес в Zero Trust указывает на hunter-book:8081 — имя сервиса в compose, не IP: cloudflared живёт контейнером в той же сети Docker. Отличие от домашних ботов, где в туннеле на aurora прописан http://192.168.x.x:порт, потому что там cloudflared на хосте.
Почему westeurope, не swedencentral. Региональная квота 4 vCPU в SwedenCentral целиком занята двумя машинами арены. Standard_D2ps_v5 (Ampere, тот же размер, что арена) в westeurope вообще не предлагается — там только x86-семейства. Взял D2ds_v5: не burstable по той же причине, что и арена — B-серия троттлится по исчерпании кредитов и незаметно меняла бы глубину поиска в середине измерения.
Почему не смущает разница архитектур с дуэлью. Дуэль померила ARM против x86 той же конфигурации и не нашла значимого расхождения: 0.83 пп разницы при интервалах ±2.9–3.3 пп, одинаковый вердикт SPRT на обеих. x86 в другом регионе — не новая переменная.
Почему приватный образ, не публичный dicechess-bot-gcp-onnx. Публичный образ моделей не запекает — ждёт монтирования, которого прод-заготовка (bot-cloud) на деле не делает: MODEL_PATH там указывает на путь, которого в образе нет, и бот тихо откатывается на синтетическую модель без сигнала. dicechess-house-bots веса запекает, поэтому только он и годится без доработок.
Почему BOT_MAX_MOVE_MS=7000, а не прод-2500. Измеренная на дуэли задержка при K=48: p50 2452 мс, p95 5502 мс. Потолок 2500 срезал бы примерно половину ходов, и бот фактически играл бы чем-то между K=24 и K=48 — та самая разница, ради которой всё затевалось, стёрлась бы. Прод-потолок ниже, потому что защищает людей от вялого бота; лестница сводит ботов с ботами, там это соображение слабее.
Почему у onnx3-k48 нет книги дебютов. Прод-oracle-3 тоже играет без книги (ORACLE_BOOK не задан). Два довода за одно решение: сравнение остаётся с одной переменной, и книжный ход обходит поиск целиком — при партиях в ~10 ходов даже пара книжных ходов на сторону заметно размыла бы эффект K. У hunter-book книга, наоборот, и есть предмет опыта — поэтому это разные боты, а не один с флагом.
Как поднять onnx3-k48
Предполагается mise, az (залогинен в правильную подписку) и gh (для GHCR-токена).
cd dicechess-infra/azure-botgit checkout feat/azure-bot-k48 # или main, если смёрженоcp terraform.tfvars.example terraform.tfvars
Заполнить terraform.tfvars:
subscription_id = "<GUID нужной подписки>"ssh_public_keys = ["ssh-ed25519 ..."]ghcr_username = "<github-user>"ghcr_token = "<PAT с read:packages>"bot_token = "" # см. регистрацию ниже
Токен bot_token показывается ОДИН РАЗ
POST /bot/register отдаёт Bearer-токен ровно один раз в ответе — сервер хранит только его хеш. Упустишь вывод — не восстановишь, придётся регистрировать новую идентичность или ротировать через POST /bot/token (что тоже требует токен, которого уже нет — тупик, если не залогинен сессией-владельцем).
Записывать сразу в файл, никогда не через промежуточный echo/print — ровно так один раз утекла живая идентичность azure|expectimax-k48: зарегистрирована, токен потерян в выводе команды, самой идентичностью пользоваться не вышло. На лидерборде она не появилась (никогда не заходила на лестницу), но осталась висеть в БД занятым именем.
Токен из ответа — сразу в bot_token в terraform.tfvars, никуда больше.
mise exec -- terraform initmise exec -- terraform plan -out=tfplanmise exec -- terraform apply tfplan
Пустой bot_token — поддерживаемое app-free состояние: compose и .env заезжают в /opt/dicechess-bot на ВМ, но контейнер не стартует (бот без токена только крашлупится). Applied позже с заполненным bot_token — ВМ пересоздаётся (custom_data меняется только на пересоздании, replace_triggered_by следит за этим) и поднимает бота.
После первого старта — заход на лестницу (отдельное явное действие, ничего не делает это автоматически):
curl -sS -X POST https://play-api.jc.id.lv/bot/ladder/join \ -H "Authorization: Bearer <bot_token>"
Порядок здесь не произволен: handshake регистрации требует уже работающего контейнера, поэтому шаги нельзя переставить.
1. Cloudflare (руками, в Zero Trust dashboard). Создать named tunnel dicechess-bot-azure. Ничего не устанавливать — нужен только токен из показанной команды cloudflared service install <TOKEN>. Затем Public Hostname:
Поле
Значение
Subdomain
hunter-book
Domain
jc.id.lv
Service Type
HTTP
URL
hunter-book:8081 ← имя сервиса в compose, не IP
2. Токен в terraform.tfvars, hunter_webhook_secret оставить пустым:
cf_tunnel_token = "eyJhIjoi..."
mise exec -- terraform plan -out=tfplan && mise exec -- terraform apply tfplan
Применение пересоздаёт ВМ и перезапускает второго бота
custom_data меняется → replace_triggered_by пересоздаёт машину, и живой onnx3-k48 уходит в перезапуск на пару минут. Снять его с лестницы до применения и вернуть после, чтобы не терять партии по часам:
curl -sS -X POST https://play-api.jc.id.lv/bot/ladder/leave -H "Authorization: Bearer <bot_token>"# ... apply ...curl -sS -X POST https://play-api.jc.id.lv/bot/ladder/join -H "Authorization: Bearer <bot_token>"
3. Залить книгу (см. следующий раздел) и перезапустить hunter-book.
4. Проверить, что туннель проложен насквозь — до регистрации, иначе handshake провалится:
curl -i https://hunter-book.jc.id.lv/api/webhook
Ожидается 400 с телом {"error":"missing \"type\""} — это отвечает бот. Если пришло 502/530/1033 — отвечает Cloudflare, туннель не дошёл до контейнера, регистрацию начинать рано.
5. Регистрация — строго в этом порядке:
# 5a. идентичность -> Bearer-токен (показывается ОДИН раз)curl -sS -X POST https://play-api.jc.id.lv/bot/register \ -H 'Content-Type: application/json' -d '{"team":"azure","name":"hunter-book"}'# 5b. вебхук: play-api тут же дёрнет URL неподписанным nonce, контейнер обязан ответить# -> возвращает signing secret (тоже ОДИН раз)curl -sS -X POST https://play-api.jc.id.lv/bot/webhook \ -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \ -d '{"url":"https://hunter-book.jc.id.lv/api/webhook"}'
6. Секрет в hunter_webhook_secret, повторный apply (и снова залить книгу — ВМ пересоздалась). Потом capacity и лестница, как у onnx3-k48.
Почему шаг 2 идёт с пустым секретом, а не сразу с обоими
Секрет выдаётся по итогу handshake, а handshake требует работающего контейнера. Поэтому пустой hunter_webhook_secret — не недоделка, а обязательное промежуточное состояние: dicechess-bot-runtime отвечает на неподписанную nonce-пробу безусловно, и Hunter это прямо логирует (only the verification handshake will succeed).
Гейт запуска в compute.tf смотрит только на cf_tunnel_token именно поэтому. В первой версии он требовал оба значения — и делал описанный здесь порядок невыполнимым: нет контейнера → нет handshake → нет секрета → гейт никогда не откроется. Ни validate, ни plan этого не ловят: ошибка в порядке операций, а не в синтаксисе.
Как обновить книгу дебютов
Экспорт из аналитики (БД на aurora; с ноутбука — через SSH-туннель):
Проверка — в логе должно быть число записей, а не ноль:
ssh azureuser@$IP 'sudo docker logs --tail 5 dicechess-bot-azure-hunter-book-1'# [hunter] opening book loaded from /opt/hunter-book/opening_book.tsv (335 entries)
terraform apply стирает книгу — всегда заливать заново после
Книга лежит на диске ВМ, а любой apply, меняющий custom_data, машину пересоздаёт. cloud-init кладёт на её место пустую заглушку, и бот поднимется с 0 записей, ничем этого не выдав, кроме одной строки в логе — партии он играть продолжит, просто без книги.
Заглушка не лишняя: без файла по этому пути Docker создал бы на его месте пустой каталог, и HUNTER_BOOK_PATH не прочитался бы вовсе. Но цена — что молчаливая деградация выглядит как нормальный старт.
Наступил на это при развёртывании: заливал книгу дважды. Стоит проверять число записей в логе после каждого apply.
Хост-ключ меняется при каждом пересоздании ВМ
IP статический, но ключ хоста — нет. После terraform apply, если ВМ была заменена, SSH откажет с REMOTE HOST IDENTIFICATION HAS CHANGED. Ожидаемо:
cloud-init логинится в GHCR и под root (нужно runcmd, чтобы стартовый docker compose up мог тянуть образ), и под admin_username отдельной командой. Не дублирование: credentials реестра живут в конфиге клиента, а не демона, так что логин только под root оставляет azureuser без доступа — docker compose up, набранный вручную по SSH, упадёт с unauthorized, хотя тот же образ прекрасно стартовал при загрузке. Первая версия модуля логинилась только под root и ловила это ровно так.
Изменение конфигурации
candidate_limit, max_move_ms и прочее в terraform.tfvars → terraform apply пересоздаёт ВМ (не in-place update — custom_data требует пересоздания). Полезно для дальнейших точек K, если K=48 не последняя: тот же модуль, другое число в tfvars, новая ВМ.
Как удалить
Полностью и без остатка — вся инфраструктура в одной resource group:
az group delete -n dicechess-bot-rg --yes
Работает даже если Terraform state потерян или рассинхронизирован — снос по факту существования группы в Azure, а не по записи в state. prevent_deletion_if_contains_resources в этом модуле не стоит (в отличие от azure-arena), так что группа уходит вместе со всем содержимым одной командой.
Terraform-путь, если state цел и нужен чистый plan после:
cd dicechess-infra/azure-botmise exec -- terraform destroy
Идентичности ботов на play-api при этом не удаляются — снос ВМ убирает только Azure-ресурсы. bot:team:azure:onnx3-k48 и bot:team:azure:hunter-book останутся зарегистрированными и на лестнице: сами оттуда не выйдут, начнут проигрывать по часам. Снимать обоих до сноса, каждого своим токеном:
curl -sS -X POST https://play-api.jc.id.lv/bot/ladder/leave -H "Authorization: Bearer <bot_token>"curl -sS -X POST https://play-api.jc.id.lv/bot/ladder/leave -H "Authorization: Bearer <hunter_book_token>"
Туннель Cloudflare тоже переживёт снос — его удалять отдельно в Zero Trust dashboard, иначе останется висеть маршрут в никуда.
Стоимость
D2ds_v5 — не Always Free уровень, идёт из пробного кредита Azure ($200/30 дней). Решение по итогу кредитного периода отложено: см. разбор «а можно ли ботом-функцией» ниже.
Azure Function как альтернатива после кредитов
Для hunter-book это реально: у Hunter нет ONNX-модели (веса — env-переменные), а именно JNI-нативы ONNX Runtime блокировали native-image у других ботов. Прецедент рабочий — dicechess-bot-azure (aggressive+book) живёт Функцией и сыграл >10k партий. Бонусом отпадает ручной шаг с туннелем: Функция даёт публичный HTTPS сама.
Минус — цена хода. Замерено на живых ботах: hunter-k48 на rpi4 p50 1731 мс / p90 4507 мс; hunter-all на dexus p50 2576 мс / p90 14136 мс. Референсный aggressive+book считает ход миллисекунды, то есть Hunter дороже в сотни раз по GB-секундам. Бесплатный грант (400k GB-с/мес) покрывает ~89–231 тыс. ходов/мес при ~1 ГБ, а по факту лестницы одна hunter-личность делает ~266 тыс. собственных ходов/мес. Выход за грант — примерно $4/мес, то есть «не бесплатно», а не «не работает».
Не проверено: соберётся ли Hunter под native-image. Признаков рефлексии не видно (чистые битборды + чтение файла книги), но это предположение. sbt-native-image под sbt 2 существует (_sbt2_3, 0.4.0/0.5.0) — поисковый индекс Maven Central его не показывает, прямой HTTP даёт 200.
Что смотреть дальше
Реальные задержки хода на этой ВМ против измеренных на арене (2452/5502 мс) — D2ds_v5 не то же железо, и если ходы окажутся дороже, BOT_MAX_MOVE_MS=7000 может оказаться тесным.
Рейтинг onnx3-k48 против прод-oracle-3 (K=24) — независимая проверка +28 elo уже против живого пула соперников, а не в контролируемой дуэли.
Доля книжных ходов у hunter-book — замерена, 7.4% на 432 ходах. Считается прямо из лога, без дополнительной телеметрии: книжный ход не вызывает поиск, поэтому diagnosticsSink не срабатывает и строка печатает legalTurns=n/a. То есть доля = grep -c "legalTurns=n/a" к общему числу строк [hunter] turn. Это и было целью рефакторинга на sink (dicechess-hunter#23) — отличать «решила книга» от «решил поиск» без нового кода. Разбор, почему так тонко, и рычаги — в «Книга дебютов: как она устроена на самом деле».