기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
| kind | slug | title | topic | topicName | project | status | lastVerifiedOn | sourceRevision | source | assets | evidence | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | two-ways-to-lose-a-node | 노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것 | losing-a-node-or-the-store | PostgreSQL 을 내리고 노드 전원을 뽑았을 때 | keycloak-session-store | 게시 전 | cdac9b8178391311d8eca1ebc6cac15bb62d79af |
|
|
|
노드를 잃는 두 가지 — 저장소가 같이 죽는 것과 들어갈 길이 없는 것
두 노드짜리 클러스터에서 노드를 하나씩 잃어 봤다. 워커를 끊었더니 외부 응답이 503 이 됐고, 컨트롤 플레인을 끊었더니 연결 자체가 되지 않아 000 이 됐다. 복구는 양쪽 다 가상머신을 다시 켜고 60 초였다.
관계
- 200 과 토큰을 받은 로그인 네 건이 데이터베이스에 없었다 거기서는 데이터베이스 프로세스만 끊었고 여기서는 데이터베이스가 노드째 사라진다. 잃는 범위가 달라서 복구 절차도 갈린다.
- readiness 가 깨진 노드를 시야에서 먼저 치운다 죽은 노드의 파드가 READY 로 남고 살아 있는 노드의 파드가 READY 에서 빠지는 역전이 이 실험에서 나왔다.
- 장애 시간의 대부분은 알아채는 데 걸린다 그 기준의 근거가 이 실험의 타이머 두 개다. 감지가 끝나기 전에는 복구 절차가 시작되지도 않는다.
문제
이 실험대는 노드가 둘이고 그중 kc-lab-2 한 곳에 PostgreSQL 과 Redis 가 몰려 있다. 진입 경로인 traefik 과 k3s API 서버는 반대쪽 kc-lab-1 에 있다.
워크로드를 두 노드에 나눠 띄웠으니 노드 하나를 잃어도 서비스가 이어져야 한다. 그런데 어느 노드를 잃느냐에 따라 남는 것이 다르고, 그것을 재기 전에는 두 경우가 같은 장애로 보인다.
결론
노드 하나를 잃으면 양쪽 다 전면 장애였고 원인은 달랐다.
워커(kc-lab-2) 상실 : 외부 응답 503 · kubectl 정상 · 원인은 데이터베이스가 같이 죽은 것 컨트롤 플레인(kc-lab-1) 상실 : 외부 응답 000 · kubectl 불통 · 원인은 들어갈 길이 없는 것 복구 : 양쪽 다 가상머신을 다시 켜고 60 초
컨트롤 플레인을 잃었을 때 keycloak-0 은 계속 돌고 있었다. 워크로드를 이중화해도 진입점이 한 노드에 있으면 그 노드가 서비스의 상한이 된다.
죽은 노드의 파드가 더 건강해 보였다.
전원이 끊긴 kc-lab-2 의 keycloak-0 : Running · READY true · up 0 살아 있는 kc-lab-1 의 keycloak-1 : Running · READY false · up 1
파드 상태는 그 노드의 kubelet 이 보고하는데 노드가 죽으면 보고할 주체가 없어져 마지막 값이 그대로 보인다.
축출까지 걸리는 시간은 설정 두 개의 합이다.
node-monitor-grace-period : 40 초 NoExecute taint 의 tolerationSeconds : 300 초 합 : 5 분 40 초
그리고 축출이 시작돼도 StatefulSet 인 keycloak-0 은 대체 파드를 만들지 않았다. 이름이 같아야 하므로 Terminating 이 풀리기를 기다린다.
검증 환경
클러스터 : k3s · 노드 둘 kc-lab-1 : k3s server (컨트롤 플레인) · traefik · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 진입 경로 : 호스트 nginx 가 TLS 를 끝내고 traefik 으로 넘긴다 Keycloak 워크로드 종류 : StatefulSet 노드를 죽인 방법 : virsh destroy · 게스트에 종료 신호를 보내지 않는다 복구 방법 : virsh start
실험대 test-server : Arch Linux, 12GB, WiFi only
측정일 : 증거 원문에 시각만 있고 날짜가 없다
재현 조건
-
노드 둘로 클러스터를 띄우고 한쪽에 데이터베이스를, 다른 쪽에 진입 경로를 둔다. 어느 노드에 무엇이 있는지를 먼저 적어 두지 않으면 두 결과를 가를 수 없다.
-
워커 노드의 전원을 끊는다. virsh destroy 로 끊는다. 게스트에 종료 신호가 가지 않아야 갑작스러운 상실이 된다.
-
끊은 시각을 적고 15 초 간격으로 세 가지를 함께 기록한다. 노드 상태 : kubectl get nodes 파드 상태와 노드 이름 : kubectl get pods -o wide 외부 응답 코드 : 진입점 주소로 요청
-
같은 시점에 Prometheus 의 up 지표도 읽는다. 파드 상태와 어긋나는지 보려는 것이므로 따로 읽어야 한다.
-
파드의 tolerationSeconds 를 조회하고 축출이 실제로 일어나는 시점까지 30 초 간격으로 관찰한다. kubectl get pod -o jsonpath='{.spec.tolerations}'
-
대체 파드가 만들어지는지 워크로드 종류별로 나눠 본다. StatefulSet 과 Deployment 의 동작이 여기서 갈린다.
-
virsh start 로 노드를 되살리고 외부 응답이 돌아오기까지 걸리는 시간을 잰다.
-
컨트롤 플레인 노드로 2 번부터 다시 한다. 이때는 kubectl 이 통하지 않으므로 외부 응답과 호스트 쪽 기록만으로 관찰한다.
본문
무엇이 어느 노드에 있었나
노드는 둘이다. kc-lab-1 이 k3s server 이자 컨트롤 플레인이고 traefik 과 keycloak-1 이 여기 있다. kc-lab-2 는 agent 이고 keycloak-0 과 PostgreSQL, Redis 가 함께 있다. 호스트의 nginx 가 TLS(Transport Layer Security) 를 끝내고 kc-lab-1 의 traefik 으로 넘긴다.
Keycloak 은 두 노드에 하나씩 떠 있어 이중화돼 있다. 저장소는 그렇지 않다. PostgreSQL 과 Redis 가 kc-lab-2 에만 있으므로 그 노드를 잃으면 세션을 읽고 쓸 곳이 함께 사라진다. 이 배치가 아래 두 결과를 갈랐다.
워커를 끊었더니 살아남은 Keycloak 이 쓸모가 없었다
virsh destroy 로 kc-lab-2 의 전원을 끊었다. 이 명령은 게스트에 ACPI(Advanced Configuration and Power Interface, 전원 관리 규격) 신호를 보내지 않고 가상머신 프로세스를 끊으므로, 게스트 쪽에서는 예고가 없다. virsh shutdown 은 쓰지 않았다. 그쪽은 종료 신호를 보내서 kubelet 이 정상 종료하고 파드를 정리하므로, 쿠버네티스가 정상적인 노드 이탈로 처리해 이 실험의 발견 두 개가 통째로 안 나온다.
=== 워커 노드(kc-lab-2) 전원 차단 — virsh destroy 는 종료 신호가 없다 ===
차단 시각: 12:07:43
Domain 'kc-lab-2' destroyed
+45초 node=NotReady | keycloak-0=Running | 외부 HTTP 503
기록을 처음부터 따라가면 응답 코드가 두 번 바뀐다. +15초 와 +30초 에는 노드가 아직 Ready 였고 외부 응답이 000 이었다. +45초 부터 노드가 NotReady 로 바뀌면서 응답이 503 이 됐고, 관찰을 멈춘 +180초 까지 503 이 이어졌다. 처음 40 초 동안 연결 자체가 안 된 이유를 증거 원문은 진입점 nginx 의 upstream 으로 적어 두었는데, 그 제목 아래에 출력이 없어서 여기서는 그 40 초의 원인까지는 말하지 못한다.
살아남은 쪽은 kc-lab-1 의 keycloak-1 이다. 프로세스는 돌고 있었지만 세션을 읽을 PostgreSQL 이 같은 순간에 사라졌으므로 로그인도 토큰 갱신도 처리할 수 없었다. 이 구성에서 워커 상실은 Keycloak 을 한 대 잃는 사건이 아니라 데이터베이스를 잃는 사건이다.
컨트롤 플레인을 끊었더니 돌고 있는데 닿을 수 없었다
kc-lab-1 을 같은 방법으로 끊으면 외부 응답이 503 도 아니고 000 이 된다. TLS 를 끝낸 nginx 가 넘길 traefik 이 없어서 연결이 맺어지지 않는다. 같은 노드에 있던 k3s API 서버도 사라지므로 kubectl 이 통하지 않고, 그래서 클러스터 안을 들여다보며 진단할 방법도 함께 없어진다.
이때 kc-lab-2 의 keycloak-0 은 계속 돌고 있었다. 같은 노드에 PostgreSQL 도 있으니 세션을 읽을 수도 있었다. 그런데 밖에서 들어갈 길이 없어서 장애였다.
| 무엇을 잃었나 | 밖과 안에서 무엇이 보였나 |
|---|---|
| 워커 kc-lab-2 | 외부 503 · kubectl 정상 · 데이터베이스가 같이 죽었다 |
| 컨트롤 플레인 kc-lab-1 | 외부 000 · kubectl 불통 · 들어갈 길이 없다 |
복구는 양쪽 다 virsh start 이후 60 초로 같았다. 복구 시간이 같아도 대비하는 방법은 갈린다. 워커 쪽은 저장소를 두 노드에 나누거나 밖으로 빼는 문제이고, 컨트롤 플레인 쪽은 진입점을 이중화하는 문제다.
죽은 파드가 더 건강해 보였다
여기까지가 노드를 잃는 두 경우를 가르려고 잰 것이고, 재는 동안 예상하지 못한 것이 셋 더 나왔다. 파드 상태가 뒤집혀 보이는 것과, 축출까지 5 분 40 초가 걸리는 것과, StatefulSet 이 대체 파드를 만들지 않는 것이다. 이 절과 다음 절이 그 셋을 적는다.
워커를 끊은 동안 kubectl get pods -o wide 와 Prometheus 를 같은 순간에 읽으면 두 값이 어긋난다.
| 어느 노드의 파드인가 | kubectl 과 up 이 말한 것 |
|---|---|
전원이 끊긴 kc-lab-2 의 keycloak-0 |
Running · READY true · up 0 |
살아 있는 kc-lab-1 의 keycloak-1 |
Running · READY false · up 1 |
파드 상태는 그 파드가 있는 노드의 kubelet 이 API 서버에 보고한다. 노드가 죽으면 보고하는 주체가 함께 사라지므로 아무도 그 상태를 갱신하지 못하고, 마지막으로 보고된 Running 과 READY true 가 화면에 계속 떠 있게 된다. 반대쪽 keycloak-1 은 살아 있으니 kubelet 이 계속 보고하는데, 데이터베이스를 잃어 readiness 프로브가 실패하므로 READY false 로 정직하게 내려간다.
Prometheus 는 kc-lab-1 에 있어서 살아남았고 keycloak-0 을 긁지 못해 up 을 0 으로 적었다. 두 화면 가운데 kubectl 쪽이 뒤집혀 있었다. 이 실험에서 kubectl get pods 의 STATUS 를 그대로 믿었다면 살아 있는 노드를 장애로, 죽은 노드를 정상으로 판단했을 뻔했다.
6 분 가까이 아무 일도 일어나지 않는다
노드가 죽었다고 쿠버네티스가 곧바로 파드를 옮기지는 않는다. 두 단계를 거친다. 컨트롤러가 노드를 NotReady 로 판정하기까지 node-monitor-grace-period 40 초가 걸리고, 그렇게 붙은 NoExecute taint 를 파드가 견디는 시간이 tolerationSeconds 300 초다. 이 실험대에서 조회한 파드의 tolerationSeconds 는 not-ready 와 unreachable 양쪽 다 300 이었다. 40 초 쪽은 조회하지 않았다. 쿠버네티스 기본값을 그대로 적었고, 이 실험대가 직접 읽어 확인한 것은 tolerationSeconds 쪽이었다. 설정값을 더하면 5 분 40 초다.
관찰 기록에서는 축출이 이렇게 나타난다.
+240초 a2-probe:Running keycloak-0:Running keycloak-1:Running postgres-7b474b88c8-2gf27:Running
+270초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+420초 a2-probe:Terminating keycloak-0:Terminating keycloak-1:Running postgres-7b474b88c8-2gf27:Terminating postgres-7b474b88c8-9cmsv:Pending
+240초 까지 네 파드가 전부 Running 이었고 +270초 에 세 파드가 한꺼번에 Terminating 으로 바뀌었다. 다만 이 관찰 기록의 + 가 앞의 응답 코드 기록과 같은 시각에서 출발했는지는 원문이 말해 주지 않는다. 응답 코드를 적은 증거 파일은 머리말에 차단 시각: 12:07:43 을 적어 두었는데 축출을 적은 파일에는 그 줄이 없다. 그래서 관측된 +270초 와 설정값의 합 5 분 40 초를 빼서 쓰지는 않는다. 설정으로 정해진 값과 눈으로 본 전환이 각각 이 크기라는 데까지가 이 측정이 대는 것이다.
바뀐 뒤로도 화면은 움직이지 않았다. keycloak-0 은 +420초 까지 Terminating 이었고 대신 들어올 파드가 나타나지 않았다. StatefulSet 은 파드 이름이 안정적이어야 하므로 같은 이름을 두 개 띄울 수 없고, Terminating 인 파드가 완전히 지워지기 전에는 대체를 만들지 않는다. 노드가 죽어 지워지지 못하면 계속 기다린다. Deployment 인 PostgreSQL 쪽은 이름 제약이 없어 새 파드가 곧바로 만들어졌는데, 그 파드는 +420초 까지 Pending 에서 움직이지 않았다.
장애 시간을 나눠 보면 복구 절차가 차지하는 몫이 작다. 노드를 되살리고 나서 서비스가 돌아오기까지는 60 초이고, 그 앞에는 클러스터가 노드의 죽음을 인정하고 축출을 시작하기까지 기다리는 구간이 있다. 이 실험이 대는 것은 그 구간이 복구 구간보다 길었다는 방향까지이고, 그 길이가 5 분 40 초였다는 것까지는 아니다.
분단은 이 두 가지에 들어가지 않는다
노드 사이를 갈라 보는 실험(A-5)은 같은 종류의 전면 장애를 만들지 못했다. 한 방향만 막으면 열린 방향으로 다시 연결되므로 클러스터가 갈라지지 않고, 양방향을 다 막으면 갈라지기는 하는데 한쪽만 DOWN 이 되어 코디네이터 쪽이 살아남는다. 분단된 쪽은 스스로 로드밸런서에서 빠지므로 서비스는 이어진다.
그래서 이 실험대에서 전면 장애로 가는 길은 둘뿐이다. 저장소가 같이 죽거나, 들어갈 길이 사라지거나.
이번에 재지 않은 것
저장소를 두 노드에 나눠 배치한 구성에서는 재지 않았다. 워커 상실이 데이터베이스 상실과 같은 사건이 된 것은 PostgreSQL 과 Redis 가 kc-lab-2 한 곳에 있었기 때문이고, 그 결과는 이 배치에 걸려 있다. 저장소를 복제하거나 클러스터 밖에 두면 워커 상실의 결과가 달라진다.
컨트롤 플레인을 잃은 동안 keycloak-0 이 어디까지 처리할 수 있었는지도 재지 않았다. 밖에서 들어갈 길이 없어 요청을 보낼 방법이 없었고, 노드 안에서 직접 요청을 넣어 확인하지는 않았다.
워커 상실의 처음 40 초가 000 이었던 이유는 증거 원문에 제목만 있고 출력이 없다.