1. 개요
2026년 9월 24일, DB 를 타는 API 가 전부 504 를 냈다. 12분이었다. 파드는 1/1 Running 이었고 홈페이지는 200 이었다. 원인을 따라가 보니 출발점은 CI 였다. 여러 저장소의 PR 을 33초 안에 연달아 머지한 것. 이 글은 그 인과 사슬을 처음부터 끝까지 재구성한 기록이다. 하드웨어 고장이 아니었다는 걸 아는 데만 한참 걸렸다.
2. 핵심 내용
2-1. 증상
- DB 를 쓰는 API 전부 504. 정적 셸을 내는 홈페이지는 200.
- 백엔드 파드는
1/1 Running. readiness 가/actuator/health/readiness라 DB 없이도 Ready 다. - MySQL 파드는 CrashLoopBackOff. 로그에
Read-only file system.
“파드가 살아 있고 홈페이지가 열린다” 는 판단 기준으로는 전면 장애를 놓친다. 이것이 첫 교훈이었다.
2-2. 인과 사슬
flowchart TD A[여러 PR 을 33초 안에 연달아 머지] --> B[같은 노드의 CI 러너 파드들이<br/>4분간 디스크를 연속 쓰기] B --> C[Longhorn 레플리카 쓰기 지연 8.7초<br/>엔진 타임아웃 8초 초과] C --> D[엔진이 레플리카를 끊고<br/>처리 중 쓰기를 I/O 에러로 반환] D --> E[iSCSI 타깃 tgtd 가<br/>SCSI Medium Error 로 번역] E --> F[게스트 ext4 가 저널을 포기하고<br/>읽기 전용으로 remount] F --> G[MySQL 쓰기 실패 → CrashLoop] C -.->|25초 뒤| H[Longhorn 볼륨은 healthy 복귀] H -.->|그러나| F
- 트리거: PR 여러 개를 연달아 머지했다. 각 저장소의 main 빌드가 동시에 시작됐다.
- 디스크 폭주: CI 러너(ARC, DinD 사이드카)는 운영 워크로드와 같은 노드, 같은 물리 NVMe 위에 있었다. 이미지 빌드는 4분간 끊임없이 쓴다.
- Longhorn 타임아웃: Longhorn 은 레플리카 쓰기에 8초 타임아웃을 둔다. 실측 지연은 8.7초였다. 0.7초 차이.
- 레플리카 분리: 엔진이 타임아웃 난 레플리카를 끊고, 처리 중이던 쓰기를 I/O 에러로 돌려줬다.
- Medium Error: Longhorn 볼륨은 iSCSI 로 노드에 붙는다. iSCSI 타깃(tgtd)은 그 I/O 에러를 SCSI
Medium Error로 번역해 게스트 커널에 준다. 커널 입장에서는 디스크 표면이 깨졌다는 신호다. - ext4 읽기 전용: ext4 는 저널 쓰기가 Medium Error 로 실패하면 데이터 보호를 위해 파일시스템을 읽기 전용으로 remount 한다. 이건 설계된 동작이다.
- 25초 뒤 볼륨은 정상: Longhorn 은 레플리카를 다시 붙여 healthy 로 돌아왔다. 하지만 게스트 안의 마운트는 이미 잠겼다. 볼륨 상태와 파일시스템 상태는 다른 층이다.
2-3. 복구
kubectl delete pod mysql-0 -n <ns>이게 전부다. 파드가 다시 뜨면서 볼륨이 재마운트되고, MySQL 은 InnoDB crash recovery 를 돌려 47초 만에 올라왔다. 데이터 손상은 없었다. 트랜잭션 로그가 제 역할을 했다.
복구 자체는 쉬웠다. 어려웠던 건 “디스크를 교체해야 하나” 를 판단하는 것이었다. dmesg 의 Medium Error 는 보통 물리 디스크 고장 신호다. zpool 상태와 SMART 를 확인하고 나서야 이게 iSCSI 타깃이 만든 신호라는 걸 확신했다.
2-4. 원인은 셋이고, 하나만 고쳐도 안 난다
- 러너와 운영이 같은 디스크를 쓴다. 러너를 다른 노드(또는 다른 스토리지)로 분리하면 안 난다.
- 8초 타임아웃. 이 값을 늘리면 안 난다. 다만 다른 장애의 감지가 늦어진다.
- 연속 머지. 같은 저장소의 다음 머지는 앞 main 빌드가 끝난 뒤에 하면 안 난다.
세 조건이 겹쳐야 터진다. 그래서 어느 하나만 고쳐도 재발은 막을 수 있지만, 하나에만 의존하면 그 하나가 흔들릴 때 다시 터진다. 러너 동시 실행 상한을 1 로 내리고, 연속 머지 금지를 운영 규칙으로 적었다. 스토리지 분리는 홈랩 하드웨어 문제라 남아 있다.
며칠 뒤 러너 1개만 돌아도 세 노드의 io PSI 가 함께 튀는 것을 확인했다. VM 세 대의 디스크가 물리 NVMe 하나에 있으니 local-path 든 Longhorn 이든 물리 경쟁은 그대로다. 동시 실행 상한은 방어선일 뿐 근본 해법이 아니다.
2-5. 비슷한 시기의 다른 정지: 메모리 압력
같은 주에 다른 두 서비스의 백엔드가 같은 초 에 프로브 타임아웃을 냈다. 그 순간 노드 CPU 는 2% 였다. OOMKilled 도 없었다.
원인은 메모리 여유가 없는 것이었다. 호스트 RAM 이 84% 라 balloon 이 VM 에 더 못 주고, 게스트의 Committed_AS 가 151%, 파드 memory requests 합이 allocatable 의 91% 였다. /proc/pressure/memory 의 full avg10 이 이웃 노드의 10배. 여기에 RollingUpdate 가 구·신 파드를 겹쳐 배포 순간 메모리를 두 배로 밀어 정점을 만들었다.
고치는 순서가 중요했다. CPU requests 부터 올리면 스케줄이 더 막힌다. 메모리를 먼저 풀고, 러너 노드를 분리하고, 그 다음 CPU 다.
2-6. 오진 두 건
- CI 잡이
log not found로 중단되면 “내 코드가 깨뜨렸다” 고 생각하기 쉽다. 실제로는 러너 파드의 메모리 압박이었다. cgroup v2 의 direct reclaim 은 OOMKilled 기록 없이 프로세스를 멈출 수 있다. 재실행부터. - 어느 날의 CI 실패를 “러너 오설정” 으로 결론 내렸는데, 실제 원인은 GitHub 조직의 결제 실패였다. 인프라를 의심하기 전에 계정 상태를 본다.
3. 마무리
요약
Medium Error는 디스크 고장이 아닐 수 있다. iSCSI 타깃이 만든 신호인지 먼저 본다.- 볼륨이 healthy 로 돌아와도 게스트 마운트는 잠긴 채다. 파드를 다시 띄워 재마운트한다.
- 파드 Running 과 홈페이지 200 은 “살았다” 의 증거가 아니다. DB 를 타는 API 를 찍어야 한다.
- 원인이 셋이면 하나만 고쳐도 재발은 막지만, 하나에만 의존하지 않는다.
- 압력은 OOMKilled 없이도 서비스를 멈춘다.
/proc/pressure/memory를 본다.
다음은 PWA 오프라인이다.