Документ описывает полный путь развёртывания HashiCorp Vault и Nomad для инфраструктуры AgroLlama: с какими проблемами столкнулись, как их решили, и какие выводы из этого стоит сделать на будущее.
┌──────────────────┐
│ GitHub Actions │ 1. Собирает Docker-образ
│ │ 2. Пушит в docker-registry
│ │ 3. Дёргает Nomad API → "разверни новую версию"
└─────────┬─────────┘
│
▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ devops (72.61.159.57) │ │ application (72.62.95.160) │
│ │ │ │
│ • docker-registry │◄───────┤ • Nomad │
│ • Vault (vault.agrollama. │ │ (тянет образ из registry, │
│ com) │◄───────┤ секреты из Vault) │
└──────────────────────────┘ Nomad └────────────────────────────┘
читает
секреты
Два сервера:
Начали с классического подхода:
services:
vault:
image: hashicorp/vault:1.17
container_name: vault
restart: always
cap_add:
- IPC_LOCK
ports:
- "8200:8200"
volumes:
- vault_data:/vault/file
- ./config.hcl:/vault/config/config.hcl
command: server -config=/vault/config/config.hcl
volumes:
vault_data:
и config.hcl:
ui = true
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
storage "file" {
path = "/vault/file"
}
api_addr = "http://<IP>:8200"
Лог показывал одновременно две разные ошибки в каждом цикле:
Couldn't start vault with IPC_LOCK. Disabling IPC_LOCK, please use --cap-add IPC_LOCK
Error parsing listener configuration.
Error initializing listener of type tcp: listen tcp4 0.0.0.0:8200: bind: address already in use
Гипотеза: порт 8200 занят на хосте.
Проверка lsof -i :8200 и ss -tulpn | grep 8200 — ничего не нашли. Порт на хосте был свободен. Гипотеза отклонена.
Гипотеза: mlock/IPC_LOCK недоступен из-за виртуализации VPS.
Убрали cap_add: IPC_LOCK, добавили disable_mlock = true — ошибка про mlock не исчезла (Couldn't start vault with IPC_LOCK), и "address already in use" тоже осталась. Гипотеза не объясняла всё целиком.
Гипотеза: конфликт из-за двойного объявления слушателя (config.hcl задвоился).
Проверили содержимое файла внутри контейнера — конфиг был чист, без дублей.
Настоящая причина — нашли, прочитав entrypoint-скрипт образа:
docker run --rm --entrypoint cat hashicorp/vault:1.17 /usr/local/bin/docker-entrypoint.sh
В скрипте нашли ключевую строку:
if [ "$1" = 'server' ]; then
shift
set -- vault server \
-config="$VAULT_CONFIG_DIR" \
-dev-root-token-id="$VAULT_DEV_ROOT_TOKEN_ID" \
-dev-listen-address="${VAULT_DEV_LISTEN_ADDRESS:-"0.0.0.0:8200"}" \
"$@"
Entrypoint сам подставляет -config="$VAULT_CONFIG_DIR" (то есть -config=/vault/config, вся директория) к любой команде server. А наш command в docker-compose был:
command: server -config=/vault/config/config.hcl
После shift первого server всё остальное (-config=/vault/config/config.hcl) подставлялось через "$@" в конец команды. Итоговая команда получалась такой:
vault server -config=/vault/config -dev-root-token-id="" -dev-listen-address="0.0.0.0:8200" -config=/vault/config/config.hcl
Два флага -config — Vault читал конфиг дважды в рамках одного процесса, и второй проход натыкался на порт, уже занятый первым же самим собой. Отсюда и «address already in use», и то, что ошибка выглядела как race condition.
Убрали явный путь к конфигу из command, оставили entrypoint работать с директорией целиком:
services:
vault:
image: hashicorp/vault:1.17
container_name: vault
restart: always
cap_add:
- IPC_LOCK
ports:
- "8200:8200"
volumes:
- vault_data:/vault/file
- ./config.hcl:/vault/config/config.hcl
command: server
volumes:
vault_data:
После этого контейнер поднялся с первого раза, лог показал чистый старт:
==> Vault server configuration:
...
Mlock: supported: true, enabled: true
...
==> Vault server started! Log data will stream in below:
Вывод: если используешь официальный образ hashicorp/vault в контейнере, не указывай -config=... явно в command — просто command: server, а конфиг клади в /vault/config/ как volume. Entrypoint сам подхватит директорию правильно.
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 vault operator init"
Вывод даёт 5 Unseal Keys и Initial Root Token — единственный момент, когда они показываются полностью. Обязательно сохранить сразу в надёжном месте.
Error unsealing: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/sys/unseal
Code: 400. Errors:
* 'key' must be a valid hex or base64 string
Причина — забыт VAULT_ADDR, CLI по умолчанию лезет на https://127.0.0.1:8200, а слушатель настроен на http (tls_disable = 1). Правильный вызов:
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal"
Столкнулись с ситуацией: после ввода 2 корректных ключей и одного ошибочного, прогресс показывал 1/3 вместо ожидаемого 2/3.
Причина: если введённый ключ невалиден (опечатка, обрезалось при вставке), Vault сбрасывает весь прогресс текущей сессии unseal — не просто игнорирует плохую попытку, а обнуляет всё. Следующий введённый (уже корректный) ключ засчитывается как новый первый в новой сессии.
Практический вывод: вводить 3 ключа подряд аккуратно, копируя каждый целиком, без пробелов. Если где-то произошла ошибка — начинать отсчёт заново с нуля, не пытаться "довводить недостающее".
В процессе диагностики root token был случайно вставлен прямо в чат. Правильная реакция — считать его скомпрометированным и немедленно сгенерировать новый:
# Отзыв текущего
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN='<текущий_root>' vault token revoke -self"
# Генерация нового через recovery-процедуру
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 vault operator generate-root -init"
# → выдаёт Nonce и OTP
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 vault operator generate-root -nonce=<NONCE>"
# ввести 3 unseal-ключа по очереди (тем же принципом, что и unseal)
# После Complete: true — получаем Encoded Token, декодируем через OTP:
docker exec -it vault sh -c "vault operator generate-root -decode=<ENCODED_TOKEN> -otp=<OTP>"
# → новый root token
Урок: любые секреты (пароли, токены), однажды попавшие в чат/лог/историю команд, нужно считать утёкшими и ротировать, даже если канал кажется приватным.
http://<IP>:8200/ui не отвечал, хотя curl http://127.0.0.1:8200/v1/sys/health локально на сервере отрабатывал нормально.
Диагностика:
sudo ufw status — показал, что 8200 не был в списке разрешённых портовsudo ufw allow 8200/tcp — не помоглоufw не видит и не может обойти).Решение по факту: отказались от прямого доступа через голый IP:порт, перешли на схему через nginx-proxy-manager + HTTPS на поддомене. Голый HTTP-порт 8200 в итоге снова закрыли в ufw как небезопасный и ненужный.
dig vault.agrollama.com возвращал NXDOMAIN, при том что запись A vault → <IP> в DNS-панели Hostinger была видна.
Диагностика:
dig NS agrollama.com +short
# → ns1.dns-parking.com / ns2.dns-parking.com
На первый взгляд показалось подозрительным название dns-parking.com (звучит как "парковка домена, не используется"), но по факту это оказались легитимные nameservers Hostinger для доменов, управляемых через их DNS-панель. То есть NS были настроены верно.
Реальная причина оказалась в задержке распространения новой DNS-записи / кэшировании NXDOMAIN локальным резолвером. После ожидания и повторной проверки через публичный DNS (dig vault.agrollama.com @8.8.8.8) запись благополучно резолвилась.
Урок: не спешить менять nameservers или "чинить" DNS радикально — сначала исключить баналньную задержку распространения и локальное кэширование.
Чтобы не светить голый HTTP наружу, оба сервиса вывели через nginx-proxy-manager:
vault.agrollama.com → http://vault:8200 (или IP:порт, если разные docker-сети)nomad.agrollama.com → http://127.0.0.1:4646Дополнительно рассматривался вариант с HTTP Basic Auth на уровне nginx-proxy-manager (Access Lists) как дополнительный периметровый слой защиты перед страницей логина — независимый от токенов самих Vault/Nomad.
docker exec -it vault sh -c "VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN='<root>' vault auth enable userpass"
path "secret/data/agrollama/*" {
capabilities = ["read", "list"]
}
vault policy write agrollama-policy agrollama-policy.hcl
vault write auth/userpass/users/AGROLLAMA \
password='<пароль>' \
token_policies='agrollama-policy' \
token_ttl=1h \
token_max_ttl=4h
kv/
├── docker-registry # DOCKER_REGISTRY_HOST/USERNAME/PASSWORD
└── backend/
└── ai-service # переменные окружения конкретного сервиса
path "kv/data/backend/*" {
capabilities = ["read"]
}
path "kv/data/docker-registry" {
capabilities = ["read"]
}
vault policy write nomad-server nomad-policy.hcl
vault token create -policy=nomad-server -period=87600h -orphan
Важный момент про периодические токены: -period — это не "срок жизни один раз", а интервал автопродления. Токен с периодом (например 87600h = 10 лет) фактически не истекает, пока сервис (Nomad), который его использует, жив и может достучаться до Vault для продления. Для инфраструктурных service-токенов это нормальная практика — не то же самое, что короткий TTL для человека, логинящегося вручную.
Про root token: его никогда не используют для регулярных задач или интеграций (типа Nomad↔Vault). Для каждой интеграции — свой ограниченный токен с минимально необходимой policy.
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install nomad -y
Почему не через Docker Compose: Nomad сам управляет Docker-демоном хоста (создаёт/удаляет контейнеры). Запуск Nomad внутри контейнера потребовал бы прокидывать /var/run/docker.sock, privileged: true и network_mode: host — по сути превращая контейнер в "почти хост" без реальной изоляции, без какого-либо выигрыша по сравнению с нативной установкой. Официальный и рекомендуемый способ — через apt/systemd, аналогично тому, как ставят сам Docker.
/etc/nomad.d/nomad.hcl)Пакет apt install nomad уже создаёт базовый конфиг с data_dir, bind_addr, server, client. Его дополнили:
plugin "docker" {
config {
allow_privileged = true
}
}
vault {
enabled = true
address = "https://vault.agrollama.com"
token = "<ограниченный_токен_nomad-server>"
}
| Порт | Назначение | Открывать наружу? |
|---|---|---|
| 4646 | HTTP API + Web UI | Да, если нужен внешний доступ |
| 4647 | RPC между server/client нодами | Нет (нужен только для мульти-нодового кластера) |
| 4648 | Serf gossip-протокол (обнаружение нод) | Нет (аналогично, только для кластера) |
При одной ноде (bootstrap_expect = 1) 4647/4648 не участвуют во внешнем трафике вообще.
sudo systemctl enable --now nomad
sudo systemctl status nomad
curl http://127.0.0.1:4646/v1/status/leader
acl {
enabled = true
}
sudo systemctl restart nomad
nomad acl bootstrap
Вывод содержит два разных значения, которые легко перепутать:
Accessor ID = 5b7e2c31-...
Secret ID = 8a3f9d21-...
Name = Bootstrap Token
Type = management
При попытке создать policy/токен для CI/CD команда падала с 403 Permission denied, хотя NOMAD_TOKEN был явно экспортирован. Причина — в переменную по ошибке был подставлен Accessor ID вместо Secret ID. После замены на правильное значение команды прошли успешно.
Урок: всегда используй именно Secret ID в NOMAD_TOKEN / при логине в UI — Accessor ID для этого не подходит, это просто "имя" токена, а не сам ключ.
Policy для CI/CD (ограниченная — только деплой в default namespace):
namespace "default" {
policy = "write"
}
nomad acl policy apply -description "CI/CD deploy policy" ci-deploy ci-policy.hcl
nomad acl token create -name="github-actions" -policy="ci-deploy"
Policy для оператора/администратора (себя):
namespace "*" {
policy = "write"
}
node { policy = "read" }
agent { policy = "read" }
operator { policy = "read" }
quota { policy = "read" }
nomad acl policy apply -description "Full admin access for operators" admin admin-policy.hcl
nomad acl token create -name="daniyar-admin" -policy="admin"
Итоговая раскладка токенов:
| Токен | Тип | Когда использовать |
|---|---|---|
| Bootstrap Token | management | Только для аварийных ситуаций и первоначальной настройки policy/токенов. Хранить как root token Vault — не использовать ежедневно |
| daniyar-admin | client (policy: admin) | Повседневная работа в UI/CLI — создание и просмотр job'ов |
| github-actions | client (policy: ci-deploy) | Только внутри CI/CD, никогда не вставлять вручную в UI |
Токен github-actions имеет policy только на namespace "default" { policy = "write" } — этого достаточно для запуска job'ов через API, но недостаточно для просмотра общих разделов UI (Clients, Servers, Evaluations), которые требуют более широких/глобальных прав. Отсюда вывод: для собственной работы в UI нужен отдельный, более широкий токен (daniyar-admin), а не переиспользование CI/CD-токена.
У Nomad (open-source версия) нет встроенной модели username/password. Авторизация только через ACL-токены (Secret ID вставляется в специальное поле на странице логина UI). SSO/OIDC/SAML доступны только в Nomad Enterprise (платная версия). Если нужен классический логин на уровне периметра — его добавляют отдельным слоем через nginx-proxy-manager (HTTP Basic Auth), что не заменяет, а дополняет ACL-токены Nomad.
ai-servicejob "ai-service" {
datacenters = ["dc1"]
type = "service"
group "ai-service" {
count = 1
network {
port "rest" {
static = 8810 # AI_SERVICE_REST_PORT
}
port "grpc" {
static = 9810 # AI_SERVICE_GRPC_PORT
}
}
task "ai-service" {
driver = "docker"
vault {
policies = ["nomad-server"]
}
template {
data = <<EOH
{{ with secret "kv/data/docker-registry" }}
DOCKER_REGISTRY_HOST={{ .Data.data.DOCKER_REGISTRY_HOST }}
DOCKER_REGISTRY_USERNAME={{ .Data.data.DOCKER_REGISTRY_USERNAME }}
DOCKER_REGISTRY_PASSWORD={{ .Data.data.DOCKER_REGISTRY_PASSWORD }}
{{ end }}
EOH
destination = "secrets/registry.env"
env = true
}
config {
image = "docker.registry.agrollama.com/ai-service:latest"
force_pull = true
ports = ["rest", "grpc"]
auth {
username = "${DOCKER_REGISTRY_USERNAME}"
password = "${DOCKER_REGISTRY_PASSWORD}"
}
}
template {
data = <<EOH
{{ with secret "kv/data/backend/ai-service" }}
AI_SERVICE_GRPC_PORT={{ .Data.data.AI_SERVICE_GRPC_PORT }}
AI_SERVICE_HOST={{ .Data.data.AI_SERVICE_HOST }}
AI_SERVICE_REST_PORT={{ .Data.data.AI_SERVICE_REST_PORT }}
APP_GRPC_PORT={{ .Data.data.APP_GRPC_PORT }}
APP_HOST={{ .Data.data.APP_HOST }}
APP_REST_PORT={{ .Data.data.APP_REST_PORT }}
{{ end }}
EOH
destination = "secrets/app.env"
env = true
}
resources {
cpu = 500
memory = 512
}
}
}
}
В отличие от docker-compose (ports: ["8810:8810"]), в Nomad порты объявляются в блоке network группы:
network {
port "http" {
static = 8810 # аналог "8810:8810"
}
}
и подключаются к контейнеру через config.ports = ["http"]. Есть и динамический вариант (to = 8810 без static), когда Nomad сам выбирает свободный хост-порт — полезно при масштабировании нескольких инстансов одного сервиса.
Подстановка ${DOCKER_REGISTRY_USERNAME} / ${DOCKER_REGISTRY_PASSWORD} внутри config.auth зависит от того, что Nomad успеет отрендерить template (registry.env) раньше, чем начнёт резолвить сам config-блок — порядок выполнения не всегда гарантирован в реальных сетапах, это частая boль в комьюнити Nomad.
Запасной, более надёжный вариант, если возникнут проблемы с авторизацией при пуле образа — выполнить docker login один раз вручную прямо на хосте application:
docker login docker.registry.agrollama.com -u <user> -p <pass>
После этого config.auth в job-файле можно вообще убрать — docker daemon на хосте уже аутентифицирован, и force_pull = true будет работать без явных кредов в job-спеке.
DOCKER_REGISTRY_HOST
DOCKER_REGISTRY_USERNAME
DOCKER_REGISTRY_PASSWORD
NOMAD_TOKEN ← Secret ID токена github-actions (НЕ Management Token)
NOMAD_ADDR ← http://<application-ip>:4646 (или https://nomad.agrollama.com)
.github/workflows/deploy.ymlname: Build, Push and Deploy AgroLlama AI service
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker Registry
timeout-minutes: 10
run: |
for i in {1..5}; do
echo "${{ secrets.DOCKER_REGISTRY_PASSWORD }}" | docker login ${{ secrets.DOCKER_REGISTRY_HOST }} -u ${{ secrets.DOCKER_REGISTRY_USERNAME }} --password-stdin && break || {
echo "Login failed, retrying in 10 seconds... (Attempt $i/5)"
sleep 10
}
done
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_REGISTRY_HOST }}/ai-service:latest
${{ secrets.DOCKER_REGISTRY_HOST }}/ai-service:${{ github.sha }}
- name: Install Nomad CLI
run: |
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com jammy main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install nomad -y
- name: Deploy job to Nomad
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
run: |
nomad job run ai-service.nomad.hcl
GitHub Actions --[NOMAD_TOKEN, ограниченная policy ci-deploy]--> Nomad API
│
└──[nomad-server токен, policy read-only]──> Vault → секреты
CI/CD никогда не обращается к Vault напрямую и не видит никаких Vault-токенов — только Nomad, у которого свой отдельный, ограниченный доступ. Каждый компонент цепочки имеет минимально необходимые права.
jq -r 'to_entries[] | "\(.key)=\(.value)"' config.json > .env
jq -Rn '[inputs | gsub("\r$"; "") | select(test("^[A-Za-z_][A-Za-z0-9_]*=")) | capture("^(?<k>[^=]+)=(?<v>.*)$")] | from_entries' .env > config.json
Конфуз по пути: первая версия команды падала с jq: error (at .env:N): Cannot use null (null) as object key. Причина — в файле оказался посторонний символ (≈), не подходящий под ожидаемый формат KEY=VALUE, но и не отфильтрованный простыми проверками на пустую строку/комментарий. Найден через:
grep -vE '^[A-Za-z_][A-Za-z0-9_]*=.*$' .env | grep -v '^$' | grep -v '^#'
После этого regex в jq был доработан так, чтобы строго требовать формат ИМЯ_ПЕРЕМЕННОЙ=значение и молча игнорировать всё остальное, не падая с ошибкой.
-period) — нормальная практика для service-to-service интеграций (Nomad↔Vault), в отличие от коротких TTL для токенов, которыми пользуются люди..env, но и config.json. Оба должны быть в .gitignore, а генерация одного из другого — через скрипты, а не ручное копирование значений.Документ сгенерирован на основе диалога по развёртыванию инфраструктуры AgroLlama, июль 2026.