mTLS Everywhere: APISIX에서 TLS를 구성하는 방법
July 31, 2023
간단히 살펴보는 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
- 차트 저장소 추가
- 전용 네임스페이스에 객체 설치
- 이 글의 범위에서는 모니터링하지 않음
다음 명령어를 통해 모든 것이 예상대로 작동하는지 확인할 수 있습니다:
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
- 클러스터 전체에서 인증서를 생성하는 인증 기관
- 데모를 위한 네임스페이스 생성
- 클러스터 전체 발급자를 사용하는 네임스페이스 루트 인증서. 네임스페이스 발급자를 생성하는 데만 사용됨
- 네임스페이스 발급자. 글에서 다른 모든 인증서를 생성하는 데 사용됨
이전 매니페스트를 적용한 후, 생성된 단일 인증서를 확인할 수 있습니다:
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를 제공합니다. 마지막으로, 게이트웨이는 클라이언트의 호출을 업스트림으로 전달합니다. 다음은 아키텍처와 필요한 인증서의 개요입니다:
기초적인 구성 요소인 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
- etcd용 인증서
- Kubernetes
Secret
이름, 아래 참조 - 이 인증서의 용도
- Kubernetes
Service
이름, 아래 참조 - 이전에 생성한 네임스페이스 발급자 참조
- etcd의 클라이언트로서 Apache APISIX용 인증서
- 클라이언트의 필수 속성
위 매니페스트를 적용한 후, 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
- 이전에 생성된 인증서
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
- 신뢰할 수 있는 CA 설정
- 인증서 설정
- 개인 키 설정
- 클라이언트가 인증서를 전달하도록 요구하여 상호 인증 보장
- 이전에 생성된 시크릿을 컨테이너에 마운트하여 접근 가능
이제 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
- Apache APISIX는 환경 변수를 통해 구성을 허용하지 않습니다. 일반
config.yaml
파일을 반영하는ConfigMap
을 사용해야 합니다. - etcd에 대한 클라이언트 인증 구성
- Admin API에 대한 서버 인증 구성
- 일반 HTTPS 포트
- Admin HTTPS 포트
- 서버 인증을 위한 인증서
- 클라이언트 인증을 위한 인증서
- 두 세트의 인증서가 사용됩니다. 하나는 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'
--resolve
는/etc/hosts
파일을 오염시키지 않습니다.curl
은admin
을localhost
로 변환하지만, 쿼리는 Kubernetes 클러스터 내부의admin
으로 전송되어 올바른Service
를 사용합니다.Secret
내부의 필요한 데이터를 가져와 디코딩하고 임시 파일로 사용합니다.
모든 것이 작동한다면, 결과는 다음과 같아야 합니다:
{"total":0,"list":[]}
아직 경로를 생성하지 않았으므로, 사용 가능한 경로가 없습니다.
업스트림과의 TLS
마지막으로, 업스트림과의 TLS를 구성해야 합니다. 다음에서는 정적 콘텐츠를 반환하는 간단한 nginx 인스턴스를 사용할 것입니다. 더 복잡한 업스트림을 위한 예시로 사용하세요.
첫 번째 단계는 항상 그렇듯이 업스트림용 전용 Certificate
를 생성하는 것입니다. 이미 몇 개를 생성했으므로, 이를 수행하는 방법은 생략하겠습니다. 이를 upstream-server
라고 부르고, 그 Secret
은 upstream-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
- NGINX는 환경 변수를 통해 구성을 허용하지 않습니다.
ConfigMap
접근 방식을 사용해야 합니다. Certificate
로 생성된 키-인증서 쌍 사용- 이 글의 범위에서는 중요하지 않은 정적 콘텐츠
다음 단계는 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
}
}
}"
- Admin API에 대한 클라이언트 인증, 위와 동일
- 업스트림에 HTTPS 사용
- 경로에 대한 키-인증서 쌍 구성. 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 관리에 대한 질문이나 문의 사항이 있으면 문의하기를 통해 연락주세요.
더 알아보기: