BlueByte
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

작성 Haneul Seo2026년 9월 26일 업데이트14 min

안녕하세요, BlueByte입니다. 어제까지 잘 되던 kubectl apply가 1초도 안 되어 Internal error occurred: failed calling webhook으로 실패하는데, 매니페스트는 그대로입니다. 사실 객체는 검사조차 받지 못했습니다. 그 쓰기 앞에 admission webhook이 서 있고, API server가 거기서 응답을 받아내지 못했으며, 기본 실패 정책이 그 침묵을 거부로 바꾼 것입니다. 오늘은 이 메시지의 구조, 내가 설치하지도 않은 webhook이 쓰기를 막는 이유, 네트워크 차단과 인증서 불일치를 구분하는 법, 원인별 해결, 그리고 다시는 잠기지 않도록 webhook 범위를 좁히는 법까지 하나씩 짚어보겠습니다.

API server가 실제로 보고하는 내용

webhook이 매칭하는 모든 verb에서 서버 측 오류로 돌아옵니다:

Error from server (InternalError): error when creating "ingress.yaml": Internal error occurred: failed calling webhook "validate.example.com": failed to call webhook: Post "https://webhook-service.webhook-system.svc:443/validate?timeout=10s": context deadline exceeded

정보를 담고 있는 조각은 셋입니다. 따옴표 안의 webhook 이름은 ValidatingWebhookConfiguration 또는 MutatingWebhookConfiguration 안의 항목 하나를 가리킵니다. URL은 clientConfig.service에서 조립되며 — namespace·name·port — 문서상 기본값은 port 443, path /입니다. 마지막 콜론 뒤 꼬리가 진단이고, 몇 가지 정해진 모양으로 옵니다:

  • context deadline exceeded — 호출은 나갔는데 제한 시간 안에 응답이 오지 않음.
  • connect: connection refused — 그 주소까지는 닿았고 해당 포트에서 아무도 듣고 있지 않음.
  • no endpoints available for service "webhook-service" — Service는 있는데 ready 상태의 백엔드가 없음.
  • x509: certificate signed by unknown authority — webhook이 내민 인증서를 API server가 신뢰하지 않음.
  • x509: certificate is valid for ..., not webhook-service.webhook-system.svc — 신뢰는 하는데 이름이 틀림.

이 꼬리부터 읽으십시오. 아래 내용은 전부 여기서 갈라집니다.

응답하지 못한 webhook이 쓰기를 거부로 돌리는 이유

webhook 항목마다 failurePolicy가 붙어 있고, Kubernetes 레퍼런스는 기본값이 Fail이라고 명시합니다. webhook 호출 오류는 admission 실패가 되고 요청은 거부됩니다. 다른 값인 Ignore는 요청을 통과시킵니다. 이 정책은 네트워크 오류, 타임아웃, non-2xx 응답, 형식이 깨진 응답, 직렬화 실패를 모두 포함합니다 — 쓸 만한 답을 받지 못하는 모든 경우입니다.

시계는 timeoutSeconds이고, 문서상 허용 범위는 1~30초, 기본값은 10초입니다. 이 시간이 지나면 같은 실패 정책에 따라 호출이 무시되거나 요청이 거부됩니다.

한 가지 구분을 먼저 잡아두면 헛수고를 크게 줄입니다. webhook이 정상 동작하면서 "안 된다"고 답한 경우는 완전히 다른 사건입니다. 레퍼런스는 올바르게 전달된 명시적 거부는 failurePolicy 설정과 무관하게 항상 API 요청을 거부한다고 적고 있습니다. 그 메시지는 failed calling webhook이 아니라 admission webhook "..." denied the request로 나옵니다.

API server가 webhook에 닿지 못하게 만드는 네 가지

  • ready 백엔드 없음. webhook Deployment가 0으로 스케일됐거나, evict됐거나, 크래시 루프 중입니다. Service 이름은 풀리는데 엔드포인트 목록이 비어 있습니다.
  • 막힌 네트워크 경로. 관리형 클러스터에서는 control plane이 내 VPC 바깥에 있고 좁은 구멍으로만 노드에 닿습니다. Google의 GKE 문서는 기본적으로 방화벽이 포트 443(HTTPS)과 10250(kubelet)을 제외하면 노드로의 TCP 연결을 허용하지 않으며, 다른 포트의 Pod로 통신하려는 admission webhook은 커스텀 방화벽 규칙이 없으면 실패한다고 기술합니다.
  • 깨진 TLS 신뢰. caBundle은 API server가 검증에 쓰는 PEM 인코딩(필드 값은 base64) CA이고, 서빙 인증서는 <svc_name>.<svc_namespace>.svc에 대해 유효해야 합니다. 인증서를 재발급했거나, 만료됐거나, Secret을 다시 만들었다면 이 짝이 깨집니다.
  • 살아 있지만 느린 webhook. 외부 API나 DB를 호출하는 핸들러는 부하가 걸리면 timeoutSeconds를 넘길 수 있습니다. 정작 자기 로그에는 아무 문제도 남지 않습니다.

스스로 자초하는 다섯 번째 경우도 이름을 붙여둘 값어치가 있습니다. 자기가 떠 있는 namespace를 검사하는 webhook은 한번 죽으면 되살릴 수 없습니다. 자기 Pod 생성을 자기가 막기 때문입니다.

꼬리가 가리키는 세 곳을 확인하기

먼저 admission 경로에 무엇이 들어 있는지, 정책과 타임아웃까지 함께 봅니다:

kubectl get validatingwebhookconfigurations -o custom-columns=NAME:.metadata.name,HOOKS:.webhooks[*].name,POLICY:.webhooks[*].failurePolicy,TIMEOUT:.webhooks[*].timeoutSeconds
NAME                HOOKS                  POLICY   TIMEOUT
example-admission   validate.example.com   Fail     10

그다음 URL에 적힌 Service 뒤에 무언가 붙어 있는지 확인합니다:

kubectl -n webhook-system get endpoints webhook-service
kubectl -n webhook-system get pods -l app=webhook-server
NAME              ENDPOINTS         AGE
webhook-service   10.20.1.23:8443   41d

ENDPOINTS 열이 <none>이면 조사는 여기서 끝입니다 — 워크로드를 살리면 됩니다. 엔드포인트가 있다면 클러스터 안에서 같은 URL을 두드려 봅니다:

kubectl -n webhook-system run probe --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- curl -sk -o /dev/null -w '%{http_code}\n' https://webhook-service.webhook-system.svc:443/

이 결과는 읽는 법이 중요합니다. Pod는 클러스터 네트워크 안에 있고, 관리형 control plane의 API server는 그렇지 않습니다. Pod에서는 상태 코드가 돌아오는데 API server는 여전히 타임아웃이라면, 그것은 아픈 webhook이 아니라 방화벽 규칙의 흔적입니다. TLS 쪽이 의심되면 신뢰 짝의 양쪽을 맞대어 봅니다:

kubectl get validatingwebhookconfiguration example-admission -o jsonpath='{.webhooks[0].clientConfig.caBundle}' | base64 -d | openssl x509 -noout -subject -dates
kubectl -n webhook-system get secret webhook-server-cert -o go-template='{{index .data "tls.crt"}}' | base64 -d | openssl x509 -noout -ext subjectAltName -dates

서빙 인증서의 발급자가 caBundle에 든 CA여야 하고, subjectAltName에 webhook-service.webhook-system.svc가 들어 있어야 합니다. 이 명령을 한 번도 써본 적 없어도 겁먹지 않아도 됩니다. 읽기만 하는 명령이라, 멀쩡한 클러스터에서 먼저 돌려보면서 정상적인 짝이 어떤 모습인지 눈에 익혀도 됩니다.

원인별로, 값싼 수부터

엔드포인트가 비었을 때: 워크로드를 되살리고 다음 재시도를 통과시킵니다.

kubectl -n webhook-system rollout restart deploy/webhook-server
kubectl -n webhook-system rollout status deploy/webhook-server --timeout=120s

프라이빗 클러스터에서 포트가 막혔을 때: webhook Pod이 실제로 듣고 있는 포트를 control plane 대역에 열어 줍니다. Google 문서는 gcloud container clusters describe에서 masterIpv4CidrBlock을, 기존 gke- 규칙에서 노드 target tag를 먼저 모으라고 안내한 뒤 다음을 제시합니다:

gcloud compute firewall-rules create allow-webhook-8443 \
  --action ALLOW \
  --direction INGRESS \
  --source-ranges CONTROL_PLANE_RANGE \
  --rules tcp:8443 \
  --target-tags TARGET

인증서 불일치일 때: caBundle에 든 CA로 서빙 인증서를 재발급하거나, webhook 설치 도구가 관리하는 CA를 다시 주입합니다. cert-manager가 맡고 있다면 설정 객체의 cert-manager.io/inject-ca-from 애너테이션이 그 필드를 다시 채웁니다 — 손으로 패치하기 전에 그 애너테이션이 아직 붙어 있는지부터 보십시오.

핸들러가 느릴 때: 문서상 상한까지 타임아웃을 올리되, 이것은 지연을 고친 게 아니라 시간을 산 것으로 취급하십시오.

kubectl patch validatingwebhookconfiguration example-admission --type=json -p='[{"op":"replace","path":"/webhooks/0/timeoutSeconds","value":20}]'

배포를 앞두고 잠겨버렸을 때: 문제가 된 항목 하나만 failurePolicy: Ignore로 바꾸면 쓰기는 즉시 풀리고, 되돌릴 때까지 검사받지 않은 객체가 통과합니다. 설정 객체 자체를 지워도 결과는 같지만 훨씬 거칩니다. 게다가 Helm 릴리스나 operator가 다음 reconcile에서 대개 다시 만들어 놓습니다.

실제 사례: 프라이빗 클러스터와 8443 포트의 webhook

한 팀이 프라이빗 GKE 클러스터에 정책 컨트롤러를 추가합니다. 설치는 깔끔하고 Pod은 Running이며 로그도 조용합니다. 그런데 매칭되는 리소스에 kubectl apply를 할 때마다 딱 10초쯤 뒤 context deadline exceeded로 끝나는 failed calling webhook이 납니다. 엔드포인트는 10.20.1.23:8443이고, Pod에서 쏜 curl 프로브는 200을 돌려줘 워크로드 문제는 제외됩니다. 10초라는 벽은 문서상 기본 타임아웃과 맞아떨어지고, 포트는 8443 — control plane이 열 수 있는 두 포트 중 어느 것도 아닙니다. 팀은 control plane CIDR에서 노드 태그로 tcp:8443을 허용하는 위 방화벽 규칙을 추가합니다. 다음 apply는 매니페스트를 한 글자도 고치지 않은 채 ingress.networking.k8s.io/site created를 돌려줍니다.

자리를 뜨기 전에 admission이 다시 도는지 확인하기

실패했던 쓰기를 다시 실행하고, 이어서 거부당할 것으로 예상되는 객체로 webhook을 일부러 건드려 봅니다:

kubectl apply -f ingress.yaml
kubectl apply -f deliberately-invalid.yaml

첫 번째는 성공, 두 번째는 admission webhook "validate.example.com" denied the request — 이게 원하는 결과입니다. 두 번째까지 성공한다면 webhook이 건너뛰어지고 있다는 뜻이고, 그것은 고쳐진 것과 다릅니다.

다시는 잠기지 않도록 범위 좁히기

Kubernetes good practices 문서는 시스템 namespace와 webhook 자신의 namespace를 제외해 스스로의 복구를 막지 않도록 하라고 권합니다:

namespaceSelector:
  matchExpressions:
  - key: kubernetes.io/metadata.name
    operator: NotIn
    values:
    - kube-system
    - webhook-system

같은 문서는 짧은 타임아웃, 좁은 rules, objectSelector·namespaceSelector 필터링으로 호출 횟수를 줄일 것, 그리고 Service 뒤에 레플리카를 둘 이상 둘 것을 함께 권합니다. 인증서 만료도 함께 지켜보십시오 — 1년 동안 아무 말 없던 webhook이 인증서가 갱신되는 날 무너집니다.

kubectl의 x509 오류·거부 응답과 다른 점

kubectl: x509: certificate signed by unknown authority는 같은 연결의 반대 방향을 가리킵니다. 그쪽은 내 클라이언트가 API server 인증서를 신뢰하지 않는 것이고, 고칠 곳은 kubeconfig입니다. 여기서는 API server가 클라이언트이고 webhook이 서버이므로, 고칠 곳은 클러스터 안의 caBundle과 서빙 인증서입니다. 내 kubeconfig는 멀쩡하며, 그래서 쓰기만 실패하는 동안에도 kubectl get은 계속 잘 됩니다.

두 번째 닮은꼴은 admission webhook "..." denied the request입니다. 이건 webhook이 제 일을 한 것입니다. 객체를 받아 규칙을 적용하고 거절했습니다. failurePolicy는 여기 관여하지 않고, 고칠 곳은 클러스터가 아니라 매니페스트입니다.

다음에 webhook에서 쓰기가 죽으면 이 순서를 거꾸로 따라가 보세요. 메시지 꼬리를 읽고, 엔드포인트를 확인하고, Pod에서 Service를 두드려 보고, 그다음 CA와 서빙 인증서를 맞대어 보면 됩니다.

관련 질문

배포를 풀려면 ValidatingWebhookConfiguration을 그냥 지워도 되나요?

동작은 하지만 그건 해결이 아니라 우회입니다. 객체를 지우면 webhook이 admission 경로에서 빠지므로 쓰기는 검사 없이 통과합니다. 그 webhook을 설치해서 얻으려던 보장이 돌아올 때까지 전부 꺼진 상태가 됩니다. Helm이나 operator가 관리하는 것이라면 다음 reconcile에서 다시 만들어지므로 우회 효과도 일시적인 경우가 많습니다. 문제가 된 항목 하나만 failurePolicy: Ignore로 바꾸는 것이 같은 조치의 좁은 버전입니다.

아예 failurePolicy: Ignore로 고정해 두면 안 되나요?

그로 인해 통과하는 요청을 감당할 수 있을 때만 그렇습니다. Kubernetes 가이드는 이를 맞바꿈으로 설명합니다. Fail은 실제로 의존하는 검증에는 더 안전하지만 webhook이 죽었을 때 장애 위험을 안고, Ignore는 클러스터를 계속 쓸 수 있게 하는 대신 거부됐어야 할 객체를 들일 수 있습니다. 보안·정책 컨트롤러는 보통 Fail로 두고 대신 고가용성으로 만들며, 편의성 webhook은 Ignore 후보로 볼 만합니다.

Pod에서 쏜 curl은 webhook에 닿는데 왜 API server는 계속 타임아웃인가요?

둘이 같은 네트워크 경로에 있지 않기 때문입니다. Pod은 클러스터 네트워크 안에 있지만 관리형 control plane의 API server는 그렇지 않습니다. Google GKE 문서는 기본 방화벽이 포트 443과 10250을 제외하면 노드로의 TCP 연결을 허용하지 않으며, 다른 포트에서 듣는 webhook은 커스텀 규칙이 없으면 실패한다고 기술합니다. Pod 프로브는 성공하는데 API server만 타임아웃이라면 webhook이 아니라 그 규칙을 가리키는 신호입니다.

timeoutSeconds를 60으로 올렸더니 API server가 거부했습니다.

문서상 허용 범위가 1~30초(기본 10초)라서 60은 필드가 받지 않는 값입니다. 핸들러가 정말로 30초를 넘게 필요로 한다면 타임아웃은 풀 값어치가 있는 문제가 아닙니다. 느린 작업을 admission 경로 밖으로 빼거나, 핸들러가 조회하는 것을 캐시하거나, rules를 좁혀 호출 자체를 줄이는 쪽이 맞습니다.

특정 namespace에서만 실패하고 같은 매니페스트가 다른 곳에서는 잘 적용됩니다.

그건 스코핑이 제 일을 하는 모습이고, 보통 webhook은 멀쩡한데 적용 범위에 대한 기대가 어긋난 경우입니다. 항목의 namespaceSelector·objectSelector·rules를 읽어 지금 적용하는 namespace와 라벨에 맞춰 보십시오. 놓치기 쉬운 필드가 하나 더 있습니다. matchPolicy의 기본값은 Exact라서, rules에 적힌 것과 다른 API 그룹·버전으로 보낸 요청은 webhook에 아예 매칭되지 않습니다.

참고 자료

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

같은 카테고리 다른 글

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.

Git
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)

V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트

컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.

Docker
curl: (60) SSL certificate problem: unable to get local issuer certificateFixed

curl: (60) SSL certificate problem: unable to get local issuer certificate

curl이 서버가 보낸 인증서 체인을 따라가다가, 자기가 읽는 CA 저장소에 발급자가 없는 인증서에 닿아 종료 코드 60으로 연결을 거부한 것입니다. 발급자가 없는 이유는 넷 중 하나입니다. 서버가 중간 인증서 없이 리프만 보내거나(브라우저는 스스로 받아 와서 가려 줍니다), TLS 검사 프록시가 컨테이너·러너가 신뢰하지 않는 회사 CA로 사이트를 다시 서명했거나, curl이 생각과 다른 CA 번들(CURL_CA_BUNDLE, SSL_CERT_FILE, 벤더 curl)을 읽고 있거나, ca-certificates 패키지가 너무 오래된 경우입니다. curl -v로 어느 저장소를 썼는지, openssl s_client로 서버가 무엇을 보냈는지 보고 고리가 빠진 쪽을 고친 뒤 -w '%{ssl_verify_result}'로 확인합니다.

curl