mTLS Everywhere: APISIX에서 TLS를 구성하는 방법

Nicolas Fränkel

Nicolas Fränkel

July 31, 2023

Ecosystem

간단히 살펴보는 TLS

TLS는 다음과 같은 기능을 제공합니다:

  • 서버 인증: 클라이언트가 데이터를 교환하는 서버가 올바른 서버임을 확신할 수 있습니다. 이는 기밀 데이터를 잘못된 대상에게 보내는 것을 방지합니다.
  • 선택적 클라이언트 인증: 반대로, 서버는 신원이 확인된 클라이언트만 허용합니다.
  • 기밀성: 제3자는 클라이언트와 서버 간에 교환된 데이터를 읽을 수 없습니다.
  • 무결성: 제3자는 데이터를 변조할 수 없습니다.

TLS는 인증서를 통해 작동합니다. 인증서는 ID와 유사하며, 인증서 소유자의 신원을 증명합니다. ID와 마찬가지로, 이를 발급한 주체를 신뢰해야 합니다. 신뢰는 체인을 통해 확립됩니다: 내가 Alice를 신뢰하고, Alice가 Bob을 신뢰하며, Bob이 Charlie를 신뢰하고, Charlie가 인증서를 발급했다면, 나는 후자를 신뢰합니다. 이 시나리오에서 Alice는 루트 인증 기관으로 알려져 있습니다.

TLS 인증은 공개 키 암호화를 기반으로 합니다. Alice는 공개 키/개인 키 쌍을 생성하고 공개 키를 공개합니다. 공개 키로 데이터를 암호화하면, 해당 공개 키를 생성한 개인 키만이 이를 복호화할 수 있습니다. 다른 사용법은 개인 키로 데이터를 암호화하고, 공개 키를 가진 모든 사람이 이를 복호화하여 신원을 증명하는 것입니다.

마지막으로, 상호 TLS, 즉 mTLS는 양방향 TLS의 구성입니다: 서버가 클라이언트에게 인증하는 것은 물론, 클라이언트도 서버에게 인증합니다.

이제 개념을 충분히 이해했으니, 실제로 적용해 보겠습니다.

cert-manager로 인증서 생성하기

기본적으로 몇 개의 루트 인증 기관이 브라우저에 설치되어 있습니다. 이것이 우리가 HTTPS 웹사이트를 안전하게 탐색할 수 있는 이유이며, https://apache.org가 그들이 주장하는 사이트임을 신뢰할 수 있습니다. 인프라는 사전 설치된 인증서가 없으므로, 처음부터 시작해야 합니다.

최소한 하나의 루트 인증서가 필요합니다. 이 루트 인증서는 다른 모든 인증서를 생성할 것입니다. 모든 것을 수동으로 할 수도 있지만, Kubernetes에서 cert-manager를 사용할 것입니다. 이름에서 알 수 있듯이, cert-manager는 인증서를 관리하는 솔루션입니다.

Helm을 사용하여 설치하는 것은 간단합니다:

helm repo add jetstack https://charts.jetstack.io  #1

helm install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \                       #2
  --create-namespace \                             #2
  --version v1.11.0 \
  --set installCRDs=true \
  --set prometheus.enabled=false                   #3
  1. 차트 저장소 추가
  2. 전용 네임스페이스에 객체 설치
  3. 이 글의 범위에서는 모니터링하지 않음

다음 명령어를 통해 모든 것이 예상대로 작동하는지 확인할 수 있습니다:

kubectl get pods -n cert-manager
cert-manager-cainjector-7f694c4c58-fc9bk  1/1  Running  2  (2d1h ago)  7d
cert-manager-cc4b776cf-8p2t8              1/1  Running  1  (2d1h ago)  7d
cert-manager-webhook-7cd8c769bb-494tl     1/1  Running  1  (2d1h ago)  7d

cert-manager는 여러 소스(HashiCorp Vault, Let's Encrypt 등)에서 인증서를 서명할 수 있습니다. 간단하게 하기 위해:

  • 우리는 전용 루트 인증서, 즉 Self-Signed를 생성할 것입니다.
  • 인증서 회전은 처리하지 않을 것입니다.

다음으로 시작해 보겠습니다:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer                           #1
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
apiVersion: v1
kind: Namespace
metadata:
  name: tls                                   #2
---
apiVersion: cert-manager.io/v1
kind: Certificate                             #3
metadata:
  name: selfsigned-ca
  namespace: tls
spec:
  isCA: true
  commonName: selfsigned-ca
  secretName: root-secret
  issuerRef:
    name: selfsigned-issuer
    kind: ClusterIssuer
    group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer                                  #4
metadata:
  name: ca-issuer
  namespace: tls
spec:
  ca:
    secretName: root-secret
  1. 클러스터 전체에서 인증서를 생성하는 인증 기관
  2. 데모를 위한 네임스페이스 생성
  3. 클러스터 전체 발급자를 사용하는 네임스페이스 루트 인증서. 네임스페이스 발급자를 생성하는 데만 사용됨
  4. 네임스페이스 발급자. 글에서 다른 모든 인증서를 생성하는 데 사용됨

이전 매니페스트를 적용한 후, 생성된 단일 인증서를 확인할 수 있습니다:

kubectl get certificate -n tls
NAME            READY   SECRET        AGE
selfsigned-ca   True    root-secret   7s

인증서 인프라가 준비되었으니, Apache APISIX를 살펴보겠습니다.

샘플 Apache APISIX 아키텍처 개요

Apache APISIX는 API 게이트웨이입니다. 기본적으로 구성은 etcd에 저장되며, 이는 분산 키-값 저장소입니다. Kubernetes에서 사용하는 것과 동일합니다. 실제 시나리오에서는 etcd 클러스터링을 설정하여 솔루션의 복원력을 향상시켜야 합니다. 이 글에서는 단일 etcd 인스턴스로 제한하겠습니다. Apache APISIX는 HTTP 엔드포인트를 통해 관리 API를 제공합니다. 마지막으로, 게이트웨이는 클라이언트의 호출을 업스트림으로 전달합니다. 다음은 아키텍처와 필요한 인증서의 개요입니다:

Apache APISIX 아키텍처

기초적인 구성 요소인 etcd와 Apache APISIX부터 시작하겠습니다. 두 개의 인증서가 필요합니다: 하나는 etcd용(서버 역할), 다른 하나는 Apache APISIX용(etcd 클라이언트).

네임스페이스 발급자로부터 인증서를 설정해 보겠습니다:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: etcd-server                         #1
  namespace: tls
spec:
  secretName: etcd-secret                   #2
  isCA: false
  usages:
    - client auth                           #3
    - server auth                           #3
  dnsNames:
    - etcd                                  #4
  issuerRef:
    name: ca-issuer                         #5
    kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: apisix-client                       #6
  namespace: tls
spec:
  secretName: apisix-client-secret
  isCA: false
  usages:
    - client auth
  emailAddresses:
    - apisix@apache.org                     #7
  issuerRef:
    name: ca-issuer                         #5
    kind: Issuer
  1. etcd용 인증서
  2. Kubernetes Secret 이름, 아래 참조
  3. 이 인증서의 용도
  4. Kubernetes Service 이름, 아래 참조
  5. 이전에 생성한 네임스페이스 발급자 참조
  6. etcd의 클라이언트로서 Apache APISIX용 인증서
  7. 클라이언트의 필수 속성

위 매니페스트를 적용한 후, tls 네임스페이스의 인증서를 나열할 수 있습니다:

kubectl get certificates -n tls
NAME              READY   SECRET                 AGE
selfsigned-ca     True    root-secret            8m59s    //1
apisix-client     True    apisix-client-secret   8m22s    //2
etcd-server       True    etcd-secret            8m54s    //2
  1. 이전에 생성된 인증서
  2. selfsigned-ca로 서명된 새로 생성된 인증서

cert-manager의 인증서

지금까지 Certificate 객체를 생성했지만, 그것들이 무엇인지 설명하지 않았습니다. 실제로, 이들은 cert-manager가 제공하는 간단한 Kubernetes CRD입니다. 내부적으로 cert-manager는 Certificate에서 Kubernetes Secret을 생성합니다. 전체 라이프사이클을 관리하므로, Certificate를 삭제하면 연결된 Secret도 삭제됩니다. 위 매니페스트의 secretName 속성은 Secret 이름을 설정합니다.

kubectl get secrets -n tls
NAME                   TYPE                DATA   AGE
apisix-client-secret   kubernetes.io/tls   3      35m
etcd-secret            kubernetes.io/tls   3      35m
root-secret            kubernetes.io/tls   3      35m

Secret을 살펴보겠습니다. 예를 들어, apisix-client-secret:

kubectl describe apisix-client-secret -n tls
Name:         apisix-client-secret
Namespace:    tls
Labels:       controller.cert-manager.io/fao=true
Annotations:  cert-manager.io/alt-names:
              cert-manager.io/certificate-name: apisix-client
              cert-manager.io/common-name:
              cert-manager.io/ip-sans:
              cert-manager.io/issuer-group:
              cert-manager.io/issuer-kind: Issuer
              cert-manager.io/issuer-name: ca-issuer
              cert-manager.io/uri-sans:

Type:  kubernetes.io/tls

Data
====
ca.crt:   1099 bytes
tls.crt:  1115 bytes
tls.key:  1679 bytes

Certificate로 생성된 Secret은 세 가지 속성을 제공합니다:

  • tls.crt: 인증서 자체
  • tls.key: 개인 키
  • ca.crt: 인증서 체인의 서명 인증서, 즉 root-secret/tls.crt

Kubernetes는 Secret 내용을 base64로 인코딩합니다. 위의 내용을 일반 텍스트로 얻으려면, 디코딩해야 합니다. 예를 들어:

 kubectl get secret etcd-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64
-----BEGIN CERTIFICATE-----
MIIDBjCCAe6gAwIBAgIQM3JUR8+R0vuUndjGK/aOgzANBgkqhkiG9w0BAQsFADAY
MRYwFAYDVQQDEw1zZWxmc2lnbmVkLWNhMB4XDTIzMDMxNjEwMTYyN1oXDTIzMDYx
NDEwMTYyN1owADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMQpMj/0
giDVOjOosSRRKUwTzl1Wo2R9YYAeteOW3fuMiAd+XaBGmRO/+GWZQN1tyRQ3pITM
ezBgogYAUUNcuqN/UAsgH/JM58niMjZdjRKn4+it94Nj1e24jFL4ts2snCn7FfKJ
3zRtY9tyS7Agw3tCwtXV68Xpmf3CsfhPmn3rGdWHXyYctzAZhqYfEswN3hxpJZxR
YVeb55WgDoPo5npZo3+yYiMtoOimIprcmZ2Ye8Wai9S4QKDafUWlvU5GQ65VVLzH
PEdOMwbWcwiLqwUv889TiKiC5cyAD6wJOuPRF0KKxxFnG+lHlg9J2S1i5sC3pqoc
i0pEQ+atOOyLMMECAwEAAaNkMGIwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUF
BwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAU2ZaAdEficKUWPFRjdsKSEX/l
gbMwEgYDVR0RAQH/BAgwBoIEZXRjZDANBgkqhkiG9w0BAQsFAAOCAQEABcNvYTm8
ZJe3jUq6f872dpNVulb2UvloTpWxQ8jwXgcrhekSKU6pZ4p9IPwfauHLjceMFJLp
t2eDi5fSQ1upeqXOofeyKSYjjyA/aVf1zMI8ReCCQtQuAVYyJWBlNLc3XMMecbcp
JLGtd/OAZnKDeYYkUX7cJ2wN6Wl/wGLM2lxsqDhEHEZwvGL0DmsdHw7hzSjdVmxs
0Qgkh4jVbNUKdBok5U9Ivr3P1xDPaD/FqGFyM0ssVOCHxtPxhOUA/m3DSr6klfEF
McOfudZE958bChOrJgVrUnY3inR0J335bGQ1luEp5tYwPgyD9dG4MQEDD3oLwp+l
+NtTUqz8WVlMxQ==
-----END CERTIFICATE-----

etcd와 APISIX 간의 mTLS 구성

인증서가 준비되었으니, 이제 etcd와 APISIX 간의 상호 TLS를 구성할 수 있습니다. 먼저 etcd부터 시작하겠습니다:

apiVersion: v1
kind: Pod
metadata:
  name: etcd
  namespace: tls
  labels:
    role: config
spec:
  containers:
    - name: etcd
      image: bitnami/etcd:3.5.7
      ports:
        - containerPort: 2379
      env:
        - name: ETCD_TRUSTED_CA_FILE        #1
          value: /etc/ssl/private/ca.crt
        - name: ETCD_CERT_FILE              #2
          value: /etc/ssl/private/tls.crt
        - name: ETCD_KEY_FILE               #3
          value: /etc/ssl/private/tls.key
        - name: ETCD_ROOT_PASSWORD
          value: whatever
        - name: ETCD_CLIENT_CERT_AUTH       #4
          value: "true"
        - name: ETCD_LISTEN_CLIENT_URLS
          value: https://0.0.0.0:2379
      volumeMounts:
        - name: ssl
          mountPath: /etc/ssl/private       #5
  volumes:
    - name: ssl
      secret:
        secretName: etcd-secret             #5
  1. 신뢰할 수 있는 CA 설정
  2. 인증서 설정
  3. 개인 키 설정
  4. 클라이언트가 인증서를 전달하도록 요구하여 상호 인증 보장
  5. 이전에 생성된 시크릿을 컨테이너에 마운트하여 접근 가능

이제 Apache APISIX의 차례입니다:

apiVersion: v1
kind: ConfigMap                                            #1
metadata:
  name: apisix-config
  namespace: tls
data:
  config.yaml: >-
    apisix:
      ssl:
        ssl_trusted_certificate: /etc/ssl/certs/ca.crt     #2
    deployment:
      etcd:
        host:
          - https://etcd:2379
        tls:
          cert: /etc/ssl/certs/tls.crt                     #2
          key: /etc/ssl/certs/tls.key                      #2
      admin:
        allow_admin:
          - 0.0.0.0/0
        https_admin: true                                  #3
        admin_api_mtls:
          admin_ssl_cert: /etc/ssl/private/tls.crt         #3
          admin_ssl_cert_key: /etc/ssl/private/tls.key     #3
          admin_ssl_ca_cert: /etc/ssl/private/ca.crt       #3
---
apiVersion: v1
kind: Pod
metadata:
  name: apisix
  namespace: tls
  labels:
    role: gateway
spec:
  containers:
    - name: apisix
      image: apache/apisix:3.2.0-debian
      ports:
        - containerPort: 9443                              #4
        - containerPort: 9180                              #5
      volumeMounts:
        - name: config                                      #1
          mountPath: /usr/local/apisix/conf/config.yaml
          subPath: config.yaml
        - name: ssl                                        #6
          mountPath: /etc/ssl/private
        - name: etcd-client                                #7
          mountPath: /etc/ssl/certs
  volumes:
    - name: config
      configMap:
        name: apisix-config
    - name: ssl                                            #6,8
      secret:
        secretName: apisix-server-secret
    - name: etcd-client                                    #7,8
      secret:
        secretName: apisix-client-secret
  1. Apache APISIX는 환경 변수를 통해 구성을 허용하지 않습니다. 일반 config.yaml 파일을 반영하는 ConfigMap을 사용해야 합니다.
  2. etcd에 대한 클라이언트 인증 구성
  3. Admin API에 대한 서버 인증 구성
  4. 일반 HTTPS 포트
  5. Admin HTTPS 포트
  6. 서버 인증을 위한 인증서
  7. 클라이언트 인증을 위한 인증서
  8. 두 세트의 인증서가 사용됩니다. 하나는 Admin API 및 일반 HTTPS에 대한 서버 인증용이고, 다른 하나는 etcd에 대한 클라이언트 인증용입니다.

이 시점에서 위 매니페스트를 적용하고 두 포드가 통신하는 것을 볼 수 있습니다. 연결 시 Apache APISIX는 HTTPS를 통해 apisix-client 인증서를 전송합니다. etcd가 신뢰하는 기관이 서명한 인증서이므로 연결을 허용합니다.

간결성을 위해 Service 정의는 생략했지만, 관련 GitHub 저장소에서 확인할 수 있습니다.

NAME     READY   STATUS    RESTARTS   AGE
apisix   1/1     Running   0          179m
etcd     1/1     Running   0          179m

클라이언트 접근

기본 인프라를 설정했으니, 클라이언트로 접근을 테스트해야 합니다. 우리는 충성스러운 curl을 사용할 것이지만, 인증서를 구성할 수 있는 모든 클라이언트가 작동할 것입니다. 예를 들어, httpie.

첫 번째 단계는 클라이언트용 전용 인증서-키 쌍을 생성하는 것입니다:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: curl-client
  namespace: tls
spec:
  secretName: curl-secret
  isCA: false
  usages:
    - client auth
  emailAddresses:
    - curl@localhost.dev
  issuerRef:
    name: ca-issuer
    kind: Issuer

curl은 인증서 파일의 경로를 요구합니다. zsh의 마법을 통해 이 제한을 우회할 수 있습니다: =( ... ) 구문은 임시 파일을 생성할 수 있게 합니다. 다른 셸을 사용한다면, 동등한 구문을 찾거나 파일을 수동으로 다운로드해야 합니다.

Admin API를 쿼리하여 기존 모든 경로를 확인해 보겠습니다. 이 간단한 명령어는 Apache APISIX가 etcd에 연결되어 있고, 그곳에서 구성을 읽을 수 있는지 확인할 수 있습니다.

curl --resolve 'admin:32180:127.0.0.1' https://admin:32180/apisix/admin/routes \                     #1
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \  #2
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \   #2
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d) \ #2
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
  1. --resolve/etc/hosts 파일을 오염시키지 않습니다. curladminlocalhost로 변환하지만, 쿼리는 Kubernetes 클러스터 내부의 admin으로 전송되어 올바른 Service를 사용합니다.
  2. Secret 내부의 필요한 데이터를 가져와 디코딩하고 임시 파일로 사용합니다.

모든 것이 작동한다면, 결과는 다음과 같아야 합니다:

{"total":0,"list":[]}

아직 경로를 생성하지 않았으므로, 사용 가능한 경로가 없습니다.

업스트림과의 TLS

마지막으로, 업스트림과의 TLS를 구성해야 합니다. 다음에서는 정적 콘텐츠를 반환하는 간단한 nginx 인스턴스를 사용할 것입니다. 더 복잡한 업스트림을 위한 예시로 사용하세요.

첫 번째 단계는 항상 그렇듯이 업스트림용 전용 Certificate를 생성하는 것입니다. 이미 몇 개를 생성했으므로, 이를 수행하는 방법은 생략하겠습니다. 이를 upstream-server라고 부르고, 그 Secretupstream-secret이라고 부르겠습니다. 이제 후자를 사용하여 NGINX를 보호할 수 있습니다:

apiVersion: v1
kind: ConfigMap                                           #1
metadata:
  name: nginx-config
  namespace: tls
data:
  nginx.conf: >-
    events {
      worker_connections 1024;
    }
    http {
      server {
        listen              443 ssl;
        server_name         upstream;
        ssl_certificate     /etc/ssl/private/tls.crt;     #2
        ssl_certificate_key /etc/ssl/private/tls.key;     #2

        root /www/data;
        location / {
            index index.json;
        }
      }
    }
---
apiVersion: v1
kind: Pod
metadata:
  name: upstream
  namespace: tls
  labels:
    role: upstream
spec:
  containers:
    - name: upstream
      image: nginx:1.23-alpine
      ports:
        - containerPort: 443
      volumeMounts:
        - name: config
          mountPath: /etc/nginx/nginx.conf                #1
          subPath: nginx.conf
        - name: content
          mountPath: /www/data/index.json                 #3
          subPath: index.json
        - name: ssl                                       #2
          mountPath: /etc/ssl/private
  volumes:
    - name: config
      configMap:
        name: nginx-config
    - name: ssl                                           #2
      secret:
        secretName: upstream-secret
    - name: content                                       #3
      configMap:
        name: nginx-content
  1. NGINX는 환경 변수를 통해 구성을 허용하지 않습니다. ConfigMap 접근 방식을 사용해야 합니다.
  2. Certificate로 생성된 키-인증서 쌍 사용
  3. 이 글의 범위에서는 중요하지 않은 정적 콘텐츠

다음 단계는 Admin API를 사용하여 경로를 생성하는 것입니다. 이전 단계에서 모든 것을 준비했으니, 이제 API를 사용할 수 있습니다:

curl --resolve 'admin:32180:127.0.0.1' https://admin:32180/apisix/admin/routes/1 \
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \     #1
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \      #1
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d) \    #1
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d "{
        \"uri\": \"/\",
        \"upstream\": {
          \"scheme\": \"https\",                                                                        #2
          \"nodes\": {
            \"upstream:443\": 1
          },
          \"tls\": {
            \"client_cert\": \"$(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d)\", #3
            \"client_key\": \"$(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d)\"   #3
          }
        }
     }"
  1. Admin API에 대한 클라이언트 인증, 위와 동일
  2. 업스트림에 HTTPS 사용
  3. 경로에 대한 키-인증서 쌍 구성. Apache APISIX는 데이터를 etcd에 저장하고, 경로를 호출할 때 이를 사용합니다. 또는, 전용 객체로 쌍을 유지하고 새로 생성된 참조를 사용할 수 있습니다(업스트림과 마찬가지로). 이는 인증서가 필요한 경로 수에 따라 다릅니다. 자세한 내용은 SSL 엔드포인트를 확인하세요.

마지막으로, 예상대로 작동하는지 확인할 수 있습니다:

curl --resolve 'upstream:32443:127.0.0.1' https://upstream:32443/ \
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d)

그리고 작동합니다:

{ "hello": "world" }

결론

이 글에서는 작동하는 Apache APISIX 아키텍처를 설명하고, 모든 구성 요소 간의 상호 TLS를 구현했습니다: etcd와 APISIX, 클라이언트와 APISIX, 그리고 마지막으로 클라이언트와 업스트림. 여러분도 동일한 결과를 얻을 수 있기를 바랍니다.

APISIX 및 API 관리에 대한 질문이나 문의 사항이 있으면 문의하기를 통해 연락주세요.

더 알아보기:

Tags: