Kubernetes 사설 CA 인증서를 Mutating Webhook으로 자동 주입하기
문제 상황
자체 서명한 사설 CA(Certificate Authority)로 내부 서비스 도메인에 TLS를 적용하면, 클라이언트 파드들이 해당 CA를 신뢰하지 않아 x509: certificate signed by unknown authority 에러가 납니다. 파드마다 CA 인증서를 직접 마운트하거나 이미지에 번들링하는 방식은 관리 포인트가 너무 많습니다.
해결 전략은 세 가지입니다.
- CoreDNS template:
*.test.local도메인을 클러스터 호스트 IP로 라우팅 - Reflector: CA 인증서가 담긴 ConfigMap을 모든 네임스페이스에 자동 복제
- Mutating Webhook: 파드 생성 시
ca-injection: enabled라벨이 있으면 CA 볼륨을 자동 주입
이렇게 하면 파드 레벨에서 라벨 하나만 추가하면 CA 신뢰가 적용됩니다.
전체 흐름
sequenceDiagram
participant Dev as 개발자
participant API as kube-apiserver
participant WH as Webhook Server
participant Pod as 파드
Dev->>API: Pod 생성 요청 (ca-injection: enabled)
API->>WH: AdmissionReview 전송
WH->>WH: 라벨 확인
WH-->>API: Patch (volume + volumeMount 추가)
API->>Pod: 변형된 스펙으로 파드 생성
Pod->>Pod: /etc/ssl/certs/ca-bundle.crt 마운트됨
flowchart LR
subgraph kube-system-ns["kube-system 네임스페이스"]
CM["ConfigMap\nca-bundle"]
end
subgraph ns-a["namespace: app-a"]
CM_A["ConfigMap\nca-bundle (복제)"]
Pod_A["파드\nca-injection: enabled"]
end
subgraph ns-b["namespace: app-b"]
CM_B["ConfigMap\nca-bundle (복제)"]
end
CM -->|Reflector 복제| CM_A
CM -->|Reflector 복제| CM_B
CM_A -->|볼륨 마운트| Pod_A
1단계: CoreDNS template으로 내부 도메인 라우팅
클러스터 내부에서 *.test.local 도메인을 사용하려면 CoreDNS가 해당 도메인을 클러스터 호스트 IP로 응답하도록 설정해야 합니다. CoreDNS의 template 플러그인을 활용합니다.
kubectl edit configmap coredns -n kube-system
Corefile에 template 블록을 추가합니다.
.:53 {
# 기존 설정 유지
errors
health
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
}
prometheus :9153
forward . /etc/resolv.conf
cache 30
loop
reload
loadbalance
# *.test.local → 클러스터 호스트 IP로 응답
template IN A test.local {
match .*\.test\.local\.$
answer "{{ .Name }} 60 IN A <your-cluster-host-ip>"
fallthrough
}
}
<your-cluster-host-ip>에는 Ingress나 LoadBalancer가 바라보는 노드 IP를 넣습니다. 이렇게 하면 클러스터 안의 모든 파드가 anything.test.local을 해당 IP로 resolve합니다.
ConfigMap을 저장하면 CoreDNS가 reload 플러그인 덕분에 자동으로 반영됩니다. 이후 클러스터 안의 모든 파드에서 *.test.local 도메인이 정상 resolve됩니다.
2단계: CA ConfigMap 생성 + Reflector 설정
CA 인증서 ConfigMap
CA 인증서를 kube-system 네임스페이스에 ConfigMap으로 저장합니다.
# ca-bundle-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: ca-bundle
namespace: kube-system
annotations:
# Reflector가 이 어노테이션을 보고 복제 대상 네임스페이스를 결정
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "" # 빈 값 = 전체
data:
ca-bundle.crt: |
-----BEGIN CERTIFICATE-----
<your-ca-cert>
-----END CERTIFICATE-----
Reflector 설치
emberstack/kubernetes-reflector를 Helm으로 설치합니다.
helm repo add emberstack https://emberstack.github.io/helm-charts
helm upgrade --install reflector emberstack/reflector \
--namespace kube-system
설치 후 네임스페이스가 생성될 때마다 Reflector가 자동으로 ca-bundle ConfigMap을 복제합니다.
# 복제 확인
kubectl get configmap ca-bundle -n <your-namespace>
3단계: Webhook 서버 구현
Webhook 서버는 파드 생성 요청을 받아 ca-injection: enabled 라벨이 있으면 볼륨과 볼륨마운트를 추가하는 JSON Patch를 반환합니다.
# webhook/main.py
import base64
import json
import jsonpatch
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
CA_VOLUME_NAME = "ca-bundle"
CA_MOUNT_PATH = "/etc/ssl/certs/ca-bundle.crt"
CA_CONFIGMAP_NAME = "ca-bundle"
INJECTION_LABEL = "ca-injection"
def needs_injection(pod_spec: dict) -> bool:
labels = pod_spec.get("metadata", {}).get("labels", {})
return labels.get(INJECTION_LABEL) == "enabled"
def build_patch(pod_spec: dict) -> list:
patch = []
existing_volumes = pod_spec.get("spec", {}).get("volumes", [])
existing_names = {v["name"] for v in existing_volumes}
# 볼륨 추가 (이미 있으면 스킵)
if CA_VOLUME_NAME not in existing_names:
op = "add" if existing_volumes else "add"
patch.append({
"op": op,
"path": "/spec/volumes/-" if existing_volumes else "/spec/volumes",
"value": {
"name": CA_VOLUME_NAME,
"configMap": {"name": CA_CONFIGMAP_NAME},
} if existing_volumes else [{
"name": CA_VOLUME_NAME,
"configMap": {"name": CA_CONFIGMAP_NAME},
}],
})
# 모든 컨테이너에 volumeMount 추가
containers = pod_spec.get("spec", {}).get("containers", [])
for i, container in enumerate(containers):
existing_mounts = {m["mountPath"] for m in container.get("volumeMounts", [])}
if CA_MOUNT_PATH not in existing_mounts:
if container.get("volumeMounts"):
patch.append({
"op": "add",
"path": f"/spec/containers/{i}/volumeMounts/-",
"value": {
"name": CA_VOLUME_NAME,
"mountPath": CA_MOUNT_PATH,
"subPath": "ca-bundle.crt",
"readOnly": True,
},
})
else:
patch.append({
"op": "add",
"path": f"/spec/containers/{i}/volumeMounts",
"value": [{
"name": CA_VOLUME_NAME,
"mountPath": CA_MOUNT_PATH,
"subPath": "ca-bundle.crt",
"readOnly": True,
}],
})
return patch
@app.post("/mutate")
async def mutate(request: Request):
body = await request.json()
uid = body["request"]["uid"]
pod = body["request"]["object"]
if not needs_injection(pod):
return JSONResponse({
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {"uid": uid, "allowed": True},
})
patch_ops = build_patch(pod)
patch_b64 = base64.b64encode(json.dumps(patch_ops).encode()).decode()
return JSONResponse({
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": uid,
"allowed": True,
"patchType": "JSONPatch",
"patch": patch_b64,
},
})
웹훅 서버는 반드시 TLS로 서빙해야 합니다.
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install fastapi uvicorn jsonpatch
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8443", \
"--ssl-keyfile", "/tls/tls.key", "--ssl-certfile", "/tls/tls.crt"]
4단계: Kubernetes 리소스 배포
TLS 인증서 발급 (수동 CSR 서명)
cert-manager 없이 사설 CA로 직접 서명합니다. SAN(Subject Alternative Name)에 서비스 DNS를 포함해야 kube-apiserver가 검증을 통과합니다.
# 1. 웹훅 서버용 키 + CSR 생성
openssl req -newkey rsa:2048 -nodes \
-keyout tls.key \
-subj "/CN=ca-injector.kube-system.svc" \
-out tls.csr
# 2. SAN 확장 설정 파일 작성
cat > san.ext <<EOF
subjectAltName = DNS:ca-injector.kube-system.svc,DNS:ca-injector.kube-system.svc.cluster.local
EOF
# 3. 사설 CA로 서명 (ca.crt / ca.key는 기존 사설 CA)
openssl x509 -req \
-in tls.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-days 3650 \
-extfile san.ext \
-out tls.crt
# 4. Secret으로 등록
kubectl create secret tls ca-injector-tls \
--cert=tls.crt --key=tls.key \
-n kube-system
# 5. MutatingWebhookConfiguration에 넣을 caBundle 추출 (base64)
cat ca.crt | base64 | tr -d '\n'
Webhook 서버 Deployment + Service
# webhook-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ca-injector
namespace: kube-system
spec:
replicas: 2
selector:
matchLabels:
app: ca-injector
template:
metadata:
labels:
app: ca-injector
spec:
containers:
- name: ca-injector
image: <your-registry>/ca-injector:latest
ports:
- containerPort: 8443
volumeMounts:
- name: tls
mountPath: /tls
readOnly: true
volumes:
- name: tls
secret:
secretName: ca-injector-tls
---
apiVersion: v1
kind: Service
metadata:
name: ca-injector
namespace: kube-system
spec:
selector:
app: ca-injector
ports:
- port: 443
targetPort: 8443
MutatingWebhookConfiguration
caBundle에는 위에서 추출한 사설 CA 인증서의 base64 값을 직접 넣습니다.
# mutating-webhook.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: ca-injector
webhooks:
- name: ca-injector.kube-system.svc
admissionReviewVersions: ["v1"]
clientConfig:
caBundle: "<base64-encoded-ca.crt>" # 위 5번 커맨드 출력값
service:
name: ca-injector
namespace: kube-system
path: /mutate
rules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system"] # 시스템 네임스페이스 제외
objectSelector:
matchLabels:
ca-injection: "enabled" # 라벨이 있는 파드만 웹훅 호출
sideEffects: None
failurePolicy: Ignore # 웹훅 장애 시 파드 생성 차단하지 않음
objectSelector.matchLabels로 라벨이 없는 파드는 웹훅 호출 자체를 건너뜁니다. failurePolicy: Ignore로 웹훅 서버 장애가 파드 스케줄링에 영향을 주지 않도록 합니다.
5단계: 적용 및 검증
라벨 추가
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: app-a
spec:
template:
metadata:
labels:
app: my-app
ca-injection: "enabled" # 이 라벨 하나로 CA 주입
확인
# 파드에 볼륨이 주입됐는지 확인
kubectl get pod <pod-name> -n app-a -o jsonpath='{.spec.volumes}' | jq .
# 컨테이너 안에서 인증서 확인
kubectl exec -n app-a <pod-name> -- ls -la /etc/ssl/certs/ca-bundle.crt
# TLS 연결 테스트
kubectl exec -n app-a <pod-name> -- curl https://<internal-domain>
정리
| 컴포넌트 | 역할 |
|---|---|
| Reflector | CA ConfigMap을 모든 네임스페이스에 자동 복제 |
| Webhook 서버 | ca-injection: enabled 라벨 감지 → 볼륨 주입 |
| MutatingWebhookConfiguration | API 서버와 웹훅 서버 연결, 라벨 기반 필터링 |
| openssl | 웹훅 TLS 인증서 수동 CSR 서명 |
CA 인증서가 교체될 때는 kube-system 네임스페이스의 ConfigMap만 업데이트하면 Reflector가 전 네임스페이스에 자동으로 반영합니다. 파드는 재시작 시 최신 인증서를 마운트받습니다.