Після міграції на новий Kubernetes Cluster на Backend API почали виникати помилки 503.
Чому з’явились саме на 1.33 – так і не зрозумів, бо в параметрах AWS ALB та Kubernetes Ingress нічого не мінялось, а на 1.30 їх не було.
Може спрацювали деякі мої фікси в моніторингу – або це щось пов’язане новим AMI чи версією VPC CNI.
503 виникали у нас в трьох випадках:
- іноді без всякою деплою, коли всі поди були Running && Ready
- іноді під час деплою – але тільки на Dev, бо там один Pod для API
- і під час Karpenter Consolidation
Давайте покопаємо можливі причини.
Зміст
Трохи контексту: наш сетап
Маємо AWS EKS, в якому запущені Pods з Backend API.
Для доступу до них маємо Kubernetes Service з типом ClusterIP та Ingress resource з атрибутом alb.ingress.kubernetes.io/target-type: ip
.
Маємо AWS LoadBalancer Controller, який створює AWS Application LoadBalancer.
При створенні Kubernetes Service – Endpoints controller створює ресурс Endpoints зі списком Pod IPs, який потім використовується ALB Controller для додавання таргетів в Target Group.
При деплої Backend API Ingress – ALB Controller створює ALB з Listeners на кожен hostname
з Ingress з власним SSL, і з Target Group для кожного Listener, в якій задаються IP Pods зі списку адрес в Endpoints, на які треба слати трафік від клієнтів.
Тобто схема виглядає так: client => ALB => Listener => Target Group => Pod IP.
Більше – Kubernetes: что такое Endpoints та Kubernetes: Service, балансировка нагрузки, kube-proxy и iptables.
Правда, в Kubernetes 1.33 Endpoints вже deprecated, див. Kubernetes v1.33: Continuing the transition from Endpoints to EndpointSlices.
ALB та EKS: 502 vs 503 vs 504
Для початку розглянемо різницю між помилками.
502 у нас виникає, якщо ALB не зміг отримати коректну відповідь від бекенда, тобто помилка на рівні самого сервісу (застосунку), application layer (“я додзвонився, але на тому кінці відповіли щось незрозуміле або кинули слухавку посеред розмови”):
- у Pod не відкритий порт
- Pod впав, але Readiness probe каже що він живий, і Kubernetes не відключає його від трафіку (не видаляє IP зі списку в Endpoints)
- Pod повертає помилку від сервісу при підключенні
По темі:
- Kubernetes: NGINX/PHP-FPM graceful shutdown – избавляемся от 502 ошибок: як NGINX та сигнал
SIGTERM
призводили до 502 - Kubernetes: Ingress, ошибка 502, readinessProbe и livenessProbe: як виклик
panic
в Golang-сервісі призводив до 502
503 виникає, якщо ALB не має жодного Healthy target, або Ingress не зміг знайти Pod (“абонент не може прийняти ваш дзвінок”):
- Pods проходять
readinessProbe
, додаються в список Endpoints і в ALB targets, але не проходять Health checks в TargetGroup – тоді ALB шле трафік на всі Pods (targets), див. Health checks for Application Load Balancer target groups - Pods не проходять
readinessProbe
, Kubernetes видаляє їх з Endpoints, і Target Group стає порожньою – ALB нема куди слати запити - Kubernetes Service має помилки в конфігурації (наприклад – неправильний Pod selector)
- Kubernetes Service налаштований коректно, але кількість запущених подів == 0
- ALB встановив підключення до Pod, але підключення розірване на рівні TCP (наприклад, через різні keep-alive таймаути на бекенді та ALB), і ALB отримав
TCP RST
(reset)
504 виникає коли ALB відправив запит, але не отримав відповідь у встановлений таймаут (дефолт 60 секунд на ALB) (“я додзвонився, але мені надто довго не відповідають, і я кладу слухавку”)
- процес в Pod надто довго обробляє запит
- мережеві проблеми в AWS VPC або EKS кластері, і пакети від ALB до Pod проходять надто довго
Див. також Troubleshoot your Application Load Balancers.
Можливі причин 503
Інші можливі причини 503, і чи пов’язані вони з нашим випадком:
- неправильні правила Security Groups:
- при створенні TargetGroups, AWS Load Balancer Controller має створити або оновити SecurityGroups, і може, наприклад, не мати доступу до AWS API на редагування SecurityGroup
- але не наш кейс, бо помилка виникає періодично, а в такому випадку була б відразу і постійно
- unhealthy targets в Target Group:
- якщо всі Pods (ALB targets) періодично “відвалюються” – то будемо мати 503, бо ALB починає слати трафік на всі наявні таргети
- теж не наш кейс – перевіряємо метрику
UnHealthyHostCount
в CloudWatch – вона показує, що проблем з таргетами не було
- некоректні теги на VPC Subnets:
- ALB Контролер шукає Subnets за тегом
kubernetes.io/cluster/<cluster-name>: owned
аби знайти в них Pods і зареєструвати targets - знов-таки не наш кейс, бо помилка виникає періодично, а не є постійною
- ALB Контролер шукає Subnets за тегом
- затримка при connection draining:
- при деплойменті або скейлінгу старі Pods видаляються, і їхні IP видаляються з TargegtGroup, але це може виконуватись з затримкою – тобто Pod вже мертвий, а його IP в Targets ще є
- але в першому випадку рестартів чи деплоїв не було, див. далі
- ліміти пакетів в секунду або трафіку в секунду на Worker Node:
- EC2 мають ліміти залежно від типу інстансу (див. Amazon EC2 instance network bandwidth та Packets-per-second limits in EC2)
- теж не наш кейс, бо трафіку дуже мало
- неузгодженість таймаутів Keep-Alive:
- на Load Balancer idle timeout вищий, ніж на бекенді – і ALB може відправити запит по конекту, який вже закритий на бекенді
Ну а тепер до наших проблем.
Problem 1: різні keep-alive timeouts
ALB idle timeout 600 секунд, Backend – 75 секунд
На нашому ALB маємо Connection idle timeout в 600 seconds:
А на Backend API – 75 секунд:
... CMD ["gunicorn", "challenge_backend.run_api:app", "--bind", "0.0.0.0:8000", "-w", "3", "-k", "uvicorn.workers.UvicornWorker", "--log-level", "debug", "--keep-alive", "75"] ...
Тобто через 75 секунд Backend шле сигнал TCP FIN
, і закриває підключення – але ALB в цей час ще може відправити запит.
Як можна подебажити?
Я цього разу не робив, але на майбутнє – можна перевірити трафік з tcpdump
:
$ sudo tcpdump -i any -nn -C 100 -W 5 -w /tmp/alb_capture.pcap 'port 8080 and host <ALB_IP_1> or host <ALB_IP_2>)'
Що може відбуватись:
- якщо connection не активний протягом 75 секунд, то Pod закриває зєднання – відправляє пакет з
[FIN, ACK]
- ALB намагається через те саме з’єднання відправити пакет:
<ALB_IP> => <POD_IP> | TCP | [PSH, ACK] Seq=1 Ack=2 Len=...
- а Pod відповідає пакетом з
[RST]
(reset):<POD_IP> => <ALB_IP> | TCP | [RST] Seq=2
ALB в такому випадку клієнту поверне 503 помилку.
Тому для початку – просто на ALB задаємо дефолтні 60 секунд (менше, ніж на бекенді). Ну, або збільшуємо на бекенді.
А звідки взявся 600 секунд на Ingress?
Тут я трохи покопався, бо не відразу знайшов звідки ж задавалось 600 секунд на ALB.
Дефолтний таймаут в AWS ALB 60 секунд, див. Configure the idle connection timeout for your Classic Load Balancer.
У нас використовується єдиний Ingress, який створює AWS ALB, і до якого потім через анотацію alb.ingress.kubernetes.io/group.name
підключаються інші Ingres (див. Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress).
В цьому основному Ingress у нас нема ніяких атрибутів для зміни idle timeout:
... alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}' alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket={{ .Values.alb_aws_logs_s3_bucket }} alb.ingress.kubernetes.io/actions.default-action: > {"Type":"fixed-response","FixedResponseConfig":{"ContentType":"text/plain","StatusCode":"200","MessageBody":"It works!"}} ...
Хоча це можна зробити через Custom atributes – там є приклад як задати 600 секунд, але це custom, а не дефолт для ALB Ingress Controller.
Тобто дефолт має бути 60.
Спробував просто через цей Custom задати 60 секунд – і отримав помилку “conflicting load balancer attributes idle_timeout.timeout_seconds“:
... aws-load-balancer-controller-6f7576c58b-5nmp7:aws-load-balancer-controller {"level":"error","ts":"2025-06-20T10:39:51Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-33-external-alb"},"namespace":"","name":"ops-1-33-external-alb","reconcileID":"24bf8a2e-72ca-4008-8308-0f3b5595649c","error":"conflicting load balancer attributes idle_timeout.timeout_seconds: 60 | 600"} ...
Тоді вирішив перевірити просто всі Ingress в усіх Namespaces:
$ kk get ingress -A -o yaml | grep idle_timeout alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600 alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600
І потім вже без grep
просто знайшов імя Ingress з цим атрибутом:
... - apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: ... alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600 ... name: atlas-victoriametrics-victoria-metrics-auth ...
Не пам’ятаю для чого додавав, але трошки вилізло боком.
Добре – це поміняли, і 503 стало набагато менше – але все ще іноді бували.
Problem 2: 503 під час деплою
Знов прилетіла 503.
Тепер бачимо, що дійсно – в цей час був деплой:
Хоча Deployment має maxSurge 100%
і maxUnavailable: 0
– див. Rolling Update:
... strategy: type: RollingUpdate rollingUpdate: maxSurge: 100% # run all replicas before stop old Pods maxUnavailable: 0 # keep all old Pods until new Pods will be Running (passed the readinessProbe) ...
Тобто маючи навіть один Pod на Dev-оточенні – спочатку має запуститись новий, пройти всі probes – і тільки після цього почне видалятись старий.
Але ми при деплої на Dev все одно отримуємо 503.
Kubernetes, AWS TargetGroup та targets registration
Як виглядає процес додавання в TargetGroups?
- створюємо новий Pod
kubelet
перевіряєreadinessProbe
- поки
readinessProbe
не пройдений – Pod в статусіReady == False
- поки
- коли
readinessProbe
пройдена – Kubernetes оновлює Endpoints і додає Pod IP в список адрес - ALB Controller бачить новий IP в Endpoints, і починає процес додавання цього IP в TargetGroup
- після реєстрації в TargetGroup цей IP не відразу отримує трафік, а переходить в статус Initial
- ALB починає виконувати свої Health checks
- коли Health check пройдено – target стає Healthy, і на нього роутиться трафік
Kubernetes, AWS TargetGroup та targets deregistration
Але є нюанс в тому, як targets видаляються з Target Group:
- під час деплою з Rolling Update – Kubernetes створює новий Pod, і чекає поки він стане Ready (пройде
readinessProbe
) - після цього Kubernetes починає видаляти старий под – він переходить в статус Terminating
- в це жеж час новий IP нового Pod, який було додано в ALB Target Group ще може проходити Health checks, тобто бути в статусі Initial
- а target того Pod, який вже почав Terminating – переходить в статус Draining і потім видаляється з Target Group
І ось тут ми і можемо ловити 503.
Перевірка
Спробуємо зарепроьюсити цю проблему:
- робимо деплой
- слідкуємо за статусом Pod
- він стане
Ready
, і почне вбиватись старий Pod - в цей час глянемо ALB – чи пройшов новий таргет Initial, і який статус старого target
- він стане
Що маємо перед деплоєм:
Сам Pod:
$ kk get pod -l app=backend-api -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES backend-api-deployment-849557c54b-jmkz4 1/1 Running 0 44m 10.0.47.48 ip-10-0-38-184.ec2.internal <none> <none>
І Edpoints:
$ kk get endpoints backend-api-service Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice NAME ENDPOINTS AGE backend-api-service 10.0.47.48:8000 7d3h
Таргети в TargetGroup – зараз один:
Запускаємо деплой – і маємо новий в Ready
та Running
, а старий в Terminating
:
І в цей в TargetGroup маємо новий таргет в статусі Initial, а старий вже в Draining:
Правда, з
curl
в циклі по 1 секунді не вийшло відловити 503 – надто швидко проходять Health checks.
Рішення: Pod readiness gate
Pod readiness gate додає ще одну перевірку до Pod: поки відповідний Target в ALB не пройде Helth check – старий Pod не буде видалятись:
The condition status on a pod will be set to
True
only when the corresponding target in the ALB/NLB target group shows a health state of »Healthy«.
Але це працює тільки якщо у вас target_type == IP.
Включаємо Readiness gate в Kubernetes Namespace:
$ kubectl label namespace dev-backend-api-ns elbv2.k8s.aws/pod-readiness-gate-inject=enabled namespace/dev-backend-api-ns labeled
Деплоїмо ще раз, і дивимось.
Pod перейшов в Ready
, але в Readiness Gates він ще не готовий – 0/1
:
Старий target не дрейниться, поки новий в Initial:
Бо старий Pod ще не почав видалятись:
Як тільки новий target став Healthy:
То і старий Pod почав видалятись:
Problem 3: Karpenter consolidation
Ще кілька раз 503 виникала в момент, коли Karpeter видаляв WorkerNode.
Перевіримо поди і ноди:
sum(kube_pod_info{namespace="dev-backend-api-ns", pod=~"backend-api.*"}) by (pod, node)
- старий под на ip-10-0-32-209.ec2.internal
- новий на ip-10-0-36-145.ec2.internal
і в цей відбувався ребаланс WorkerNodes – інстанс ip-10-0-32-209.ec2.internal видалявся через "reason":"underutilized"
:
Давайте згадаємо як виглядає процес Karpenter consolidation – див. Karpenter Disruption Flow в пості Kubernetes: забезпечення High Availability для Pods:
- Karpenter бачить underutilized EC2
- він додає Node
taint
зNoSchedule
, аби нові поди не створювались на цій WorkerNode (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах) - виконує Pod Eviction, аби видалити з цієї ноди існуючі Pods
- Kubernetes отримує Eviction event, і починає процес видалення контейнерів – переводить їх в стан Ternimating через відправку сигналу
SIGTERM
- ALB Controller бачить, що Pod в статусі Terminating – і починає процес видалення target – переводить його в стан Draining
- в цей жеж час Kubernetes бачить, що кількість
replicas
в Deployment не дорівнює desired state, і створює новий Pod на заміну старому- новий Pod не може бути запущеним на старій WorkerNode, бо та має taint
NoSchedule
, і якщо на існуючих WorkerNodes нема місця – Karpenter створює новий інстанс EC2, що займає пару хвилин – це ще додатково збільшить вікно для 503
- новий Pod не може бути запущеним на старій WorkerNode, бо та має taint
- новий Pod в цей час в статусі
Pending
абоContainerCreating
, і не додається до LoadBalancer TargetGroup – тоді як старий target вже видаляється- насправді не видаляється відразу, бо Draining state – це коли ALB перестає створювати нові з’єднання з target, але дає час на завершення існуючих – див. Registered targets
- відповідно в цей час ALB просто нема куди слати трафік
- …
- отримуємо 503
Перевірка
Можемо спробувати зарепродьюсити помилку:
- виконаємо
node drain
там де Dev Pod - подивимось на статус подів
- подивимось на ALB Targets
Знаходимо ноду Pod з Backend API в Dev Namespace:
$ kk -n dev-backend-api-ns get pod -l app=backend-api -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES backend-api-deployment-7b5fd6bb9b-mrm2m 1/1 Running 0 7m56s 10.0.42.9 ip-10-0-40-204.ec2.internal <none> 1/1
Виконуємо drain
цієї WrorkerNode:
$ kubectl drain --ignore-daemonsets ip-10-0-40-204.ec2.internal
Бачимо, що старий Pod в статусі Terminating
, а новий – ContainerCreating
:
І в цей час в TargetGroup маємо тільки один target, і він вже в статусі Draining – бо старий Pod в Terminating, а новий ще не додано в TargetGroup – бо Ednpoints оновиться тільки тоді, коли новий Pod стане Ready:
$ while true; curl -X GET -s -o /dev/null -w "%{http_code}\n" https://dev.api.example.co/ping; do sleep 1; done | grep 503 503 503 503 ...
Рішення: PodDisruptionBudget
Як цьому запобігти?
Мати PodDisruptionBudget
, детальніше в тому ж пості Kubernetes: забезпечення High Availability для Pods.
Але в цьому конкретному випадку помилки виникають на Dev-оточенні, де тільки один контейнер з API і нема PDB – а на Staging і Production вони є, тому там ми 503 помилки більше не отримуємо:
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: backend-api-pdb spec: minAvailable: {{ .Values.deployment_api.poddisruptionbudget.min_avail }} selector: matchLabels: app: backend-api
Власне, остання 503 помилка вирішена теж.