{"id":"IL-0271","org":"Discord","date":"2020-03-16","docs":["https://github.com/etcd-io/etcd"],"title":{"en":"An empty etcd service entry crashed Discord's Elixir chat nodes, and Discord was down for most users for an hour","ko":"서비스 디스커버리에 잘못된 항목이 들어가 Elixir 서비스들이 한꺼번에 죽으면서 Discord 가 대부분의 사용자에게 한 시간 동안 열리지 않았다"},"domain":["database","gcp"],"impact":{"scope":"Discord real-time connections and chat; Discord was unavailable for most users for about an hour and the API returned increased errors","users":"most Discord users","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"Discord's service discovery code handled many etcd failure modes but did not expect a client to write a key without a value because of a transient network error. Go's net/http accepts a request whose body is shorter than its Content-Length header, which breaks the HTTP 1.1 specification and added a failure case nobody had anticipated.","ko":"서비스 디스커버리 시스템은 etcd 안의 여러 장애에는 견디도록 만들었지만, 일시적인 네트워크 오류로 값 없는 키가 쓰일 수 있다는 것은 예상하지 못했다. 이 경우 Go 의 net/http 는 HTTP 1.1 규격을 어겨, Content-Length 헤더가 있고 본문이 불완전한 요청을 거부하지 않는다. 이 때문에 예상하지 못한 장애 상황이 하나 더 생겼다."},"related":[],"sources":[{"url":"https://discordstatus.com/incidents/62gt9cgjwdgf","kind":"official-status","archived":"https://web.archive.org/web/20260714012610/https://discordstatus.com/incidents/62gt9cgjwdgf"}],"summary":{"en":"Discord stores service discovery information in etcd through the etcd v2 API. A discoverable service announces itself by setting a key with a 60-second TTL in its cluster's directory and keeps re-announcing it as a heartbeat; the key's value is JSON metadata. Discord's real-time chat services use service discovery to find the RPC endpoint they call to load data from the databases when a user connects or when a guild is created or reloaded. The etcd client uses Python's built-in HTTP client, which sends the PUT request with its Content-Length header in one packet and the urlencoded body in a second packet. etcd's v2 keys API parses the form with the Go standard library's net/http Request.ParseForm() method, which does not check that the body length matches the Content-Length header.\n\nAt 14:18 PDT a new API node, api-prd-main-m1ds, joined the API cluster during normal autoscaling before peak hours. Its connection was reset between the two packets, so etcd stored the key with an empty string as the value. The Elixir nodes watching the \"discord_api\" service failed to deserialize the entry and crash-looped, and the supervisor's \"max restart intensity\" restarted whole nodes on about half of the watching nodes across several clusters. The crashes lasted until the API node retried its announcement and rewrote the key, but by then Discord had lost most of its real-time capacity and the failure had cascaded into dependent systems. A SEV1 incident was declared at 14:21.\n\nEngineers restarted the lost capacity, throttled connections to Discord and stopped anti-entropy repairs on two ScyllaDB clusters so the reconnecting users would not starve them of resources. From 15:07 to 15:26 the connection throttle was raised step by step as services recovered, and at 15:32 the service was declared fully operational. A mitigation was deployed at 23:00, and engineers reproduced the failure in the development environment.","ko":"Discord 는 서비스 디스커버리 정보를 etcd 에 저장하고 etcd v2 API 를 쓴다. 서비스는 자기 클러스터 디렉터리에 TTL 60초짜리 키를 쓰고 하트비트로 다시 등록한다. 실시간 채팅 서비스는 사용자가 접속하거나 guild 를 처음 만들거나 데이터베이스에서 다시 불러올 때, 이 서비스 디스커버리로 데이터를 읽는 RPC 엔드포인트를 찾는다.\n\n14:18 PDT 에 오토스케일러가 API 클러스터에 새 노드를 넣었다. 하루에 수백 번 일어나는 평범한 일이다. Discord 의 etcd 클라이언트는 Python 내장 HTTP 클라이언트로 PUT 요청과 Content-Length 헤더를 한 패킷에, 요청 본문을 다른 패킷에 보낸다. 이 노드가 자신을 등록할 때 첫 패킷을 보낸 뒤 연결이 끊겼다. etcd 는 Go 표준 라이브러리 net/http 의 Request.ParseForm() 으로 본문을 읽는데, 이 메서드는 본문 길이가 Content-Length 와 맞는지 확인하지 않는다. 그래서 키가 빈 문자열 값으로 쓰였다. 이 잘못된 키를 읽은 Elixir 노드 대부분에서 서비스 감시 프로세스가 JSON 파싱에 실패해 크래시 루프에 빠졌다. \"max restart intensity\" 가 발동해 노드 전체가 재시작되면서 실시간 시스템 용량이 한순간에 사라졌다. API 노드가 다시 등록하며 키를 고칠 때까지 이 상태가 이어졌다.\n\n엔지니어들은 잃은 용량을 다시 띄우고, Discord 접속을 조절하고, ScyllaDB 클러스터 두 곳의 anti-entropy repair 를 멈췄다. 처음에는 etcd watcher 한도를 의심해 런타임 설정으로 올려 보려 했다. 서비스가 회복되는 대로 접속 허용량을 15:07 부터 15:26 까지 계속 늘렸고, 15:32 에 상태 페이지 사고를 해결했다. 원인을 다 이해한 뒤 23:00 에 재발을 막는 완화책을 운영에 배포했다. 개발 환경에서 같은 문제를 재현했다."},"trigger":{"en":"At 14:18 the Google Cloud managed instance group autoscaler added a node to Discord's API cluster, and the TCP connection of its first announce request to etcd was reset after the request headers were sent but before the body.","ko":"2020년 3월 16일 14:18 PDT 에 Google Cloud 관리형 인스턴스 그룹 오토스케일러가 API 클러스터에 새 노드를 넣었고, 이 노드가 etcd 에 자신을 등록하는 도중 연결이 끊겨 값이 빈 키가 서비스 디스커버리에 쓰였다."},"severity":"sev1","symptoms":["service-unavailable","high-error-rate"],"timeline":{"detected_after":"3m","resolved_after":"1h 14m"},"date_kind":"incident","detection":{"en":"At 14:21 several internal alarms fired on a drop in key metrics, along with anomaly alerts for cluster utilization, and a SEV1 incident was declared.","ko":"14:21 PDT 에 핵심 지표가 떨어졌다는 알림과 클러스터 사용률 이상 알림이 여럿 울렸고, SEV1 이 선언됐다."},"components":["coordination-store","service-discovery"],"root_cause":"software-bug","remediation":[{"text":{"en":"Restarted the lost real-time capacity and repaired the failed nodes","ko":"잃은 실시간 용량을 다시 띄우고 장애가 난 노드를 고쳤다."}},{"text":{"en":"Throttled connections to Discord to help services recover","ko":"서비스 복구를 돕기 위해 Discord 로 들어오는 접속을 조절하기 시작했다."}},{"text":{"en":"Stopped anti-entropy repairs on two ScyllaDB clusters so reconnecting users would not starve them of resources","ko":"모두가 다시 접속하는 동안 자원이 바닥나지 않도록 ScyllaDB 클러스터 두 곳의 데이터베이스 유지 작업인 anti-entropy repair 를 멈췄다."}},{"text":{"en":"Raised the connection throttle step by step to let more users reconnect","ko":"서비스가 회복되는 대로 접속 허용량을 계속 늘려 더 많은 사용자가 다시 접속하게 했다."}}],"action_items":[{"text":{"en":"Make the etcd clients send each announce request in a single TCP packet, so headers and body arrive together or not at all","ko":"값이 잘못된 키가 쓰일 가능성을 줄이려고, etcd 클라이언트가 자신을 등록하는 요청을 TCP 패킷 두 개가 아니라 하나로 보내게 바꿨다."}},{"text":{"en":"Add error handling that ignores services whose key value is corrupt instead of crash-looping","ko":"키 값이 깨진 서비스는 무시하도록 오류 처리를 더해, 같은 일이 다시 나도 그 서비스가 발견되지 않을 뿐 크래시 루프에 빠지지 않게 했다."}},{"text":{"en":"File an upstream bug report with the Go project about net/http not checking the body against Content-Length","ko":"Go 프로젝트에 이 문제를 버그로 보고한다."}}],"contributing":["automation-amplified"],"recurrence_of":null,"symptoms_text":{"en":"On 16 March 2020 at 14:19 PDT most of Discord's Elixir services, which handle real-time connections and chat message processing, crashed at once. Discord lost much of its real-time capacity, the failure cascaded into dependent systems, and most users could not connect for about an hour.\n\nAt 14:21 several internal alarms fired on a drop in key metrics, together with anomaly alerts for cluster utilization. The status page reported that a number of Discord servers were completely unavailable, and later that a series of fatal errors had made the majority of servers unavailable. A minute before the crash, a newly added API node, api-prd-main-m1ds, had logged \"http.client.RemoteDisconnected: Remote end closed connection without response\" while announcing itself to service discovery. Almost all Elixir nodes then logged that the service watcher for the \"discord_api\" service had crashed while parsing the JSON metadata stored in the key's value in etcd.\n\nThe service discovery processes crash-looped on the invalid data. Their supervisor saw them crashing too often, hit \"max restart intensity\" and restarted the whole node, which happened at the same moment on about 50% of the nodes watching for API nodes, across several clusters. By 14:55 engineers had tied the outage to a spike of errors from the service discovery modules. The first explanation was that Discord had hit a cap on the number of watchers in etcd, and engineers tried to raise it through runtime configuration while they kept repairing failed nodes.","ko":"2020년 3월 16일 14:19 PDT 에 실시간 연결과 채팅 메시지를 처리하는 Elixir 서비스 대부분이 죽어 용량이 한순간에 사라졌고, 여기에 의존하는 다른 시스템으로 장애가 번졌다. 14:21 에 핵심 지표가 떨어졌다는 알림과 클러스터 사용률 이상 알림이 여럿 울렸고 SEV1 이 선언됐다. 대부분의 사용자가 Discord 에 접속하지 못했다.\n\n장애 직전인 14:18 에 API 트래픽을 처리하는 노드 api-prd-main-m1ds 가 API 클러스터에 들어오며 etcd 에 자신을 등록하다가 \"http.client.RemoteDisconnected: Remote end closed connection without response\" 오류를 로그에 남겼다.\n\n거의 동시에 Elixir 노드 대부분의 로그에, \"discord_api\" 서비스를 감시하는 프로세스가 etcd 키 값에 담긴 JSON 메타데이터를 파싱하다 죽었다는 기록이 남았다. 이 프로세스들은 예상하지 못한 역직렬화 오류로 크래시 루프에 빠졌다. 프로세스가 너무 자주 죽는다고 슈퍼바이저가 판단해 \"max restart intensity\" 가 발동했고, 노드 전체가 재시작됐다. 여러 클러스터에 걸쳐 API 노드를 감시하던 노드의 약 50% 에서 이 일이 동시에 일어났다.\n\n14:55 에 엔지니어들은 서비스 디스커버리 모듈의 오류 급증이 장애와 시점이 거의 일치한다는 것을 찾았다. 처음에는 etcd 의 watcher 수 한도에 걸렸다고 보고, 런타임 설정으로 한도를 올려 봤다."},"updated_at":"2026-11-14T12:33:52.263497Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0271","org_slug":"discord"}