{"id":"IL-0011","org":"Skyscanner","date":"2019-04-23","docs":["https://github.com/jcmoraisjr/haproxy-ingress","https://golang.org/pkg/text/template/","https://github.com/jcmoraisjr/haproxy-ingress/commit/ee43ed47b4dd78e3af0e35878dd623974d8c0ccd","https://blog.golang.org/go-maps-in-action","https://github.com/golang/go/issues/2696","https://github.com/jcmoraisjr/haproxy-ingress/issues/240","https://github.com/jcmoraisjr/haproxy-ingress/pull/241","https://github.com/jcmoraisjr/haproxy-ingress/issues/274","https://github.com/jcmoraisjr/haproxy-ingress/pull/247","https://kubernetes.io/docs/concepts/services-networking/service/#virtual-ips-and-service-proxies","https://www.slideshare.net/try_except_/running-kubernetes-in-production-a-million-ways-to-crash-your-cluster-devopscon-munich-2018","https://www.youtube.com/watch?v=xZO9nx6GBu0"],"title":{"en":"HAProxy ingress piled requests onto the same pods because a Go template visited the backend map in sorted pod IP order","ko":"HAProxy 인그레스 템플릿이 백엔드 파드를 IP 순으로 정렬해 일부 파드에 요청이 몰리고 인그레스 컨테이너가 OOMKilled 됐다"},"domain":["kubernetes","networking"],"impact":{"scope":"services behind the ingress layer of the affected cluster","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"The team already knew that sorted backends cause hot-podding and still did not test the new flag well enough to see that it did not work; changes to cluster components need tests aimed at the exact behaviour that matters, under conditions like production. A keyword can behave differently in different parts of a language: range over a map is unordered in Go code but sorted in text/template. A small cluster component can do outsized harm to every user of the cluster and, in the end, to customers.","ko":"클러스터 컴포넌트를 바꿀 때는 특정 동작과 조건을 염두에 두고 테스트해야 한다. 팀은 백엔드 목록이 정렬되면 hot-podding 이 생긴다는 것을 이미 알았지만, 새 플래그가 기대대로 동작하는지 충분히 테스트하지 않았다. 같은 키워드라도 언어의 한 곳에서 동작하는 방식이 다른 곳에서도 같으리라고 기대하면 안 된다. Go 의 range 와 text/template 의 range 는 맵을 순회하는 순서가 달랐다. 쿠버네티스 클러스터의 작은 컴포넌트 하나만 바꿔도 클러스터 사용자와 고객에게 큰 피해를 줄 수 있다."},"related":[],"sources":[{"url":"https://medium.com/@SkyscannerEng/misunderstanding-the-behaviour-of-one-templating-line-and-the-pain-it-caused-our-k8s-clusters-a420f30a99f1","kind":"blog","license":"unknown","archived":"http://web.archive.org/web/20241202203401/https://medium.com/@SkyscannerEng/misunderstanding-the-behaviour-of-one-templating-line-and-the-pain-it-caused-our-k8s-clusters-a420f30a99f1"}],"summary":{"en":"Skyscanner exposes services through the open source HAProxy ingress controller, a Go wrapper that watches the Kubernetes API server for pods and services, generates the HAProxy configuration from a template with Go's text/template library, and reloads HAProxy. It creates one HAProxy backend per service. When a graceful reload is not possible the controller restarts the HAProxy process, so round-robin load balancing starts again from the top of each server list. The controller runs as a DaemonSet, about 80 instances on the largest clusters, behind an ELB that spreads traffic across them.\n\nIn v0.3 the data structure holding each backend's servers sorted them, so every HAProxy instance listed pods in the same order, and every non-graceful restart sent all instances back to the same first pod. The workaround was a default annotation on all services to use the Service virtual IP instead of pod IPs, which left load balancing to Kubernetes but meant HAProxy could no longer mark individual pods unhealthy. When the v0.5 documentation introduced sort-backends with a default of false, the team read the code, tested briefly and upgraded to v0.6, returning to pod IPs. The load test ran on a fairly static cluster, so it never produced the frequent configuration rewrites and non-graceful restarts of the production clusters.\n\nThe Go code did shuffle the servers, and kept them shuffled right up to templating. The template, lightly modified by Skyscanner from upstream, iterated with range over a map keyed by pod_ip:port. In Go code, range over a map has no guaranteed order, but text/template has visited maps with comparable keys in sorted key order since Go 1.0, so every instance listed pods by IP in ascending order. On the Sunday the team stopped the damage by switching the affected service back to its Service virtual IP through a config change in Slingshot, the internal deployment tool. The fix was a small controller patch that keeps a slice in insertion order and a template that iterates over that slice and looks each entry up in the map. After testing in sandbox clusters and rolling it out to production, the request graphs evened out. The upstream pull request broke functionality Skyscanner did not use, because the project had no test suite, so the maintainer shipped an alternative fix in the next release and offered a backport to v0.6.","ko":"Skyscanner 는 오픈소스 HAProxy 인그레스 컨트롤러를 데몬셋으로 노드마다 띄운다. 가장 큰 클러스터에서는 인스턴스가 80개쯤이고, 앞단 ELB 가 이들에게 고르게 부하를 나눈다. 컨트롤러는 쿠버네티스 API 서버를 지켜보다가 템플릿과 Go 의 text/template 라이브러리로 HAProxy 설정을 만들고 HAProxy 를 리로드한다. 무중단 리로드가 안 되면 HAProxy 프로세스를 재시작하고, 라운드 로빈은 서버 목록의 처음부터 다시 시작한다.\n\nv0.3 을 쓰던 때 백엔드 서버 목록이 정렬돼 있어 hot-podding 이 생겼다. 팀은 모든 서비스에 기본 어노테이션을 달아 파드 IP 대신 Service VIP 를 쓰게 하는 임시 우회책을 두었다. 부하 분산은 쿠버네티스가 맡았지만, HAProxy 는 개별 파드를 비정상으로 표시할 수 없게 됐다.\n\nv0.5 문서에 sort-backends 플래그가 생기고 기본 동작이 무작위 섞기라고 나오자, 팀은 코드를 읽고 짧게 테스트한 뒤 v0.6 으로 올렸다. 부하 테스트는 변화가 적은 클러스터에서 해서, 운영 클러스터처럼 설정이 자주 다시 쓰이고 HAProxy 가 재시작되는 상황을 놓쳤다. Go 의 range 는 맵을 순회할 때 순서를 보장하지 않지만, text/template 은 키가 순서를 정할 수 있는 기본 타입이면 정렬된 키 순서로 방문한다. 그래서 Go 코드에서 섞어 둔 순서가 템플릿 단계에서 다시 IP 순으로 정렬됐다.\n\n팀은 맵에 넣은 순서를 슬라이스에 따로 보관하고, 템플릿이 그 슬라이스를 순회하며 맵에서 값을 찾도록 컨트롤러를 고쳤다. 패치를 배포하자 요청 분포가 고르게 돌아왔다. 업스트림 프로젝트에 이슈와 PR 을 올렸고, 메인테이너는 다른 방식의 수정을 다음 릴리스에 넣었다."},"trigger":{"en":"Upgrading the HAProxy ingress controller to v0.6 and going back from Service virtual IPs to individual pod IPs as backends, on the understanding that the sort-backends flag introduced in v0.5, false by default, meant backends were randomly shuffled.","ko":"HAProxy 인그레스 컨트롤러를 v0.6 으로 올렸다. v0.5 문서는 sort-backends 플래그의 기본값이 false 이고 백엔드 서버를 기본으로 무작위로 섞는다고 설명했다. 그러나 템플릿이 pod_ip:port 를 키로 하는 맵을 순회할 때 Go 의 text/template 이 키 순서대로 방문해, 파드는 늘 IP 오름차순으로 설정 파일에 들어갔다. 운영 클러스터처럼 변화가 잦은 곳에서는 설정이 자주 다시 쓰였고, HAProxy 가 무중단 리로드를 하지 못해 재시작하는 일이 잦았다. 그때마다 모든 HAProxy 인스턴스가 같은 목록의 처음부터 라운드 로빈을 시작했다."},"severity":"unknown","symptoms":["high-latency","uneven-load","oom-killed","intermittent-connection-failures"],"timeline":{"detected_after":null,"resolved_after":null},"date_kind":"published","detection":{"en":"A cluster user manually paged the out-of-hours Kubernetes support about elevated latency; Prometheus queries then showed the uneven request distribution across pods.","ko":"클러스터 사용자가 p90 과 최댓값 지연이 오른 것을 보고 업무 시간 외 쿠버네티스 지원 담당을 직접 호출했다. 그 전에도 한 사용자가 파드 사이 요청 분포가 고르지 않다고 알렸지만 팀은 우선순위를 낮게 두었다."},"components":["ingress","haproxy","config-templating"],"root_cause":"software-bug","remediation":[{"text":{"en":"switched the affected service from pod IPs back to the Service virtual IP through a config change in the internal deployment tool Slingshot","ko":"내부 배포 도구 Slingshot 의 설정을 바꿔, 영향받은 서비스의 매니페스트가 파드 IP 대신 Service VIP 를 쓰게 했다."}},{"text":{"en":"patched the controller to keep each backend's servers in an insertion-ordered slice and changed the template to iterate over that slice","ko":"컨트롤러가 맵에 넣은 순서를 슬라이스에 보관하고 템플릿이 그 슬라이스를 순회하도록 컨트롤러 코드와 설정 템플릿을 패치했다."}},{"text":{"en":"tested the patch in sandbox clusters and rolled it out to the production clusters","ko":"패치를 샌드박스 클러스터에서 테스트한 뒤 운영 클러스터에 배포했다."}}],"action_items":[{"text":{"en":"Run the internal controller patch in production and return to pod IPs as the default backends","ko":"컨트롤러 패치를 운영에서 돌리고, 백엔드 주소는 다시 파드 IP 를 기본으로 쓴다."}},{"text":{"en":"Raise the problem upstream with a GitHub issue and pull request","ko":"업스트림 프로젝트에 GitHub 이슈와 PR 을 올려 문제를 알렸다."}},{"text":{"en":"Test changes to cluster components against the specific behaviour and conditions that matter","ko":"클러스터 컴포넌트를 바꿀 때는 구체적인 동작과 조건을 염두에 두고 테스트한다."}}],"contributing":["staging-not-representative","assumption-unverified","early-signal-ignored"],"recurrence_of":null,"symptoms_text":{"en":"On a Sunday, a team using one of Skyscanner's Kubernetes clusters manually paged the out-of-hours Kubernetes support because their service showed elevated latency, worst at the p90 and maximum levels.\n\nAfter some joint digging and a few false starts, the engineers found that the request queues of a few pods of the service were full and those pods refused further requests, while other pods of the same service received far less. Prometheus queries showed the pattern the platform team called \"hot-podding\": traffic that came in through the ingress layer was spread unevenly, with some pods consistently getting a much higher request rate than others.\n\nThe imbalance fed itself, in what the team described as a \"spiral of failures\". A pod with a full queue correctly marked itself unready, which removed it from its HAProxy backend, which caused a configuration reload, which made the next pod in the list the overloaded one. Some ingress controller containers became so overloaded with requests that they were OOMKilled, which put the remaining ones under even more stress.\n\nThere had been warnings. Users had reported hot-podding back on v0.3 of the ingress controller, and after the upgrade to v0.6 one squad again pointed out unusual request distribution across its pods. The platform team filed that as a curiosity: its load test, its reading of the code and the fact that only one squad had reported it made it look like a low priority.","ko":"어느 일요일, 클러스터 사용자 한 명이 지연 시간이 올라갔다며 쿠버네티스 업무 시간 외 지원 담당을 직접 호출했다. 특히 p90 과 최댓값 지연이 높았다.\n\n함께 파고들고 몇 번 헛짚은 끝에, 일부 파드의 요청 큐가 가득 차서 요청을 더 받지 않는다는 것을 찾았다. Prometheus 쿼리를 더 돌려 보니 원인은 백엔드 목록이 정렬돼 생긴 hot-podding 이었다. hot-podding 은 일부 파드가 다른 파드보다 늘 더 많은 요청을 받는 현상을 팀이 부르는 이름이다.\n\n실패는 연쇄로 번졌다. 큐가 찬 파드는 스스로를 unready 로 표시했고, 백엔드 서버 목록에서 빠지면서 설정 리로드가 일어났다. 그러면 목록에서 그다음 파드에 부하가 몰렸다. 인그레스 컨테이너 일부는 요청이 너무 몰려 OOMKilled 됐고, 남은 컨테이너의 부담은 더 커졌다.\n\n신호는 그 전에도 있었다. 인그레스 컨트롤러를 v0.6 으로 올린 뒤 한 사용자가 파드 사이 요청 분포가 이상하다고 알려 왔다. 팀은 테스트와 코드 검토 결과를 믿었고, 한 개발 팀만 보고한 현상이라 우선순위를 낮게 두었다."},"updated_at":"2026-11-10T00:00:32.217540Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0011","org_slug":"skyscanner"}