У растущего Docker-сервера есть примета беды: список проброшенных портов в firewall становится длиннее списка сервисов. Каждый контейнер хочет наружу, каждому нужен сертификат, и к третьему проекту конфигурация веб-сервера превращается в картотеку, к которой боятся подходить. Traefik v3 переворачивает схему: в интернет смотрит один контейнер с портами 80 и 443, маршруты строятся по меткам на самих сервисах, а сертификаты Let's Encrypt выпускаются и продлеваются без участия человека. Ниже рабочая связка для свежего сервера: файл traefik.yml, метки приложений, панель с паролем, проверка всей цепочки и разбор того, что изменилось в третьей версии для тех, кто переходит со второй.

Зачем отдельный контейнер на входе в сервер

Идея reverse proxy умещается в одну фразу: наружу смотрит кто-то один, остальные сидят во внутренней сети. Запрос приходит на порты 80 и 443, прокси читает имя хоста и решает, какому контейнеру отдать трафик. Сертификаты тоже живут в одном месте, а не размазаны по проектам, и вопрос о сроке истечения очередного домена перестаёт быть лотереей.

Классический путь с ручным конфигом веб-сервера требует перезагрузки на каждый новый сервис и правки центрального файла, где один неверный символ останавливает половину сервера. Traefik работает иначе: он подписан на события Docker и перестраивает таблицу маршрутов на лету. Появился контейнер с метками, маршрут готов, удалили контейнер, маршрут исчез. Конфигурация каждого сервиса живёт в его же compose-файле, рядом с образом и переменными окружения, и переезжает вместе с ним на другой сервер без археологии.

Выигрыш не только в порядке, но и в ресурсах: шифрование терминирует один контейнер, приложениям не нужно тратить воркеры на TLS, и их образы остаются без встроенных веб-серверов. Автоматика доходит до смешного: домен добавляется в DNS, метка дописывается в compose-файл, и через минуту сайт отвечает по HTTPS без единого ручного шага с сертификатами.

Отдельный плюс для команды: у прокси есть панель с живой таблицей маршрутов, и спор о причине молчащего домена решается одним взглядом вместо перекрёстного чтения трёх конфигураций. Свежая линия третьей мажорной версии на момент написания дошла до выпуска 3.7.14, и всё из примеров ниже между минорными обновлениями стабильно.

Статическая конфигурация traefik.yml с двумя точками входа

Настройка Traefik делится на два слоя, и граница между ними стоит понимания. Статический слой читается один раз при старте: точки входа, провайдеры, резолверы сертификатов. Его удобно держать файлом traefik.yml рядом с compose-файлом. Динамический слой, то есть маршруты и middleware, живёт в метках контейнеров и меняется на лету без перезапуска чего-либо.

services:
  traefik:
    image: traefik:v3.7
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

volumes:
  letsencrypt:

Сам файл начинается с точек входа и глобального правила перенаправления.

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"
providers:
  docker:
    exposedByDefault: false
api:
  dashboard: true
certificatesResolvers:
  letsencrypt:
    acme:
      email: Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

Чтение сверху вниз отвечает на все вопросы. Точка web слушает восьмидесятый порт и сама отправляет всё на шифрованную websecure, отдельного middleware для редиректа описывать не придётся. Провайдер docker с флагом exposedByDefault в значении false берёт в работу только контейнеры с явной меткой включения: случайный запуск сервиса без метки не подарит его всему интернету. Секция api включает панель, а резолвер letsencrypt описывает, у кого прокси просит сертификаты и куда складывает ключи. Включение через api.dashboard принципиально отличается от старого ключа api.insecure: тот поднимал панель на отдельном порту без пароля, и в рабочей конфигурации ему места нет. Сокет Docker подключён в режиме только чтения: событий для наблюдения хватает, а управлять контейнерами через этот канал нельзя.

Метки контейнеров и вход только для обозначенных

Настоящее приложение подключается к прокси тремя строками: включение, правило и порт. Представительный набор для веб-сервиса выглядит так.

  app:
    image: myapp:1.0
    labels:
      - traefik.enable=true
      - traefik.http.routers.app.rule=Host(`app.example.com`) && PathPrefix(`/`)
      - traefik.http.routers.app.entrypoints=websecure
      - traefik.http.routers.app.tls.certresolver=letsencrypt
      - traefik.http.services.app.loadbalancer.server.port=8080

Правило читается как предложение: запрос с именем app.example.com и любым путём уходит на порт 8080 этого контейнера. Порт указывается явно, потому что прокси не угадывает, что слушает приложение внутри: у типичного веб-фреймворка это 3000 или 8080, а не классические восемьдесят. Новый домен означает новый набор строк в compose-файле сервиса, и сам прокси при этом не перезапускается ни разу.

Тот же приём закрывает и экзотику. Отдельный путь для API собирается вторым условием через двойной амперсанд, домены складываются отдельными маршрутами, и несколько приложений живут на своих поддоменах, не зная друг о друге.

Когда проекты живут в отдельных compose-файлах, их связывает общая внешняя сеть: она создаётся один раз, объявляется внешней в каждом файле и подключается и прокси, и приложению. Без общей сети прокси метку увидит, но доехать до контейнера не сможет, и запрос закончится ошибкой шлюза.

networks:
  default:
    name: web
    external: true

Именно за это Traefik любят на растущих серверах: добавление проекта не создаёт работы администратору прокси, а панель тут же показывает новый маршрут в общей таблице.

Автоматический выпуск сертификатов через проверку ACME

Резолвер из статического файла работает по протоколу ACME: прокси доказывает центру сертификации владение доменом и получает ключи. Проверка идёт по HTTP: центр обращается на восьмидесятый порт домена, Traefik отвечает специальным файлом, и после совпадения сертификат выпускается. Из этого следует единственное жёсткое условие всей автоматики: A-запись домена обязана смотреть на адрес сервера до первого запуска, иначе проверка ходит по кругу, а журнал заполняется отказами.

Хранилище ключей задано строкой storage с путём к acme.json, и том letsencrypt в compose-файле обязателен. Причина арифметическая: у Let's Encrypt жёсткие недельные лимиты выпуска на домен, и прокси без постоянного хранилища будет просить новый сертификат при каждом старте, упираясь в лимит в самый неудачный день. Файл acme.json требует прав 600: с более широкими правами Traefik отказывается работать и честно пишет об этом в журнал. Если старт закончился ошибкой прав, хранилище подправляется одноразовым контейнером по имени тома.

Срок жизни сертификата девяносто дней, продление стартует само задолго до истечения, и в календаре администратора для этого нет ни одной записи. Для отладки у резолвера есть режим песочницы: флаг caServer переключает центр на тестовый, тот выдаёт сертификаты без ограничений, и эксперименты с маршрутами не расходуют лимиты боевого выпуска. Как только конфигурация собрана, флаг убирается.

Маска со звёздочкой на все поддомены выпускается другим способом проверки, через управление записями DNS: для одиночного сервера это лишняя сложность, а для десятка поддоменов экономит и лимиты, и строки меток.

Панель управления за базовой авторизацией

Панель включена в статическом файле, и наружу она выходит тем же способом, что и любое приложение: метками на самом контейнере traefik.

    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls.certresolver=letsencrypt
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=auth
      - traefik.http.middlewares.auth.basicauth.users=admin:$$2y$$05$$...

Служебное имя api@internal указывает маршруту отдавать внутренности прокси, а middleware auth прикрывает их базовой авторизацией. Хеш пароля готовится на хосте одной командой.

htpasswd -nbB admin

Утилита печатает строку с bcrypt-хешем, и хвост строки вставляется в метку целиком. Ловушка одна и хорошо известная: compose читает знак доллара как начало переменной, поэтому каждый доллар в хеше удваивается. Из $2y$ в метке получается $$2y$$, иначе авторизация молча не сойдётся, и разбираться с отказом придётся по журналу.

Панель показывает живую карту сервера: маршруты, сервисы, middleware и цепочки сертификатов. Для вопроса о молчащем домене это быстрее любого терминала, но открывать её на общий доступ без авторизации не стоит даже на домашней машине: таблица маршрутов рассказывает о сервере слишком много.

Перенаправление на HTTPS и проверка всей цепочки

Перенаправление уже описано в статическом файле: любая просьба по восьмидесятому порту уезжает на шифрованный, и дублировать это правило для каждого сервиса не нужно. Проверка собранной связки занимает две минуты и четыре команды.

dig +short app.example.com
docker compose logs --tail 30 traefik
curl -I http://app.example.com
curl -I https://app.example.com

Первая команда обязана вернуть адрес сервера, вторая показывает, как резолвер получил сертификат с первого раза. Третья отвечает кодом 308: это и есть перенаправление на шифрованный порт, здоровая реакция. Четвёртая должна принести заголовки приложения и код 200, и на этом сервер считается собранным.

Если четвёртая отвечает кодом 404, маршрут не собрался, и причина почти всегда в одном из двух: контейнер и прокси не в одной сети или потерялась метка включения. Код 502 означает, что маршрут собрался, но приложение молчит: порт в метке не совпадает с тем, что слушает контейнер, и журналы самого приложения подскажут, живо ли оно вообще. Ошибка сертификата сразу после смены DNS намекает на спешку: запись ещё не разошлась по серверам имён, и проверка центра проваливается не из-за конфигурации. Сбой часов даёт третий класс загадок, и пара минут настройки времени лечит его надёжнее любых правок в файлах.

Новое в третьей версии для тех, кто идёт со второй

Третья мажорная версия вышла аккуратнее, чем принято ожидать от мажорных выпусков: ломающих изменений минимум, синтаксис правил второй версии поддерживается, и переход делается спокойно, без ночи переписывания меток. Материал перехода умещается в короткий список:

  1. Старый синтаксис правил сохранён как устаревший, и переключатель defaultRuleSyntax в установке или правило ruleSyntax на конкретном маршруте держит второй формат до конца миграции;
  2. Новый синтаксис по умолчанию ждёт одиночное значение в каждом матчере, а несколько условий объединяются логическими операторами вместо перечисления через запятую;
  3. Матчер заголовков переименован из Headers в Header, динамические сегменты пути переехали в PathRegexp, матчер HostHeader убран;
  4. Платформа Pilot и провайдеры Rancher v1 с Marathon убраны из состава как потерявшие поддержку.

Для типового Docker-стенда со строкой Host плюс PathPrefix новшества ничего не ломают: одиночное имя домена и оператор двойного амперсанда валидны в обеих версиях. Отдельная строка биографии третьей версии живёт в мире Kubernetes: провайдер Gateway API встроен в состав, и кластеры получают стандартный интерфейс входа вместо собственных аннотаций.

По сути, вечер с traefik.yml и парой меток закрывает входную точку сервера на годы: новые проекты подключаются без правок прокси, сертификаты живут сами, а панель отвечает на вопросы раньше терминала. Всё описанное переживает обновления без переделок, и третья ветка с её аккуратной совместимостью тому подтверждение. А само обновление до свежего минорного выпуска сводится к смене тега образа и команде docker compose up -d в каталоге прокси.