55 KiB
Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2 в репозитории VM2_services. Он не
разворачивает VM1 и не затрагивает VM1_app/codebase/backend. Все команды
ниже — операторские; создание репозитория их не выполняет.
Корень репозитория ВМ2: HAN_chat_specification/VM2_services. Compose и
deployment-артефакты: VM2_services/codebase/services/. Локальные команды
ниже предполагают текущий каталог VM2_services, если не указано иное.
Блокеры production перед первым запуском
- Замените каждый плейсхолдер в
.envна проверенные несекретные значения. ДержитеBITRIX_SYNC_ENABLED=false, пока не подписаны миграции, гранты, поля портала, контракты роботов и cutover. (для этого нужно еще образы отправить в conteiner registry, пункт 2) - Заполните каждую переменную
*_IMAGEпроверенным digest из registry. Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые теги не являются доказательством для production. - Устанавливайте production-файлы от
root:root; пользовательdeployне должен входить в группуdockerи не должен иметь возможность писать Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов. (смысл: заходим под админом, sudo -i) - Заполните отдельные проверенные активные CIDR-файлы из двух
.template. Их закоммиченные активные версии намеренно содержатdeny all. (в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса) - Выпустите публичный ACME-сертификат в host-каталог
/etc/letsencrypt. Разместите CA управляемой PostgreSQL в/etc/han/ca. Выпустите сертификат внутренней CA, SAN которого совпадает с приватным именем VM2. Разрешайте хостовый порт8443только из SG VM1 и, при необходимости, одобренных приватных/VPN-сетей операторов. (выпуск сертификатов) - Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только
имена из
deployment/secrets/config.example.json. Никогда не переиспользуйте принципал VM1. (отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member) REDIS_SAFETY_ACL— полный ACL-файл, а не просто пароль. Он должен открывать неаутентифицированныйPINGтолько для health и защищённого паролем пользователяsafety, ограниченного необходимыми ключами/командамиhan:safety:*. (Пароль вMESSAGE_SAFETY_REDIS_URLдолжен совпадать. Используйтеredis/redis-safety.acl.template, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)- Выделите отдельные учётные данные БД для runtime и миграций.
MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URLможет мигрировать/активировать политику, аMESSAGE_SAFETY_DATABASE_URL— нет;BITRIX_SYNC_MIGRATION_DATABASE_URLвладеет DDL, аBITRIX_SYNC_DATABASE_URL— runtime-роль с минимальными привилегиями. Учётные данные миграций монтируются только в jobs профиляops. - Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До production ограничьте egress правилами Selectel SG/NAT/proxy до утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и источников ClamAV. Registry/package repositories оставляйте только на controlled maintenance window.
Кто что выполняет
- Локальный компьютер оператора: создаёт архив релиза и передаёт его на VM2. Локальные команды ниже показаны для PowerShell.
rootна VM2: только bootstrap host OS, активация проверенного релиза, установка root-owned файлов, настройка.env, secret mapping, credentials, TLS/allow-list, миграции и первый старт.deployна VM2: принимает релиз только в/var/lib/han-deploy/incoming, проверяет статус/логи и запускает уже установленные fixed systemd operations через точные sudo-правила.deployне запускаетdocker, не редактирует/opt/han-chat/servicesи не входит в группуdocker.adminна VM2: персональная break-glass роль с отдельным SSH-ключом и отдельным локальным паролем дляsudo. Не используется для штатного деплоя, не входит вdocker/lxd; каждый вход и sudo-вызов считается инцидентной операцией.
Предварительные условия (до §1)
Этот runbook описывает операции на уже созданной VM2. До bootstrap подготовьте вне Compose:
- Инфраструктура Selectel — VPC/subnet, SG (
80/443/22public;8443только private; egress default-deny после bootstrap), sizing (4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см.module-10-deployment-vm2.md), public и private IP VM2, DNS A-записьPROCESSING_PUBLIC_HOST. - Managed PostgreSQL — schemas/roles для
message_safetyиbitrix_sync, отдельные migration/runtime DSN; см.arch-10-deployment.md§6. - Образы — собрать и push
han-message-safety,han-bitrix-sync; получить immutable digest для всех*_IMAGEв.env.example(nginx, redis, clamav, otel-collector). - Selectel Secrets Manager — заполнить все remote names из
deployment/secrets/config.example.json(DSN, tokens, S3 read-only keys,REDIS_SAFETY_ACL, internal TLS PEM для8443). Отдельный IAM principal VM2 с read-only доступом только к этим именам. - S3 quarantine bucket и SigNoz OTLP endpoint — значения в
.env. - Internal TLS — сертификат внутренней CA с SAN = private DNS VM2; PEM хранится в Secrets Manager, не в каталоге релиза.
Порядок разделов §1–§5 → Gates 1–9 → §7 (post-acceptance). systemctl enable
и systemctl start han-processing.service — только после успешного Gate 5.
1. Bootstrap свежей VM2
На локальном компьютере один раз создайте два разных ключа. Закрытые части остаются только у соответствующих операторов и никогда не передаются на VM:
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy `
-C "han-vm2-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin `
-C "han-vm2-break-glass-admin"
Для production ключ admin должен принадлежать отдельному назначенному
break-glass оператору и храниться отдельно от deploy key. Если команды
выполняет один человек на этапе bootstrap, это всё равно две разные key pairs
с раздельной последующей передачей/ротацией.
Скопируйте setup-скрипт и только публичные части ключей во временный root каталог:
# текущий каталог: ...\HAN_chat_specification\VM2_services
scp -i C:\Users\MI\.ssh\hansel `
.\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel `
C:\Users\MI\.ssh\han_vm2_deploy.pub `
C:\Users\MI\.ssh\han_vm2_admin.pub `
root@<VM2_PUBLIC_IP>:/root/
На VM2 в текущей root-сессии задайте приватный CIDR VM1. /0 скрипт отклоняет:
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
/root/setup-vm2.sh
Скрипт устанавливает Ubuntu-пакеты, Docker Engine + Compose plugin, UFW,
fail2ban, unattended upgrades, swap, sysctl и цепочку DOCKER-USER; создаёт
deploy, break-glass admin, staging и root-owned production-каталог. Скрипт
не запускает Compose/контейнеры. 80/443 и SSH открываются публично; SSH
остаётся key-only и защищён fail2ban. 8443 доступен только на приватном IP
VM2 из VM1_PRIVATE_CIDRS. Если оператору нужен прямой доступ к внутреннему
API через приватный маршрут или VPN, дополнительно передайте необязательный
OPS_CIDRS='<OPS_PRIVATE_OR_VPN_CIDR>'.
В текущей root-сессии задайте admin отдельный сложный sudo-пароль. Он не
разрешает password SSH: пароль нужен только после входа по admin key:
passwd admin
Не закрывая root-сессию, на локальном компьютере проверьте оба входа:
ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@<VM2_PUBLIC_IP>
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@<VM2_PUBLIC_IP>
В admin-сессии проверьте запрос именно admin-пароля и получение root shell, после чего сразу выйдите из него:
sudo -v
sudo -i
id
exit
Только после успешной проверки deploy, admin и sudo повторите на VM2
под root:
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
Это добавит PermitRootLogin no и AllowUsers deploy admin. Ещё раз откройте
обе новые SSH-сессии после reload и только затем закрывайте старую root.
Публичные bootstrap-копии после проверки можно удалить под admin:
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
2. Передача релиза под deploy
На локальном компьютере из каталога VM2_services:
$Release = "<VERSION_OR_GIT_SHA>"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C .\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" `
deploy@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/
Под deploy на VM2 вычислите checksum. Значение должно совпасть с локальным:
RELEASE='<VERSION_OR_GIT_SHA>'
cd /var/lib/han-deploy/incoming
sha256sum "vm2-services-${RELEASE}.tar.gz"
tar -tzf "vm2-services-${RELEASE}.tar.gz"
На этом действия deploy с файлами заканчиваются. Не распаковывайте релиз
через sudo и не копируйте его в production от имени deploy.
3. Активация и установка файлов под root
Под root ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте,
если архив содержит абсолютные пути, .., symlink/hardlink или лишний проект:
RELEASE='<VERSION_OR_GIT_SHA>'
EXPECTED_SHA256='<SHA256_С_ЛОКАЛЬНОЙ_МАШИНЫ>'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env \
--chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
Повторите setup под root: теперь он установит helpers и units из активного
релиза. Приложение всё ещё не запускается:
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
Скрипт устанавливает:
/usr/local/lib/han-secrets-vm2/{secrets_loader.py,han-secrets};/usr/local/sbin/han-vm2-compose;/usr/local/sbin/han-message-safety-mode;/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx;/etc/systemd/system/{han-secrets-vm2,han-processing}.service;/etc/sudoers.d/{han-vm2-deploy,deploy-message-safety-mode};- группу
han-message-safetyс GID10001; - стандартный
/etc/han-chat/message-safety-mode.envс правамиroot:han-message-safety 0640.
4. Несекретная конфигурация и Selectel под root
APP_ENV определяет имя loader config. При значении из .env.example
APP_ENV=production-like файл обязан называться
/etc/han/secrets/vm2-production-like.selectel.json:
cd /opt/han-chat/services
install -m 0600 -o root -g root .env.example .env
editor .env
install -m 0600 -o root -g root \
deployment/secrets/config.example.json \
/etc/han/secrets/vm2-production-like.selectel.json
editor /etc/han/secrets/vm2-production-like.selectel.json
В .env заменяются только несекретные плейсхолдеры и image digests. Значения
DSN, token, password, access/secret key туда не записываются. Для Selectel
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
из mapping.
Зашифруйте пароль Selectel service user через systemd credentials, не помещая его в аргументы или history:
read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/vm2.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/vm2.selectel-password.cred
chmod 0600 /etc/han/credentials/vm2.selectel-password.cred
Активные nginx allow-list файлы редактирует только root; последней строкой
обязательно остаётся deny all;. До cutover Bitrix public allow-list должен
оставаться закрытым:
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
editor /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
chown root:root /opt/han-chat/services/nginx/allowlists/*.conf
chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf
Для контролируемого восстановления без провайдера используйте явный file
config и root-only каталог 0700 с одним файлом на ключ. При сбое Selectel
автоматический fallback запрещён.
5. Сертификат PostgreSQL и первоначальный выпуск public TLS
CA управляемой PostgreSQL
Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во
временный путь. Под root установите сертификат вне каталога релиза:
install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
/tmp/<PROVIDER_POSTGRESQL_CA_FILE> \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<PROVIDER_POSTGRESQL_CA_FILE>
В .env должно быть:
PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem
Compose монтирует этот файл read-only во все runtime и migration контейнеры как
/run/config/postgresql-ca.pem. DB-клиенты создают обязательный TLS context с
проверкой цепочки и имени сервера по этому CA. Не добавляйте libpq-параметры
sslmode/sslrootcert в SQLAlchemy postgresql+asyncpg URL: asyncpg получает
SSL context отдельно, а такие query-параметры могут быть переданы как
неподдерживаемые keyword arguments. DSN в Secrets Manager имеет обычный вид:
postgresql+asyncpg://<USER>:<PASSWORD>@<MANAGED_POSTGRES_HOST>:<PORT>/<DATABASE>
Первоначальный выпуск Let's Encrypt
PROCESSING_PUBLIC_HOST должен быть DNS-именем, A-запись которого уже указывает
на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт
80 должен быть разрешён в cloud firewall/UFW и пока не занят nginx.
Под root задайте значения только для текущей shell-сессии и подготовьте
постоянный webroot:
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
ACME_EMAIL='<ADMIN_EMAIL>'
install -d -m 0755 -o root -g root /var/lib/han-chat/acme
getent ahostsv4 "$PUBLIC_HOST"
ss -lntp | grep -E ':80[[:space:]]' && {
echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2
exit 1
} || true
Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует:
certbot certonly --standalone --preferred-challenges http \
--staging \
-d "$PUBLIC_HOST" \
--cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
После успешного staging-теста выпустите production-сертификат:
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot certificates
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem"
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem"
getent group han-nginx-tls
test -d /var/lib/han-chat/public-tls
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
/var/lib/han-chat/public-tls/fullchain.pem
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
/var/lib/han-chat/public-tls/privkey.pem
Nginx с primary GID 11001 получает только подготовленные public certificate
и private key из /var/lib/han-chat/public-tls с host read-only. Исходный
/etc/letsencrypt остаётся доступен только root/Certbot. Не копируйте private
key в каталог релиза и не делайте его world-readable.
6. Gates 1–9: preflight, миграции и первый запуск под root
Выполняйте gates строго по порядку 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9.
Не включайте han-processing.service и не делайте systemctl enable, пока
Gate 5 не завершился успешно.
Установка/редактирование unit, Compose, .env, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями root.
После Gate 5 штатный restart/status/logs для deploy — см. блок
«Дальнейшие штатные операции» ниже.
Gate 1 — секреты материализованы
Под root на VM2:
systemctl restart han-secrets-vm2.service
systemctl is-active han-secrets-vm2.service
journalctl --no-pager -u han-secrets-vm2.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
Ожидается active; журнал не содержит значений секретов; последняя команда
показывает только имена всех ключей из mapping. Не выполняйте cat файлов
секретов и не вставляйте реальные значения в terminal history.
Gate 2 — статический preflight и Compose
Под root на VM2:
cd /opt/han-chat/services
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose config --services
/usr/local/sbin/han-vm2-compose config --images
Все команды должны завершиться с кодом 0. В списке services нет PostgreSQL,
а все production images содержат @sha256:. Вывод полного resolved Compose в
файл не сохраняйте.
Gate 3 — миграции и активный Message Safety config
Под root. Перед первым bitrix-sync-migrate владелец han_app или
администратор БД выдаёт Bitrix migration-role временный read-only доступ к
legacy mapping:
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO <BITRIX_SYNC_MIGRATION_ROLE>;
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
После успешного bitrix-sync-migrate администратор БД отзывает временные
права. Право USAGE отзывайте только если оно не требуется этой роли для
других согласованных операций:
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
Проверьте head revision и активную config version:
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
message-safety-migrate current
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
bitrix-sync-migrate current
Ожидается по одной head revision каждого сервиса. create --version 1
выполняется только при первом развёртывании. Для следующего конфига используйте
новый монотонный номер и отдельные значения --actor/--approved-by; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
seed и schema: seed max_signature_age_hours=240, schema maximum 720
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose --profile ops pull \
message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
validate /app/app/artifacts/seed-config.yaml
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml \
--version "$NEXT_VERSION" --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
unset NEXT_VERSION
Старый image, schema которого ограничивает поле значением 168, нельзя
оставлять после активации значения 240: сначала обновите
MESSAGE_SAFETY_IMAGE на новый digest и проверьте config --quiet.
Gate 4 — конфигурация nginx до запуска
После выпуска public TLS в /etc/letsencrypt и материализации internal TLS
secrets. В Selectel значения VM2_INTERNAL_TLS_CERTIFICATE и
VM2_INTERNAL_TLS_PRIVATE_KEY сохраняются как исходный PEM с настоящими
переводами строк, не как повторный base64 и не как строка с литералами \n.
После изменения remote secret перезапустите han-secrets-vm2.service; preflight
проверит формат PEM и соответствие certificate/key без вывода их содержимого:
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose run --rm --no-deps \
-e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \
-e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \
nginx \
nginx -t -c /etc/nginx/nginx.conf
Базовый nginx.conf подключает обязательный
/etc/nginx/conf.d/10-vm2.conf, поэтому команда завершится ошибкой, если
entrypoint не создал конфигурацию из шаблона. Временные значения upstream
нужны только для проверки до первого запуска backend-контейнеров; production
Compose подставляет DNS-имена сервисов. Ожидается syntax is ok и test is successful; ошибок conf.d is not writable и предупреждения о превышении
open-file limit быть не должно. Ошибка отсутствующего сертификата является
блокером, а не основанием временно убрать TLS.
Gate 5 — упорядоченный первый запуск
Под root на VM2:
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation
/usr/local/sbin/han-vm2-compose up -d nginx
/usr/local/sbin/han-vm2-compose ps
otel-collector автоматически запускает одноразовый otel-queue-init. Он
выставляет владельца persistent queue 10001:10001 и завершается с кодом 0;
сам Collector стартует только после этого.
Healthcheck nginx использует встроенный nginx -t: утверждённый
nginx-unprivileged image не содержит wget/curl. ExitCode 127 с
сообщением wget: not found означает, что на VM2 остался старый Compose.
Если запуск выполнялся со старым релизом и Message Safety уже попал в
permission/restart loop, после активации исправленного релиза под root
восстановите контракт файла и пересоздайте затронутые контейнеры:
getent group 10001 >/dev/null ||
groupadd --system --gid 10001 han-message-safety
test "$(getent group han-message-safety | cut -d: -f3)" = 10001
chown root:han-message-safety /etc/han-chat/message-safety-mode.env
chmod 0640 /etc/han-chat/message-safety-mode.env
install -m 0755 -o root -g root \
/opt/han-chat/services/deployment/han-message-safety-mode \
/usr/local/sbin/han-message-safety-mode
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
otel-queue-init otel-collector
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps
Не заменяйте это на chmod 0644/0666, запуск контейнеров от root или
рекурсивный chown Docker volumes. Если после восстановления прав nginx
остаётся в Restarting, это отдельная ошибка конфигурации/TLS, а не права
Message Safety; проверьте её без вывода секретов:
/usr/local/sbin/han-vm2-compose logs --tail 100 nginx otel-collector
Дождитесь healthy у сервисов с healthcheck. Не продолжайте при
Restarting, unhealthy, OOM или неожиданном Exited. После успешного
первого запуска передайте дальнейший lifecycle systemd:
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
journalctl --no-pager -u han-processing.service
Дальнейшие штатные операции может выполнить deploy:
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
Gate 6 — host ports и сертификаты
Под root на VM2:
ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]'
/usr/local/sbin/han-vm2-compose ps --format json | jq .
Ожидаются host listeners только nginx: public 80, 443 и private-bound
8443 на PROCESSING_PRIVATE_BIND_ADDRESS. 6379, container 8080 и OTLP
4317/4318 на host отсутствуют.
С доверенной рабочей станции проверьте public chain:
openssl s_client -connect <PROCESSING_PUBLIC_HOST>:443 \
-servername <PROCESSING_PUBLIC_HOST> \
-verify_hostname <PROCESSING_PUBLIC_HOST> -verify_return_error </dev/null
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile <INTERNAL_CA_FILE> -verify_return_error </dev/null
Обе команды должны завершить certificate verification без ошибки.
Переключите renewal с первоначального standalone на webroot, который nginx
обслуживает по /.well-known/acme-challenge/. certbot reconfigure сам
проверит новый способ через staging CA:
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
certbot reconfigure \
--cert-name "$PUBLIC_HOST" \
--authenticator webroot \
--webroot-path /var/lib/han-chat/acme
Повторный setup после активации релиза устанавливает deploy-hook
/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx: после успешного
обновления он атомарно размещает certificate/key с группой han-nginx-tls в
/var/lib/han-chat/public-tls, проверяет конфигурацию nginx и отправляет
контейнеру HUP.
Проверьте полный цикл и включите штатное расписание Certbot:
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl --no-pager status certbot.timer
systemctl list-timers certbot.timer
certbot.timer проверяет необходимость продления дважды в сутки; сертификат
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
блокер. Итог Congratulations, all simulated renewals succeeded означает
успешный dry-run. Старый hook мог при этом дать ложное
Hook 'deploy-hook' ran with error output: Compose писал Killing/Killed, а
успешный nginx -t — syntax is ok в stderr. Исправленный hook показывает
вывод config test только при ненулевом exit code и использует тихий
docker kill --signal HUP. Порт 80 после этого остаётся доступен для
HTTP-01 renewal.
Gate 7 — public routing
С внешней тестовой машины:
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
Ожидаемые коды по порядку: 308, 404, 426, 404, 405. Для HTTPS
используйте только валидный public certificate, без -k.
POST к webhook с адреса вне Bitrix allow-list должен получить 403; если
cloud firewall настроен на drop, допустим timeout. Затем повторите с
разрешённого source IP и заведомо неверным receiver token: upstream должен
ответить 403, не 2xx.
Gate 8 — private API только с VM1/ops
Следующие команды выполняются на VM1 или approved ops host, не на VM2. Используйте private DNS/SAN и внутреннюю CA.
Safety status:
curl --fail --silent --show-error \
--cacert <INTERNAL_CA_FILE> \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
Benign text check через тот же private listener:
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "<INTERNAL_CA_FILE>"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
В standard mode ожидается HTTP 200, verdict=allow и непустые
config_version/rules_version. Проверку 202 → Location → task GET
выполняйте отдельным file smoke только с реальным versioned quarantine object:
выдуманные S3 key/version/ETag не являются валидным тестом.
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в переменную и передайте curl config через stdin, чтобы значение не попало в argv/history:
BITRIX_TOKEN="$(cat <BITRIX_SYNC_SERVICE_TOKEN_FILE_ON_VM1>)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
--write-out '%{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/sync/v1/status"
cacert = "<INTERNAL_CA_FILE>"
header = "Authorization: Bearer ${BITRIX_TOKEN}"
EOF
unset BITRIX_TOKEN
cat /tmp/vm2-sync-status.json
rm -f /tmp/vm2-sync-status.json
При BITRIX_SYNC_ENABLED=false ожидается закрытая/неготовая синхронизация, а
не ложный успешный full-mode status. С машины вне VM1_PRIVATE_CIDRS и
необязательных приватных/VPN-сетей OPS_CIDRS подключение к 8443 должно
завершиться timeout/reject.
Gate 9 — canary на отсутствие секретов в логах и traces
Создайте фейковый, не production token marker и отправьте его с разрешённого тестового source IP:
CANARY="HAN_VM2_REDACTION_$(date +%s)"
curl -sS -o /dev/null \
-H "Authorization: Bearer ${CANARY}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "auth[application_token]=${CANARY}" \
"https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact?token=${CANARY}"
На VM2 под root:
CANARY='<ЗНАЧЕНИЕ_CANARY_С_ТЕСТОВОЙ_МАШИНЫ>'
if /usr/local/sbin/han-vm2-compose logs --no-color \
nginx bitrix-sync message-safety-api otel-collector |
grep -F -- "$CANARY"; then
echo 'FAIL: canary попал в логи' >&2
exit 1
fi
unset CANARY
В SigNoz выполните поиск этого же marker по logs и span attributes за окно теста: результат должен быть пустым. Отдельными фейковыми markers повторите проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для такой проверки не используйте.
Не открывайте webhook-трафик, пока bitrix-sync отключён. Отключённый или
упавший receiver должен возвращать retryable 503/закрытую маршрутизацию,
никогда успешный 2xx ignored.
7. После Gate 9 — post-acceptance
Gate 9 завершает техническую приёмку VM2, но не означает production cutover Message Safety на VM1 и не разрешает включать Bitrix sync.
Дальнейший порядок:
- Проверить автозапуск:
systemctl is-enabled \
docker.service \
han-chat-vm2-docker-firewall.service \
han-secrets-vm2.service \
han-processing.service \
certbot.timer
systemctl is-active \
docker.service \
han-chat-vm2-docker-firewall.service \
han-processing.service \
certbot.timer
/usr/local/sbin/han-vm2-compose ps
- Провести reboot-gate. Только после проверки отдельного входа
adminи доступа к консоли Selectel:
systemctl reboot
После переподключения повторить команды выше и кратко Gate 6–8: HTTPS, firewall, private Safety API.
- Зафиксировать итог релиза:
/usr/local/sbin/han-vm2-compose config --images
/usr/local/sbin/han-vm2-compose ps
systemctl list-timers certbot.timer
journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
Сохранить версии образов, дату приёмки и результаты gates без значений секретов.
- Настроить эксплуатационный мониторинг:
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
- недоступность private Safety API.
-
Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки.
-
bitrix-syncпока оставить:
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
Public allow-list — только deny all;. Включать Bitrix можно лишь после
выполнения gates module-07-bitrix-sync.md:
поля портала, webhooks, migrations, grants, backfill/watermark и rollback
rehearsal.
После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам по себе не разрешает переключать caller.
Controlled cutover Message Safety на VM1
Cutover выполняется в согласованное окно совместно с Safety Service,
Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и
предыдущий schema-compatible immutable release VM1, ответственного за rollback
и stop conditions. bitrix-sync в это окно не включается.
1. Предварительные условия
До изменения VM1 должны быть выполнены все условия:
- reboot-gate VM2 и повторные Gates 6–8 успешны;
- Safety работает в
standard, не вmock; - active config и rules bundle утверждены, Safety v2 migrations находятся на ожидаемом head;
text,links,files,workerимеют состояниеready;- VM2 имеет только read-only доступ к versioned S3 quarantine objects;
- performance, egress negative tests и redaction Gate 9 закрыты;
- private DNS VM2 резолвится с VM1 только в private VPC address;
- security group/firewall разрешает
8443от VM1 и approved ops, но не из интернета; - на VM1 подготовлен release без local
message-safety, Redis DB2, local rules env и stub fallback; - один и тот же production service token подготовлен в раздельных secret
bundles VM1 и VM2; значение токена не печатается и не копируется в
/etc/han/vm1.env.
На VM1 под root повторите проверку TLS и readiness:
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile /etc/han/ca/vm2-internal-ca.pem \
-verify_return_error </dev/null
curl --fail --silent --show-error \
--cacert /etc/han/ca/vm2-internal-ca.pem \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются
processing_mode=standard, непустой config_version и
text|links|files|worker=ready. stub, mock, not_ready или недоступная
capability — stop condition.
2. Прямой pre-cutover smoke с VM1
Прямой smoke доказывает route, CA и paired token до перезапуска caller:
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "/etc/han/ca/vm2-internal-ca.pem"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "X-Request-ID: ${MESSAGE_ID}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM1 to VM2 cutover canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
Ожидается HTTP 200, verdict=allow, processing_mode=standard и непустые
config_version/rules_version. Отдельный запрос с фейковым token marker
должен вернуть 401; production token для negative test не изменяйте.
До переключения также выполните через API VM2:
- deny smoke на утверждённом безопасном corpus case — ожидается
403; - повтор запроса с тем же
message_idи тем же body — тот же sticky result; - тот же
message_idс другим body —409; - file smoke только с реальным versioned quarantine object:
202 + Location + Retry-After, затем terminal200или403; - lease/fencing smoke с остановкой/возвратом worker по утверждённому test case: task не исполняется двумя владельцами и сохраняет sticky terminal result.
Не используйте выдуманные S3 key/version/ETag и не загружайте EICAR в production bucket вне согласованного security test.
3. Переключение caller на VM1
На VM1 установите и проверьте internal CA по процедуре
RUNBOOK.production.ru.md
§6. В /etc/han/vm1.env должны быть:
MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS_NAME>:8443
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
В root-owned Selectel secret mapping VM1 переменная
MESSAGE_SAFETY_SERVICE_TOKEN должна ссылаться на согласованный remote secret.
После review config и mapping:
cd /opt/han-chat/current/backend
./scripts/validate-env /etc/han/vm1.env
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
./deployment/preflight.sh
/usr/local/sbin/han-vm1-compose config --quiet
/usr/local/sbin/han-vm1-compose config --services
/usr/local/sbin/han-vm1-compose config --images
В config --services не должно быть local message-safety, bitrix-sync,
Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите
secret values. Если preflight успешен, переключите caller:
systemctl restart han-stack@production.service
systemctl is-active han-stack@production.service
/usr/local/sbin/han-vm1-compose ps
4. Post-cutover проверки через caller
Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к VM2:
- benign text проходит Safety и отправляется ровно один раз;
- утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic
message_blockedбез internalrule_id; - сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки;
- реальный quarantine file проходит
pendingи terminal result, после allow продвигается штатным caller flow; deny-файл не продвигается; - повтор client/idempotency request не создаёт второе сообщение или второй Safety task;
- correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения, token, object key и других секретов.
Затем согласованным способом кратко сделайте VM2 недоступной только для test request и подтвердите fail-closed: VM1 не отправляет и не продвигает контент, возвращает контролируемую retryable ошибку, а local/stub fallback не активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте отказ остановкой всей VM2, если на ней уже есть другой production traffic.
5. Rollback rehearsal
Rollback caller — только на заранее проверенный предыдущий
schema-compatible immutable release VM1 по
RUNBOOK.production.ru.md
§12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные
Safety tasks:
PREVIOUS='<PREVIOUS_COMPATIBLE_GIT_SHA>'
test -d "/opt/han-chat/releases/${PREVIOUS}/backend"
ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
systemctl daemon-reload
systemctl restart han-secrets@production.service
/opt/han-chat/current/backend/deployment/preflight.sh
systemctl restart han-stack@production.service
После rehearsal повторите public smoke, верните approved current release тем же атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open, переключение на local stub или обход Safety. При несовместимой migration rollback запрещён: используйте forward fix либо заранее согласованный recovery plan.
6. Фиксация cutover
Сохраните без secret values:
- VM1/VM2 release и image digests, Safety schema head;
- private certificate fingerprint/expiry,
config_versionиrules_version; - результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks;
- firewall counters и доказательство недоступности
8443с запрещённого source; - traces/log search и результат redaction canary;
- фактическое время переключения и rollback rehearsal;
- approvals Safety Service, Rule Pack, Security, Product и Operations.
Только после успешного выполнения всех пунктов Message Safety cutover считается
завершённым. Cutover bitrix-sync остаётся отдельным изменением по
module-07-bitrix-sync.md.
Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает
docker compose down -v.
Аварийный MOCK
Разрешены только эти пять sudo-команд:
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
Хелпер атомарно пишет только
/etc/han-chat/message-safety-mode.env, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного standard, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте read_only, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только freshclam; clamd — нет.