Kubernetes: помилки 503 з AWS ALB – можливі причини та рішення

Автор |  25/06/2025
 

Після міграції на новий 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 повертає помилку від сервісу при підключенні

По темі:

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
    • знов-таки не наш кейс, бо помилка виникає періодично, а не є постійною
  • затримка при connection draining:
    • при деплойменті або скейлінгу старі Pods видаляються, і їхні IP видаляються з TargegtGroup, але це може виконуватись з затримкою – тобто Pod вже мертвий, а його IP в Targets ще є
    • але в першому випадку рестартів чи деплоїв не було, див. далі
  • ліміти пакетів в секунду або трафіку в секунду на Worker Node:
  • неузгодженість таймаутів 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>)'

Що може відбуватись:

  1. якщо connection не активний протягом 75 секунд, то Pod закриває зєднання – відправляє пакет з [FIN, ACK]
  2. ALB намагається через те саме з’єднання відправити пакет: <ALB_IP> => <POD_IP> | TCP | [PSH, ACK] Seq=1 Ack=2 Len=...
  3. а 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 в цей час в статусі 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:

І в цей час ловимо купу 503:

$ 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 помилка вирішена теж.