🔥 Как ПРАВИЛЬНО настроить Service Discovery в Docker Swarm
Yegor KarpachevВступление для тех, кто не сбежал в K8s
Ладно, допустим ты застрял в Swarm. Может бюджет кончился на Kubernetes, может CTO Петров А.В. считает, что "нам хватит Swarm". Поздравляю — теперь твоя задача сделать так, чтобы это говно хотя бы работало стабильно.
Твоя собака научится приносить тапки в зубах быстрее, чем ты поймёшь все нюансы overlay networks. Но я тебе покажу. ( или постараюсь )
🎯 Чек-лист: что нужно сделать ОБЯЗАТЕЛЬНО
✅ Overlay network с правильными параметрами ✅ Health checks с правильным timing ✅ Update strategy с delay и rollback ✅ Connection pooling с keepAlive: false ✅ Мониторинг DNS resolution time ✅ Graceful shutdown (минимум 30 секунд) ✅ Endpoint mode: dnsrr для stateful сервисов
💀 ЧАСТЬ 1: Правильная overlay network
❌ Как делают джуны (и ты 100% так делал)
docker network create --driver overlay myapp
✅ Как нужно делать (production-ready)
docker network create \ --driver overlay \ --attachable \ --opt encrypted=true \ --opt com.docker.network.driver.mtu=1400 \ --subnet 10.20.0.0/16 \ --gateway 10.20.0.1 \ --label environment=production \ myapp-backend
Разбор параметров (читай внимательно, блять):
--attachable— позволяет подключать standalone контейнеры (для дебага в 3 ночи)--opt encrypted=true— шифрование трафика между нодами (IPSec). Минус 10-15% throughput, но безопасность--opt com.docker.network.driver.mtu=1400— КРИТИЧНО: дефолтные 1500 часто вызывают packet fragmentation в облаках--subnet— явный subnet, чтобы не было конфликтов с другими сетями--label— маркировка (поможет при cleanup)
Проверка:
# Инспектим сеть
docker network inspect myapp-backend
# Смотрим метрики
docker stats --no-stream --format "table {{.Container}}\t{{.NetIO}}"
💀 ЧАСТЬ 2: Service с правильными health checks
❌ Типичное говно
version: "3.8"
services:
api:
image: api:latest
deploy:
replicas: 3
healthcheck:
test: ["CMD", "curl", "localhost/health"]
Проблемы:
- Нет
interval→ дефолт 30s (слишком долго) - Нет
start_period→ помечается unhealthy сразу curl localhost— проверяет ВНУТРИ контейнера, а не снаружи
✅ Production-grade конфиг
version: "3.8"
services:
api:
image: api:latest
networks:
- backend
ports:
- "8080:8080"
environment:
- NODE_ENV=production
- KEEPALIVE_TIMEOUT=65000 # БОЛЬШЕ чем у nginx (60s)
deploy:
replicas: 3
placement:
max_replicas_per_node: 1 # НЕ ВСЕ ЯЙЦА В ОДНУ КОРЗИНУ
constraints:
- node.role == worker
update_config:
parallelism: 1 # ПО ОДНОМУ, НЕ СПЕШИ
delay: 45s # ЖДЁМ ПОЛНОГО ПРОГРЕВА (connection pool, кэши)
failure_action: rollback
monitor: 60s # НАБЛЮДАЕМ МИНУТУ ПОСЛЕ ДЕПЛОЯ
max_failure_ratio: 0.3 # ОТКАТЫВАЕМ, ЕСЛИ 30%+ упало
rollback_config:
parallelism: 2 # ПРИ ОТКАТЕ БЫСТРЕЕ
delay: 0s
failure_action: pause
monitor: 30s
restart_policy:
condition: on-failure
delay: 10s # НЕ СРАЗУ, ПОДОЖДИ
max_attempts: 3
window: 120s # ОКНО ДЛЯ ПОДСЧЁТА ПОПЫТОК
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 10s # ПРОВЕРЯЕМ КАЖДЫЕ 10 СЕКУНД
timeout: 5s # ТАЙМАУТ ОТВЕТА
retries: 3 # ТРИ НЕУДАЧИ = UNHEALTHY
start_period: 60s # 60 СЕКУНД НА ПРОГРЕВ (зависит от твоего приложения)
stop_grace_period: 45s # ВАЖНО: время на graceful shutdown
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
nginx:
image: nginx:alpine
networks:
- backend
ports:
- "80:80"
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
deploy:
replicas: 2
placement:
max_replicas_per_node: 1
update_config:
parallelism: 1
delay: 10s
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 5s
timeout: 3s
retries: 2
start_period: 10s
networks:
backend:
driver: overlay
driver_opts:
encrypted: "true"
com.docker.network.driver.mtu: "1400"
attachable: true
labels:
com.example.environment: "production"
configs:
nginx_config:
file: ./nginx.conf
💀 ЧАСТЬ 3: Nginx с правильным upstream
❌ Конфиг, который уронит прод
upstream api {
server api:8080; # DNS КЭШИРУЕТСЯ НАВСЕГДА
}
✅ Правильный конфиг (с DNS resolver)
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096; # НЕ 1024
use epoll;
multi_accept on;
}
http {
# DNS resolver — КРИТИЧНО
resolver 127.0.0.11 valid=10s ipv6=off; # 127.0.0.11 — встроенный Docker DNS
resolver_timeout 5s;
# Keepalive к upstream
upstream api_backend {
least_conn; # НЕ round_robin
# ДИНАМИЧЕСКОЕ РЕЗОЛВИНГ
server api:8080 max_fails=3 fail_timeout=30s;
keepalive 64; # POOL СОЕДИНЕНИЙ
keepalive_requests 1000; # ЗАПРОСОВ НА СОЕДИНЕНИЕ
keepalive_timeout 60s; # НЕ ЗАКРЫВАТЬ СРАЗУ
}
# Timeouts
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
send_timeout 60s;
client_body_timeout 60s;
client_header_timeout 60s;
# Keepalive от клиента
keepalive_timeout 65s; # БОЛЬШЕ чем у приложения (60s)
keepalive_requests 1000;
server {
listen 80;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
# Используем переменную для динамического DNS
set $backend_api api:8080;
proxy_pass http://$backend_api;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Keepalive к upstream
proxy_http_version 1.1;
proxy_set_header Connection "";
# Retry logic
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
}
}
}
Ключевые моменты:
resolver 127.0.0.11 valid=10s— перерезолвим DNS каждые 10 секундset $backend_api api:8080; proxy_pass http://$backend_api;— динамическое резолвинг (без этого nginx закэширует IP навечно)proxy_next_upstream— retry на другую реплику при ошибке
💀 ЧАСТЬ 4: Код приложения (Node.js пример)
❌ Типичный говнокод
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('OK'));
app.listen(8080);
Проблемы:
- Нет graceful shutdown
- Keepalive по дефолту (кэширует соединения)
- Нет health check endpoint
✅ Production-ready код
const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
// КРИТИЧНО: отключаем keepalive таймаут больше nginx
server.keepAliveTimeout = 65000; // 65 секунд
server.headersTimeout = 66000; // 66 секунд (больше keepAliveTimeout)
// Health check endpoint
let isShuttingDown = false;
app.get('/health', (req, res) => {
if (isShuttingDown) {
res.status(503).send('shutting down');
return;
}
// ПРОВЕРЯЕМ РЕАЛЬНОЕ СОСТОЯНИЕ
// DB connection, Redis, etc.
res.status(200).send('ok');
});
app.get('/', (req, res) => {
if (isShuttingDown) {
res.status(503).send('shutting down');
return;
}
res.send('OK');
});
// HTTP клиент для межсервисных вызовов
const httpAgent = new http.Agent({
keepAlive: false, // ОТКЛЮЧАЕМ KEEPALIVE (да, убиваем перформанс)
maxSockets: 50,
timeout: 10000
});
// Пример запроса к другому сервису
async function callUserService() {
return fetch('http://user-service:3000/api/users', {
agent: httpAgent,
timeout: 5000
});
}
// GRACEFUL SHUTDOWN
const gracefulShutdown = async (signal) => {
console.log(`${signal} received, starting graceful shutdown`);
isShuttingDown = true;
// ЖДЁМ 15 СЕКУНД — nginx должен убрать нас из пула
await new Promise(resolve => setTimeout(resolve, 15000));
server.close(async (err) => {
if (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
// Закрываем соединения с БД, Redis, etc.
// await db.close();
// await redis.quit();
console.log('Server closed gracefully');
process.exit(0);
});
// ЖЁСТКИЙ ТАЙМАУТ — если через 30 секунд не закрылись
setTimeout(() => {
console.error('Forced shutdown after 30s');
process.exit(1);
}, 30000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
💀 ЧАСТЬ 5: Stateful сервисы (БД, Redis)
Проблема: VIP mode vs DNS Round Robin
По умолчанию Swarm использует VIP mode (Virtual IP):
- Service получает один ClusterIP
- iptables балансирует между репликами
- Проблема: не подходит для stateful сервисов (PostgreSQL Primary/Replica)
✅ Решение: endpoint_mode dnsrr
version: "3.8"
services:
postgres-primary:
image: postgres:15
networks:
- db-network
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
- postgres-primary-data:/var/lib/postgresql/data
deploy:
replicas: 1
placement:
constraints:
- node.labels.db-role == primary
endpoint_mode: dnsrr # ВОЗВРАЩАЕТ IP КОНТЕЙНЕРА НАПРЯМУЮ
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
postgres-replica:
image: postgres:15
networks:
- db-network
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres-replica-data:/var/lib/postgresql/data
deploy:
replicas: 2
placement:
constraints:
- node.labels.db-role == replica
endpoint_mode: dnsrr
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
db-network:
driver: overlay
driver_opts:
encrypted: "true"
volumes:
postgres-primary-data:
driver: local
postgres-replica-data:
driver: local
Использование в коде:
// Primary для записи
const primaryPool = new Pool({
host: 'postgres-primary', // DNS вернёт IP primary
port: 5432,
user: 'admin',
password: process.env.DB_PASSWORD,
max: 20
});
// Replica для чтения
const replicaPool = new Pool({
host: 'postgres-replica', // DNS вернёт ОДИН из replica IPs
port: 5432,
user: 'admin',
password: process.env.DB_PASSWORD,
max: 50
});
💀 ЧАСТЬ 6: Мониторинг и алерты
Метрики для сбора
# 1. DNS resolution time
dig @127.0.0.11 api +stats | grep "Query time"
# 2. Service endpoint count
docker service inspect api --format='{{range .Endpoint.VirtualIPs}}{{.NetworkID}} {{.Addr}}{{end}}'
# 3. Task state
docker service ps api --format "{{.Name}}\t{{.CurrentState}}\t{{.Error}}"
# 4. Network stats
docker stats --no-stream --format "table {{.Container}}\t{{.NetIO}}\t{{.MemUsage}}"
Prometheus exporter (обязательно)
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
networks:
- monitoring
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
deploy:
mode: global # НА КАЖДОЙ НОДЕ
ports:
- "8081:8080"
node-exporter:
image: prom/node-exporter:latest
networks:
- monitoring
deploy:
mode: global
ports:
- "9100:9100"
Алерты (Prometheus rules)
groups:
- name: swarm_alerts
interval: 30s
rules:
- alert: ServiceReplicaDown
expr: (sum(up{job="docker"}) by (service_name) / count(up{job="docker"}) by (service_name)) < 0.7
for: 2m
annotations:
summary: "Service {{ $labels.service_name }} has < 70% replicas"
- alert: DNSResolutionSlow
expr: dns_query_duration_seconds > 0.5
for: 1m
annotations:
summary: "DNS resolution > 500ms"
- alert: HighConnectionRefused
expr: rate(connection_refused_total[5m]) > 10
for: 2m
annotations:
summary: "Connection refused rate > 10/s"
## 📊 Финальный чек-лист
✅ Overlay network с MTU 1400 и encryption
✅ Health checks: interval 10s, start_period 60s
✅ Update strategy: parallelism 1, delay 45s
✅ Graceful shutdown: 45s stop_grace_period
✅ Nginx с resolver 127.0.0.11 и динамическим upstream
✅ Keepalive отключен в HTTP клиентах
✅ endpoint_mode: dnsrr для stateful сервисов
✅ Мониторинг: Prometheus + Grafana + Alertmanager
✅ Логирование: json-file с ротацией
✅ Placement constraints для критичных сервисов
🎯 Вывод
Docker Swarm МОЖНО использовать в продакшене, если:
- Ты не ленивый мудак
- Прочитал эту статью 3 раза
- Протестировал failover вручную
- Настроил мониторинг ДО деплоя в прод
Когда сваливать в Kubernetes:
- Больше 50 сервисов
- Нужны CronJobs, StatefulSets с автоскейлингом
- Бюджет позволяет нанять DevOps, который не долбоёб
P.S. Если Петров А.В. говорит "Docker Compose в продакшене норм" — беги из этой компании.