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

Файлы деплоя ​

Эта страница описывает все файлы, необходимые для деплоя нового сервиса. Каждый раздел содержит готовые примеры, которые можно скопировать и адаптировать.

Обзор ​

mermaid
flowchart LR
    A[git push] --> B[GitLab CI]
    B --> C[Docker Build]
    C --> D[Registry]
    D --> E[Nomad Deploy]
    E --> F[Traefik Routes]

Пайплайн деплоя работает следующим образом:

  1. Вы пушите код в GitLab.
  2. GitLab CI собирает Docker-образ и пушит его в container registry.
  3. GitLab CI запускает деплой через Nomad, который подтягивает новый образ.
  4. Nomad регистрирует сервис в Consul, а Traefik подхватывает теги маршрутизации.
  5. Traefik терминирует TLS и направляет трафик на ваш сервис.

1. Dockerfile ​

Dockerfile использует multi-stage сборку для минимизации размера итогового образа.

dockerfile
# ---- Build Stage ----
FROM node:20-alpine AS build

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Serve Stage ----
FROM nginx:1.27-alpine

COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]
dockerfile
# ---- Dependencies Stage ----
FROM node:22-alpine AS deps

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ---- Production Stage ----
FROM node:22-alpine

RUN apk add --no-cache tini

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NODE_ENV=production
EXPOSE 3000

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "src/app.js"]
dockerfile
# ---- Frontend Build Stage ----
FROM node:20-alpine AS frontend-build

WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# ---- Backend Dependencies Stage ----
FROM node:22-alpine AS backend-deps

WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev

# ---- Frontend Serve Image ----
FROM nginx:1.27-alpine AS frontend

COPY frontend/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

# ---- Backend Production Image ----
FROM node:22-alpine AS backend

RUN apk add --no-cache tini

WORKDIR /app

COPY --from=backend-deps /app/backend/node_modules ./node_modules
COPY backend/ .

ENV NODE_ENV=production
EXPOSE 3000

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "src/app.js"]

TIP

Для full-stack репозитория с отдельными образами фронтенда и бэкенда каждый target собирается явно в CI:

bash
docker build --target frontend -t registry/my-tool-frontend .
docker build --target backend -t registry/my-tool-backend .

2. nginx.conf ​

Конфигурация nginx для раздачи SPA с правильным кэшированием, gzip-сжатием и health-эндпоинтом.

nginx
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    sendfile    on;
    tcp_nopush  on;
    tcp_nodelay on;

    keepalive_timeout 65;

    # ---- Gzip ----
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml;

    server {
        listen 80;
        server_name _;
        root /usr/share/nginx/html;
        index index.html;

        # ---- Health check ----
        location /health {
            access_log off;
            return 200 'ok';
            add_header Content-Type text/plain;
        }

        # ---- Hashed assets — cache aggressively ----
        location /assets/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
            try_files $uri =404;
        }

        # ---- index.html — never cache ----
        location = /index.html {
            add_header Cache-Control "no-cache, no-store, must-revalidate";
            add_header Pragma "no-cache";
            add_header Expires "0";
        }

        # ---- SPA fallback ----
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
}

WARNING

SPA fallback (try_files $uri $uri/ /index.html) обязателен. Без него обновление страницы на клиентском маршруте вроде /dashboard/settings вернёт 404.


3. .gitlab-ci.yml ​

CI-пайплайн подключает шаблон сборки из репозитория meduza/infra, а задачи деплоя описывает сам — они обращаются к Nomad API напрямую.

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"

build:frontend:
  extends: .docker_build
  tags:
    - new-server
  variables:
    SERVICE_NAME: frontend
    SERVICE_DIR: .

.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/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=dev my-tool
  only:
    - dev
  environment:
    name: development

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/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=stage my-tool
  only:
    - stage
  environment:
    name: staging

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/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=prod my-tool
  only:
    - main
  when: manual
  environment:
    name: production
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"

build:frontend:
  extends: .docker_build
  tags:
    - new-server
  variables:
    SERVICE_NAME: frontend
    SERVICE_DIR: front

build:backend:
  extends: .docker_build
  tags:
    - new-server
  variables:
    SERVICE_NAME: backend
    SERVICE_DIR: back

.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
  needs:
    - build:frontend
    - build:backend
  script:
    - nomad job run -namespace=dev -var image_tag=dev
      -var deploy_ts=$CI_PIPELINE_ID
      -var-file=nomad/vars-dev.hcl nomad/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=dev my-tool
  only:
    - dev
  environment:
    name: development

deploy:stage:
  extends: .nomad_new
  needs:
    - build:frontend
    - build:backend
  script:
    - nomad job run -namespace=stage -var image_tag=stage
      -var deploy_ts=$CI_PIPELINE_ID
      -var-file=nomad/vars-stage.hcl nomad/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=stage my-tool
  only:
    - stage
  environment:
    name: staging

deploy:prod:
  extends: .nomad_new
  needs:
    - build:frontend
    - build:backend
  script:
    - nomad job run -namespace=prod -var image_tag=prod
      -var deploy_ts=$CI_PIPELINE_ID
      -var-file=nomad/vars-prod.hcl nomad/my-tool.nomad.hcl
    - sleep 5
    - nomad job status -namespace=prod my-tool
  only:
    - main
  when: manual
  environment:
    name: production

Разбор построчно

Что делает каждый блок и какие тут грабли — на странице Новый сервис: от репозитория до прода.


4. Nomad job-файл ​

Nomad job-файл определяет конфигурацию запуска: количество инстансов, лимиты ресурсов, health-проверки и теги маршрутизации Traefik. Один файл обслуживает все три окружения — различия приходят через -var-file.

hcl
variable "env" {
  type = string
}

variable "domain" {
  type = string
}

variable "image_tag" {
  type    = string
  default = "prod"
}

variable "deploy_ts" {
  type    = string
  default = ""
}

variable "count" {
  type    = number
  default = 1
}

job "my-tool" {
  datacenters = ["dc1"]
  type        = "service"

  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 "frontend" {
    count          = var.count
    shutdown_delay = "8s"

    constraint {
      attribute = "${meta.env}"
      value     = var.env
    }

    network {
      mode = "bridge"

      port "http" {
        to = 80
      }
    }

    service {
      name = "my-tool-${var.env}"
      port = "http"

      tags = [
        "env=${var.env}",
        "traefik.enable=true",
        "traefik.http.routers.my-tool-${var.env}.rule=Host(`${var.domain}`)",
        "traefik.http.routers.my-tool-${var.env}.entrypoints=websecure",
        "traefik.http.routers.my-tool-${var.env}.tls.certresolver=letsencrypt",
      ]

      check {
        type     = "http"
        path     = "/health"
        port     = "http"
        interval = "10s"
        timeout  = "3s"
      }
    }

    task "frontend" {
      driver = "docker"

      kill_timeout = "20s"
      kill_signal  = "SIGTERM"

      config {
        image      = "registry.amztop.io/integrations/my-tool/frontend:${var.image_tag}"
        force_pull = true
        ports      = ["http"]
      }

      env {
        DEPLOY_TS = var.deploy_ts
      }

      resources {
        cpu    = var.env == "dev" ? 100 : 200
        memory = var.env == "dev" ? 64 : 128
      }
    }
  }
}
hcl
variable "env" {
  type = string
}

variable "domain" {
  type = string
}

variable "image_tag" {
  type    = string
  default = "prod"
}

variable "deploy_ts" {
  type    = string
  default = ""
}

job "my-tool" {
  datacenters = ["dc1"]
  type        = "service"

  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"
  }

  # ---- Frontend Group ----
  group "frontend" {
    count          = var.env == "dev" ? 1 : 2
    shutdown_delay = "8s"

    constraint {
      attribute = "${meta.env}"
      value     = var.env
    }

    network {
      mode = "bridge"

      port "http" {
        to = 80
      }
    }

    service {
      name = "my-tool-frontend-${var.env}"
      port = "http"

      tags = [
        "env=${var.env}",
        "traefik.enable=true",
        "traefik.http.routers.my-tool-frontend-${var.env}.rule=Host(`${var.domain}`)",
        "traefik.http.routers.my-tool-frontend-${var.env}.entrypoints=websecure",
        "traefik.http.routers.my-tool-frontend-${var.env}.tls.certresolver=letsencrypt",
      ]

      check {
        type     = "http"
        path     = "/health"
        port     = "http"
        interval = "10s"
        timeout  = "3s"
      }
    }

    task "frontend" {
      driver = "docker"

      config {
        image      = "registry.amztop.io/integrations/my-tool/frontend:${var.image_tag}"
        force_pull = true
        ports      = ["http"]
      }

      env {
        DEPLOY_TS = var.deploy_ts
      }

      resources {
        cpu    = var.env == "dev" ? 100 : 200
        memory = var.env == "dev" ? 64 : 128
      }
    }
  }

  # ---- Backend Group ----
  group "backend" {
    count          = var.env == "dev" ? 1 : 2
    shutdown_delay = "8s"

    consul {}   # без него template не получит доступ к KV

    constraint {
      attribute = "${meta.env}"
      value     = var.env
    }

    network {
      mode = "bridge"

      port "http" {
        to = 3000
      }
    }

    service {
      name = "my-tool-backend-${var.env}"
      port = "http"

      tags = [
        "env=${var.env}",
        "traefik.enable=true",
        "traefik.http.routers.my-tool-backend-${var.env}.rule=Host(`${var.domain}`) && PathPrefix(`/api`)",
        "traefik.http.routers.my-tool-backend-${var.env}.entrypoints=websecure",
        "traefik.http.routers.my-tool-backend-${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/my-tool/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
      }

      # ---- Конфигурация из Consul KV ----
      template {
        data = <<EOH
{{- with key "my-tool/${var.env}/config" }}
{{- $c := . | parseJSON }}
PORT={{ $c.port }}
DATABASE_URL={{ $c.database_url }}
{{- end }}
EOH

        destination = "secrets/app.env"
        env         = true
        change_mode = "restart"
      }
    }
  }
}

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

Группа с template без consul {} не получает токен Consul: задача стартует с пустыми переменными и падает без внятной причины. Группам без шаблонов блок не нужен.

Конфигурация без передеплоя

change_mode = "restart" означает, что правка ключа в Consul сама перезапустит задачу с новыми значениями — гонять пайплайн ради смены настройки не нужно.

Статический порт ломает канарейку

С static = NNNN вторая копия не займёт тот же порт, и выкатка зависнет. Для таких групп ставьте canary = 0 — автооткат при этом продолжит работать.


5. Nomad vars-файлы ​

Каждое окружение получает свой vars-файл в каталоге nomad/. Они передаются в nomad job run через -var-file.

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

Держите vars-файлы короткими: в них попадает только то, что действительно различается между средами. Количество инстансов и лимиты ресурсов удобнее считать прямо в джобе через var.env == "dev" ? ... : ... — тогда добавление среды не требует правки трёх файлов.

image_tag в vars-файле не нужен

Тег образа приходит из пайплайна аргументом -var image_tag=... и совпадает с именем окружения. Если продублировать его в vars-файле, значение из файла переопределит переданное и деплой поедет не тем образом.

Основные различия между окружениями:

ПараметрDevStageProd
Доменdev-имя.amzhub.aistage-имя.amzhub.aiимя.amzhub.ai
Namespacedevstageprod
Нода192.168.100.20192.168.100.30192.168.100.40
Инстансы112+
Запуск деплояавтоматомавтоматомвручную

Размещение файлов ​

Вот где располагается каждый файл в репозитории:

my-tool/
├── frontend/
│   ├── src/
│   ├── nginx.conf          # Конфигурация nginx для SPA
│   ├── package.json
│   └── ...
├── backend/
│   ├── src/
│   ├── package.json
│   └── ...
├── nomad/
│   ├── my-tool.nomad.hcl    # Nomad job-спецификация
│   ├── vars-dev.hcl         # Переменные для dev-окружения
│   ├── vars-stage.hcl       # Переменные для stage-окружения
│   └── vars-prod.hcl        # Переменные для prod-окружения
├── Dockerfile               # Multi-stage Dockerfile
├── .gitlab-ci.yml           # CI/CD-пайплайн
└── README.md

TIP

Для сервиса только с фронтендом можно упростить структуру — поместите всё в корень репозитория и уберите директорию backend/.

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