Файлы деплоя
Эта страница описывает все файлы, необходимые для деплоя нового сервиса. Каждый раздел содержит готовые примеры, которые можно скопировать и адаптировать.
Обзор
flowchart LR
A[git push] --> B[GitLab CI]
B --> C[Docker Build]
C --> D[Registry]
D --> E[Nomad Deploy]
E --> F[Traefik Routes]Пайплайн деплоя работает следующим образом:
- Вы пушите код в GitLab.
- GitLab CI собирает Docker-образ и пушит его в container registry.
- GitLab CI запускает деплой через Nomad, который подтягивает новый образ.
- Nomad регистрирует сервис в Consul, а Traefik подхватывает теги маршрутизации.
- Traefik терминирует TLS и направляет трафик на ваш сервис.
1. Dockerfile
Dockerfile использует multi-stage сборку для минимизации размера итогового образа.
# ---- 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;"]# ---- 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"]# ---- 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:
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-эндпоинтом.
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 напрямую.
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: productioninclude:
- 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.
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
}
}
}
}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.
env = "dev"
domain = "dev-my-tool.amzhub.ai"env = "stage"
domain = "stage-my-tool.amzhub.ai"env = "prod"
domain = "my-tool.amzhub.ai"Держите vars-файлы короткими: в них попадает только то, что действительно различается между средами. Количество инстансов и лимиты ресурсов удобнее считать прямо в джобе через var.env == "dev" ? ... : ... — тогда добавление среды не требует правки трёх файлов.
image_tag в vars-файле не нужен
Тег образа приходит из пайплайна аргументом -var image_tag=... и совпадает с именем окружения. Если продублировать его в vars-файле, значение из файла переопределит переданное и деплой поедет не тем образом.
Основные различия между окружениями:
| Параметр | Dev | Stage | Prod |
|---|---|---|---|
| Домен | dev-имя.amzhub.ai | stage-имя.amzhub.ai | имя.amzhub.ai |
| Namespace | dev | stage | prod |
| Нода | 192.168.100.20 | 192.168.100.30 | 192.168.100.40 |
| Инстансы | 1 | 1 | 2+ |
| Запуск деплоя | автоматом | автоматом | вручную |
Размещение файлов
Вот где располагается каждый файл в репозитории:
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.mdTIP
Для сервиса только с фронтендом можно упростить структуру — поместите всё в корень репозитория и уберите директорию backend/.