Skip to main content

OKE 플랫폼 엔지니어링 랩 4편 — Istio + cert-manager로 TLS 인증서 자동화하기

3 min 660 words

이 포스트는 OKE 플랫폼 엔지니어링 랩 시리즈의 4편입니다.


1. 배경

클러스터에 서비스를 올리면 외부에서 접근할 수 있는 Ingress가 필요합니다. 일반적으로 Nginx Ingress Controller를 사용하지만, 이 프로젝트에서는 Istio를 선택했습니다.

Istio는 Service Mesh이면서 동시에 Ingress Gateway 역할도 합니다. Nginx Ingress만 쓰면 나중에 서비스 간 mTLS나 트래픽 정책을 추가할 때 별도 도구가 필요하지만, Istio를 처음부터 도입하면 Ingress와 Mesh 기능을 하나의 컨트롤 플레인으로 관리할 수 있습니다.

인증서 관리는 cert-manager + Let’s Encrypt + Cloudflare DNS-01 조합을 사용합니다.

  • HTTP-01 챌린지는 도메인별로 인증서를 따로 발급해야 하고, 80 포트가 외부에 노출되어야 합니다.
  • DNS-01 챌린지는 DNS TXT 레코드로 검증하므로, *.cluster.<your-domain> 같은 와일드카드 인증서를 발급할 수 있습니다. 서비스가 늘어나도 인증서 하나로 모두 커버됩니다.

2. 전체 구성

flowchart LR
  subgraph left["인증서 발급 흐름"]
    direction TB
    V["Vault\nsecret/prod/cert-manager"]
    ES["ExternalSecret\ncloudflare-token-secret 생성"]
    CI["ClusterIssuer (Let's Encrypt)\napiTokenSecretRef 참조"]
    CF["Cloudflare DNS API\n_acme-challenge TXT 레코드"]
    LE["Let's Encrypt ACME\nDNS-01 → 와일드카드 인증서"]
    TS["TLS Secret (cert-manager NS)\nReflector → istio-system 복제"]
    V --> ES --> CI --> CF --> LE --> TS
  end

  subgraph right["인그레스 라우팅 흐름"]
    direction TB
    CLT[클라이언트 HTTPS 요청]
    LB["OCI Load Balancer"]
    IGW["Istio IngressGateway\nistio-system"]
    GW["Gateway (TLS 종료)\ncredentialName: cert-manager-tls-wildcard-cluster"]
    VS["VirtualService\n호스트 기반 라우팅"]
    SVC[K8s Service → Pod]
    CLT --> LB --> IGW --> GW --> VS --> SVC
  end

  TS -.->|Reflector 복제| GW

3편에서 다룬 ExternalSecret으로 Cloudflare 토큰을 가져오고, 그 토큰을 cert-manager가 사용해 DNS-01 챌린지를 수행합니다. 발급된 인증서는 Reflector로 istio-system 네임스페이스에 복제되어 Istio Gateway가 참조합니다.


3. Istio 설치

Istio는 세 개의 Helm 차트를 순서대로 설치합니다. ArgoCD Sync Wave로 순서를 제어합니다.

3.1 istio-base — CRD 등록 (Wave -2)

# infra/core/istio-base-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: istio-base
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-2"
spec:
  project: default
  source:
    repoURL: https://istio-release.storage.googleapis.com/charts
    chart: base
    targetRevision: 1.26.4
  destination:
    server: https://kubernetes.default.svc
    namespace: istio-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
  ignoreDifferences:
    - group: admissionregistration.k8s.io
      kind: ValidatingWebhookConfiguration
      name: istiod-default-validator
      jsonPointers:
        - /webhooks/0/clientConfig/caBundle
        - /webhooks/0/failurePolicy

ignoreDifferences가 필요한 이유는 ArgoCD와 Istio 사이의 충돌 때문입니다. Istiod는 기동 후 ValidatingWebhookConfigurationcaBundle을 자신의 CA 인증서로 동적으로 업데이트합니다. ArgoCD는 이 변경을 Git과의 drift로 감지해 계속 Sync를 시도하고, Sync 과정에서 caBundle을 빈 값으로 덮어씁니다. ignoreDifferences로 해당 필드를 무시하도록 설정해 이 루프를 차단합니다.

3.2 istiod — Controlplane (Wave -2)

# infra/core/istio-istiod-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: istio-istiod
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-2"
spec:
  project: default
  source:
    repoURL: https://istio-release.storage.googleapis.com/charts
    chart: istiod
    targetRevision: 1.26.4
  destination:
    server: https://kubernetes.default.svc
    namespace: istio-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
  ignoreDifferences:
    - group: admissionregistration.k8s.io
      kind: ValidatingWebhookConfiguration
      name: istio-validator-istio-system
      jsonPointers:
        - /webhooks/0/clientConfig/caBundle
        - /webhooks/0/failurePolicy

istio-base와 같은 Wave -2에 배포합니다. Istio 1.24 이후부터 base 차트가 CRD 전용이 아니라 istiod와 독립적으로 설치 가능한 구조가 되었기 때문에, istiod를 한 Wave 뒤로 미룰 필요가 없습니다.

3.3 istio-ingressgateway — 게이트웨이 (Wave -1)

# infra/core/istio-ingressgateway-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: istio-ingressgateway
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  project: default
  sources:
    - repoURL: https://istio-release.storage.googleapis.com/charts
      chart: gateway
      targetRevision: 1.26.4
      helm:
        valueFiles:
          - $values/infra/istio/values.yaml
    - repoURL: https://github.com/<your-org>/gitops.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: istio-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

istiod가 떠야 IngressGateway Pod가 사이드카 없이 정상 동작하므로 Wave -1로 한 단계 뒤에 배포합니다.

# infra/istio/values.yaml
name: istio-ingressgateway
labels:
  istio: ingressgateway

service:
  type: LoadBalancer
  annotations:
    service.beta.kubernetes.io/oci-load-balancer-shape: "flexible"
    service.beta.kubernetes.io/oci-load-balancer-shape-flex-min: "10"
    service.beta.kubernetes.io/oci-load-balancer-shape-flex-max: "10"
  ports:
    - port: 80
      name: http2
      protocol: TCP
      targetPort: 8080
    - port: 443
      name: https
      protocol: TCP
      targetPort: 8443

OCI의 Load Balancer는 service.beta.kubernetes.io/oci-load-balancer-shape 어노테이션으로 제어합니다. flexible 타입을 사용하면 트래픽에 따라 처리 용량이 자동 조정되며, shape-flex-min/max로 OCPU 범위를 지정합니다. Free Tier 범위 내에서 운영하기 위해 최소/최대를 모두 10으로 설정했습니다.


4. cert-manager 설치 (Wave -3)

# infra/core/cert-manager-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-3"
spec:
  project: default
  sources:
    - repoURL: https://charts.jetstack.io
      chart: cert-manager
      targetRevision: v1.20.0
      helm:
        valueFiles:
          - $values/infra/cert-manager/values.yaml
    - repoURL: https://github.com/<your-org>/gitops.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: cert-manager
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
# infra/cert-manager/values.yaml
installCRDs: true

Wave -3으로 다른 모든 앱보다 먼저 설치합니다. ClusterIssuer, Certificate 같은 CRD가 클러스터에 등록된 뒤에야 Platform 그룹에서 해당 리소스를 apply할 수 있기 때문입니다.


5. Cloudflare 토큰 — Vault에서 가져오기

3편에서 상세히 다룬 내용이므로 여기서는 흐름만 정리합니다.

# Vault에 Cloudflare API 토큰 저장
vault kv put secret/prod/cert-manager cf-api-token=<CLOUDFLARE_API_TOKEN>
# infra/cert-manager/config/cloudflare-token-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: cloudflare-token-secret
  namespace: cert-manager
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: cloudflare-token-secret
    creationPolicy: Owner
  data:
    - secretKey: api-token
      remoteRef:
        key: secret/prod/cert-manager
        property: cf-api-token

ESO가 Vault에서 토큰을 읽어 cloudflare-token-secret K8s Secret을 자동 생성합니다. 이후 ClusterIssuer가 이 Secret을 참조해 Cloudflare DNS API를 호출합니다.


6. ClusterIssuer — Let’s Encrypt + DNS-01

# infra/cert-manager/config/cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: cluster-issuer-letsencrypt
spec:
  acme:
    email: <your-email>
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: cluster-issuer-letsencrypt
    solvers:
      - dns01:
          cloudflare:
            email: <your-email>
            apiTokenSecretRef:
              name: cloudflare-token-secret # ExternalSecret이 생성한 Secret
              key: api-token

server에는 Let’s Encrypt 프로덕션 ACME 서버를 지정합니다. 처음 구성할 때는 스테이징 서버(https://acme-staging-v02.api.letsencrypt.org/directory)로 먼저 테스트하는 것이 좋습니다. 프로덕션 서버는 실패 횟수 제한이 있기 때문입니다.

dns01.cloudflareapiTokenSecretRef를 지정하면 cert-manager가 DNS-01 챌린지 수행 시 Cloudflare API를 호출해 _acme-challenge TXT 레코드를 자동으로 생성하고, 검증 완료 후 삭제합니다.


7. Certificate — 와일드카드 인증서

# infra/cert-manager/config/certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: tls-wildcard-cluster
  namespace: cert-manager
spec:
  secretName: cert-manager-tls-wildcard-cluster
  issuerRef:
    name: cluster-issuer-letsencrypt
    kind: ClusterIssuer
  commonName: "*.cluster.<your-domain>"
  dnsNames:
    - "*.cluster.<your-domain>"
    - "cluster.<your-domain>"
  secretTemplate:
    annotations:
      reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
      reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: ""
      reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
      reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: ""

dnsNames에 와일드카드(*.cluster.<your-domain>)와 베이스 도메인(cluster.<your-domain>)을 모두 등록합니다. 와일드카드는 서브도메인(vault.cluster.<your-domain>)은 커버하지만 베이스 도메인 자체는 커버하지 않으므로 둘 다 필요합니다.

secretTemplate.annotations의 Reflector 어노테이션은 다음 섹션에서 설명합니다.


8. Reflector — 인증서 Secret 복제

cert-manager는 Certificate 리소스와 같은 네임스페이스(cert-manager)에 TLS Secret을 생성합니다. 그런데 Istio Gateway는 istio-system 네임스페이스에 있고, credentialName으로 참조하는 Secret도 istio-system에 있어야 합니다.

네임스페이스가 다르기 때문에 Secret을 직접 참조할 수 없습니다. 이 문제를 해결하는 도구가 Reflector입니다.

# infra/core/reflector-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: reflector
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-3"
spec:
  project: default
  source:
    repoURL: https://emberstack.github.io/helm-charts
    chart: reflector
    targetRevision: 10.0.17
  destination:
    server: https://kubernetes.default.svc
    namespace: kube-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Reflector는 특정 어노테이션이 붙은 Secret/ConfigMap을 다른 네임스페이스로 자동 복제합니다. CertificatesecretTemplate 어노테이션이 그 트리거입니다.

secretTemplate:
  annotations:
    reflector.v1.k8s.emberstack.com/reflection-allowed: "true" # 복제 허용
    reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "" # 모든 네임스페이스 허용
    reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true" # 자동 복제 활성화
    reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "" # 모든 네임스페이스에 자동 복제

이 어노테이션이 생성된 Secret에 자동으로 붙어, Reflector가 cert-manager의 TLS Secret을 클러스터 전체 네임스페이스에 복제합니다. Istio Gateway가 istio-system에서 인증서를 찾을 수 있게 됩니다.


9. Istio Gateway

# infra/istio/gateway/istio-gateway.yaml
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: istio-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 80
        name: http
        protocol: HTTP
      hosts:
        - "*.cluster.<your-domain>"
      tls:
        httpsRedirect: true # HTTP → HTTPS 자동 리다이렉트
    - port:
        number: 443
        name: https
        protocol: HTTPS
      hosts:
        - "*.cluster.<your-domain>"
      tls:
        mode: SIMPLE
        credentialName: cert-manager-tls-wildcard-cluster # Reflector가 복제한 Secret

httpsRedirect: true를 설정하면 80포트로 들어오는 모든 요청이 자동으로 HTTPS로 리다이렉트됩니다. 별도 리다이렉트 VirtualService를 만들 필요가 없습니다.

credentialName은 Reflector가 istio-system에 복제한 Secret 이름입니다. Istio는 이 Secret에서 TLS 인증서와 키를 읽어 TLS를 종료합니다.

이 Gateway는 Platform 그룹에서 Wave 1로 배포됩니다.

# infra/platform/istio-gateway-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: istio-gateway
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "1" # cert-manager-config(인증서 발급) 이후
spec:
  project: default
  source:
    repoURL: https://github.com/<your-org>/gitops.git
    targetRevision: main
    path: infra/istio/gateway
  destination:
    server: https://kubernetes.default.svc
    namespace: istio-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Wave 1로 설정한 이유는 credentialName으로 참조하는 TLS Secret이 먼저 존재해야 Gateway가 정상 기동되기 때문입니다. cert-manager-config(Wave 0 기본값)에서 인증서 발급과 Reflector 복제가 완료된 뒤에 Gateway를 적용합니다.


10. VirtualService 패턴

Gateway가 준비되면 각 서비스에 VirtualService만 추가하면 외부에서 접근할 수 있습니다. Vault와 ArgoCD의 VirtualService를 예시로 보겠습니다.

# infra/vault/config/vault-vs.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: vault-vs
  namespace: vault
spec:
  hosts:
    - "vault.cluster.<your-domain>"
  gateways:
    - istio-system/istio-gateway # 앞서 생성한 Gateway 참조
  http:
    - match:
        - uri:
            prefix: /
      route:
        - destination:
            port:
              number: 8200
            host: vault
# infra/argocd/argocd-vs.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: argocd-vs
  namespace: argocd
spec:
  hosts:
    - "argocd.cluster.<your-domain>"
  gateways:
    - istio-system/istio-gateway
  http:
    - match:
        - uri:
            prefix: /
      route:
        - destination:
            port:
              number: 80
            host: argocd-server

패턴은 항상 동일합니다. hosts에 서비스 도메인, gateways에 Gateway 참조, route.destination에 K8s Service 이름과 포트를 지정합니다. 새로운 서비스를 외부에 노출할 때는 VirtualService 하나만 추가하면 됩니다. 인증서 발급이나 Load Balancer 설정은 변경할 필요가 없습니다.


11. 마무리

전체 흐름을 배포 순서 기준으로 정리합니다.

flowchart TD
  subgraph w3["Core Wave −3"]
    cm["cert-manager\nCRD 등록"]
    ref["reflector\nSecret 복제"]
  end
  subgraph w2["Core Wave −2"]
    ib["istio-base + istiod\nCRD + 컨트롤 플레인"]
    esov["external-secrets + vault\nESO CRD + 시크릿 저장소"]
  end
  subgraph w1["Core Wave −1"]
    igw["istio-ingressgateway\nOCI LB External IP 할당"]
  end
  subgraph p0["Platform Wave 0"]
    cert["ExternalSecret → K8s Secret 생성\nClusterIssuer + Certificate\nLet's Encrypt 와일드카드 인증서 발급\nReflector → istio-system 복제"]
  end
  subgraph p1["Platform Wave 1"]
    gvs["Istio Gateway + VirtualService\ncredentialName으로 TLS Secret 참조"]
  end

  cm & ref --> ib & esov
  ib & esov --> igw
  igw --> cert
  cert --> gvs

Vault에 저장된 Cloudflare 토큰 하나로 인증서 발급부터 갱신까지 자동화됩니다. 인증서 만료 전 cert-manager가 자동으로 갱신하고, Reflector가 복제본을 업데이트하며, Istio가 새 인증서를 핫리로드합니다. 만료를 신경 쓸 필요가 없습니다.