Skip to main content

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

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

Материал обезличен. Вместо реальных доменов используйте YOU.DOMAIN. Вместо реальных IP — поясняющие подписи. Все команды — понятные, без автоподстановок через shell-переменные.

Эта статья — практический отчёт по реальной миграции «зоопарка» сервисов (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 имени сервиса. Причин несколько, но типовые:

                            Контейнер не подключён к нужной 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
                            

                            Firewall мешает overlay-госсипу и VXLAN. Даже если порты формально «open», на edge мог стоять UFW поверх nftables и ломать порядок правил.

                              Решение: выключить UFW, оставить CrowdSec nftables как единственный «дирижёр». Убедиться, что 7946/TCP+UDP и 4789/UDP разрешены между edge и всеми менеджерами в обе стороны.

                              Сломан локальный DNS в контейнере из-за search-домена. Ранее стоял Netbird, который добавлял к resolv.conf search netbird.selfhosted. В итоге запросы пытались резолвиться как dockerapi_dockerproxy.netbird.selfhosted.

                                Решение: удалить Netbird (пакеты, конфиги, WireGuard-интерфейс), вернуть systemd-resolved к нормальному виду, перезапустить Docker и сервисы.

                                Непоследовательность состояния сети. Иногда помогает «пере-вступить» 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) Короткий чеклист «здоровья» кластера

                                    Менеджеры:


                                    docker node ls
                                    


                                    — все 3 в Ready, один Leader, двое Reachable.

                                    Edge-узел — worker с меткой:


                                    docker node inspect EDGE-HOST --format '{{json .Spec.Labels}}'
                                    


                                    — есть node.labels.edge=true.

                                    Сети:


                                    docker network ls
                                    docker network inspect proxy
                                    docker network inspect dockerapi
                                    


                                    — обе overlay, dockerapi = Internal: true.

                                    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.

                                    Traefik:


                                    docker service ls | grep traefik
                                    docker service logs -f traefik_traefik
                                    


                                    — нет no such host, нет port is missing.

                                    ACME: — первый заход на https://traefik.YOU.DOMAIN → в логах DNS-01, выпуск сертификата.

                                    Прокси-тест:


                                    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-сервиса. Всё остальное — уже творческая работа по маршрутизации, аутентификации и наблюдаемости.