Traefik → APISIX на одном узле: что пошло не так при миграции ingress

Published: 2026-07-15

Traefik на моём k0s стоял 224 дня без единой жалобы — держал 80/443 через hostNetwork, отдавал JSON access-логи, ни разу не падал. Причина переезда на APISIX была не «Traefik плохой», а «хочу более молодой и лёгкий стек, раз уж всё равно навожу порядок в мониторинге и логах». На бумаге замена ingress-контроллера звучит как rolling update. На единственной ноде bare-metal кластера это оказалось прямым cutover с даунтаймом и десятком находок, часть из которых могла молча сломать продакшн, если бы не проверил каждую вживую.


Проблема

Кластер — один физический узел, k0s, без внешнего LoadBalancer. Traefik держал 80/443 через hostNetwork: true — единственный работающий вариант, когда Service типа LoadBalancer вечно висит в <pending>. 19 доменов (личный сайт, VPN-дашборд, Docker registry, Grafana, VictoriaMetrics, webmail) шли через Traefik IngressRoute — собственный CRD, а не стандартный networking.k8s.io/v1 Ingress.

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

text0/1 nodes are available: 1 node(s) didn't have free ports for the requested pod ports

Два пода с hostNetwork: true, оба просят 80 и 443, узел один. Blue-green здесь физически невозможен без второй ноды. Значит — Traefik под нож первым, APISIX следом, и всё, что сломается в процессе, чинится по живому.

Как это работает

apisix-ingress-controller в связке с чартом apisix/apisix — это два компонента в одном Helm-релизе: сам gateway (data plane на OpenResty) и контроллер, который следит за Ingress-объектами и пушит конфигурацию в APISIX через Admin API. По умолчанию APISIX тащит за собой etcd — не для хранения приложений, а как промежуточное хранилище маршрутов между контроллером и gateway.

bashhelm repo add apisix https://apache.github.io/apisix-helm-chart
helm repo update

Дальше — install с полным набором ключей, которые реально понадобились (объясню каждый ниже):

bashhelm upgrade --install apisix apisix/apisix \
  --namespace apisix \
  -f apisix-logs-values.yaml \
  --set apisix.ssl.enabled=true \
  --set apisix.ssl.containerPort=443 \
  --set ingress-controller.enabled=true \
  --set ingress-controller.gatewayProxy.createDefault=true \
  --set ingress-controller.gatewayProxy.provider.controlPlane.service.name=apisix-admin \
  --set ingress-controller.gatewayProxy.provider.controlPlane.auth.adminKey.value=edd1c9f034335f136f87ad84b625c8f1 \
  --set hostNetwork=true \
  --set service.http.containerPort=80 \
  --set service.type=ClusterIP \
  --set 'securityContext.capabilities.add[0]=NET_BIND_SERVICE' \
  --set securityContext.runAsUser=0 \
  --set updateStrategy.type=Recreate \
  --set etcd.replicaCount=1 \
  --set etcd.persistence.enabled=false \
  --wait --timeout 180s

Каждая строчка тут — цена одной сломанной попытки.

Установка шаг за шагом

Шаг 1: HTTPS по умолчанию выключен

Первый install прошёл чисто, поды поднялись, но HTTPS не отвечал вообще. Причина — apisix.ssl.enabled в values чарта по умолчанию false. Без него gateway просто не открывает listener на 443, независимо от того, что написано в Service или в hostNetwork.

bashhelm show values apisix/apisix | grep -A3 "^  ssl:"
yamlssl:
  enabled: false          # вот он
  containerPort: 9443

Я потратил минут двадцать, разглядывая порты и Service'ы, прежде чем догадался проверить сам флаг фичи, а не её обвязку.

Шаг 2: правильный ключ для TLS-порта

По аналогии с service.http.containerPort логично ожидать service.tls.containerPort. Такого ключа в чарте нет — я проверил через helm show values, а не угадал по памяти:

yamlservice:
  tls:
    servicePort: 443
    # containerPort здесь нет вообще

Порт для HTTPS-листенера настраивается в другом месте — apisix.ssl.containerPort, в отдельной секции про SSL, не про Service. Один и тот же смысл, два разных места в дереве values — типичная ловушка для Helm-чартов, где структура эволюционировала быстрее документации.

Шаг 3: запятые в --set ломают JSON-строку

Access-логи в JSON — то, ради чего частично затевалась вся замена. Задал формат:

bash--set apisix.nginx.logs.accessLogFormat='{"time":"$time_iso8601","host":"$http_host","status":$status}'

Установка прошла без ошибок. Логи внутри пода выглядели так:

text["time":"2026-07-15T07:26:53+00:00" "host":"antonnovikov.com" "status":200]

Квадратные скобки вместо фигурных, пробелы вместо запятых — валидный JSON превратился в мусор без единой ошибки на стороне Helm. Причина: --set у Helm разбирает значение по незаэкранированным запятым, считая их разделителями между разными key=value. Строка с JSON-форматом вся состоит из запятых — Helm тихо превратил одну строку в YAML-список из отдельных пар "ключ":"значение", а шаблон чарта отрендерил список как попало.

Правильный способ — вообще не трогать --set для значений с запятыми, а передавать через файл:

yaml# apisix-logs-values.yaml
apisix:
  nginx:
    logs:
      accessLogFormat: '{"time":"$time_iso8601","remote_addr":"$remote_addr","host":"$http_host","request_uri":"$request_uri","status":$status}'
      accessLogFormatEscape: json
bashhelm upgrade apisix apisix/apisix -n apisix -f apisix-logs-values.yaml --reuse-values

Шаг 4: под не биндится на 80

text2026/07/15 07:26:53 [emerg] 1#1: bind() to 0.0.0.0:80 failed (13: Permission denied)

Порты ниже 1024 требуют либо root, либо capability CAP_NET_BIND_SERVICE. У Traefik это уже было решено в его собственном chart values — я просто забыл перенести ту же настройку для APISIX:

yamlsecurityContext:
  capabilities:
    add: [NET_BIND_SERVICE]
  runAsNonRoot: false
  runAsUser: 0

Шаг 5: etcd просит PersistentVolumeClaim, которого нет

textFailedBinding: no persistent volumes available for this claim and no storage class is set

На bare-metal k0s без динамического provisioning штатный etcd из чарта пытается заказать PVC и виснет в Pending навсегда. etcd.persistence.enabled=false — единственные данные, которые в нём живут, это runtime-конфигурация маршрутов, а не что-то, что нужно переживать рестарт пода: контроллер пересоберёт её из Ingress-объектов за секунды.

Шаг 6: GatewayProxy не создаётся сам

ingress-controller.enabled=true включает контроллер, но без GatewayProxy он не знает, куда слать конфигурацию:

textINFO  provider.client  syncing all resources
INFO  provider.client  no GatewayProxy configs provided

GatewayProxy — отдельный CRD, который связывает IngressClass с Admin API конкретного gateway (адрес, порт, ключ авторизации). Флаг gatewayProxy.createDefault=true создаёт его автоматически, но всё равно нужно явно указать имя Service с Admin API и ключ:

bash--set ingress-controller.gatewayProxy.createDefault=true \
--set ingress-controller.gatewayProxy.provider.controlPlane.service.name=apisix-admin \
--set ingress-controller.gatewayProxy.provider.controlPlane.auth.adminKey.value=edd1c9f034335f136f87ad84b625c8f1

Ключ edd1c9f034335f136f87ad84b625c8f1 — дефолтный demo-key самого APISIX; он совпадает у gateway и у контроллера «из коробки», но лучше выставлять его явно, а не полагаться на совпадение дефолтов.

Шаг 7: hostNetwork + rolling update — тупик на одной ноде

Любой следующий helm upgrade подвисал на минуты:

textWaiting for daemon set... 0 out of 1 new pods have been updated

Дефолтная стратегия деплоя — RollingUpdate: сначала поднять новый под, потом убить старый. С hostNetwork: true и одной нодой это невозможно — новый под просит те же 80/443, которые всё ещё держит старый. Проблема из Шага 0, только теперь она бьёт по каждому апдейту, а не только по установке рядом с Traefik.

bash--set updateStrategy.type=Recreate

Recreate сначала убивает старый под, потом создаёт новый — секунда простоя на каждом апдейте, зато без вечного зависания.

Шаг 8: 404 на CSS и JS после переключения маршрутов

Сайт поднялся, домены отвечали 200, но браузер сыпал:

textRefused to apply style from '.../assets/css/styles.css' because its MIME type
('text/html') is not a supported stylesheet MIME type

Причина — в моей же логике миграции. У Traefik семь поддоменов (cv., weblog., status. и другие) шарят один под сайта через addPrefix middleware: cv.antonnovikov.com/ превращается в /cv/ перед проксированием. Переводя маршруты на стандартный Ingress, я применил rewrite-аннотацию ко всем путям без исключения — включая /assets/*, которые физически лежат в общей папке, без префикса. Получилось /cv/assets/... вместо /assets/... — 404, а бэкенд услужливо отдавал HTML-страницу ошибки вместо CSS, отсюда и MIME-тип text/html.

У Traefik это решалось отдельным route-правилом без rewrite специально для /assets — я его в первой версии миграции просто не заметил. Починка — два Ingress на домен вместо одного: один без rewrite для статики и «своего» префикса, второй с rewrite для всего остального.

yaml# passthrough — без rewrite, для /assets и статических файлов
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cv-subdomain-passthrough
  annotations:
    k8s.apisix.apache.org/http-to-https: "true"
spec:
  ingressClassName: apisix
  rules:
    - host: cv.antonnovikov.com
      http:
        paths:
          - path: /assets
            pathType: Prefix
            backend: {service: {name: antonnovikov-site, port: {number: 80}}}
          - path: /cv
            pathType: Prefix
            backend: {service: {name: antonnovikov-site, port: {number: 80}}}
---
# catchall — с rewrite, для корня и всего остального
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cv-subdomain
  annotations:
    k8s.apisix.apache.org/http-to-https: "true"
    k8s.apisix.apache.org/rewrite-target-regex: "^/(.*)"
    k8s.apisix.apache.org/rewrite-target-regex-template: "/cv/$1"
spec:
  ingressClassName: apisix
  rules:
    - host: cv.antonnovikov.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: {service: {name: antonnovikov-site, port: {number: 80}}}

Стандартный Ingress API сам разруливает приоритет — правило с более специфичным путём (/assets, /cv) выигрывает у общего /, ничего дополнительно настраивать не пришлось.

Шаг 9: два домена оказались не сайтом, а редиректом

tg.antonnovikov.com и sofyanovikova.ru в дампе Traefik выглядели как обычные маршруты на бэкенд сайта. При ближайшем разборе middleware выяснилось — это постоянный редирект на Telegram-каналы (redirectRegex с шаблоном .* → статичный URL), а не проксирование. Первая версия миграции честно копировала их как обычные домены — и они перестали бы редиректить, а начали отдавать сайт. У APISIX для этого есть готовая аннотация, без отдельного CRD:

yamlmetadata:
  annotations:
    k8s.apisix.apache.org/http-redirect: "https://t.me/example"
    k8s.apisix.apache.org/http-redirect-code: "301"

Шаг 10: метрики есть, но не те

apisix_nginx_http_current_connections в /apisix/prometheus/metrics появился сразу. apisix_http_status, apisix_http_latency, apisix_bandwidth — нет, хотя трафик уже шёл. Base-метрики nginx-уровня пишутся всегда, а per-request метрики требуют глобально включённого плагина prometheus:

yamlapiVersion: apisix.apache.org/v2
kind: ApisixGlobalRule
metadata:
  name: prometheus-metrics
  namespace: apisix
spec:
  ingressClassName: apisix
  plugins:
    - name: prometheus
      enable: true

Плюс сам экспортер по умолчанию слушает 127.0.0.1:9091 внутри пода — снаружи через Kubernetes Service до него не достучаться, только через port-forward, который маскирует проблему на этапе отладки. Пришлось явно перебиндить:

yamlapisix:
  pluginAttrs:
    prometheus:
      export_addr:
        ip: "0.0.0.0"
        port: 9091

И создать Service, которого чарт сам не заводит:

yamlapiVersion: v1
kind: Service
metadata:
  name: apisix-metrics
  namespace: apisix
spec:
  selector:
    app.kubernetes.io/name: apisix
    app.kubernetes.io/instance: apisix
  ports:
    - name: metrics
      port: 9091
      targetPort: 9091

Шаг 11: --reuse-values после неудачного апдейта тихо всё откатил

После одного из фиксов JSON-формата запустил helm upgrade --reuse-values --set updateStrategy.type=Recreate — а формат логов вернулся к сломанному виду из Шага 3. --reuse-values подтягивает значения не из последней попытки апдейта, а из последнего успешно задеплоенного релиза. Одна из промежуточных попыток была помечена failed (упал --wait-таймаут, хотя ресурсы применились) — и Helm откатился на релиз до неё, молча похоронив фикс. Урок: после --reuse-values всегда сверять helm get values -n <ns> -a, а не доверять, что апдейт применил именно то, что вы только что поправили.

Что может пойти не так

Домен отдаёт 200, но контент не тот. Проверяйте не только код ответа, а реальный <title> или конкретный файл — 404 от бэкенда тоже может маскироваться под 200, если фронт перехватывает ошибку своей SPA-страницей.

Ingress-controller залогировал no GatewayProxy configs provided бесконечно. GatewayProxy не создался или ссылается не на тот Service. Проверить: kubectl get gatewayproxy -n apisix и kubectl logs -n apisix -l app.kubernetes.io/name=ingress-controller.

После helm upgrade под завис в Pending. На единственной ноде с hostNetwork: true это почти всегда конфликт портов со старым подом той же самой роли. Проверить: kubectl get events -n <ns> | grep FailedScheduling. Лечится updateStrategy.type=Recreate, а не увеличением таймаута.

Метрика в PromQL есть, а данных нет. Сверьте, что plugin реально прикреплён (ApisixGlobalRule) и что порт экспортера слушает 0.0.0.0, а не 127.0.0.1kubectl exec в под и локальный curl этого не покажет, нужен запрос именно через Service.

Итого

  • На одной bare-metal ноде с hostNetwork blue-green между двумя ingress-контроллерами невозможен — планировщик блокирует конфликтующие порты. Это прямой cutover с осознанным даунтаймом, не rolling update.
  • helm show values перед install — не формальность. Три реальных бага (ssl.enabled, неверный ключ порта, битый --set с запятыми) нашлись только через него, а не через документацию.
  • Никогда не передавайте через --set значения с запятыми — JSON-строки, списки, что угодно с , внутри. Только -f values.yaml.
  • Миграция маршрутов — не механическая замена CRD один в один. Middleware, которые казались декоративными (addPrefix только на части путей, redirectRegex вместо проксирования), меняют поведение, если их не воспроизвести точно.
  • hostNetwork + одна нода означают updateStrategy: Recreate навсегда — не только на первую установку.
  • Метрики и логи ingress-контроллера — отдельная работа после самого cutover, а не автоматическое следствие установки чарта.