Skip to main content

Kubernetes 사설 CA 인증서를 Mutating Webhook으로 자동 주입하기

2 min 328 words

문제 상황

자체 서명한 사설 CA(Certificate Authority)로 내부 서비스 도메인에 TLS를 적용하면, 클라이언트 파드들이 해당 CA를 신뢰하지 않아 x509: certificate signed by unknown authority 에러가 납니다. 파드마다 CA 인증서를 직접 마운트하거나 이미지에 번들링하는 방식은 관리 포인트가 너무 많습니다.

해결 전략은 세 가지입니다.

  1. CoreDNS template: *.test.local 도메인을 클러스터 호스트 IP로 라우팅
  2. Reflector: CA 인증서가 담긴 ConfigMap을 모든 네임스페이스에 자동 복제
  3. 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

Corefiletemplate 블록을 추가합니다.

.: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>

정리

컴포넌트역할
ReflectorCA ConfigMap을 모든 네임스페이스에 자동 복제
Webhook 서버ca-injection: enabled 라벨 감지 → 볼륨 주입
MutatingWebhookConfigurationAPI 서버와 웹훅 서버 연결, 라벨 기반 필터링
openssl웹훅 TLS 인증서 수동 CSR 서명

CA 인증서가 교체될 때는 kube-system 네임스페이스의 ConfigMap만 업데이트하면 Reflector가 전 네임스페이스에 자동으로 반영합니다. 파드는 재시작 시 최신 인증서를 마운트받습니다.