46 KiB
운영 중 만난 함정 9건 — 사건 카탈로그
이 문서는 과거
dev단일-aggregate 시기의 incident 기록이다. node 이름, host, 인증서, 수동 apply 명령은 당시 상태를 설명하며 현재 실행 runbook이 아니다. 현재 배포와 복구는docs/operations.md와 staged bootstrap을 따른다.
K3s 기반 로컬 클러스터에서 Project-Infra 를 부트스트랩 / 운영하면서 실제로 만났던 사건을 "무엇을 보고 / 왜 그랬고 / 어떻게 풀었는지" 순서로 기록한다.
| # | 사건 |
|---|---|
| 1 | Registry image pull 실패 (ImagePullBackOff) |
| 2 | vault-0 가 0/1 Running 에서 멈춤 |
| 3 | helm upgrade 가 has no deployed releases 로 실패 |
| 4 | VSO 가 기존 K8s Secret 을 덮어쓰지 않음 |
| 5 | ForwardAuth 로그인 E2E 검증 실패 |
| 6 | namespace 가 Terminating 에 걸림 |
| 7 | PodSecurity 위반 경고 (admission) |
| 8 | VSO 가 Vault 로그인 실패 |
| 9 | Registry 는 살아났지만 auth-server 새 이미지 pull 이 끝나지 않음 |
1. Registry image pull 실패 (ImagePullBackOff)
한 줄 요약
사설 registry 도메인(registry.project.com) 이 클러스터 노드의 호스트 OS DNS 에 등록되지 않아, 노드의 containerd 가 image pull 단계에서 도메인을 해석하지 못하고 모든 Pod 이 ImagePullBackOff 로 멈췄다.
배경
- 클러스터 안에 사설 OCI registry (MinIO + 도메인
registry.project.com) 를 띄우고, 다른 앱이 그 registry 의 이미지를 pull 하도록 구성. - 도메인은 K8s service DNS 에는 보이지만 클러스터 외부 DNS / 호스트 OS DNS 에는 없음.
증상
Failed to pull image "registry.project.com/...": rpc error: code = Unknown
desc = failed to resolve reference: failed to do request: ... no such host
kubectl describe pod 의 Events 에 ErrImagePull → ImagePullBackOff. Pod 자체는 스케줄링 됐지만 컨테이너가 시작되지 못함.
Root Cause
K8s 의 service DNS (CoreDNS) 와 노드의 image pull 경로는 분리되어 있다.
- Pod 이 런타임에
registry.project.com으로 HTTP 호출 → CoreDNS 가 해석 (✅ 동작함) - 노드의 containerd 가 image pull → 호스트 OS 의
/etc/resolv.conf만 본다 (CoreDNS 안 봄)
따라서 호스트 OS 에서 registry.project.com 을 해석하지 못하면, 클러스터 안에 service / endpoint 가 정상이어도 image pull 은 실패한다.
해결
K3s 가 사용하는 containerd 에 mirror 또는 host 매핑을 직접 알려준다.
A. registries.yaml mirror (권장)
# /etc/rancher/k3s/registries.yaml (각 노드)
mirrors:
registry.project.com:
endpoint:
- "https://<클러스터 내부 ingress 주소>"
configs:
registry.project.com:
tls:
insecure_skip_verify: true # 사설 인증서일 경우
설정 후 systemctl restart k3s.
B. 임시 우회 — /etc/hosts
<ingress IP> registry.project.com
검증
노드에서 직접:
sudo crictl pull registry.project.com/<image>:<tag>
성공하면 Pod 의 ImagePullBackOff 도 자동으로 회복된다 (kubelet 의 backoff retry).
교훈
- K8s service DNS 가 보인다고 image pull 도 된다고 가정하지 말 것. image pull 은 노드의 컨테이너 런타임이 직접 수행하고, 호스트 OS 의 resolver 를 따른다.
- 사설 registry 를 클러스터 안에 두면 bootstrap 순서 의존성 이 생긴다 (registry 가 떠야 다른 이미지 pull 가능). 이 의존성은 mirror config / hosts 매핑으로만 풀린다.
9. Registry 는 살아났지만 auth-server 새 이미지 pull 이 끝나지 않음
한 줄 요약
docker-registry 자체는 MinIO S3 backend 설정 오류를 고쳐 정상화했고 auth-server 새 이미지는 registry 에 업로드했다. 하지만 K3s 노드의 containerd pull 경로는 아직 완전히 검증되지 않아, 새 auth-server Pod 는 ImagePullBackOff 상태로 남아 있다.
배경
auth-server에 Keycloak 사용자 정보를 token claim 에서 받아 간단히 저장하는 변경을 적용했다.- 로컬 빌드 태그는
manual-20260512071751이고, 배포 대상 이미지는registry.project.com/auth-platform/auth-server:manual-20260512071751이다. - 현재 dev 클러스터 namespace 는
mnt이며,auth-serverDeployment 는 기존0.1.0이미지 Pod 1개가 계속 Running 중이다. - registry 는
docker-registryDeployment + MinIO bucketdocker-registry조합으로 동작한다.
증상 1: registry Pod 가 readiness/liveness 에서 무너짐
docker-registry Pod 가 /v2/ probe 에서 일시적으로 200 을 반환하다가 503 으로 떨어지고 CrashLoopBackOff 로 진입했다.
관찰된 설정:
REGISTRY_STORAGE: "s3"
REGISTRY_STORAGE_S3_REGIONENDPOINT: "https://minio.mnt.svc.cluster.local"
하지만 dev MinIO Service 는 HTTP 로 노출되어 있었다.
service/minio
port: 80
targetPort: 9000
Root Cause 1
registry 의 S3 endpoint 가 https://... 로 설정되어 있었지만, 실제 MinIO Service 경로는 HTTP 였다. registry 가 storage health check 와 blob 접근에서 MinIO 에 정상 접근하지 못해 /v2/ probe 가 실패했다.
또한 endpoint 를 http://minio 로 바꾸면 registry egress NetworkPolicy 도 HTTP port 80 을 허용해야 한다. 기존 policy 는 443, 9000 만 열려 있었다.
해결 1
Git/Kustomize 원천 파일을 수정했다.
변경 파일:
gitops/platform/registry/base/configmap.yamlgitops/platform/registry/overlays/lab/networkpolicy.yaml
변경 내용:
REGISTRY_STORAGE_S3_REGIONENDPOINT: "http://minio"
REGISTRY_STORAGE_REDIRECT_DISABLE: "true"
ports:
- protocol: TCP
port: 80
- protocol: TCP
port: 443
- protocol: TCP
port: 9000
적용:
kubectl diff -k gitops/platform/registry/overlays/lab
kubectl apply -k gitops/platform/registry/overlays/lab
kubectl -n mnt rollout restart deployment/docker-registry
kubectl -n mnt rollout status deployment/docker-registry --timeout=180s
검증 결과:
deployment.apps/docker-registry 1/1 Available
GET /v2/ HTTP/1.1 200
증상 2: Docker push 가 포트포워딩에서 반복 실패
kubectl -n mnt port-forward svc/docker-registry 5000:5000 후 docker push localhost:5000/... 를 시도했지만, Docker 의 동시 layer upload 와 kubectl port-forward 의 SPDY stream 이 맞물려 connection reset / timeout 이 반복됐다.
대표 오류:
write: connection reset by peer
error creating error stream for port 5000 -> 5000: Timeout occurred
BuildKit builder 에 HTTP/insecure registry 설정을 넣어도 image exporter 가 https://localhost:5000 또는 https://127.0.0.1:5000 로 HEAD 요청을 시도해 실패했다.
Root Cause 2
문제가 두 겹이었다.
- registry 의 기본 S3 redirect 가 켜져 있으면 client 가
http://minio/...presigned URL 로 직접 접근하려고 한다. 클러스터 밖 client 는minioDNS 를 해석할 수 없다. - Docker/BuildKit push 는 여러 blob stream 을 동시에 열고, 이 환경의
kubectl port-forward가 긴 업로드 stream 을 안정적으로 유지하지 못했다.
해결 2
registry 에 REGISTRY_STORAGE_REDIRECT_DISABLE: "true" 를 추가해 client 가 MinIO 로 직접 redirect 되지 않게 했다.
그 다음 Docker daemon 설정을 바꾸지 않기 위해, 이미지를 OCI tar 로 내보낸 뒤 Registry HTTP API 로 blob 과 manifest 를 순차 업로드했다.
진행 요약:
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--sbom=false \
-f deploy/docker/application/Dockerfile \
--output type=oci,dest=/tmp/auth-server-manual-20260512071751.oci.tar \
.
# Registry HTTP API 로 auth-platform/auth-server:manual-20260512071751 업로드
검증:
GET http://registry.project.com/v2/auth-platform/auth-server/tags/list
{"name":"auth-platform/auth-server","tags":["manual-20260512071751"]}
GET http://registry.project.com/v2/auth-platform/auth-server/manifests/manual-20260512071751
200 application/vnd.oci.image.manifest.v1+json
증상 3: 새 auth-server Pod 가 TLS 오류로 image pull 실패
Deployment image 를 새 태그로 변경했다.
kubectl -n mnt set image deployment/auth-server \
auth-server=registry.project.com/auth-platform/auth-server:manual-20260512071751
처음에는 kubelet 이 아래 오류를 냈다.
failed to do request:
Head "https://registry.project.com/v2/auth-platform/auth-server/manifests/manual-20260512071751":
tls: failed to verify certificate:
x509: certificate is valid for ...traefik.default, not registry.project.com
Root Cause 3
registry Ingress 에 TLS 가 붙어 있어서가 아니다. 현재 registry Ingress 는 HTTP 80 으로 노출되어 있고 TLS secret 을 명시하지 않는다.
문제는 containerd 의 기본 image pull 동작이다. image: registry.project.com/... 는 scheme 을 쓸 수 없고, containerd 는 기본적으로 https://registry.project.com/v2/... 를 먼저 호출한다. Traefik 의 443 default/self-signed certificate 를 밟으면서 hostname mismatch 가 발생했다.
해결 3
각 K3s 노드의 /etc/rancher/k3s/registries.yaml 에 dev registry 를 HTTP/insecure registry 로 등록했다.
mirrors:
registry.project.com:
endpoint:
- "http://registry.project.com"
configs:
registry.project.com:
tls:
insecure_skip_verify: true
적용 대상:
dev-wk-1:systemctl restart k3s-agentdev-wk-2:systemctl restart k3s-agentdev-cp-1:systemctl restart k3s
검증:
kubectl wait node/dev-wk-1 --for=condition=Ready --timeout=120s
kubectl wait node/dev-wk-2 --for=condition=Ready --timeout=120s
kubectl wait node/dev-cp-1 --for=condition=Ready --timeout=180s
이후 Pod event 에서 TLS certificate mismatch 오류는 사라졌다.
증상 4: registries.yaml 을 HTTP 로 바꿨는데도 여전히 not found
해결 3 으로 TLS cert mismatch 는 사라졌지만, 새 Pod 는 여전히 ImagePullBackOff 였다. 메시지가 바뀌었다.
Failed to pull image "registry.project.com/auth-platform/auth-server:manual-20260512071751":
rpc error: code = NotFound desc =
failed to resolve reference "...":
registry.project.com/auth-platform/auth-server:manual-20260512071751: not found
진단 절차에서 확인된 사실:
- registry 에 자격증명 + OCI Accept 헤더로 curl 하면 manifest 200 OK.
imagePullSecrets는auth-server-sa와 Pod spec 양쪽에 정상 박힘.crictl pull --creds testuser:...도 동일하게not found.ctr images pull --plain-http --user testuser:...만 정상 동작.- registry access log 에 ImagePullBackOff Pod 시도 시 manifest 호출 자체가 안 도착.
→ containerd 가 registry 까지 HTTP 호출을 시도조차 하지 않는 상태. ctr 의 --plain-http 플래그가 결정적이라는 건 containerd 가 HTTP scheme 을 인식하지 못하고 있음 을 의미했다.
K3s 가 자동 생성한 hosts.toml 을 직접 확인했더니 원인이 드러났다.
# /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com/hosts.toml
# File generated by k3s. DO NOT EDIT.
server = "https://registry.project.com/v2" # ← origin 은 HTTPS 강제
capabilities = ["pull", "resolve", "push"]
skip_verify = true
[host."http://registry.project.com/v2"] # ← mirror 만 HTTP
capabilities = ["pull", "resolve"]
skip_verify = true
Root Cause 4
K3s 1.34 + containerd 2.x 환경에서 registries.yaml → hosts.toml 자동 변환 동작:
mirrors.<host>.endpoint는[host."<endpoint>"]블록으로 그대로 옮겨진다 (HTTP scheme 보존).- 그러나 최상위
server는host이름 기준으로https://<host>/v2가 강제된다 — registry 가 default 로 HTTPS 라는 가정. - containerd 는 mirror 가 fail 하거나 manifest 협상 실패 시
server로 fallback. 우리 registry 는 origin 도 HTTP 라서 fallback 이 cert mismatch 또는 connection 실패로 끝남. - 추가로 endpoint URL 에
/v2path 까지 박혀 있다. containerd hosts.toml 명세상[host."<URL>"]의 URL 은 scheme + host 만 허용, path 는 host matcher 를 깨뜨릴 수 있다.
즉 registry 가 HTTPS 인 일반 환경을 가정한 K3s 의 자동 변환 로직이, HTTP-only registry 환경과 불일치 를 일으킨 것이다.
해결 4
K3s 의 hosts.toml 자동 생성 자체를 끄고 직접 작성해야 한다. K3s 는 /etc/rancher/k3s/registries.yaml 이 존재하는 한 무조건 hosts.toml 을 재생성한다 (재시작 시 사용자 작성을 덮어씀).
각 노드에서:
# 1) 자동 생성을 막기 위해 registries.yaml 비활성화
sudo mv /etc/rancher/k3s/registries.yaml /etc/rancher/k3s/registries.yaml.bak
# 2) hosts.toml 직접 작성
sudo mkdir -p /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com
sudo tee /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com/hosts.toml > /dev/null <<'EOF'
server = "http://registry.project.com"
[host."http://registry.project.com"]
capabilities = ["pull", "resolve", "push"]
skip_verify = true
[host."http://registry.project.com".auth]
username = "<registry-username>"
password = "<registry-password>"
EOF
# 3) 적용
sudo systemctl restart k3s-agent # 워커 노드
sudo systemctl restart k3s # control-plane
핵심 차이:
server도http://명시 → fallback 도 HTTP.- endpoint URL 에서
/v2path 제거. auth를 hosts.toml 에 박아imagePullSecrets와 무관하게 노드 단에서 인증 자동 첨부.
검증
# 노드의 registry 연결 직접 검증
sudo /usr/local/bin/k3s ctr -a /run/k3s/containerd/containerd.sock \
images pull --plain-http --user '<registry-username>:<registry-password>' \
registry.project.com/auth-platform/auth-server:manual-20260512071751
# Pod 가 새 이미지로 정상 Running 되는지
kubectl -n mnt get pod -l app.kubernetes.io/name=auth-server \
-o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[0].ready,IMAGE:.spec.containers[0].image'
# → READY=true, IMAGE=...:manual-20260512071751
# registry pod 의 access log 에서 containerd 호출 확인
kubectl -n mnt logs deployment/docker-registry --tail=50 | grep "containerd/v"
# → "useragent": "containerd/v2.2.2-bd1.34" 가 manifests/blobs 호출에 보이면 OK
이전 항목과의 관계 — 같은 증상, 다른 root cause
이번 사건은 표면 증상이 #1 과 비슷해 보이지만 실제 원인 layer 가 다르다.
| 항목 | 시점의 환경 | 원인 layer | 해결 |
|---|---|---|---|
| #1 | registry 가 HTTPS (사설 인증서) | 노드 호스트 OS 가 registry.project.com 을 DNS 해석 못 함 |
/etc/hosts 또는 registries.yaml mirror endpoint |
| #9 해결 3 | registry 를 HTTP-only 로 변경한 직후 | containerd 가 default HTTPS 로 시도 → cert mismatch | registries.yaml 의 endpoint 를 http://... 로 |
| #9 해결 4 (이번) | 위 변경 후에도 남아 있던 문제 | K3s 자동 생성 hosts.toml 의 server 가 여전히 https:// 강제 + /v2 path 포함 |
registries.yaml 제거 + hosts.toml 직접 작성 |
→ #1 의 해결이 잘못됐던 것이 아니다. #1 은 그 시점 (registry HTTPS) 의 정확한 fix 였고 한동안 정상 동작했다. registry 를 HTTP-only 로 변경하면서 새 layer 의 호환성 문제가 드러난 것이며, K3s + containerd 2.x 의 자동 변환 로직이 HTTP-only origin 을 상정하지 않은 것이 진짜 원인이다.
교훈
- registry Pod 의
/v2/readiness 가200이라고 해서 push/pull 경로 전체가 정상인 것은 아니다. S3 backend, redirect, NetworkPolicy, ingress auth, containerd mirror 설정을 분리해서 봐야 한다. - registry 를 MinIO S3 backend 로 둘 때 클러스터 밖 client 가 접근할 수 없는 내부 DNS 로 redirect 되지 않게
REGISTRY_STORAGE_REDIRECT_DISABLE를 검토해야 한다. - dev 에서 TLS 를 의도적으로 빼더라도 containerd 는 registry 를 기본 HTTPS 로 당긴다.
image:필드에는http://scheme 을 넣을 수 없다. - K3s 의
registries.yaml자동 변환은 registry 가 HTTPS 라는 가정을 깔고 동작한다. HTTP-only registry 인 경우 자동 변환을 끄고 (registries.yaml제거)hosts.toml을 직접 작성해야serverURL scheme 을 통제할 수 있다. - 진단 시
crictl pull과ctr pull의 차이 (특히--plain-http동작 여부) 를 비교하면 containerd 가 HTTP scheme 을 인식하고 있는지 빠르게 분리할 수 있다. - 같은 증상이 다시 나타날 때, 이전 사건의 해결책을 그대로 적용하기 전에 그 시점의 환경 가정과 현재 환경이 같은지 부터 확인해야 한다. 표면 증상이 같아도 layer 가 다른 경우가 흔하다.
- 노드 런타임 설정은 K8s resource 가 아니므로, 임시
kubectl debug node변경은 반드시 후속으로 운영 source-of-truth (Ansible / Fleet / cloud-init) 에 반영해야 한다. Kustomize manifest 로는 이 파일을 관리하지 않는다.
2. vault-0 가 0/1 Running 에서 멈춤
한 줄 요약
Vault Pod 의 readiness probe 는 vault status 가 sealed: false 여야만 통과한다. 처음 띄운 Vault 는 sealed/uninitialized 상태이므로 의도적으로 0/1 로 멈추고, 운영자가 init + unseal 을 명시적으로 해야 Ready 가 된다.
배경
- Vault Helm chart 의 기본 readiness probe 는
vault status의 health 코드 기반. - Sealed Vault 가 트래픽을 받으면 안 되므로, Sealed = NotReady 가 정상.
증상
NAME READY STATUS RESTARTS AGE
vault-0 0/1 Running 0 5m
kubectl logs vault-0 에는 에러 없음. kubectl exec -it vault-0 -- vault status 하면 Initialized: false 또는 Sealed: true.
Root Cause
- Vault 는 첫 기동 시 자동으로 init / unseal 되지 않는다 — unseal key 를 누가 / 어떻게 보관할지가 운영 정책 영역이기 때문.
- 따라서 첫 부트스트랩에는 반드시 운영자 / 자동화 스크립트의 init 절차가 필요하다.
해결
REPO_ROOT="$(pwd)" ENV_NAME=lab bash scripts/tasks/vault-init.sh
이 스크립트는 idempotent — 다음을 차례로 처리한다.
vault operator init(이미 init 됐으면 skip)- unseal keys 를 사용해 unseal
kubernetesauth method enable +kubernetes_ca_cert+token_reviewer_jwt설정- policy / role 등록
unseal 이 끝나면 readiness probe 가 통과하고 Pod 이 1/1 Ready 로 전환된다.
검증
kubectl get pod -n mnt vault-0
# vault-0 1/1 Running
kubectl exec -n mnt vault-0 -- vault status
# Sealed: false
교훈
- Pod 이
Running인데0/1일 때, 무한 대기하지 말고 readiness probe 의 의미부터 본다 — 많은 경우 의도된 NotReady 다. - Vault 같이 운영자 수동 절차가 필요한 컴포넌트는, 이 절차를 스크립트로 idempotent 하게 묶어두는 게 부트스트랩 / 재부팅 / DR 복구를 단순하게 만든다.
3. helm upgrade 가 has no deployed releases 로 실패
한 줄 요약
이전 helm upgrade --install 시도가 --atomic 으로 인해 자동 rollback 되면서 release 가 failed / uninstalled 상태로만 남았고, 다음 호출이 upgrade 분기로 진입하려다 deployed release 가 없어 실패했다.
증상
Error: UPGRADE FAILED: "vault-secrets-operator" has no deployed releases
helm list -A 로는 release 가 보이지 않거나 STATUS=failed / uninstalled 로 보임.
Root Cause
helm upgrade --install은 release metadata 가 있으면 upgrade 분기로 간다.--atomic은 설치 실패 시 자동 rollback. rollback 결과로 metadata 는 남고 실제 배포물은 없는 상태가 되면 다음--install도 "이미 release 가 있다고 판단 → upgrade → deployed release 없음 → 실패" 로 간다.- 즉
--atomic+ 실패 케이스가 쌓이면 멱등성이 무너진다.
해결
tasks/vso-install.sh 에서 두 가지를 바꿈.
-
release status 선검사 + 자동 uninstall
status=$(helm -n vault-secrets-operator-system status vault-secrets-operator -o json | jq -r '.info.status') case "$status" in failed|pending-*|uninstalling|uninstalled) helm -n vault-secrets-operator-system uninstall vault-secrets-operator || true ;; esac -
--atomic제거 — 실패 시 자동 rollback 보다 다음 실행에서 cleanup + 재시도가 더 안전.
검증
helm list -n vault-secrets-operator-system
# vault-secrets-operator ... STATUS=deployed
교훈
helm --atomic은 단발성 install 에는 좋지만, 부트스트랩 스크립트에서 반복 실행으로 복구되어야 하는 경로에는 안 어울린다.- install/upgrade 스크립트에선 항상 현재 상태를 먼저 검사하고, 망가진 상태면 cleanup 후 재시작 하는 패턴이 더 견고하다.
4. VSO 가 기존 K8s Secret 을 덮어쓰지 않음
한 줄 요약
Vault KV 에 새 값을 넣었는데 K8s Secret 은 옛날 값을 유지. VaultStaticSecret 의 destination.overwrite 기본값(false) 때문에, 이미 존재하는 Secret 을 보면 VSO 가 손대지 않는 안전한 default 가 자동화와 충돌한 사건.
증상
- Vault KV 의 값을 갱신해도
kubectl get secret -o yaml의data가 안 바뀜. - Pod 재시작해도 새 값 반영 안 됨.
Root Cause
- VSO 는 소유권 경합 방지 목적으로
destination.overwrite: false가 기본. - 이 기본은 "운영자가 수동으로 만든 Secret 을 VSO 가 무단 덮어쓰지 않는다" 는 안전 장치 — 단, 처음 부트스트랩 전에 stale Secret 이 남아 있으면 그것도 덮어쓰지 않음.
해결
bootstrap 스크립트에 명시적 opt-in 환경변수를 둠.
RESET_STALE_SECRETS=yes bash scripts/bin/bootstrap.sh lab
이 옵션이 있을 때만 VSO-managed K8s Secret 후보들을 선제 삭제 → VSO 가 새로 생성.
수동 우회:
kubectl delete secret <name> -n <ns>
# VSO reconcile (수 초~수십 초) 대기
검증
kubectl get secret <name> -n <ns> -o yaml
# data: 새 값
# metadata.ownerReferences: VaultStaticSecret 으로 설정됨
교훈
- 안전한 default (
overwrite: false) 는 자동화 / 운영 흐름과 자주 충돌한다. 깨려면 명시적 opt-in 플래그 로 깨야지, default 를 무작정 바꾸면 운영자 수동 자산이 날아간다. - bootstrap 스크립트의 파괴적 옵션은 환경변수 이름에 의도가 드러나야 한다 (
RESET_STALE_SECRETS=yes처럼).
5. ForwardAuth 로그인 E2E 검증 실패
한 줄 요약
oauth2-proxy / Keycloak / Traefik / auth-server 사이의 설정이 각각 조금씩 어긋나 있어, "로그인 화면은 뜨는가" 와 "로그인 후 API 가 인증된 요청으로 통과하는가" 가 단계별로 실패했다. 문제는 하나가 아니라 Traefik CRD 누락, TLS Secret 부재, Keycloak client secret 불일치, oauth2-proxy Authorization header 미전달, auth-server issuer 설정 미반영이 연쇄적으로 겹친 사건이었다.
배경
목표 흐름은 다음과 같다.
Browser
-> https://project.com/*
-> Traefik Ingress
-> oauth2-proxy ForwardAuth (/oauth2/auth)
-> Keycloak OIDC login
-> oauth2-proxy callback (/oauth2/callback)
-> auth-server
Boundary 기준으로 나누면 다음과 같다.
| Boundary | 정상 신호 | 실패 신호 |
|---|---|---|
| Browser local DNS/TLS | Chrome 이 project.com 을 Traefik IP 로 열고 self-signed 인증서를 통과 |
public DNS 로 빠짐, ERR_CERT_*, HSTS/인증서 경고에서 진행 불가 |
| Traefik routing/TLS | host rule 이 잡히고 TLS Secret 으로 handshake 성공 | Traefik 404, unknown TLS options, secret ... does not exist, SNI 실패 |
| Traefik ForwardAuth | 미인증 요청이 oauth2-proxy /oauth2/auth 로 위임 |
backend 로 바로 감, 또는 항상 Unauthorized |
| Traefik error redirect | 미인증 요청이 302 Location: keycloak... 로 변환 |
Location 은 있는데 status 가 401 이라 브라우저가 이동하지 않음 |
| oauth2-proxy -> Keycloak authorize | Keycloak 로그인 화면 200 |
authorize URL 생성 실패, 잘못된 redirect URI |
| Keycloak -> oauth2-proxy callback/token | callback 후 oauth2-proxy session cookie 발급 | unauthorized_client, invalid client credentials |
| oauth2-proxy -> auth-server header | /oauth2/auth 가 Authorization: Bearer ... 반환 |
auth-server 가 anonymous 로 처리 |
| auth-server JWT validation | issuer/JWK 검증 통과 후 application response 반환 | issuer mismatch, JWK 조회 실패, 401 |
| auth-server application route | 실제 API/화면 응답 | 인증은 통과했지만 route 없음, 예: 404 PRES-005 |
dev 환경에서는 실제 공인 DNS / ACME 인증서가 아직 준비되지 않았다. 그래서 CLI 검증은 아래처럼 DNS 와 TLS 검증을 임시 우회했다.
curl -k \
--resolve project.com:443:10.208.141.123 \
--resolve keycloak.dev.example.com:443:10.208.141.123 \
https://project.com/oauth2/start?rd=https://project.com/api/me
브라우저는 curl --resolve 와 -k 를 쓸 수 없으므로, 직접 웹사이트로 검증하려면 로컬 /etc/hosts 와 self-signed 인증서 예외가 필요하다.
10.208.141.123 project.com
10.208.141.123 keycloak.dev.example.com
증상 1: Ingress 가 404 또는 TLS handshake 실패
Boundary: Traefik routing/TLS
처음에는 https://project.com/api/me 가 Traefik 기본 404 page not found 를 반환했고, Keycloak discovery 도 TLS 단계에서 실패했다.
대표 증상:
HTTP/2 404
404 page not found
curl: (35) OpenSSL: tlsv1 unrecognized name
Root Cause 1
auth-server, oauth2-proxy, keycloak-public Ingress 는 모두 아래 annotation 을 참조하고 있었다.
traefik.ingress.kubernetes.io/router.tls.options: kube-system-modern-tls@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: kube-system-https-redirect@kubernetescrd,...
하지만 live cluster 에는 modern-tls, https-redirect, security-headers 가 없었다. Traefik 로그에는 다음 오류가 반복됐다.
unknown TLS options: kube-system-modern-tls@kubernetescrd
결과적으로 Traefik 가 해당 router 를 정상 구성하지 못했고, host/path 가 맞아도 요청이 backend 로 가지 않았다.
해결 1
Traefik packaged manifest 를 직접 수정하지 않고, Git source-of-truth 인 overlay 를 적용했다.
kubectl apply -k gitops/platform/traefik/overlays/lab
적용된 리소스:
HelmChartConfig/traefikMiddleware/https-redirectMiddleware/security-headersTLSOption/modern-tls
검증:
kubectl -n kube-system get tlsoption,middleware
kubectl -n kube-system logs deploy/traefik --tail=200
증상 2: TLS Secret 이 없어 HTTPS 라우팅이 SNI 에서 실패
Boundary: Traefik routing/TLS 와 cert-manager -> Traefik TLS Secret
Traefik CRD 를 적용한 뒤에도 HTTPS 요청은 tlsv1 unrecognized name 으로 실패했다. Traefik 로그에는 아래 메시지가 있었다.
Error configuring TLS: secret mnt/project-com-tls does not exist
Error configuring TLS: secret mnt/keycloak-dev-example-com-tls does not exist
Root Cause 2
dev Certificate 리소스가 letsencrypt-staging 을 참조하고 있었다. 하지만 현재 dev 도메인(project.com, keycloak.dev.example.com) 은 외부 공인 DNS 가 Traefik 진입점으로 향하지 않는다. ACME HTTP-01 은 public DNS 와 80/443 도달성이 필요하므로 인증서 발급이 완료될 수 없었다.
또한 TLSOption 의 sniStrict: true 때문에 TLS Secret 이 없는 host 는 handshake 단계에서 차단됐다. 보안상 의도한 동작이지만, dev 검증에는 별도 인증서가 필요했다.
해결 2
dev 전용 ClusterIssuer/dev-selfsigned 를 추가하고, dev TLS Certificate 들이 이를 참조하도록 바꿨다.
변경 파일:
gitops/platform/cert-manager/overlays/lab/issuers/dev-selfsigned-clusterissuer.yamlgitops/platform/cert-manager/overlays/lab/issuers/kustomization.yamlgitops/platform/cert-manager/overlays/lab/certificates/project-com-certificate.yamlgitops/platform/cert-manager/overlays/lab/certificates/keycloak-dev-certificate.yamlgitops/platform/cert-manager/overlays/lab/certificates/registry-project-com-certificate.yaml
적용:
kubectl apply -k gitops/platform/cert-manager/overlays/lab/issuers
kubectl apply -k gitops/platform/cert-manager/overlays/lab/certificates
kubectl -n mnt wait --for=condition=Ready certificate/project-com --timeout=120s
kubectl -n mnt wait --for=condition=Ready certificate/keycloak-dev-example-com --timeout=120s
검증 결과 Keycloak discovery 가 HTTPS 로 200 을 반환했다.
GET https://keycloak.dev.example.com/realms/platform/.well-known/openid-configuration
HTTP/2 200
issuer: https://keycloak.dev.example.com/realms/platform
증상 3: 로그인 화면은 뜨지만 callback 에서 500
Boundary: Keycloak -> oauth2-proxy callback/token
/oauth2/start 는 Keycloak authorize URL 로 302 되고, Keycloak 로그인 화면까지는 열렸다. 하지만 로그인 후 /oauth2/callback 에서 oauth2-proxy 가 500 Internal Server Error 를 반환했다.
oauth2-proxy 로그:
Error redeeming code during OAuth2 callback:
token exchange failed: oauth2: "unauthorized_client" "Invalid client or Invalid client credentials"
Root Cause 3
Vault / K8s Secret 의 auth-server-ingress client secret 과 Keycloak live realm 의 client secret 이 달랐다.
이유:
- Vault seed 스크립트가
keycloak/clients/auth-server-ingress와oauth2-proxy/forward-authsecret 을 생성했다. - oauth2-proxy 는 VSO 가 만든 최신 K8s Secret 을 읽었다.
- 하지만 이미 import 된 Keycloak realm/client 는 새 secret 으로 다시 동기화되지 않았다.
KeycloakRealmImport는 source 에서 secret placeholder 를 보도록 수정했지만, 기존 import 결과가 자동으로 다시 적용되지 않았다.
비교는 값을 출력하지 않고 hash 로 했다.
k8s_client_secret_sha == oauth2_secret_sha
keycloak_client_sha != oauth2_secret_sha
해결 3
Keycloak admin API 를 내부 port-forward 로만 열고, auth-server-ingress client representation 의 secret 을 K8s Secret 값과 동기화했다.
kubectl -n mnt port-forward svc/keycloak 18080:80
그 뒤 admin token 으로 client 를 조회하고 PUT /admin/realms/platform/clients/{id} 로 secret 을 반영했다. 반영 후 hash 가 일치했다.
desired_sha == keycloak_sha
update_status=204
주의: Keycloak public ingress 는 의도적으로 /admin/ 을 노출하지 않는다. admin API 작업은 port-forward, VPN, 또는 내부 운영 경로로만 수행한다.
증상 4: 미인증 요청이 Keycloak 으로 자동 이동하지 않음
Boundary: Traefik ForwardAuth 와 Traefik error redirect
미인증 상태에서 https://project.com/ 또는 https://project.com/api/me 를 열면 oauth2-proxy 의 로그인 시작 응답이 본문에는 보였지만, HTTP status 는 여전히 401 이었다. 이 경우 브라우저는 Location header 가 있어도 자동으로 따라가지 않는다.
대표 응답:
HTTP/2 401
location: https://keycloak.dev.example.com/realms/platform/protocol/openid-connect/auth?...
<a href="https://keycloak.dev.example.com/...">Found</a>.
Root Cause 4
Traefik errors middleware 는 /oauth2/start?rd={url} 를 내부 호출해 응답 body/header 를 가져오지만, 기본 동작만으로는 원래 오류 status 를 유지할 수 있다. 그 결과 oauth2-proxy 가 302 Location 을 만들었더라도 최종 클라이언트 응답이 401 로 남아 브라우저 redirect 가 일어나지 않았다.
또한 errors middleware 가 forwardAuth 의 401 을 감싸려면 middleware 순서가 중요하다. oauth2-proxy-errors 가 oauth2-proxy-auth 앞에 있어야 forwardAuth 실패 응답을 로그인 시작 응답으로 바꿀 수 있다.
해결 4
oauth2-proxy-errors 에 statusRewrites 를 추가하고, auth-server Ingress middleware 순서를 조정했다.
spec:
errors:
status:
- "401-403"
statusRewrites:
"401": 302
"403": 302
service:
name: oauth2-proxy
port: 4180
query: /oauth2/start?rd={url}
auth-server Ingress 순서:
kube-system-https-redirect@kubernetescrd,
mnt-oauth2-proxy-errors@kubernetescrd,
mnt-oauth2-proxy-auth@kubernetescrd,
kube-system-security-headers@kubernetescrd
검증:
curl -k -D - \
--resolve project.com:443:10.208.141.123 \
https://project.com/
정상 응답:
HTTP/2 302
location: https://keycloak.dev.example.com/realms/platform/protocol/openid-connect/auth?...
증상 5: callback 은 성공하지만 auth-server 가 계속 401
Boundary: oauth2-proxy -> auth-server header
Keycloak client secret 을 맞춘 뒤 oauth2-proxy callback 은 성공했고, _oauth2_proxy 세션 쿠키도 발급됐다. oauth2-proxy 로그에도 인증 성공이 찍혔다.
[AuthSuccess] Authenticated via OAuth2:
email:dev-login-check@project.local
groups:[role:platform-user ...]
하지만 https://project.com/api/me 는 계속 401 이었다. auth-server 로그는 사용자를 anonymous 로 보고 있었다.
Authentication required. actorId=anonymous method=GET requestPath=/api/me
Root Cause 5
oauth2-proxy 의 /oauth2/auth 는 X-Auth-Request-Access-Token 은 반환했지만 Authorization: Bearer ... 헤더를 반환하지 않았다. auth-server 는 Spring Security Resource Server 이므로 JWT 를 Authorization 헤더에서 읽는다. 따라서 ForwardAuth 는 통과해도 backend 는 anonymous 요청으로 처리했다.
해결 5
oauth2-proxy config 에 아래 설정을 추가했다.
set_authorization_header = true
변경 파일:
gitops/platform/forward-auth/component/oauth2-proxy-config.yaml
반영 후 oauth2-proxy 를 재시작했다.
kubectl -n mnt rollout restart deployment/oauth2-proxy
kubectl -n mnt rollout status deployment/oauth2-proxy --timeout=180s
검증:
curl -k -D - \
-b /tmp/oauth2-authenticated-cookies.txt \
--resolve project.com:443:10.208.141.123 \
https://project.com/oauth2/auth
응답에 Authorization: Bearer ... 와 X-Auth-Request-Access-Token 이 함께 나타나면 정상이다.
증상 6: Authorization 은 생겼지만 auth-server 가 issuer mismatch 로 실패
Boundary: auth-server JWT validation
Authorization 헤더가 생긴 뒤 auth-server 는 더 이상 단순 anonymous 만 보지 않았다. 대신 JWT decoder 초기화에서 issuer mismatch 를 냈다.
The Issuer "https://keycloak.dev.example.com/realms/platform"
provided in the configuration did not match the requested issuer
"http://keycloak/realms/platform"
Root Cause 6
auth-server 의 live Pod 가 예전 issuer 설정(http://keycloak/realms/platform) 을 들고 있었다. Git source 와 live ConfigMap 은 이미 외부 issuer 로 맞춰져 있었지만, Deployment 가 재시작되지 않아 Pod 환경변수에는 반영되지 않았다.
정상 설정:
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=https://keycloak.dev.example.com/realms/platform
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://keycloak/realms/platform/protocol/openid-connect/certs
설계 의도는 issuer claim 검증은 외부 issuer 로 맞추고, JWK 조회는 클러스터 내부 Service 로 수행하는 것이다.
해결 6
auth-server Deployment 를 재시작했다.
kubectl -n mnt rollout restart deployment/auth-server
kubectl -n mnt rollout status deployment/auth-server --timeout=180s
최종 검증 결과
새 로그인 흐름으로 다음이 확인됐다.
/oauth2/start→ Keycloak authorize URL302- Keycloak 로그인 화면
200 - 로그인 폼 제출 → authorization code 발급
/oauth2/callback→ oauth2-proxy session cookie 발급- oauth2-proxy
/oauth2/auth→202 - auth response header:
Authorization: Bearer ...X-Auth-Request-Access-TokenX-Auth-Request-EmailX-Auth-Request-UserX-Auth-Request-Preferred-Username
- 미인증 요청은
302로 Keycloak 로그인 화면으로 이동 /api/me요청은 ForwardAuth 를 통과해 auth-server 까지 도달
최종 /api/me 응답은 auth-server 의 애플리케이션 404 PRES-005 였다.
{
"success": false,
"code": "PRES-005",
"message": "요청한 리소스를 찾을 수 없습니다."
}
이는 ForwardAuth 실패가 아니라 auth-server 에 해당 route 가 없다는 의미다. 인증 계층 검증 관점에서는 401 이 사라지고 auth-server business response 가 나온 시점이 통과 기준이다.
브라우저로 직접 검증하는 방법
curl 로는 DNS 와 TLS 를 아래 옵션으로 우회한다.
curl -k \
--resolve project.com:443:10.208.141.123 \
--resolve keycloak.dev.example.com:443:10.208.141.123 \
https://project.com/oauth2/start?rd=https://project.com/api/me
브라우저는 같은 우회를 옵션으로 줄 수 없으므로 로컬 머신에서 다음을 준비한다.
-
/etc/hosts에 ingress IP 매핑:10.208.141.123 project.com 10.208.141.123 keycloak.dev.example.com -
https://project.com/oauth2/start?rd=https://project.com/api/me접속 -
dev self-signed 인증서 경고 허용 또는 인증서 trust 등록
-
Keycloak 로그인
-
callback 후
project.com으로 돌아오는지 확인
교훈
- ForwardAuth E2E 는 하나의 설정만 맞아서는 동작하지 않는다. Traefik CRD, TLS Secret, oauth2-proxy secret, Keycloak client secret, redirect status, backend issuer/JWK 설정이 모두 같은 세계관이어야 한다.
- "로그인 화면이 뜬다" 는 검증의 중간 지점일 뿐이다. 반드시 callback, token exchange, session cookie,
/oauth2/auth 202, backend 도달까지 나눠 봐야 한다. - dev 에서 ACME 가 안 되는 상황은 정상일 수 있다. public DNS 가 없으면
dev-selfsigned로 검증하고, staging/prod 에서 ACME issuer 로 전환한다. - Keycloak client secret 은 Vault/K8s/oauth2-proxy/Keycloak live realm 네 곳이 한 값으로 수렴해야 한다.
- Spring Resource Server 는 issuer claim 을 엄격히 검증한다. 내부 Service URL 과 외부 issuer URL 을 섞을 때는
issuer-uri와jwk-set-uri의 역할을 분리해야 한다.
6. namespace 가 Terminating 에 걸림
한 줄 요약
VSO controller 가 먼저 사라진 뒤 CRD finalizer / PVC protection finalizer / 일부 namespaced 리소스 finalizer 가 풀리지 못해 namespace 가 Terminating 에서 무한 대기. teardown.sh 가 finalizer 를 단계적으로 정리하고, 마지막 수단으로 /finalize API 를 직접 호출해 풀어준다.
증상
NAME STATUS AGE
mnt Terminating 3h
kubectl get all,pvc,vaultstaticsecret -n mnt 에 잔존 리소스 있음.
Root Cause
namespace 삭제는 그 안의 모든 리소스 finalizer 가 풀려야 끝난다. 멈추는 패턴은 보통 셋:
- VSO controller 는 이미 삭제됐는데
VaultStaticSecret/VaultAuth/VaultConnection의 CRD finalizer 가 남음 → 풀어줄 컨트롤러 부재 - PVC protection finalizer (
kubernetes.io/pvc-protection) 가 PV 와의 정리 순서 때문에 남음 - 다른 namespaced 리소스 finalizer 도 컨트롤러 부재로 cleanup 안 됨
해결
teardown.sh 가 단계적으로 처리:
| Phase | 작업 |
|---|---|
| 1 | VSO CRD 삭제 → 60s timeout 시 VaultStaticSecret / VaultAuth / VaultConnection finalizer 강제 해제 |
| 2 | PVC 보호 finalizer 제거 |
| 3 | 전체 namespaced 리소스 finalizer 일괄 제거 |
| 4 | kubectl delete namespace 60s 대기 → 실패 시 namespace /finalize API 직접 호출 |
| 5 | cluster-scoped 리소스 (vault-tokenreview-binding 등) 정리 |
검증
kubectl get ns mnt
# Error from server (NotFound): namespaces "mnt" not found
교훈
Terminating무한대기는 거의 항상 finalizer 누락. 어떤 컨트롤러가 풀어줘야 하는지 / 그 컨트롤러가 살아있는지부터 확인./finalizeAPI 직접 호출은 마지막 수단 — orphan PV / PVC 바인딩이 남을 수 있어, 이후 클러스터 정리에서 별도로 챙겨야 함.- 정상적인 teardown 순서는 "역의존 순" — 컨트롤러를 마지막에 죽이기. 자동 스크립트가 이를 강제하지 않으면 운영자 손에 버그가 옮겨붙는다.
7. PodSecurity 위반 경고 (admission)
한 줄 요약
namespace 에 pod-security.kubernetes.io/enforce: restricted 라벨이 붙어 있어 admission 단에서 securityContext 누락이 모두 거부됨. 모든 워크로드 매니페스트에 Restricted 필드 체크리스트를 적용해서 해결.
증상
Warning: would violate PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (...)
unrestricted capabilities (...)
runAsNonRoot != true (...)
seccompProfile (...)
kubectl apply 또는 deploy 시점에 Pod 생성이 거부 / 경고.
Root Cause
PSS Restricted 는 default-deny 에 가깝다. Pod / container 둘 다에서 다음 필드를 명시해야 통과한다.
| Pod | Container |
|---|---|
runAsNonRoot: true |
runAsNonRoot: true |
seccompProfile.type: RuntimeDefault |
allowPrivilegeEscalation: false |
capabilities.drop: ["ALL"] |
|
seccompProfile.type: RuntimeDefault |
|
runAsUser / runAsGroup non-zero |
(image 가 root 로 빌드됐으면 별도 처리) |
해결
모든 Deployment / StatefulSet / Job 매니페스트에 일관된 securityContext 블록 적용. 예시:
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
이 패턴은 docs/security-hardening.md 의 체크리스트에 정리되어 있고, scripts/ci/validate.sh 가 kube-linter 로 회귀를 막는다.
검증
bash scripts/ci/validate.sh
# build=ok schema=ok lint=ok
bash scripts/bin/bootstrap.sh lab
# Warning 없음
교훈
- PSS Restricted 는 사후 디버깅이 비싸다 — 매니페스트 작성 시점에 체크리스트로 박는 게 가장 싸다.
- Pod-level + Container-level 양쪽 모두에서 명시해야 한다 (어느 한쪽만 있으면 다른 쪽은 default 로 평가되어 거부될 수 있음).
- 회귀 방지는
kube-linter/kubeconform/kustomize build3단 검증을 CI 단계로 끌어올리는 게 최소.
8. VSO 가 Vault 로그인 실패
한 줄 요약
VSO 가 자기 ServiceAccount JWT 로 Vault 의 kubernetes auth method 에 로그인하려는데, Vault → kube-apiserver 의 TokenReview 호출 권한 (vault-tokenreview-binding ClusterRoleBinding) 또는 Vault 쪽 config (token_reviewer_jwt / kubernetes_ca_cert) 가 빠져 인증이 거부된 사건.
증상
VSO Pod logs:
permission denied (vault.errors.PermissionDenied)
authentication failed: invalid token (...)
K8s Secret 이 sync 되지 않고 빈 상태.
Root Cause
K8s 인증의 의존 그래프는 두 단:
- Vault → kube-apiserver
TokenReview호출 권한- 이건
system:auth-delegatorClusterRole 을 Vault 의 ServiceAccount 에 묶는 ClusterRoleBinding (vault-tokenreview-binding) 으로 부여.
- 이건
- Vault 자체의 kubernetes auth config
vault write auth/kubernetes/config에kubernetes_host+kubernetes_ca_cert+token_reviewer_jwt(Vault SA 의 JWT) 설정.
둘 중 하나만 빠져도 로그인 실패.
해결
# 1. ClusterRoleBinding 적용
kubectl apply -k gitops/platform/vault/overlays/<env>/
# 2. vault auth/kubernetes/config 설정 (idempotent)
REPO_ROOT="$(pwd)" ENV_NAME=<env> bash scripts/tasks/vault-init.sh
vault-init.sh 는 다음을 자동으로 한다:
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token
검증
kubectl get clusterrolebinding vault-tokenreview-binding
# 존재해야 함
kubectl logs -n vault-secrets-operator-system -l app.kubernetes.io/name=vault-secrets-operator
# successfully authenticated to Vault
kubectl get secret <vso-managed-secret> -o yaml
# data 필드 채워짐
교훈
- "Vault 로그인 실패" 는 거의 항상 두 권한 중 하나의 누락:
- Vault SA 가 kube-apiserver 의 TokenReview 를 호출할 수 있나? (RBAC)
- Vault config 에 SA token + CA cert 가 있나? (Vault 측 설정)
- 두 단을 한 idempotent 스크립트(
vault-init.sh) 로 묶어두면 재현 / 복구 / 환경 이전이 단순해진다.
같이 보기
guide.md15장 — 운영자 절차서 톤의 동일 사건 정리docs/operations.md— bootstrap / teardown / validate 의 설계 의도docs/security-hardening.md— PSS Restricted 체크리스트, NetworkPolicydocs/vault-vso.md— VSO 운영 모델,destination.overwrite정책 근거