Skip to main content

Docker Swarm с «edge-ingress» на Traefik, Cloudflare и CrowdSec: детальный разбор, грабли и устойчивая схема

Эта статья — практический отчёт по реальной миграции «зоопарка» сервисов (SSO, прокси, CMS, внутренние приложения) на Docker Swarm с выделенным edge-узлом под ingress-контроллер (Traefik v3), фронтом через Cloudflare (orange cloud, без Tunnel), DDoS/банами от CrowdSec (nftables-bouncer) и без прямого доступа к docker.sock (через docker-socket-proxy).

Мы шаг за шагом разберём:

  • почему 3 менеджера и отдельный edge-worker — хорошая базовая топология;
  • как готовить сеть и секреты под ACME DNS-01 в Cloudflare;
  • как правильно запускать Traefik в Swarm (host-порты только на edge, provider=swarm через socket-proxy, без монтирования docker.sock);
  • диагностику каверзных проблем Swarm-DNS (когда сервисы «не видят» друг друга) — и как их чинить;
  • нюанс с port is missing в Traefik;
  • минимальную, но аккуратную операционку: firewall, sudo NOPASSWD, sanity-чеклисты.

1) Архитектура и обоснования

1.1 Менеджеры и edge-worker

  • 3 менеджера в одном регионе/датацентре — золотой стандарт для Raft (нечётное число, кворум, отказоустойчивость).
  • Отдельный edge-узел = обычный worker с меткой, например node.labels.edge=true. На нём живёт только ingress (Traefik) и, возможно, другие «краевые» вещи (но не храните там состояние и не делайте его manager). Это:
    • уменьшает поверхность атаки на Raft;
    • даёт удобную точку фильтрации/логирования/проверок;
    • позволяет жёстко контролировать публикации портов 80/443.

1.2 Traefik вместо NPM

Для Swarm это логично:

  • встроенная интеграция с providers.swarm (сервисы находят друг друга по Overlay-DNS);
  • полноценный ACME (в т.ч. DNS-01) и гибкая маршрутизация;
  • безопасно, если не монтировать docker.sock, а использовать docker-socket-proxy.

1.3 Cloudflare (orange cloud)

  • DNS «за тучкой», без Cloudflare Tunnel;
  • A/CNAME записи на edge-узел, включённое проксирование (оранжевая тучка);
  • ACME DNS-01: Traefik расставляет TXT-записи через Cloudflare API token с минимальными правами.

1.4 CrowdSec + nftables и UFW

  • В современном Ubuntu UFW часто работает поверх nftables. Два «дирижёра» одновременно — плохая идея.
  • Решение: или чистый nftables (и bouncer), или UFW как фронтенд, но без конфликтов. Здесь выбран CrowdSec nftables-bouncer и UFW выключен.

2) Подготовка: пользователи и firewall

2.1 Sudo без пароля для админа

echo 'adminuser ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-adminuser
sudo chmod 440 /etc/sudoers.d/90-adminuser

Где adminuser — ваш пользователь.

2.2 Базовые порты Swarm межузлово (в обе стороны)

  • 2377/TCP — управление Swarm (менеджер<->ноды)
  • 7946/TCP+UDP — gossip (overlay-сеть)
  • 4789/UDP — VXLAN (overlay-сеть)

В nftables добавляйте allow-правила до deny-цепочек (и не забывайте зеркалить на обеих сторонах). CrowdSec-списки не должны перекрывать эти порты.


3) Cloudflare: токен и DNS

3.1 Минимальные права токена

Создайте API Token уровня зоны:

  • Zone → Zone → Read (чтение зоны)
  • Zone → DNS → Edit (правка DNS — для TXT при DNS-01) Ограничьте токен конкретной зоной (YOU.DOMAIN).

3.2 Сохранение токена в секрет Swarm

printf '%s' 'PASTE_CF_DNS_API_TOKEN_HERE' | docker secret create cf_dns_api_token -

3.3 DNS-записи на edge

В Cloudflare:

  • A запись для traefik.YOU.DOMAIN → публичный IP edge-узла, проксирование включено.
  • Аналогично для будущих сервисов (например, whoami.YOU.DOMAIN), указывающих на тот же IP edge (Traefik разрулит внутри).

Примечание: держите список доверенных IP Cloudflare в Traefik в актуальном состоянии — они время от времени меняются. Мы покажем, куда вставлять.


4) Overlay-сети Swarm

Создайте две сети:

  • proxy — внешняя «маршрутизируемая» overlay-сеть для Traefik и бэкендов;
  • dockerapiinternal overlay-сеть только для API (Traefik ↔ docker-socket-proxy).
docker network create -d overlay --attachable proxy
docker network create -d overlay --attachable --internal dockerapi

Проверка:

docker network ls
docker network inspect proxy
docker network inspect dockerapi

В dockerapi будет Internal: true — правильно.


5) Безопасный доступ к Docker API: docker-socket-proxy

Зачем: не монтируем docker.sock в Traefik, чтобы не превращать его в «root в коробке». Вместо этого запускаем прокси на менеджерах (где есть Swarm API), а Traefik ходит к нему по overlay-DNS.

5.1 Стек dockerapi-stack.yaml

version: "3.8"

services:
  dockerproxy:
    image: tecnativa/docker-socket-proxy:latest
    # ЧИТАЕМ socket ТОЛЬКО в режиме ro
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

    # Подключаем К ТОЛЬКО внутренней сети dockerapi
    networks:
      dockerapi:
        aliases:
          - dockerproxy   # имя, под которым Traefik будет стучаться

    # Включаем только нужные эндпоинты Docker API
    environment:
      SERVICES: "1"
      TASKS: "1"
      NETWORKS: "1"
      NODES: "1"
      SWARM: "1"
      INFO: "1"
      LOG_LEVEL: "info"

    deploy:
      mode: global                # на каждом менеджере
      placement:
        constraints:
          - node.role == manager  # только менеджеры

networks:
  dockerapi:
    external: true

Деплой и проверка:

docker stack deploy -c dockerapi-stack.yaml dockerapi
docker service ls
docker service ps dockerapi_dockerproxy

# Самотест из overlay-сети:
docker run --rm --network dockerapi curlimages/curl:8.8.0 -s http://dockerapi_dockerproxy:2375/_ping
# Должно вернуть: OK

Пояснение: VIP по умолчанию устраивает. Имя сервиса dockerapi_dockerproxy должно резолвиться в overlay-DNS для любых контейнеров, подключённых к сети dockerapi. Никаких «tasks.» и DNSRR не требуется, если всё в порядке с overlay.


6) Traefik v3 на edge-worker (ingress)

Только на edge-узле пробрасываем 80/443 в режиме host. Traefik подключён к двум сетям: proxy (наружу к бэкендам) и dockerapi (внутрь к API-прокси).

6.1 Секреты

  • Cloudflare API token (уже создан): cf_dns_api_token
  • BasicAuth для панели (рекомендуется через секрет):
    printf '%s\n' 'admin:$apr1$PASTE_HTPASSWD_HASH' | docker secret create traefik_users -

Хеш можно сделать заранее утилитой htpasswd. Пример ввода в секрет — уже готовый хеш.

6.2 Файловая структура

/app/traefik/
├── traefik-stack.yaml
└── dynamic/
    └── (необязательно, если всё в labels)

6.3 Стек traefik-stack.yaml

version: "3.8"

services:
  traefik:
    image: traefik:v3.4

    # Публикуем порты ТОЛЬКО на edge-узле (host-mode)
    ports:
      - target: 80
        published: 80
        protocol: tcp
        mode: host
      - target: 443
        published: 443
        protocol: tcp
        mode: host

    # НИКОГДА не монтируем docker.sock
    volumes:
      - acme:/letsencrypt                 # хранилище сертификатов
      - /app/traefik/dynamic:/dynamic:ro  # опционально: динамические правила

    # Считываем Cloudflare токен из секрета
    environment:
      CF_DNS_API_TOKEN_FILE: /run/secrets/cf_dns_api_token

    secrets:
      - cf_dns_api_token
      - traefik_users

    command:
      # === Entrypoints ===
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"
      - "--entrypoints.websecure.address=:443"

      # Доверяем X-Forwarded-* ТОЛЬКО Cloudflare, чтобы видеть реальный клиентский IP
      # Список сетей Cloudflare: актуализируйте по их документации
      - "--entrypoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22"
      - "--entrypoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22"

      # === ACME (Let's Encrypt) DNS-01 через Cloudflare ===
      - "--certificatesresolvers.le.acme.email=admin@YOU.DOMAIN"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.dnschallenge=true"
      - "--certificatesresolvers.le.acme.dnschallenge.provider=cloudflare"

      # === Провайдер Swarm (через docker-socket-proxy) ===
      - "--providers.swarm=true"
      - "--providers.swarm.endpoint=http://dockerapi_dockerproxy:2375"
      - "--providers.swarm.watch=true"
      - "--providers.swarm.exposedbydefault=false"
      - "--providers.swarm.network=proxy"   # сеть, где Traefik будет искать бэкенды

      # === Дашборд и логи ===
      - "--api.dashboard=true"
      - "--api.insecure=false"
      - "--accesslog=true"
      - "--log.level=INFO"

    networks:
      - proxy
      - dockerapi

    deploy:
      mode: replicated
      replicas: 1
      placement:
        constraints:
          - node.labels.edge == true   # привязываем к edge-узлу
      labels:
        # Включаем Traefik для самого Traefik (чтобы открыть дашборд)
        - "traefik.enable=true"

        # Роутер для панели
        - "traefik.http.routers.traefik.rule=Host(`traefik.YOU.DOMAIN`)"
        - "traefik.http.routers.traefik.entrypoints=websecure"
        - "traefik.http.routers.traefik.tls.certresolver=le"
        - "traefik.http.routers.traefik.service=api@internal"

        # BasicAuth через секрет (файл с htpasswd-хешем)
        - "traefik.http.middlewares.traefik-auth.basicauth.usersFile=/run/secrets/traefik_users"
        - "traefik.http.routers.traefik.middlewares=traefik-auth@swarm"

        # Критически важный нюанс!
        # Когда вы "включили" Traefik как сервис (traefik.enable=true),
        # Traefik ожидает "порт" у backend-сервиса. Даже если роутер указывает api@internal,
        # объявите порт явно, иначе получите "port is missing".
        - "traefik.http.services.traefik.loadbalancer.server.port=80"

networks:
  proxy:
    external: true
  dockerapi:
    external: true

volumes:
  acme:

secrets:
  cf_dns_api_token:
    external: true
  traefik_users:
    external: true

Деплой и контроль:

docker stack deploy -c /app/traefik/traefik-stack.yaml traefik
docker service ps traefik_traefik
docker service logs -f traefik_traefik

Ожидаемо:

  • нет ошибок no such host (Traefik видит dockerapi_dockerproxy);
  • нет ошибок port is missing (мы добавили явный loadbalancer.server.port=80);
  • сертификаты по DNS-01 выпускаются при первом запросе на https://traefik.YOU.DOMAIN.

7) Тестовый бэкенд (whoami) и правила

Чтобы быстро проверить роутинг:

version: "3.8"
services:
  whoami:
    image: traefik/whoami:latest
    networks:
      - proxy
    deploy:
      labels:
        - "traefik.enable=true"
        - "traefik.http.routers.whoami.rule=Host(`whoami.YOU.DOMAIN`)"
        - "traefik.http.routers.whoami.entrypoints=websecure"
        - "traefik.http.routers.whoami.tls.certresolver=le"
        # Важно: явно указать порт backend-сервиса (он слушает 80)
        - "traefik.http.services.whoami.loadbalancer.server.port=80"
networks:
  proxy:
    external: true

Деплой:

docker stack deploy -c whoami.yaml whoami

Проверьте https://whoami.YOU.DOMAIN — должен отвечать JSON-ом.


8) Разбор граблей и как мы их лечили

8.1 «no such host» у Traefik (providers.swarm)

Симптом: в логах Traefik бесконечно: error during connect: Get "http://dockerapi_dockerproxy:2375/...": dial tcp: lookup dockerapi_dockerproxy on 127.0.0.11:53: no such host

Суть: контейнер Traefik не видит Overlay-DNS имени сервиса. Причин несколько, но типовые:

  1. Контейнер не подключён к нужной overlay-сети. Проверка:
    docker ps --filter label=com.docker.swarm.service.name=traefik_traefik
    docker inspect <CONTAINER_ID> --format '{{json .NetworkSettings.Networks}}'

    Должны быть оба: proxy и dockerapi. Лечение:
    docker service update --network-add dockerapi traefik_traefik
    docker service update --force traefik_traefik
  2. Firewall мешает overlay-госсипу и VXLAN. Даже если порты формально «open», на edge мог стоять UFW поверх nftables и ломать порядок правил.
    • Решение: выключить UFW, оставить CrowdSec nftables как единственный «дирижёр».
    • Убедиться, что 7946/TCP+UDP и 4789/UDP разрешены между edge и всеми менеджерами в обе стороны.
  3. Сломан локальный DNS в контейнере из-за search-домена. Ранее стоял Netbird, который добавлял к resolv.conf search netbird.selfhosted. В итоге запросы пытались резолвиться как dockerapi_dockerproxy.netbird.selfhosted.
    • Решение: удалить Netbird (пакеты, конфиги, WireGuard-интерфейс), вернуть systemd-resolved к нормальному виду, перезапустить Docker и сервисы.
  4. Непоследовательность состояния сети. Иногда помогает «пере-вступить» edge-узел в Swarm (только для worker):
    docker swarm leave
    docker swarm join --token PASTE_WORKER_TOKEN_HERE MANAGER_IP:2377

    Но в нашем случае достаточно было выключить UFW.

Как проверяли: — из любого контейнера в сети dockerapi:

docker run --rm --network dockerapi alpine:3.20 sh -lc 'getent hosts dockerapi_dockerproxy; getent hosts tasks.dockerapi_dockerproxy'

— из самого контейнера Traefik:

docker ps --filter label=com.docker.swarm.service.name=traefik_traefik
docker exec -it <CONTAINER_ID> sh -lc 'getent hosts dockerapi_dockerproxy; wget -qO- http://dockerapi_dockerproxy:2375/_ping || true'

Ожидаем IP и OK.

8.2 «port is missing» у Traefik

Симптом: регулярная ошибка: providerName=swarm error: service "traefik-traefik" error: port is missing

Суть: мы «включили» сам Traefik как сервис через traefik.enable=true, указали роутер на api@internal, но не объявили порт backend-сервиса. Traefik требует это даже для self-service (иначе он не может смоделировать LB).

Лечение: добавить лейбл:

- "traefik.http.services.traefik.loadbalancer.server.port=80"

После обновления ошибка исчезает.


9) CrowdSec, nftables и Docker

  • Один «главный» firewall. Если используете crowdsec-firewall-bouncer-nftables, UFW должен быть выключен, либо аккуратно интегрирован в nftables с приоритетами.
  • Порты Swarm должны быть «allow» до deny-цепочек.
  • Docker создаёт свои таблицы/цепочки; не ломайте их.

Проверки:

nft list ruleset | less
journalctl -u crowdsec-firewall-bouncer -n 200 --no-pager

10) Короткий чеклист «здоровья» кластера

  1. Менеджеры:
    docker node ls

    — все 3 в Ready, один Leader, двое Reachable.
  2. Edge-узел — worker с меткой:
    docker node inspect EDGE-HOST --format '{{json .Spec.Labels}}'

    — есть node.labels.edge=true.
  3. Сети:
    docker network ls
    docker network inspect proxy
    docker network inspect dockerapi

    — обе overlay, dockerapi = Internal: true.
  4. docker-socket-proxy:
    docker service ls | grep dockerproxy
    docker run --rm --network dockerapi curlimages/curl:8.8.0 -s http://dockerapi_dockerproxy:2375/_ping

    OK.
  5. Traefik:
    docker service ls | grep traefik
    docker service logs -f traefik_traefik

    — нет no such host, нет port is missing.
  6. ACME: — первый заход на https://traefik.YOU.DOMAIN → в логах DNS-01, выпуск сертификата.
  7. Прокси-тест:
    curl -I https://whoami.YOU.DOMAIN

    HTTP/2 200 и корректный ответ.

11) Почему отказались от Netbird (и когда mesh-VPN всё же уместен)

Внутри одного региона/провайдера:

  • Overlay-сети Swarm + открытые межузловые порты обеспечивают связность.
  • Mesh-VPN добавляет лишний слой (DNS-search, MTU, маршрутизацию), может ломать Swarm-DNS, усложняет разбор сетей.

Когда mesh-VPN пригодится:

  • кросс-региональная/мультипровайдерная топология с NAT/CGNAT;
  • потребность в сквозной L3 связности без пробросов портов;
  • специфические SDS/репликации, где оверхед на VXLAN+WAN нежелателен.

Даже в этих случаях часто проще и стабильнее:

  • прямой WireGuard site-to-site под конкретные подсети;
  • для хранилищ — S3-совместимые решения вместо блочных/файловых по WAN;
  • если файловая репликация — то с чёткими требованиями к MTU и расписанием.

12) Безопасность и операционные мелочи

  • Не монтируйте docker.sock в Traefik. Только через docker-socket-proxy с минимальным набором эндпоинтов.
  • Секреты храните в Swarm Secrets, а не в environment. Так они не светятся в inspect.
  • Cloudflare trustedIPs держите актуальным списком.
  • Экспорт метрик/логов Traefik — включайте accesslog, позже добавьте format=json и отправляйте в централизованное хранилище.
  • Бэкапы acme.json (том acme) — делайте, права 600 внутри контейнера Traefik выставляются автоматически.

13) Частые команды (без переменных)

Удалить стек:

docker stack rm traefik
docker stack rm dockerapi

Создать сети:

docker network create -d overlay --attachable proxy
docker network create -d overlay --attachable --internal dockerapi

Секреты:

printf '%s' 'PASTE_CF_DNS_API_TOKEN_HERE' | docker secret create cf_dns_api_token -
printf '%s\n' 'admin:$apr1$PASTE_HTPASSWD_HASH' | docker secret create traefik_users -

Деплой:

docker stack deploy -c /app/dockerapi/dockerapi-stack.yaml dockerapi
docker stack deploy -c /app/traefik/traefik-stack.yaml traefik

Проверки:

docker service ls
docker service ps dockerapi_dockerproxy
docker run --rm --network dockerapi curlimages/curl:8.8.0 -s http://dockerapi_dockerproxy:2375/_ping
docker service logs -f traefik_traefik

Внутрь контейнера Traefik (найдите ID командой ниже):

docker ps --filter label=com.docker.swarm.service.name=traefik_traefik
docker exec -it <CONTAINER_ID> sh

Overlay-DNS из тестового контейнера:

docker run --rm --network dockerapi alpine:3.20 sh -lc 'getent hosts dockerapi_dockerproxy; getent hosts tasks.dockerapi_dockerproxy'

Стоп UFW:

sudo ufw disable

Финал

В итоге мы получили чистую и стандартную схему:

  • 3 manager узла с Raft-кворумом;
  • 1 edge worker с меткой edge=true, где слушают только 80/443;
  • Traefik v3 как ingress-контроллер в Swarm, без монтирования docker.sock;
  • docker-socket-proxy на менеджерах, доступный Traefik в internal overlay;
  • Cloudflare с orange cloud и ACME DNS-01 через минимальный токен;
  • CrowdSec nftables как единственный firewall-«дирижёр», UFW выключен;
  • Чёткие проверки Overlay-DNS и устранение причин ошибок no such host и port is missing.

С такой базой вы спокойно переносите существующие сервисы (SSO, CMS, приложения) на Swarm: каждому даёте labels, подключаете к proxy, прописываете router rules и (важно!) явный порт backend-сервиса. Всё остальное — уже творческая работа по маршрутизации, аутентификации и наблюдаемости.