연쇄 장애
연쇄 장애는 한 곳에서 난 고장이 그곳을 부르던 곳으로 번지는 고장입니다. 번지는 동안 멀쩡하던 기능까지 차례로 멈춥니다. 결제 서버 하나가 느려집니다. 그런데 상품 목록과 로그인까지 안 됩니다. 처음 무너진 곳을 되살려도 잘 안 살아납니다.
쉽고 빠른 이해
한 곳의 고장이 옆으로 옮아 시스템이 차례로 멈추는 고장입니다. 결제 호출이 느려진 탓에 결제와 상관없는 상품 조회까지 답을 못 하는 경우가 그렇습니다.
옮기는 것은 고장 자체가 아니라 기다림입니다. 답을 기다리는 쪽은 그동안 일꾼 하나를 붙들고 있습니다. 붙들린 일꾼이 다 떨어지면 그 쪽도 남의 요청을 못 받습니다.
이렇게 번집니다:
- 어느 한 곳이 느려지거나 멈춥니다
- 그곳을 부르던 쪽이 답을 기다리며 일꾼을 다 씁니다
- 기다리다 지친 쪽이 다시 보내거나 다른 곳으로 몰려 그쪽까지 무너뜨립니다
기다리는 호출·유한한 일꾼·되돌아오는 부하 셋이 겹칠 때만 번집니다. 하나라도 없으면 고장은 한 곳에 머뭅니다.
막으려면 값을 냅니다. 기다림을 끊고 감당 못 할 요청을 버리는 장치를 곳곳에 붙여야 합니다. 그러면 평소에도 일부 요청은 답 대신 거절을 받습니다.
상세
한 곳의 고장이 옆으로 옮는 길
눈길에서 앞차가 갑자기 서면 뒤차가 받습니다. 그 뒤차를 또 뒤차가 받습니다. 처음 선 차는 한 대입니다. 멈춰 선 차는 수십 대가 됩니다.
연쇄 장애가 이렇게 번집니다. 서비스 하나가 느려지면 그 서비스를 부르던 쪽이 멈춥니다. 그 쪽을 부르던 쪽이 또 멈춥니다.
고장이 난 곳은 하나입니다. 사용자가 못 쓰게 된 기능은 여럿입니다.
이 글에서 「곳」은 서로를 불러 쓰는 서버나 저장소 하나를 가리킵니다. 주문 서버가 결제 서버를 부르고 결제 서버가 데이터베이스를 부르면 곳이 셋입니다. 한 곳을 여러 대가 나눠 맡기도 합니다. 뒤에 나오는 「대」는 그렇게 한 곳을 나눠 맡은 서버 하나입니다.
아래는 결제 데이터베이스에서 시작한 고장이 어디까지 갔는지를 그린 것입니다. 화살표는 부르는 방향이 아니라 번지는 방향입니다.
flowchart TD
C["결제 데이터베이스가 느려진다"]
B["결제 서버의 일꾼이 대기에 묶인다"]
A["주문 서버가 결제 답을 기다리며 일꾼을 다 쓴다"]
U["상품 조회까지 답을 못 받는다"]
C --> B --> A --> U
맨 아래 줄이 이 고장의 특징입니다. 상품 조회는 결제를 부르지 않습니다. 그런데도 같이 멈췄습니다.
마이크로서비스처럼 서비스를 잘게 나눈 구조에서 번지는 폭이 넓습니다. 서로를 부르는 선이 많을수록 한 곳에서 시작한 고장이 닿을 수 있는 곳도 많아지기 때문입니다.
번짐을 실어 나르는 기다림
번지는 까닭은 고장이 옮아 다녀서가 아닙니다. 결제 서버가 느려졌다고 주문 서버의 코드가 망가지지는 않습니다. 옮아 가는 것은 기다림입니다.
서버는 들어온 요청을 스레드 풀에 든 일꾼에게 하나씩 맡깁니다. 스레드 풀은 미리 만들어 둔 일꾼 묶음입니다. 일꾼 수는 정해져 있습니다. 매 요청마다 일꾼을 새로 만들면 만드는 비용이 더 커서 이렇게 씁니다.
한 요청이 남의 답을 기다리는 동안 그 요청이 맡은 일꾼은 놀면서도 묶여 있습니다. 다른 요청에 넘겨줄 수 없습니다. 상대가 느려진 만큼 일꾼이 오래 묶입니다. 오래 묶인 일꾼이 늘면 새 요청을 받을 일꾼이 남지 않습니다.
커넥션 풀도 같은 방식으로 묶입니다. 커넥션 풀은 데이터베이스로 가는 연결을 미리 열어 두고 돌려 쓰는 묶음입니다. 연결 수도 정해져 있습니다. 답이 안 오는 질의가 연결을 쥐고 있으면 뒤 요청은 연결을 못 얻습니다.
그래서 늦어지는 것이 멈추는 것으로 바뀝니다. 응답이 늦는 것과 응답이 없는 것은 부르는 쪽에서 보면 같은 일입니다. 늦는 동안 유한한 것이 하나씩 없어집니다.
터지는 조건
셋이 모두 맞을 때 터집니다. 하나라도 빠지면 고장이 한 곳에 머뭅니다.
| 조건 | 무슨 뜻인가 |
|---|---|
| 기다리는 호출 | 한 곳이 다른 곳의 답을 기다리는 동안 요청을 붙들고 있다 |
| 유한한 자원 | 붙들고 있는 것이 수가 정해진 일꾼이나 연결이다 |
| 되먹임 | 한 곳이 무너져도 그 부하가 사라지지 않고 남은 곳으로 옮겨 간다 |
첫 조건은 동기 호출을 말합니다. 동기 호출은 답이 올 때까지 다음 줄로 넘어가지 않는 부르기입니다. 답을 기다리지 않고 넘어가면 일꾼이 안 묶이므로 첫 조건이 깨집니다.
셋째 조건이 이 고장을, 한 건이 반만 되고 마는 부분 실패와 가릅니다. 무너진 곳으로 가던 요청이 그냥 없어지면 거기서 끝납니다. 그 요청이 재시도가 되어 돌아오거나 남은 곳으로 몰리면 그때부터 번집니다.
셋을 차례로 물어 보면 어디서 멈추는지가 갈립니다.
flowchart TD
A["답을 기다리는 호출인가"]
B["붙드는 것이 수가 정해진 자원인가"]
C["빠진 부하가 되돌아오는가"]
S["고장이 한 곳에 머문다"]
D["연쇄 장애"]
A -->|아니오| S
A -->|예| B
B -->|아니오| S
B -->|예| C
C -->|아니오| S
C -->|예| D
아니오가 한 번이라도 나오면 거기서 끝납니다. 예가 셋 이어질 때만 번집니다.
조건은 이 정도로 좁혀야 재현이 됩니다. 「부하가 높으면 터집니다」는 조건이 아닙니다. 조건은 이렇게 적어야 합니다.
일꾼 200개를 나눠 쓰는 주문 서버에서, 결제 서버의 응답을 10초로 늘리고 초당 100건을 넣습니다. 그러면 결제를 쓰지 않는 상품 조회까지 답이 멈춥니다.
최소 재현
한 서버가 두 경로를 열어 두고 일꾼을 나눠 쓰는 것이 제일 작은 재현입니다. 한 경로는 결제 서버를 부릅니다. 다른 경로는 캐시만 읽습니다. 캐시는 비싼 계산의 결과를 저장해 두고 다음번에 다시 계산하지 않는 장치입니다.
// 주문 서버 · 일꾼 200개를 나눠 쓴다
주문() { 결제.승인(); } // 10초를 기다린다
상품() { 캐시.읽기(); } // 곧바로 답한다
두 경로가 같은 일꾼 묶음을 쓴다는 것이 이 재현의 전부입니다. 그림으로 보면 이렇습니다.
flowchart TD
subgraph SRV["주문 서버"]
I1["주문 조회"] --> P["일꾼 200개 · 묶인 200 / 남은 0"]
I2["상품 조회"] --> P
end
P --> X["결제 서버를 부르고 10초 묶인다"]
P --> Y["캐시만 읽으면 곧 끝나지만 받아 줄 일꾼이 없어 시작도 못 한다"]
일꾼 묶음이 하나뿐이라 한쪽이 다 쓰면 다른 쪽은 시작할 수도 없습니다.
초당 100건의 주문 요청이 들어오면 10초 동안 1000건이 쌓입니다. 그중 200건이 대기에 묶입니다. 나머지는 줄을 섭니다.
상품 조회는 캐시에 담아 둔 값만 읽고 끝나는 요청입니다. 넘겨받을 일꾼이 없어서 그마저 답을 못 합니다. 고장은 결제 쪽에서 났습니다. 그런데 사용자는 상품 목록이 안 뜬다고 말합니다.
여기서 타임아웃을 짧게 걸면 첫 번째 번짐이 멈춥니다. 타임아웃은 정해 둔 시간 안에 답이 없으면 기다리기를 그만둡니다. 기다림이 짧아지면 일꾼이 빨리 풀립니다.
다만 타임아웃만으로는 끝나지 않습니다. 끊긴 요청을 그대로 다시 보내면 무너진 쪽이 받는 양은 줄지 않습니다.
번지는 통로
번짐은 아무 데로나 가지 않습니다. 이름 붙은 통로가 몇 개 있습니다.
| 통로 | 무엇이 옮아 가나 |
|---|---|
| 자원 고갈 | 대기에 묶인 일꾼과 연결이 다 떨어져 새 요청을 못 받습니다 |
| 재시도 폭풍 | 실패를 본 쪽이 다시 보낸 요청이 무너진 쪽에 부하로 돌아갑니다 |
| 부하 재분배 | 같은 일을 나눠 맡던 서버 한 대가 빠지면 그 몫이 남은 대들에게 나뉘어 얹힙니다 |
| 캐시 스탬피드 | 캐시가 비면 캐시가 막아 주던 요청이 한꺼번에 뒤쪽으로 갑니다 |
아래 셋은 모두 되먹임입니다. 무너뜨린 원인이 한 바퀴 돌아 더 큰 원인이 되어 돌아옵니다. 부하 재분배로 그려 보면 이렇습니다.
flowchart TD
A["한 대가 빠진다"]
B["그 몫이 남은 대에 얹힌다"]
C["남은 대도 감당할 양을 넘는다"]
A --> B --> C --> A
화살표가 제자리로 돌아옵니다. 한 바퀴 돌 때마다 남은 대는 줄어듭니다. 한 대가 지는 몫은 늘어납니다.
고리를 한 번 돌기 시작하면 처음 빠진 한 대를 되살려도 멈추지 않습니다. 이미 남은 대들이 감당할 양을 넘겼기 때문입니다. 원인을 고친 뒤에도 장애가 안 끝나면 이 고리를 의심합니다.
되살리기가 더 어려운 까닭
멈춘 서버를 다시 켜면 평소 부하가 아니라 그동안 밀린 요청을 한꺼번에 맞습니다. 밀린 쪽에서는 기다리던 요청이 전부 살아 있습니다. 서버가 답하기 시작하는 순간 그것들이 한꺼번에 들어옵니다.
캐시도 비어 있습니다. 평소에 캐시가 받아 내던 요청까지 데이터베이스로 내려가서 한 요청이 지는 비용이 평소보다 큽니다. 부하는 평소보다 많습니다. 한 건을 치르는 값도 평소보다 비쌉니다.
그래서 켜자마자 다시 무너지는 일이 잦습니다. 재기동한 서버가 어디로 가는지는 무엇을 하고 켜느냐가 정합니다.
stateDiagram-v2
S1: 정상
S2: 무너짐
S3: 재기동
S1 --> S2
S2 --> S3
S3 --> S2: 그대로 켜기 · 밀린 요청과 빈 캐시
S3 --> S1: 재시도 멈춤 · 일부 거절 · 조금씩 올리기
되살릴 때는 들어오는 양을 먼저 줄여 놓고 켭니다. 재시도를 밖에서 멈춰 세웁니다. 그다음 일부 요청을 일부러 거절합니다. 그렇게 조금씩 올립니다.
고리를 끊는 수단
고치는 법은 각각 독립된 항목입니다. 무엇을 끊는 수단인지만 적고 넘어갑니다.
| 수단 | 무엇을 끊나 |
|---|---|
| 서킷 브레이커 | 부르기 자체. 무너진 쪽으로 가는 요청을 한동안 안 보냅니다 |
| 벌크헤드 | 자원의 공유. 경로마다 일꾼을 갈라 두어 한쪽이 다 못 먹게 합니다 |
| 부하 차단 | 들어오는 양. 감당 못 할 요청을 받는 쪽에서 미리 거절합니다 |
| 백프레셔 | 밀어 넣는 속도. 앞 단계가 스스로 덜 보내게 합니다 |
| 지수 백오프 | 되먹임. 다시 보내는 간격을 늘려 부하로 돌아가지 않게 합니다 |
이 고장과 가르는 선
가르는 잣대는 번짐입니다. 고장이 처음 난 곳에 머물면 이 고장이 아닙니다.
| 이웃 고장 | 무엇이 다른가 |
|---|---|
| 부분 실패 | 한 건이 반만 되는 고장입니다. 어중간한 상태가 남을 뿐 옆으로 안 옮습니다 |
| 단일 장애점 | 한 곳이 멈추면 다 멈추는 구조입니다. 번지는 과정 없이 처음부터 다 멈춥니다 |
| 병목 | 처리량의 상한입니다. 느려질 뿐 그 자체로는 옆을 무너뜨리지 않습니다 |
| 썬더링 허드 | 한 사건이 대기자를 한꺼번에 깨우는 고장입니다. 번지는 통로 가운데 하나입니다 |
단일 장애점과 헷갈리기 쉽습니다. 둘은 같이 나기도 합니다. 단일 장애점 하나가 멈추면 그것을 기다리던 곳들이 줄줄이 묶입니다. 그때부터 번짐이 시작됩니다.
관련 항목
연쇄 장애가 번지는 통로
재시도 폭풍 · 썬더링 허드 · 캐시 스탬피드 · 부하 재분배 · 커넥션 풀 고갈 · 스레드 고갈 · 큐 적체 · 헤드 오브 라인 블로킹 · 혼잡 붕괴
연쇄 장애를 처음 일으키는 고장
단일 장애점 · 병목 · 과부하 · 부분 실패 · 일시적 장애 · 회색 장애 · 네트워크 분단 · 포화 · 메모리 누수
연쇄 장애를 끊는 장치
서킷 브레이커 · 백프레셔 · 타임아웃 · 벌크헤드 · 부하 차단 · 속도 제한 · 지수 백오프 · 지터 · 우아한 성능 저하
연쇄 장애가 잘 나는 실행 구조
마이크로서비스 · 분산 시스템 · 동기 호출 · 원격 프로시저 호출 · 서비스 메시 · 커넥션 풀 · 스레드 풀 · 로드 밸런서 · 복제
연쇄 장애를 알아채는 수단
분산 추적 · 헬스 체크 · 모니터링 · 경보 · 꼬리 지연 · 응답 시간 · 로그 집계 · 대시보드
연쇄 장애를 미리 재 보는 방법
다른 이름: cascading failure · 연쇄 실패 · 캐스케이드 장애