Skip to content
страница целиком, в Markdown — вставьте в чат с моделью

Новый сервис: от репозитория до прода ​

Пошаговая инструкция: что положить в репозиторий, чтобы сервис сам собирался и раскатывался на dev, stage и prod.

Всё описанное ниже работает в проде. Рабочий образец целиком — репозиторий integrations/docs: это сайт, который вы сейчас читаете, и он ездит по тому же конвейеру.

Как устроен конвейер ​

Схема одна на все среды: ветка → тег образа → namespace в Nomad → своя виртуалка.

devstageprod
Веткаdevstagemain
Тег образа:dev:stage:prod
Namespacedevstageprod
Нода192.168.100.20192.168.100.30192.168.100.40
Доменdev-имя.amzhub.aistage-имя.amzhub.aiимя.amzhub.ai
Запускавтоматом при пушеавтоматом при пушевручную, кнопкой
mermaid
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. Требования к сервису ​

  1. Dockerfile в репозитории. Сборка идёт под linux/amd64.
  2. Порт слушается внутри контейнера — наружу его пробрасывает Nomad, фиксировать порт хоста не нужно.
  3. Эндпоинт /health, отдающий 200 только когда сервис реально готов принимать запросы. По нему Nomad решает, удалась выкатка или нужно откатываться.
  4. Корректное завершение по SIGTERM — при обновлении старая копия получает сигнал и имеет время дожить запросы.

Шаг 3. .gitlab-ci.yml ​

Сборка — общим шаблоном из meduza/infra, деплой — прямым вызовом Nomad.

yaml
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-джоба ​

Один файл на все три среды, различия — через переменные.

hcl
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. Переменные сред ​

Три коротких файла рядом с джобой.

hcl
env    = "dev"
domain = "dev-myservice.amzhub.ai"
hcl
env    = "stage"
domain = "stage-myservice.amzhub.ai"
hcl
env    = "prod"
domain = "myservice.amzhub.ai"

Если у сервиса нет ветки stage — не заводите ни файл, ни джобу deploy:stage. Так живёт часть наших сервисов.

Шаг 6. Конфиг и секреты ​

Пароли, ключи и настройки лежат в Consul по ключу <сервис>/<среда>/config одним JSON-документом. Nomad читает его при старте и подкладывает в контейнер как переменные окружения. Меняете значение в Consul — задача перезапускается сама, без пайплайна.

hcl
      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:

json
{
  "port": 3000,
  "log_level": "debug",
  "db": {
    "host": "myservice-pg.service.consul",
    "port": 5432,
    "name": "myservice",
    "user": "myservice",
    "password": "..."
  }
}

Пустой блок consul {} обязателен

Если в группе есть template, но нет consul {} — задача стартует с пустыми переменными и упадёт непонятно почему. Это самая частая ошибка при заведении новой джобы.

Шаг 7. Проверка до пуша ​

Полминуты, экономят разбор красного пайплайна.

bash
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/uiSign In → Token
Consul — сервисы, здоровье, KVhttp://192.168.100.1:8500/uiLog 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 до нужных минут.

Что дальше ​

Документация по инфраструктуре