🔥 Как ПРАВИЛЬНО настроить 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;
        }
    }
}

Ключевые моменты:

  1. resolver 127.0.0.11 valid=10sперерезолвим DNS каждые 10 секунд
  2. set $backend_api api:8080; proxy_pass http://$backend_api;динамическое резолвинг (без этого nginx закэширует IP навечно)
  3. proxy_next_upstreamretry на другую реплику при ошибке

💀 ЧАСТЬ 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 МОЖНО использовать в продакшене, если:

  1. Ты не ленивый мудак
  2. Прочитал эту статью 3 раза
  3. Протестировал failover вручную
  4. Настроил мониторинг ДО деплоя в прод

Когда сваливать в Kubernetes:

  • Больше 50 сервисов
  • Нужны CronJobs, StatefulSets с автоскейлингом
  • Бюджет позволяет нанять DevOps, который не долбоёб

P.S. Если Петров А.В. говорит "Docker Compose в продакшене норм" — беги из этой компании.