⚔️ Azure-бокс: onnx3-k48 и hunter-book

О чём это

Одна 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).

Что и где живёт

Общее для бокса:

Resource groupdicechess-bot-rg
Регионwesteurope
VMStandard_D2ds_v5 (x86, 2 vCPU, non-burstable), Ubuntu 24.04 LTS
IPстатический, переживает stop/start, не переживает пересоздание ВМ
Inboundтолько SSH. hunter-book доступен снаружи через исходящее соединение cloudflared, порт в NSG не открыт
Compose/opt/dicechess-bot/, профили k48 и hunter-book

azure/onnx3-k48 — опросный клиент, ingress не нужен:

Identityazure|onnx3-k48 → bot:team:azure:onnx3-k48
Образghcr.io/rabestro/dicechess-house-bots:latest (приватный)
Модель/app/models/oracle-3.onnx — sha256 5e3cd9a1fbeb…, побайтово та же, на которой измерена дуэль
КонфигORACLE_CANDIDATE_LIMIT=48, ORACLE_FEATURES=rich, ORACLE_SEARCH=expectimax, книга выключена
BOT_MAX_MOVE_MS7000 (НЕ прод-2500 — см. ниже, почему)
BOT_SEEK_TIME_CONTROL10+10, как у прод-oracle-3
Capacity8 одновременных партий

azure/hunter-book — вебхук:

Identityazure|hunter-book → bot:team:azure:hunter-book
Образghcr.io/rabestro/dicechess-hunter:latest (приватный)
Публичный адресhttps://hunter-book.jc.id.lv/api/webhook
Туннель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
Capacity4 одновременных партии

Почему один бокс на двоих. Боты не конкурируют: разные порты, независимые контейнеры, и подселение второго не стоит ничего дополнительно — машина всё равно работает под первого. Своя 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-bot
git 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: зарегистрирована, токен потерян в выводе команды, самой идентичностью пользоваться не вышло. На лидерборде она не появилась (никогда не заходила на лестницу), но осталась висеть в БД занятым именем.

Регистрация идентичности:

curl -sS -X POST https://play-api.jc.id.lv/bot/register \
  -H 'Content-Type: application/json' \
  -d '{"team":"azure","name":"onnx3-k48"}'

Токен из ответа — сразу в bot_token в terraform.tfvars, никуда больше.

mise exec -- terraform init
mise exec -- terraform plan -out=tfplan
mise 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>"

И, если умолчательной ёмкости в 1 партию мало:

curl -sS -X POST https://play-api.jc.id.lv/bot/capacity \
  -H "Authorization: Bearer <bot_token>" -H 'Content-Type: application/json' \
  -d '{"maxConcurrentGames":8}'

Проверка после применения:

IP=$(mise exec -- terraform output -raw public_ip)
ssh azureuser@$IP 'sudo docker ps; sudo docker logs --tail 30 dicechess-bot-azure-expectimax-k48-1'

Ищи [refbot] standing seek ... posted — признак успешной авторизации и живого аккаунт-стрима.

Как поднять hunter-book

Порядок здесь не произволен: handshake регистрации требует уже работающего контейнера, поэтому шаги нельзя переставить.

1. Cloudflare (руками, в Zero Trust dashboard). Создать named tunnel dicechess-bot-azure. Ничего не устанавливать — нужен только токен из показанной команды cloudflared service install <TOKEN>. Затем Public Hostname:

ПолеЗначение
Subdomainhunter-book
Domainjc.id.lv
Service TypeHTTP
URLhunter-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 -f -N -L 15432:localhost:5432 aurora
cd dicechess-analytics
DATABASE_URL="postgresql://dicechess_user:dicechess_password@localhost:15432/dicechess_analytics" \
  mise exec -- sbt "runMain dicechess.analytics.maintenance.ExportOpeningBookApp 100 2000 /tmp/opening_book.tsv"

Заливка и перезапуск — без terraform apply:

IP=$(mise exec -- terraform -chdir=dicechess-infra/azure-bot output -raw public_ip)
scp /tmp/opening_book.tsv azureuser@$IP:/tmp/ob.tsv
ssh azureuser@$IP 'sudo cp /tmp/ob.tsv /opt/dicechess-bot/opening_book.tsv && sudo chmod 644 /opt/dicechess-bot/opening_book.tsv && rm /tmp/ob.tsv
  cd /opt/dicechess-bot && sudo docker compose --env-file .env --profile k48 --profile hunter-book restart hunter-book'

Проверка — в логе должно быть число записей, а не ноль:

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. Ожидаемо:

ssh-keygen -R <IP>
ssh -o StrictHostKeyChecking=accept-new azureuser@<IP> true

GHCR-логин — нужен ДВАЖДЫ

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-bot
mise 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.

Что смотреть дальше

  1. Реальные задержки хода на этой ВМ против измеренных на арене (2452/5502 мс) — D2ds_v5 не то же железо, и если ходы окажутся дороже, BOT_MAX_MOVE_MS=7000 может оказаться тесным.
  2. Рейтинг onnx3-k48 против прод-oracle-3 (K=24) — независимая проверка +28 elo уже против живого пула соперников, а не в контролируемой дуэли.
  3. Доля книжных ходов у hunter-book — замерена, 7.4% на 432 ходах. Считается прямо из лога, без дополнительной телеметрии: книжный ход не вызывает поиск, поэтому diagnosticsSink не срабатывает и строка печатает legalTurns=n/a. То есть доля = grep -c "legalTurns=n/a" к общему числу строк [hunter] turn. Это и было целью рефакторинга на sink (dicechess-hunter#23) — отличать «решила книга» от «решил поиск» без нового кода. Разбор, почему так тонко, и рычаги — в «Книга дебютов: как она устроена на самом деле».

Связано: candidate-limit-48-vs-24-timed-duel · model-improvement-results-and-oracle-bots-roster · bot-runtimes-concurrency-and-free-tiers · hunter-on-cloud-run-runbook