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.1 — kubectl exec в под и локальный curl этого не покажет, нужен запрос именно через Service.
Итого
- На одной bare-metal ноде с
hostNetworkblue-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, а не автоматическое следствие установки чарта.