{"id":"IL-0063","org":"AdGuard VPN","date":"2025-12-02","docs":[],"title":{"en":"VPN outage after recreating a Redis StatefulSet restarted the whole Redis cluster without readiness checks","ko":"Redis 스테이트풀셋을 다시 만들자 Redis 클러스터가 한꺼번에 재시작되며 일부가 망가졌고, 재연결 폭주로 백엔드가 OOM 으로 죽어 AdGuard VPN 이 약 50분 멈췄다."},"domain":["kubernetes"],"impact":{"scope":"the AdGuard VPN service; about 50 minutes of downtime and slow responses afterwards","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"The disk resize was not the problem; the cleanup afterwards was. An immutable field pushes operators toward deleting and recreating the controller, and that can set off a full reconcile. Startup and readiness probes are what slow a rolling restart down to a safe pace; without them a distributed store restarts in the most fragile way. Recovery plans must expect a cluster to heal slowly, and dependent services need to degrade gracefully while it does.","ko":"장애를 일으킨 것은 디스크 확장이 아니라 상태를 맞추려고 컨트롤러 객체를 다시 만든 단계였다. 고칠 수 없는 필드가 있으면 객체를 통째로 바꾸는 위험한 방법을 쓰게 된다. 그렇게 바꾸면 쿠버네티스가 워크로드를 넓은 범위에서 다시 조정할 수 있다. 스타트업 프로브와 엄격한 레디니스 프로브가 있어야 롤링 재시작이 현재 파드가 정말 준비될 때까지 기다린다. 이번 장애에서 가장 뼈아픈 것은 프로브가 없었다는 점이다. 분산 클러스터가 다시 붙고 복제를 따라잡는 데는 시간이 걸리므로, 의존하는 서비스는 그동안 기능을 줄여서라도 버티도록 설계해야 한다."},"related":[],"sources":[{"url":"https://adguard-vpn.com/en/blog/redis-kubernetes-vpn-incident-post-mortem.html","kind":"blog","license":"unknown","archived":null}],"summary":{"en":"AdGuard VPN runs a Redis cluster in Kubernetes as a StatefulSet. A StatefulSet declares its disks in volumeClaimTemplates, and that field cannot be changed after creation. When the Redis disks were running out of space, the team used the storage backend's online volume expansion: the disk size was raised in Git at 7:00 UTC, the PersistentVolumeClaims were resized by hand at 7:05, and by 7:10 every pod had its larger disk without a restart. That left the StatefulSet template in Git and the cluster's real volumes describing different sizes.\n\nTo remove that drift, at 8:10 UTC the team deleted the StatefulSet with orphan deletion, expecting the pods and disks to keep running untouched, and recreated it with the new template. Kubernetes did not know the change was already in place and reconciled the workload, which restarted the whole Redis cluster between 8:10 and 8:15. The Redis pods had no startup or readiness probes, so a pod that was merely running counted as ready and Kubernetes moved on to the next pod before Redis could serve its load. The cluster came back partly degraded, with replicas attached to stale masters.\n\nFrom 8:15 to 8:35 UTC engineers repaired the cluster by hand: they made the masters forget obsolete node identities, reset the affected replicas, rejoined them and waited for replication to catch up. Meanwhile reconnect storms caused cascading out-of-memory failures in the backend service, dealt with from 8:35 to 8:50 by scaling the backend, raising memory limits for the affected component and temporarily relaxing probes so instances could take traffic immediately. The service was back, with slow responses, after about 50 minutes of downtime (8:10 to 9:00 UTC), and the temporary workarounds were reverted at 10:00 UTC.","ko":"AdGuard VPN 의 핵심 구성 요소인 Redis 는 쿠버네티스 스테이트풀셋으로 돈다. 스테이트풀셋의 저장소는 volumeClaimTemplates 로 정의하는데, 한 번 만들면 이 템플릿을 고쳐 디스크 크기를 늘릴 수 없다. 2025년 12월 2일 디스크 여유 공간이 바닥나고 있다는 알림이 오자, 팀은 저장소가 지원하는 온라인 볼륨 확장으로 PVC 를 직접 늘렸다. 7:00 UTC 에 Git 의 디스크 크기를 올렸고, 7:05 UTC 에 모든 PVC 의 크기를 손으로 바꿨고, 7:10 UTC 에 모든 파드의 온라인 확장이 끝났다. 디스크 문제는 재시작 없이 풀렸다.\n\n그러나 Git 과 스테이트풀셋 템플릿에는 여전히 옛 크기가 적혀 있었다. 이 차이를 없애려고 팀은 8:10 UTC 에 스테이트풀셋을 고아 삭제하고 새 템플릿으로 다시 만들었다. 파드와 디스크는 그대로 두고 관리 객체만 바꾸니 안전하리라 보았다. 하지만 쿠버네티스는 이미 바뀐 것이 다 반영된 줄 모르고 새 스펙에 따라 워크로드를 다시 조정하면서 Redis 클러스터 전체를 재시작했다.\n\n쿠버네티스는 재시작을 너무 서둘렀다. 스타트업 프로브도 레디니스 프로브도 없어서 파드가 running 이기만 하면 정상으로 취급되었다. 그래서 쿠버네티스는 Redis 가 실제로 요청을 받을 수 있게 되기 전에 다음 파드로 넘어갔다. 그 결과 클러스터 일부가 망가진 채 올라왔다. 팀은 마스터에서 쓸모없어진 노드 ID 를 지우고, 해당 레플리카를 리셋하고, 다시 클러스터에 붙여 복제가 따라잡기를 기다렸다. 이 작업이 예상보다 오래 걸리는 동안 백엔드 서비스가 재연결 폭주로 OOM 에 빠졌다.\n\n팀은 처음에 OOM 이 난 구성 요소를 잘못 짚었고, 남은 용량이 부족해 확장도 늦어졌다. OOM 이 난 구성 요소를 제대로 찾은 뒤 백엔드를 늘리고, 그 구성 요소의 메모리 한도를 올리고, 프로브를 잠시 느슨하게 해 인스턴스가 바로 서비스에 들어가게 했다. 다운타임은 8:10 부터 9:00 UTC 까지 약 50분이었다. 10:00 UTC 에 비상용 임시 조치를 되돌렸고 서비스가 완전히 정상으로 돌아왔다."},"trigger":{"en":"To make Git match the volumes that had been resized by hand, the Redis StatefulSet was deleted with orphan (non-cascading) deletion and recreated with the new storage template; Kubernetes reconciled the new spec by restarting the entire Redis cluster.","ko":"팀은 디스크를 온라인으로 늘린 뒤 Git 의 선언적 설정을 실제 상태와 맞추려고 Redis 스테이트풀셋을 고아 삭제했다. 고아 삭제는 파드와 디스크는 남기고 스테이트풀셋 객체만 지우는 방식이다. 그다음 늘린 크기를 적은 저장소 템플릿으로 스테이트풀셋을 다시 만들었다."},"severity":"unknown","symptoms":["service-unavailable","oom-killed","readiness-probe-failures","high-latency"],"timeline":{"detected_after":null,"resolved_after":"50m"},"date_kind":"incident","detection":{"en":"An alert about Redis instances running out of disk space started the work; the outage itself showed up as backend errors about Redis connections right after the StatefulSet was recreated.","ko":"Redis 클러스터 여러 인스턴스의 디스크 여유 공간이 바닥나고 있다는 알림이 시작이었다. 그 뒤의 장애는 스테이트풀셋을 다시 만든 직후 백엔드 서비스가 Redis 연결 오류를 내면서 드러났다."},"components":["persistent-volume","cache","memory-limit"],"root_cause":"config-change","remediation":[{"text":{"en":"made the Redis masters forget obsolete node identities, reset the affected replicas and rejoined them, then waited for replication to catch up","ko":"망가진 Redis 클러스터를 손으로 복구했다. 마스터에서 쓸모없어진 노드 ID 를 지우고, 해당 레플리카를 리셋한 뒤 클러스터에 다시 붙이고 복제가 따라잡기를 기다렸다."}},{"text":{"en":"identified the component that was actually running out of memory, scaled the backend service and raised memory limits for that component","ko":"실제로 메모리가 바닥난 구성 요소를 찾아낸 뒤, 백엔드 서비스를 확장하고 그 구성 요소의 메모리 한도를 올렸다."}},{"text":{"en":"temporarily relaxed probes so backend instances took traffic immediately instead of flapping between ready and not ready","ko":"프로브를 잠시 느슨하게 해, 백엔드 인스턴스가 ready 와 not ready 를 오가지 않고 바로 트래픽을 받게 했다."}},{"text":{"en":"reverted the temporary workarounds at 10:00 UTC","ko":"10:00 UTC 에 임시 조치를 되돌렸다."}}],"action_items":[{"text":{"en":"Add startup probes so a Redis pod is not marked ready until its dataset is loaded","ko":"빠져 있던 스타트업 프로브를 넣는다. 데이터셋을 다 읽기 전에는 파드를 ready 로 표시하지 않는다."}},{"text":{"en":"Build an own control plane for Redis that handles restarted pods stuck to an old master address","ko":"재시작한 파드가 옛 마스터 IP 주소에 묶여 있을 때 쓸 수 있도록 Redis 용 자체 컨트롤 플레인을 만든다."}},{"text":{"en":"Reduce dependence on the single Redis cluster: move some important data to other stores, such as a separate Redis cluster per server, and use Kafka for some backend components","ko":"서비스 전체의 단일 장애점이 된 이 Redis 클러스터 의존을 줄인다. 중요한 정보 일부를 서버마다 따로 둔 Redis 클러스터 같은 다른 데이터 저장소로 옮기고, 백엔드 일부 구성 요소에 Kafka 를 쓰기 시작한다."}}],"contributing":["no-health-probe","assumption-unverified","single-point-of-failure","retry-storm","no-spare-capacity"],"recurrence_of":null,"symptoms_text":{"en":"The day began with an alert that several instances of the Redis cluster were running out of disk space. That part was fixed quickly. The outage came an hour later: right after the Redis StatefulSet was deleted and recreated at 8:10 UTC, Kubernetes restarted every Redis pod within about five minutes, and problems started to appear.\n\nAt first the damage did not look large. The backend service logged complaints about its Redis connections, but the overall service graphs did not collapse straight away. When the restarts settled, the Redis cluster was partly degraded: some replicas could not rejoin because they still held stale information about which master they belonged to.\n\nWhile those replicas were being repaired and resynced, the backend service was hit by reconnect storms and higher load and began failing with out-of-memory errors, one after another. The team at first blamed the wrong component for the OOM failures. Scaling the backend was slow because there was little spare capacity. Readiness probes made it worse: a pod marked ready was flooded with traffic, fell back to not ready, and pushed its load onto the next pod, which then failed the same way.","ko":"8:10 UTC 에 팀은 Redis 스테이트풀셋을 고아 삭제했다. 고아 삭제는 파드와 디스크는 그대로 두고 스테이트풀셋 객체만 지우는 방식이다. 스테이트풀셋을 다시 만들자 쿠버네티스가 8:10 부터 8:15 UTC 사이에 스테이트풀셋 전체를 빠르게 재시작했다. 처음에는 백엔드 서비스가 Redis 연결 오류를 냈지만 서비스 전체가 곧바로 무너지지는 않았다.\n\n재시작이 끝나자 Redis 클러스터 일부가 망가진 채 올라왔다. 일부 레플리카가 클러스터에 제대로 다시 붙지 못했고, 자신이 어느 마스터에 속하는지 옛 정보를 그대로 갖고 있었다.\n\n8:15 부터 8:35 UTC 까지 Redis 클러스터를 손으로 복구하는 동안, 백엔드 서비스는 재연결 폭주와 늘어난 부하를 받았고 OOM 오류가 연쇄적으로 났다. 팀은 처음에 어느 구성 요소가 OOM 으로 죽는지 잘못 짚었다. 백엔드를 늘리려 해도 남은 용량이 부족해 늦어졌다.\n\n파드가 ready 가 되면 곧바로 트래픽이 몰려 다시 not ready 로 돌아갔고, 그만큼 다음 파드에 부하가 쏠리는 일이 되풀이되었다. 8:35 부터 8:50 UTC 까지 OOM 오류를 잡은 뒤 서비스는 응답이 느린 채로 돌아왔다."},"updated_at":"2026-11-10T00:00:39.157359Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0063","org_slug":"adguard-vpn"}