Новый сервис: от репозитория до прода
Пошаговая инструкция: что положить в репозиторий, чтобы сервис сам собирался и раскатывался на dev, stage и prod.
Всё описанное ниже работает в проде. Рабочий образец целиком — репозиторий integrations/docs: это сайт, который вы сейчас читаете, и он ездит по тому же конвейеру.
Как устроен конвейер
Схема одна на все среды: ветка → тег образа → namespace в Nomad → своя виртуалка.
| dev | stage | prod | |
|---|---|---|---|
| Ветка | dev | stage | main |
| Тег образа | :dev | :stage | :prod |
| Namespace | dev | stage | prod |
| Нода | 192.168.100.20 | 192.168.100.30 | 192.168.100.40 |
| Домен | dev-имя.amzhub.ai | stage-имя.amzhub.ai | имя.amzhub.ai |
| Запуск | автоматом при пуше | автоматом при пуше | вручную, кнопкой |
flowchart LR
A[push в ветку] --> B[раннер new-server-builder]
B --> C[сборка образа]
C --> D[registry.amztop.io]
D --> E[nomad job run<br>прямым API]
E --> F[нода своей среды]
F --> G[traefik + Let's Encrypt]Деплой идёт прямым вызовом Nomad API с раннера — без ssh на хост. Старый шаблон .nomad_deploy из meduza/infra для этого не подключается.
Что уже готово
Заводить не нужно ничего из этого списка:
- Раннер
new-server-builder— общий, виден всем проектам. Достаточно поставить джобе тегnew-server. - Registry
registry.amztop.io— авторизация идёт через GitLab, в пайплайне логин делается автоматически токеном джобы. - Переменные CI
NEW_NOMAD_ADDRиNEW_NOMAD_TOKEN— заведены на всю группуintegrations. - Кластер — три виртуалки под dev / stage / prod, на каждой свой traefik, который сам выпускает сертификат Let's Encrypt.
Личные токены в CI не нужны
Шаблон сборки логинится в registry парой $CI_REGISTRY_USER / $CI_REGISTRY_PASSWORD — это gitlab-ci-token и эфемерный токен джобы. Он живёт время джобы и ограничен своим проектом. Класть в переменные личный PAT не надо: если нужен постоянный доступ извне пайплайна — берётся deploy-токен со скоупом write_registry.
Шаг 1. Репозиторий
Проект должен лежать в группе integrations. Это не формальность: токен на запись в registry GitLab выдаёт только на путь реально существующего проекта. Отсюда и имя образа:
registry.amztop.io/integrations/<проект>/<сервис>:<dev|stage|prod>Первые сегменты задаёт GitLab, дальше — свободно. Один проект может держать сколько угодно образов:
registry.amztop.io/integrations/hub/backend:dev
registry.amztop.io/integrations/hub/frontend:devВеток нужно три: dev, stage, main.
Шаг 2. Требования к сервису
- Dockerfile в репозитории. Сборка идёт под
linux/amd64. - Порт слушается внутри контейнера — наружу его пробрасывает Nomad, фиксировать порт хоста не нужно.
- Эндпоинт
/health, отдающий200только когда сервис реально готов принимать запросы. По нему Nomad решает, удалась выкатка или нужно откатываться. - Корректное завершение по SIGTERM — при обновлении старая копия получает сигнал и имеет время дожить запросы.
Шаг 3. .gitlab-ci.yml
Сборка — общим шаблоном из meduza/infra, деплой — прямым вызовом Nomad.
include:
- project: 'meduza/infra'
ref: main
file: '.gitlab/ci/docker-build.yml'
stages:
- build
- deploy
variables:
REGISTRY_HOST: registry.amztop.io
REGISTRY_IMAGE: $REGISTRY_HOST/$CI_PROJECT_PATH
DUAL_PUSH: "false" # копия в старый registry не нужна
# ==================== BUILD ====================
build:backend:
extends: .docker_build
tags:
- new-server
variables:
SERVICE_NAME: backend # станет .../myservice/backend:<tag>
SERVICE_DIR: . # где лежит Dockerfile
BUILD_ARGS: ""
# ==================== DEPLOY ====================
.nomad_new:
stage: deploy
tags:
- new-server
image:
name: hashicorp/nomad:1.11.3
entrypoint: [""]
before_script:
- export NOMAD_ADDR=$NEW_NOMAD_ADDR
- export NOMAD_TOKEN=$NEW_NOMAD_TOKEN
deploy:dev:
extends: .nomad_new
script:
- nomad job run -namespace=dev -var image_tag=dev
-var deploy_ts=$CI_PIPELINE_ID
-var-file=nomad/vars-dev.hcl nomad/myservice.nomad.hcl
- sleep 5
- nomad job status -namespace=dev myservice
only:
- dev
environment:
name: development
url: https://dev-myservice.amzhub.ai
deploy:stage:
extends: .nomad_new
script:
- nomad job run -namespace=stage -var image_tag=stage
-var deploy_ts=$CI_PIPELINE_ID
-var-file=nomad/vars-stage.hcl nomad/myservice.nomad.hcl
- sleep 5
- nomad job status -namespace=stage myservice
only:
- stage
environment:
name: staging
url: https://stage-myservice.amzhub.ai
deploy:prod:
extends: .nomad_new
script:
- nomad job run -namespace=prod -var image_tag=prod
-var deploy_ts=$CI_PIPELINE_ID
-var-file=nomad/vars-prod.hcl nomad/myservice.nomad.hcl
- sleep 5
- nomad job status -namespace=prod myservice
only:
- main
when: manual # прод — только по кнопке
environment:
name: production
url: https://myservice.amzhub.aiШаблон .docker_build сам определяет тег по ветке: dev → :dev, stage → :stage, main → :prod. Пуш в любую другую ветку он молча пропускает — фича-ветки пайплайн не гоняют.
Зачем deploy_ts
Тег образа между выкатками не меняется (:dev и есть :dev), поэтому Nomad может не увидеть разницы и не передеплоить. Переменная deploy_ts с номером пайплайна делает каждую выкатку уникальной.
Шаг 4. Nomad-джоба
Один файл на все три среды, различия — через переменные.
variable "env" {
type = string
}
variable "domain" {
type = string
}
variable "image_tag" {
type = string
default = "prod"
}
variable "deploy_ts" {
type = string
default = ""
}
job "myservice" {
datacenters = ["dc1"]
type = "service"
# Бесшовное обновление: новая копия поднимается РЯДОМ со старой,
# трафик переключается только после успешных health-чеков.
update {
max_parallel = 1
canary = 1
auto_promote = true
auto_revert = true
health_check = "checks"
min_healthy_time = "10s"
healthy_deadline = "5m"
progress_deadline = "10m"
}
group "backend" {
count = 1
shutdown_delay = "8s" # дать traefik убрать старый адрес
consul {} # обязателен, если ниже есть template
# Прибивает джобу к виртуалке нужной среды.
constraint {
attribute = "${meta.env}"
value = var.env
}
network {
mode = "bridge"
port "http" {
to = 3000 # порт ВНУТРИ контейнера
}
}
service {
name = "myservice-${var.env}"
port = "http"
tags = [
# Consul общий на весь кластер: без env-тега traefik чужой
# среды попытается взять сертификат для вашего домена.
"env=${var.env}",
"traefik.enable=true",
"traefik.http.routers.myservice-${var.env}.rule=Host(`${var.domain}`)",
"traefik.http.routers.myservice-${var.env}.entrypoints=websecure",
"traefik.http.routers.myservice-${var.env}.tls.certresolver=letsencrypt",
]
check {
type = "http"
path = "/health"
port = "http"
interval = "10s"
timeout = "3s"
}
}
task "backend" {
driver = "docker"
kill_timeout = "20s"
kill_signal = "SIGTERM"
config {
image = "registry.amztop.io/integrations/myservice/backend:${var.image_tag}"
force_pull = true
ports = ["http"]
}
env {
DEPLOY_TS = var.deploy_ts
}
resources {
cpu = var.env == "dev" ? 200 : 500
memory = var.env == "dev" ? 256 : 512
}
}
}
}Если порт статический
При port "http" { static = 8080 } вторая копия не сможет занять тот же порт и выкатка зависнет намертво. Для таких групп ставим canary = 0 — остальные поля оставляем, автооткат продолжит работать.
Шаг 5. Переменные сред
Три коротких файла рядом с джобой.
env = "dev"
domain = "dev-myservice.amzhub.ai"env = "stage"
domain = "stage-myservice.amzhub.ai"env = "prod"
domain = "myservice.amzhub.ai"Если у сервиса нет ветки stage — не заводите ни файл, ни джобу deploy:stage. Так живёт часть наших сервисов.
Шаг 6. Конфиг и секреты
Пароли, ключи и настройки лежат в Consul по ключу <сервис>/<среда>/config одним JSON-документом. Nomad читает его при старте и подкладывает в контейнер как переменные окружения. Меняете значение в Consul — задача перезапускается сама, без пайплайна.
template {
data = <<EOH
{{- with key "myservice/${var.env}/config" }}
{{- $c := . | parseJSON }}
PORT={{ $c.port }}
LOG_LEVEL={{ or $c.log_level "info" }}
{{- with $c.db }}
DB_HOST={{ .host }}
DB_PORT={{ .port }}
DB_NAME={{ .name }}
DB_USER={{ .user }}
DB_PASSWORD={{ .password }}
{{- end }}
{{- end }}
EOH
destination = "secrets/app.env"
env = true
change_mode = "restart"
}Значение ключа myservice/dev/config в Consul:
{
"port": 3000,
"log_level": "debug",
"db": {
"host": "myservice-pg.service.consul",
"port": 5432,
"name": "myservice",
"user": "myservice",
"password": "..."
}
}Пустой блок consul {} обязателен
Если в группе есть template, но нет consul {} — задача стартует с пустыми переменными и упадёт непонятно почему. Это самая частая ошибка при заведении новой джобы.
Шаг 7. Проверка до пуша
Полминуты, экономят разбор красного пайплайна.
export NOMAD_ADDR=http://192.168.100.1:4646
export NOMAD_TOKEN=<ваш токен>
for e in dev stage prod; do
nomad job validate -var image_tag=$e -var deploy_ts=1 \
-var-file=nomad/vars-$e.hcl nomad/myservice.nomad.hcl
doneПрогоняйте для каждой среды: ошибки в HCL иначе всплывают уже в пайплайне, на стадии деплоя.
Шаг 8. Первый деплой
Push в ветку dev запускает пайплайн: сборка образа → пуш в registry → nomad job run в namespace dev. Прод точно так же, но джобу нужно запустить кнопкой в пайплайне ветки main.
Где смотреть:
| Что | Адрес | Как войти |
|---|---|---|
| Nomad — джобы, аллокации, логи | http://192.168.100.1:4646/ui | Sign In → Token |
| Consul — сервисы, здоровье, KV | http://192.168.100.1:8500/ui | Log in → Token |
Оба интерфейса наружу не опубликованы — нужен WireGuard. Конфиг и токены выдаются отдельным именным архивом. В Nomad вверху есть переключатель namespace: по умолчанию показывается default, где почти пусто, ваши джобы будут в dev / stage / prod.
Признак успеха: у деплоя статус Deployment completed successfully, все аллокации running и healthy.
Шаг 9. Домены
Сервис поднимется и получит сертификат сам, но снаружи домен не откроется, пока имя не прописано в маршрутизации на входном сервере. Это делается централизованно — конфиг общий на весь кластер и из репозитория не правится.
После успешного деплоя пришлите строку в таком виде:
myservice — dev, stage, prod — dev-myservice.amzhub.ai,
stage-myservice.amzhub.ai, myservice.amzhub.aiИмена держим по конвенции: dev-имя, stage-имя, имя в зоне amzhub.ai. Домены в vars-*.hcl пишите сразу боевые — переключение трафика от этого не зависит.
Грабли
Собраны по итогам переезда, каждая стоила отдельного разбора.
1. Retry той же джобы не передеплоит. Повторный запуск джобы в том же пайплайне отдаёт прежний deploy_ts, Nomad не создаёт новый деплой и мгновенно возвращает старый failed. Нужен новый пайплайн: Run pipeline или пустой коммит.
2. Protected-переменные не доезжают до dev. Если у переменной стоит галка Protect, она видна только защищённым веткам. NEW_NOMAD_* на группе держим без неё — не включайте её и у своих.
3. Тег env= первым в списке. Без него traefik соседней среды подхватит ваш домен, проверка Let's Encrypt уйдёт не на ту машину, а после пяти неудач домен блокируется на час.
4. health_check = "checks" без блока check. Выкатка не продвинется никогда — Nomad будет ждать здоровья, о котором ему никто не сообщает.
5. Забытый тег new-server. Джобу заберёт старый раннер, который в новый кластер не ходит. Тег нужен и у сборки, и у деплоя.
6. DUAL_PUSH по умолчанию включён. Без явного DUAL_PUSH: "false" сборка дополнительно полезет пушить копию в старый registry — лишний мусор и WARN в логе.
7. Долгие задачи убиваются на середине. По умолчанию Nomad ждёт после SIGTERM пять секунд. Если воркер держит длинную операцию — поднимите kill_timeout до нужных минут.
Что дальше
- Файлы деплоя — разбор каждого артефакта подробнее.
- Чек-лист запуска — контрольный список перед выводом в прод.
- Решение проблем — что делать, когда выкатка встала.