쿠버네티스 운영 함정 4가지 — 하룻밤 디버깅 노트
5/15 저녁 텔레그램 알림으로 시작된 한 통의 메시지 — “⚠️ Pod 132개 중 4개 CrashLoopBackOff”. 이 한 줄에서 출발해 자정 넘어까지 4가지 서로 다른 종류의 함정에 빠졌다 빠져나왔다. 각각의 함정이 K3s/K8s 운영하는 사람이라면 한 번쯤은 만나는 패턴이라 정리해둔다.
이 글에서 다루는 것
- 함정 1: GitOps selfHeal이
kubectl patch를 조용히 되돌린다 (가장 빈번)- 함정 2: scheduler의 “Insufficient memory” 메시지는 거짓말일 수 있다 (가장 헷갈림)
- 함정 3: 옛 systemd 서비스 잔재가 CNI overlay를 죽인다 (가장 치명적)
- 함정 4: 이미지에 박힌 config는 멀티 환경에서 깨진다 (가장 반복적)
- 각 함정의 증상·진단·해결 + 운영자가 들여야 할 습관
TL;DR — 4가지 함정 한눈에
| # | 함정 | 증상 | 진단 키 | 처음 마주칠 확률 |
|---|---|---|---|---|
| 1 | ArgoCD selfHeal 되돌림 | kubectl patch가 1초 후 원복 | metadata.annotations.argocd.argoproj.io/tracking-id |
매우 높음 |
| 2 | scheduler 거짓말 | “Insufficient memory”인데 사실 nodeSelector 문제 | kubectl describe pod \| grep Node-Selectors |
높음 |
| 3 | 중복 systemd 서비스 | 같은 노드에서 k3s.service + k3s-agent.service 동시 가동 |
systemctl list-units 'k3s*' |
중간 (전환 시) |
| 4 | 이미지 박힌 config | prod OK, staging 죽음 | kubectl logs ... \| grep "host not found" |
매우 높음 |
함정 1 — GitOps selfHeal이 patch를 조용히 되돌린다
가장 헷갈리고 가장 빈번한 함정. 증상은 “고쳤는데 안 고쳐졌다”.
시나리오
# Elasticsearch warm tier를 louise → ilwon으로 옮기려고 patch
kubectl -n logging patch elasticsearch logs --type=json \
-p='[{"op":"replace","path":"/spec/nodeSets/1/podTemplate/spec/nodeSelector",
"value":{"kubernetes.io/hostname":"ilwon"}}]'
# → elasticsearch.elasticsearch.k8s.elastic.co/logs patched ✓
# 1초 뒤 확인
kubectl -n logging get elasticsearch logs -o jsonpath='{.spec.nodeSets[1].podTemplate.spec.nodeSelector}'
# → {"kubernetes.io/hostname":"louise"} ← ?!?!
이 리소스를 ArgoCD가 GitOps로 관리하고 있으면 selfHeal: true 가 자동으로 git 상태로 되돌린다. kubectl edit 도, helm upgrade --install 도, 모든 manual 변경이 사라진다. 로그도 안 남고, 에러도 안 뜬다.
진단
리소스 annotation을 본다:
kubectl -n logging get elasticsearch logs -o jsonpath='{.metadata.annotations}'
# → {"argocd.argoproj.io/tracking-id":"elk-cluster:elasticsearch.k8s.elastic.co/Elasticsearch:logging/logs", ...}
argocd.argoproj.io/tracking-id 가 박혀있으면 GitOps 관리 대상.
해당 ArgoCD app의 syncPolicy를 본다:
kubectl -n argocd get application elk-cluster -o jsonpath='{.spec.syncPolicy}'
# → {"automated":{"prune":false,"selfHeal":true}, ...}
selfHeal: true = 변경 즉시 원복. 이건 GitOps 도입한 클러스터의 기본 가정이다.
해결
선택지 3가지:
- (정도) git 레포에서 고치고 push → ArgoCD가 sync. 이게 답이다. GitOps의 존재 이유.
- (임시)
selfHeal: false로 잠시 끄고 patch. 다시 켜기 전에 git 동기화 필수. 권장 안 함 — 사람이 까먹는다. - (부분) ArgoCD
ignoreDifferences로 특정 필드만 selfHeal 제외. 운영용 라벨/replica 같은 것에만 사용.
이번에는 1번. helm-deploy 레포의 charts/elk-cluster/values.yaml 에서 warm.nodeHostname: louise → ilwon 한 줄 바꿔 push. 30초 후 ArgoCD가 sync해서 STS가 갱신됐다.
운영 습관
kubectl로 변경할 때 관리 주체부터 확인하라. annotation에
argocd.argoproj.io/tracking-id또는app.kubernetes.io/managed-by: Helm이 있으면, 그 변경은 1초 후 사라진다.
함정 2 — Scheduler의 “Insufficient memory”는 거짓말일 수 있다
scheduler가 친절한 척 거짓말한다.
시나리오
ECK CRD를 git으로 patch해서 warm tier nodeSelector를 louise → ilwon 으로 바꿨다. 그런데 pod는 여전히 Pending:
FailedScheduling: 0/5 nodes are available:
1 Insufficient memory,
1 node(s) had untolerated taint(s),
3 node(s) didn't match Pod's node affinity/selector.
ilwon은 메모리 11Gi 여유. pod는 4Gi 요청. 분명히 들어가는데 “Insufficient memory” 라고 한다.
진단
핵심 질문: 지금 scheduler가 어떤 node에 배치하려고 시도 중인가?
kubectl -n logging describe pod logs-es-warm-0 | grep Node-Selectors
# → Node-Selectors: kubernetes.io/hostname=louise ← 여기!
CRD는 ilwon으로 바꿨지만 STS template 갱신이 누락됐고, pod는 여전히 louise로 nodeSelector를 들고 있었다. 그래서:
- “1 Insufficient memory” → louise (matches selector, 메모리 부족)
- “3 didn’t match selector” → david/ilwon/solomon (selector mismatch)
- “1 untolerated taint” → lemuel (control-plane taint)
scheduler는 “louise에 메모리 부족” 이라고 정확히 말했지만, 사람은 “내가 ilwon으로 옮겼는데?” 라고 받아들인다. 메시지의 주어가 누락된 셈.
해결
STS rollout 강제:
kubectl -n logging delete pod logs-es-warm-0 # ECK가 새 template으로 재생성
확인:
kubectl -n logging get sts logs-es-warm -o jsonpath='{.status.currentRevision}{"\n"}{.status.updateRevision}{"\n"}'
# → 두 줄이 같으면 rollout 완료
운영 습관
“Insufficient memory” 봤을 때 진짜 메모리 보지 말고, 어느 노드 얘기인지부터 보라. CRD/Deployment patch 후엔
kubectl describe pod | grep Node-Selectors로 지금 pod의 selector를 확인. operator가 아직 STS template을 갱신 안 했으면 옛 선택자로 시도 중.
함정 3 — 옛 systemd 서비스 잔재가 CNI overlay를 죽인다
가장 무서운 함정 — 노드는 Ready 인데 cross-node 통신만 안 된다.
시나리오
solomon 노드에 4개 pod가 CrashLoopBackOff. 다 DNS 의존 (kubernetes.default.svc, settlement-staging-postgres 등). 표면적으론 DNS 문제 같은데 노드 상태는 멀쩡:
NAME STATUS ROLES AGE
solomon Ready control-plane,etcd 5d3h
진단해보면:
| 테스트 | 결과 |
|---|---|
| ilwon pod → DNS | ✅ 정상 |
| solomon pod → 같은 solomon pod | ✅ 정상 |
| solomon pod → ilwon pod (cross-node) | ❌ ping 100% loss |
| solomon pod → ClusterIP | ❌ timeout |
→ solomon에서 cross-node 패킷이 안 나간다. flannel VXLAN 터널 단절.
진단 깊이
solomon SSH 들어가서:
ip -d link show flannel.1
# → Device "flannel.1" does not exist.
flannel 인터페이스 자체가 없다. 누가 죽이고 있나?
systemctl list-units --type=service --all 'k3s*' --no-pager
# k3s-agent.service loaded activating auto-restart ← !!!
# k3s.service loaded activating start start ← !!!
sudo journalctl -u k3s-agent -n 30
# → fatal msg="Error: flag provided but not defined: -cluster-dns"
진실: solomon이 옛날에 worker였을 때 k3s-agent.service 가 있었고, 나중에 control-plane으로 승격되면서 k3s.service 가 추가됐다. 두 서비스가 같은 kubelet/containerd 자원을 두고 충돌. agent가 무한 재시작하면서 flannel.1 인터페이스를 깜빡깜빡 내려버렸다. 게다가 agent의 systemd unit엔 옛 K3s 버전이 쓰던 -cluster-dns 플래그가 박혀있어 새 K3s 바이너리가 못 인식.
해결
sudo systemctl disable --now k3s-agent.service
# → 8초 후
ip -d link show flannel.1
# → flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
# vxlan id 1 local 192.168.219.108 dev wlp3s0b1 srcport 0 0 dstport 8472
cross-node ping 부활. 4개 CrashLoopBackOff pod 자동 복구.
운영 습관
노드 역할 변경(worker → control-plane, 또는 반대) 후엔 systemd 서비스 잔재를 반드시 정리하라. K3s에서 control-plane node는
k3s.service만 있어야 한다 (server가 agent 기능 포함). worker는k3s-agent.service만. 둘 다 활성화돼있으면 CNI가 깜빡깜빡 죽는다. 노드가Ready상태여도 마찬가지.
함정 4 — 이미지에 박힌 config는 멀티 환경에서 깨진다
전형적인 패턴. prod에선 OK, staging에선 부팅 실패.
시나리오
settlement-staging-frontend가 11번 재시작:
nginx: [emerg] host not found in upstream "settlement-app" in /etc/nginx/conf.d/nginx.conf:23
nginx 이미지의 nginx.conf에 upstream이 settlement-app:8080 으로 하드코드. prod에서는 release 이름이 settlement 라 service명이 settlement-app → 일치. 하지만 staging은 release명이 settlement-staging 이라 service명은 settlement-staging-app → host not found.
해결 — 이미지 재빌드 vs ConfigMap mount
| 방법 | 시간 | 위험 |
|---|---|---|
| 이미지 재빌드 | 5~10분 (CI) + ArgoCD pickup | 빌드 실패 가능, latest 태그면 imagePullPolicy 주의 |
| ConfigMap mount | 즉시 (helm sync 30초) | 없음 — chart 변경만 |
helm chart에서 release 이름 기반 ConfigMap 주입:
{{- if .Values.frontend.enabled }}
apiVersion: v1
kind: ConfigMap
metadata: { name: {{ .Release.Name }}-frontend-nginx }
data:
nginx.conf: |
server {
listen 80;
location ~ ^/(api|admin|...)/ {
proxy_pass http://{{ .Release.Name }}-app:8080; # ← release-scoped
}
location / { try_files $uri $uri/ /index.html; }
}
{{- end }}
Deployment에서 mount:
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/nginx.conf
subPath: nginx.conf
volumes:
- name: nginx-conf
configMap:
name: {{ .Release.Name }}-frontend-nginx
Pod template annotation에 ConfigMap checksum 박아두면 ConfigMap 변경 시 자동 rollout:
annotations:
checksum/nginx-conf: {{ include (print $.Template.BasePath "/frontend-configmap.yaml") . | sha256sum }}
prod 렌더 → settlement-app:8080, staging 렌더 → settlement-staging-app:8080. 한 이미지로 둘 다 동작.
운영 습관
환경 의존 값(service name, hostname, region 등)은 이미지에 박지 말고 차트의 ConfigMap/env로 주입하라. 특히
{{ .Release.Name }}기반으로 만들면 멀티 환경 배포에서 자동으로 갈라진다. 한 번 만들어두면 환경 추가할 때 0줄 변경.
보너스 함정 — fail2ban이 운영자 본인을 차단한다
SSH 들어갈 때 사용자명을 모르면 root, ubuntu, pi, debian, admin… 이렇게 probe하기 쉬운데 — fail2ban 기본 설정이 5번 실패 후 IP를 10분 차단한다.
이번에 6번 시도 후 마지막 solomon@ 한 번 성공했지만, 직후부터 SSH가 “Connection refused” 로 막혔다. 10분 기다려서 풀렸다.
운영 노드 SSH 사용자명은 README든 메모든 손 닿는 곳에 기록해두라. “한 번 시도해보면 되겠지”는 fail2ban이 있는 환경에선 비싸다.
마무리 — 4가지 함정 종합 점검표
다음에 비슷한 incident 만나면 이 순서로 본다:
- 변경했는데 반영 안 됨? → annotation에
argocd.argoproj.io/tracking-id있는지부터 확인 - scheduler 에러가 뭔가 이상? →
kubectl describe pod | grep Node-Selectors로 지금 pod의 selector 확인 (CRD가 아니라) - 노드 Ready인데 통신 안 됨? →
systemctl list-units 'k3s*'로 service 중복 확인,ip link로 CNI 인터페이스 살아있는지 확인 - 한 환경만 죽음? → 이미지에 환경 의존 값 박혀있는지 logs로 확인 (
host not found,connection refused on hardcoded host등)
K3s 홈랩이든 회사 EKS든 — 문제는 보통 가장 평범한 곳에서 가장 평범한 이유로 터진다. GitOps annotation, scheduler 메시지의 주어, systemd unit 잔재, 이미지에 박힌 hostname. 이 4가지만 머리에 두고 있으면 한밤중 incident에서 30분은 절약된다.
TL;DR — 오늘 4시간 디버깅으로 배운 것: scheduler를 의심하기 전에 STS template을 의심하라, ArgoCD selfHeal을 의심하기 전에 annotation을 보라, CNI를 의심하기 전에 systemd를 보라, 이미지를 의심하기 전에 환경 차이를 보라.