거짓 CI 알림을 따라 내려가니 CNI 데드락 — 위아래 두 곳을 근본 수정한 기록
정적 분석이 실패해도 배포는 되는데 알림은 배포 실패처럼 울렸다. 그 거짓말을 따라가 CNI 부트스트랩 데드락과 알림 배선 결함을 양쪽 끝에서 각각 root fix한 기록.
사내 홈랩 CI/CD 봇이 Discord에 ❌ 코드 분석 실패를 남겼습니다. 프론트엔드 서비스의 develop → dev 배포 알림이었습니다.
그런데 이 알림은 처음부터 거짓말이었습니다. 이 파이프라인에서 정적 분석은 non-blocking 게이트라, 코드 품질 점수가 낮든 스캔이 깨지든 배포는 그대로 나갑니다. 그리고 실제로 배포는 성공해 있었습니다. “코드 분석 실패”라는 문구는 배포가 깨진 것처럼 울렸지만, 배포는 멀쩡했습니다. 여기까지는 파이프라인을 아는 사람이면 놀랄 것도 없습니다.
거짓 알림은 두 가지를 한꺼번에 말합니다. 하나는 “무언가 실패했다”는 참말이고, 다른 하나는 “그게 코드 분석이다”라는 거짓말입니다. 그 거짓말을 따라 내려가 보니, 이 시스템은 위아래 두 곳이 고장나 있었습니다. 아래에서는 클러스터 CNI가 통째로 멈춰 있었고, 위에서는 그 인프라 장애를 “코드 분석 실패”라고 잘못 이름 붙이는 알림 배선이 있었습니다.
이 글은 그 두 결함을 양쪽 끝에서 각각 root fix한 기록입니다. 거짓 알림으로 들어와서, 알림이 다시는 거짓말하지 않게 고치며 나갑니다.
알림이 이름 붙인 실패와 실제로 일어난 실패가 같다는 보장은 없습니다. 그 간극 자체가 첫 단서였습니다.
빠른 triage: 여기서 두 방향으로 갈라졌다
솔직히 상단 진단은 극적이지 않았습니다. CI run을 열어 job 결과부터 봤습니다.
| Job | 결과 |
|---|---|
| Build | ● 성공 |
| SonarQube | ○ 실패 |
| Trivy | ● 성공 |
| Docker | ● 성공 |
| Smoke | ● 성공 |
| Deploy | ● 성공 |
실패는 SonarQube job 하나뿐이고 배포까지 통과했습니다. 게이트가 non-blocking이라는 걸 알면 이 그림은 이상하지 않습니다. 이어서 SonarQube 서버를 한 번 때려보니 그냥 죽어 있었습니다. 도메인이 503을 뱉었고 upstream connect error ... connection refused가 따라왔습니다. 리버스 프록시는 살아서 요청을 upstream으로 넘겼지만, 백엔드 파드가 연결을 거부한 것입니다. CI 로그도 같은 이야기였습니다. 스캐너가 코드를 분석하다 실패한 게 아니라, 분석을 시작하기도 전에 GET /api/server/version에서 503을 받고 exit 1 한 것뿐이었습니다. 코드 품질과는 무관했습니다.
여기까지는 몇 분짜리 triage입니다. 큰 발견이랄 게 없습니다. 흥미로운 지점은 이 자리에서 질문이 정확히 두 방향으로 갈라진다는 것이었습니다.
- 아래로 — 왜 SonarQube 서버가 2일 넘게 안 살아났나?
- 위로 — 왜 그 단순한 서버 다운이 하필 “코드 분석 실패”라는 배포 알림으로 울렸나?
아래로 내려가면 진짜 근본 원인이 나오고, 위로 올라가면 알림이 거짓말한 이유가 나옵니다. 둘 다 고쳐야 이 사건이 끝납니다. 먼저 아래로 갔습니다.
아래로: 503에서 클러스터 바닥까지
서버가 왜 안 살아나는지는 파드 상태를 보자 빠르게 계단을 타고 내려갔습니다. 각 단계는 다음 단계를 분명하게 가리켰고, 여기서 시간을 끌 이유는 없었습니다.
- SonarQube 앱 파드 — CrashLoopBackOff, 601회 재시작. 부팅 시 DB 연결이 필수인데 DB가 없어 죽고 재시작하기를 반복.
- postgres 파드 —
ContainerCreating에서 2일 5시간 정체. 볼륨이 안 붙어 컨테이너 자체가 안 만들어짐. - PVC —
FailedMount. 스토리지는 Longhorn인데, 볼륨 데이터 경로를 담당하는instance-manager파드가 해당 노드에 아예 없음. - 그 아래 — Longhorn도 결국 파드 간 통신 위에서 도는 워크로드입니다. 마지막으로 CNI를 봤습니다.
바닥에서 원인이 드러났습니다. cilium agent가 3개 노드 전부 0/1. 클러스터 CNI가 통째로 죽어 있었습니다. 위의 CrashLoop도, 2일 5시간 정체도, 붙지 않는 볼륨도 전부 여기서 파생된 그림자였습니다. 상단의 계단들은 각각이 별개의 발견이 아니라, 같은 한 가지 고장을 다른 높이에서 본 것뿐이었습니다.
agent 로그의 시그니처는 이것이었습니다.
level=error msg="... dial tcp <API-ClusterIP>:443: connect: operation not permitted"cilium agent가 kube-apiserver에 못 붙고 있었습니다. 그것도 API의 ClusterIP로 붙다가 operation not permitted로 거부당하면서. 방아쇠를 추적하니 앞선 kube-apiserver 재시작 시점과 맞물려 있었습니다. 진짜 질문은 이제 하나로 좁혀졌습니다. agent는 왜 하필 ClusterIP로 API에 붙으려 했고, 왜 그게 거부됐을까?
여기서부터가 이 사건에서 유일하게 깊이 파야 했던 지점입니다.
진짜 아하: kube-proxy-replacement의 부트스트랩 데드락
이 클러스터의 Cilium은 kube-proxy-replacement=true로 동작합니다. kube-proxy를 걷어내고 Service의 ClusterIP → 실제 엔드포인트 변환을 Cilium이 eBPF 데이터패스에서 직접 프로그래밍하는 모드입니다. 성능과 관측성에서 이점이 크지만, 한 가지 부트스트랩 전제가 숨어 있습니다.
kubernetes Service의 ClusterIP(<API-ClusterIP>:443)도 결국 Cilium이 프로그래밍해야 실체가 생기는 가상 IP라는 점입니다. 평소에는 이 변환이 이미 프로그래밍돼 있으니 누구나 ClusterIP로 API에 붙을 수 있습니다. 문제는 그 변환을 프로그래밍해야 할 주체가 cilium agent 자신이라는 데 있습니다.
여기서 닭-달걀 교착이 성립합니다.
flowchart LR A["cilium agent 시작"] --> B["API 서버에 붙어야 함"] B --> C["설정상 API를 ClusterIP로만 접근"] C --> D["ClusterIP 변환은 데이터패스가 필요"] D --> E["데이터패스는 agent가 프로그래밍"] E --> A- agent가 시작하려면 kube-apiserver에 붙어야 합니다.
- 그런데 설정상 API에 붙는 경로가 ClusterIP뿐입니다.
- ClusterIP가 동작하려면 eBPF 데이터패스에 변환이 프로그래밍돼 있어야 합니다.
- 그 데이터패스를 프로그래밍하는 주체가 바로 지금 시작하려는 agent입니다.
평상시에는 이 고리가 이미 완성돼 있어 문제가 드러나지 않습니다. 하지만 kube-apiserver가 재시작되거나 노드가 재부팅되어 데이터패스가 한 번 깨지면, agent는 API에 못 붙고(ClusterIP 변환이 없어서), API에 못 붙으니 데이터패스를 다시 프로그래밍하지 못합니다. operation not permitted는 프로그래밍되지 않은 ClusterIP로 향한 연결이 거부되는 시그니처였습니다. 스스로 못 빠져나오는 교착입니다.
이 함정의 원인은 cilium-config에 k8s-service-host가 설정돼 있지 않았던 것입니다. 이 값을 지정하면 agent는 ClusterIP를 거치지 않고 API 서버의 실제 주소(control-plane 노드 또는 로드밸런서)로 직접 붙습니다. 값이 비어 있으면 agent는 in-cluster 기본값, 즉 ClusterIP로 붙으려 하고, 위의 데드락에 빠집니다. Cilium 이슈 트래커에도 같은 계열(#34653)로 보고된, kube-proxy-replacement 모드의 잘 알려진 구성 함정입니다.
이 사건에서 실제로 파고들 가치가 있었던 무게중심은 여기 한 곳입니다. 위쪽 계단들은 몇 분이면 통과하는 triage였고, 깊이가 필요했던 건 이 한 겹, “왜 agent가 스스로를 부트스트랩하지 못하는가”였습니다.
고친 방법의 반전: 선언 ≠ 가장 먼저 실행되는 경로
방향은 명확했습니다. agent가 데이터패스에 의존하지 않고 API에 붙게 만들어, 데이터패스를 부트스트랩할 최초의 한 걸음을 확보하는 것입니다. 그러려면 agent가 ClusterIP가 아니라 API 서버의 실제 주소로 붙어야 합니다.
그런데 여기서 반전이 있었습니다. cilium-config configmap에 k8s-service-host 키만 넣는 것으로는 부족했습니다. cilium DaemonSet에는 config를 준비하는 init 컨테이너가 있는데, 이 init 컨테이너가 여전히 ClusterIP를 먼저 사용해서 죽고 있었습니다. configmap 값이 agent 본체에 반영되기 전에, 그 앞단에서 이미 교착에 걸린 것입니다.
그래서 configmap 키만이 아니라 환경변수까지 함께 주입했습니다.
| 대상 | 주입한 것 |
|---|---|
cilium-config configmap | k8s-service-host |
| cilium DaemonSet (config init 컨테이너) | KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT |
| cilium DaemonSet (agent 컨테이너) | KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT |
| cilium operator | KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT |
KUBERNETES_SERVICE_HOST/PORT 환경변수는 Kubernetes 클라이언트가 API 주소를 찾을 때 ClusterIP 기본값보다 우선합니다. init 컨테이너 단계부터 이 값을 박아두니, agent는 시작 순간부터 ClusterIP를 우회해 API 서버에 직접 붙었습니다. 데드락의 첫 고리가 끊어졌습니다.
여기서 아래층의 교훈이 나옵니다. 설정을 “선언”하는 것과 그 설정이 “가장 먼저 실행되는 코드 경로에까지 반영되는 것”은 다릅니다. configmap은 선언이었지만, 정작 먼저 죽던 init 컨테이너는 그 선언을 읽기 전에 ClusterIP를 썼습니다. 부트스트랩 교착은 “무엇을 선언했나”가 아니라 **“무엇이 가장 먼저 실행되나”**의 문제였습니다.
수동 핫픽스를 IaC로 승격했다
교착을 끊은 이 조작은 전부 라이브 수동 조작이었습니다. kubectl로 configmap을 패치하고 DaemonSet·operator에 env를 직접 주입했습니다. 급한 불은 껐지만, 이 상태를 그대로 두면 안 되는 이유가 있었습니다.
이 클러스터의 Cilium은 Helm으로 관리됩니다. 선언적 도구로 관리되는 클러스터에서 라이브 kubectl 패치는 다음 재적용 때 원복됩니다. 다음 helm upgrade가 도는 순간 Helm은 자기 values 기준으로 리소스를 다시 렌더하고, values에 없는 수동 env 주입과 configmap 패치는 조용히 덮어써집니다. 그러면 k8sServiceHost가 다시 비고, 그 뒤 apiserver가 재시작되거나 노드가 재부팅되면 오늘 겪은 데드락이 그대로 재현됩니다. 응급조치는 시간을 벌 뿐, 상태를 고정하지 못합니다.
그래서 같은 설정을 Git SSOT에 반영했습니다. Cilium repo의 main에 Helm values로 k8sServiceHost를 커밋·push하고 helm upgrade를 적용했습니다(revision 27). 이제 이 값은 차트가 렌더하는 정식 설정의 일부이므로, 재적용·재부팅·apiserver 재시작 어느 경로에서도 사라지지 않습니다.
| 구분 | 라이브 핫픽스 | IaC 승격 |
|---|---|---|
| 수단 | kubectl patch / env 직접 주입 | Helm values k8sServiceHost |
| 위치 | 클러스터 런타임 상태 | Git SSOT (cilium repo main) |
다음 helm upgrade | 원복됨 | 유지됨 (revision 27로 반영) |
| 재부팅·apiserver 재시작 | 데드락 재현 | 재발 없음 |
순서에도 의미가 있습니다. 장애 한복판에서 IaC PR을 먼저 올리고 리뷰를 기다리는 것은 비현실적입니다. 먼저 수동으로 끊고, 서비스가 살아난 뒤에 같은 변경을 IaC로 승격하는 것이 맞습니다. 다만 그 승격을 “나중에”로 미루면, 다음 재적용이 조용히 원복시키고 사고는 반복됩니다.
복구는 꼬리를 문다
CNI가 살아나자 나머지는 Kubernetes의 조정 루프가 알아서 되감았습니다.
flowchart LR A["cilium 3/3 Ready"] --> B["Longhorn instance-manager 재생성"] B --> C["postgres 볼륨 attach"] C --> D["postgres 1/1"] D --> E["sonarqube 1/1"] E --> F["엔드포인트 200 UP"]cilium agent가 3/3으로 올라오자 파드 간 통신이 복구됐고, Longhorn instance-manager가 각 노드에 다시 스케줄됐습니다. 볼륨이 attach되고 postgres가 1/1로 뜨자, 601번 재시작하던 SonarQube 앱도 DB를 찾아 정상 부팅했습니다. 엔드포인트가 200을 반환하는 것까지 확인했습니다.
다만 “앱이 떴다”와 “스토리지 이중화가 끝났다”는 다릅니다. 장애 동안 Longhorn 볼륨 다수(최대 12개)가 degraded 상태였고, CNI 복구 후 대부분은 자동 replica 재빌드로 healthy로 돌아왔습니다. 마지막 대용량 백업 볼륨(약 62GB)은 replica를 처음부터 full-copy로 다시 만들어야 해서, 애플리케이션이 정상화된 뒤로도 한참 재빌드가 이어졌습니다 — healthy replica가 남아 데이터는 안전한 채로. 분산 스토리지에서 복구는 순간이 아니라 데이터 재빌드라는 꼬리를 답니다. 전 볼륨이 healthy로 돌아온 것까지 확인하고서야 아래층은 진짜로 닫혔습니다.
위로: 알림이 거짓말하지 않게
아래를 다 고치고 나서, 이 모든 것의 입구였던 거짓 알림으로 돌아왔습니다. 근본 원인은 CNI였지만, 그 인프라 장애가 왜 하필 “코드 분석 실패”라는 배포 알림으로 울렸는지는 아직 그대로였습니다. 이걸 안 고치면, 다음에 스캔 서버가 죽을 때 또 같은 거짓말이 울립니다.
워크플로를 뜯어보니 배선은 단순했습니다.
- SonarQube Scan 스텝에
continue-on-error가 없었습니다. 서버가 죽어 스텝이 실패하면 job 전체가 실패합니다. - Notify 스텝은
if: always()로 항상 실행되며, 직전 job 상태를 읽어 “코드 분석 실패”로 렌더합니다.
원래 설계 의도는 품질 게이트를 non-blocking으로 두는 것이었습니다. 그런데 배선이 그 의도와 어긋나 있었습니다. 게이트가 배포를 막지는 않지만, 게이트가 깨지는 것과 게이트를 통과하지 못하는 것을 구분하지 못했습니다. 그래서 인프라 장애(스캔 서버 다운)가 코드 품질 실패와 같은 알림 문구로 흘러나온 것입니다.
근본 수정은 Scan 스텝에 continue-on-error를 부여하는 것이었습니다. 이제 스캔 서버가 죽어도 파이프라인과 배포 알림은 실패하지 않습니다. 품질 게이트 결과는 별도 신호로 남고, 인프라 장애는 인프라 알림으로 갑니다. 알림이 자기가 이름 붙인 실패(코드 분석)와 자기 밑에서 무너진 인프라를 더 이상 뒤섞지 않습니다.
이것이 위층의 root fix입니다. 우리는 거짓 알림으로 들어와서, 그 알림이 다시는 같은 거짓말을 못 하게 만들고 나왔습니다.
교훈: 위아래 두 층
하나의 거짓 알림이 위아래 두 곳의 설계 결함을 동시에 드러냈고, 둘 다 root에서 고쳤습니다.
아래층 — 부트스트랩은 “무엇을 선언했나”가 아니라 “무엇이 가장 먼저 실행되나”의 문제다. kube-proxy-replacement에는 숨은 부트스트랩 전제가 있었고, k8s-service-host 미설정은 평소엔 무해하다가 apiserver 재시작·노드 재부팅을 만나면 치명적 데드락이 됐습니다. 그리고 그 교착은 configmap 선언이 아니라 가장 먼저 죽던 init 컨테이너의 env를 고쳐야 풀렸습니다. 마지막으로, 수동 핫픽스는 Helm 재적용에 원복되므로 IaC로 승격해야 재발이 끝났습니다.
위층 — 알림은 자기가 이름 붙인 실패와 그 아래 인프라 장애를 구분해야 한다. “코드 분석 실패”는 코드 분석과 무관했습니다. non-blocking 게이트가 깨진 것을 품질 게이트를 통과 못 한 것과 같은 문구로 렌더한 배선이, 여러 계층 아래 CNI 장애를 엉뚱한 이름으로 포장했습니다. continue-on-error 한 줄이 그 이름표를 바로잡았습니다.
두 결함은 방향이 정반대입니다. 아래층은 시스템이 스스로를 못 살리는 문제였고, 위층은 시스템이 자기 상태를 잘못 말하는 문제였습니다. 그런데 둘은 한 알림에서 만났습니다. 거짓 알림 하나를 진지하게 따라간 덕분에, 진짜 근본 원인 한 곳과 그 원인을 가리던 이름표 한 곳을 같이 고칠 수 있었습니다.
관련 콘텐츠
EKS 키 회전이 로그인 장애가 되기까지
EKS ServiceAccount 서명키 회전이 로그인 장애로 증폭된 사건을 readiness 격리와 Kubernetes OIDC join 전환으로 제거한 기록.
DevOpsn8n v1 → v2 업그레이드: Kubernetes에서 메이저 버전 넘기
n8n v1.120.4에서 v2.9.4로 2단계 순차 업그레이드한 기록. 연쇄 CVE 대응, Migration Report 활용, community node emptyDir 트레이드오프, Queue 모드 호환성 튜닝.
DevOpsEC2 해킹당하고, DevSecOps 파이프라인을 구축하다
사이드 프로젝트 개발 서버가 털린 경험을 계기로 DevSecOps 파이프라인을 구축한 이야기입니다.