카테고리 보관물: Certificate

Keycloak LDAPS Fails After an Amazon Linux 2023 Update: Trusting the Right AD Root CA

dnf upgrade 뒤 LDAPS만 실패한 Keycloak

Amazon Linux 2023 서버에 2026년 11월 2일 dnf upgrade를 적용한 뒤, Windows AD와 연동하던 Keycloak LDAPS 연결이 실패한 기록입니다. LDAP(389)로 바꾸면 연결이 정상이었고, 636 포트가 열려 있는 것도 확인한 상태였습니다.

점검하면서 걸린 곳은 두 군데였습니다. OS 신뢰 저장소에 AD의 Root CA가 없었고, 처음 등록한 파일은 CA가 아니라 서버 인증서였습니다. OS에서 검증이 통과한 뒤에도 Keycloak은 자신의 truststore에 Root CA를 넣고 재시작한 뒤에야 연결됐습니다.

업그레이드가 정확히 무엇을 바꿨는지는 확인하지 못했습니다. 그 부분은 글 끝에 따로 적었습니다.

환경

구성 요소 내용
OS Amazon Linux 2023 (2026-11-02 dnf upgrade 적용)
Keycloak 26.3.3, 서버에 직접 설치(컨테이너 아님)
LDAP 서버 Windows Active Directory, AD의 사설 CA(example-DC01-CA)가 발급한 서버 인증서
Keycloak LDAP 설정 ldaps://DC01.example.local:636, StartTLS Off, Use Truststore SPI Always

포트가 열려 있어도 LDAPS는 따로 확인해야 하는 이유

389는 평문 LDAP입니다. LDAPS(636)는 연결 직후 TLS 핸드셰이크와 인증서 검증을 한 번 더 거칩니다. TCP 연결과 389 동작은 그 앞 단계까지만 보증합니다.

Keycloak 설정을 건드리기 전에, Keycloak이 동작하는 서버에서 OpenSSL로 TLS 협상과 인증서 검증을 나눠 보았습니다.

1. OpenSSL 첫 검증: 협상은 성공, 검증은 오류 21

openssl s_client \
  -connect dc01.example.local:636 \
  -servername dc01.example.local \
  -showcerts </dev/null
# 출력 일부 발췌
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 21 (unable to verify the first certificate)

TLS 1.3과 TLS_AES_256_GCM_SHA384 협상은 성공했으므로 프로토콜이나 cipher 호환성 문제는 아니었습니다. 서버 인증서는 CN=DC01.example.local이고 발급자는 사설 CA인 example-DC01-CA였습니다. 2026-09-24부터 2027-09-24까지인 유효기간도 문제가 없었습니다.

AD는 체인 없이 서버 인증서 한 장만 보내고 있었습니다. Keycloak 서버가 발급자 CA를 미리 알고 있지 않으면 검증을 끝낼 수 없는 구조입니다.

2. 이름은 ca.cer인데 서버 인증서였다

AD에서 내보낸 파일 두 개 중 example-dc01-ca.cer를 먼저 OS 신뢰 저장소에 넣었습니다. update-ca-trust extract를 실행한 뒤에도 오류 20(unable to get local issuer certificate)은 그대로였습니다. 두 파일의 subject와 issuer를 비교하자 이유가 드러났습니다.

$ openssl x509 -in example-dc01-ca.cer -noout -subject -issuer
subject=CN=DC01.example.local
issuer=DC=local, DC=example, CN=example-DC01-CA

$ openssl x509 -in example-dc01-root.cer -noout -subject -issuer
subject=DC=local, DC=example, CN=example-DC01-CA
issuer=DC=local, DC=example, CN=example-DC01-CA

example-dc01-ca.cer는 이름과 달리 DC01의 서버 인증서였습니다. 실제 CA는 subject와 issuer가 같은 자체 서명 인증서, 즉 example-dc01-root.cer 쪽이었습니다. 신뢰 저장소에는 서버 인증서가 아니라 그것을 발급한 CA를 넣어야 합니다.

CA 파일을 등록하기 전에 두 가지를 확인해 두면 같은 혼동을 피할 수 있습니다. Basic Constraints가 CA:TRUE인지 보고, 그 CA로 서버 인증서가 실제로 검증되는지 시험합니다.

openssl x509 -in example-dc01-root.cer -noout -text | grep -A1 'Basic Constraints'
openssl verify -CAfile example-dc01-root.cer example-dc01-ca.cer

3. Root CA를 넣자 OS 검증은 통과

sudo cp example-dc01-root.cer /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract

openssl s_client \
  -connect dc01.example.local:636 \
  -servername dc01.example.local \
  -verify_hostname dc01.example.local \
  -verify_return_error </dev/null
# 출력 일부 발췌
Verified peername: DC01.example.local
Verify return code: 0 (ok)

OpenSSL 기준으로는 인증서 체인과 호스트명 검증이 모두 통과했습니다. 앞서 잘못 넣은 서버 인증서 파일은 anchors 디렉터리에서 빼 두는 편이 나중에 덜 헷갈립니다.

4. OS는 통과했는데 Keycloak은 여전히 실패

Keycloak 관리 콘솔의 User Federation(LDAP) 화면에서 Test connection과 Test authentication이 모두 실패했습니다. Keycloak은 Java로 동작하므로 OpenSSL과 같은 저장소를 보지 않습니다.

Keycloak 공식 문서에 따르면 Keycloak은 Java 기본 truststore의 인증서를 항상 신뢰합니다. 추가 인증서는 conf/truststores 디렉터리나 truststore-paths 옵션으로 넣습니다. JDK 패키지에 따라 Java 기본 truststore(cacerts)가 OS 신뢰 저장소와 별개의 파일일 수 있어서, OS 저장소를 고쳐도 JVM에 반영된다는 보장이 없습니다.

Keycloak이 쓰는 Java의 cacerts가 어디를 가리키는지는 아래처럼 확인할 수 있습니다. 서비스가 별도의 JAVA_HOME을 쓴다면 그 경로의 java로 바꿔서 봐야 합니다.

JAVA_BIN=$(readlink -f "$(command -v java)")
readlink -f "$(dirname "$JAVA_BIN")/../lib/security/cacerts"

결과가 /etc/pki/ 아래를 가리키면 OS 저장소를 따르는 것이고, JDK 디렉터리 안의 파일이면 별도 저장소입니다. JVM은 시작할 때 truststore를 읽으므로 어느 쪽을 바꾸든 Keycloak을 재시작해야 반영됩니다.

Keycloak LDAPS 복구 과정 개념도: OpenSSL 검증 오류 21과 20을 거쳐 Root CA 등록으로 OS 검증이 통과한 뒤, Keycloak truststore 등록과 재시작으로 LDAP 연동이 복구되기까지의 단계와 세 신뢰 저장소의 역할
Keycloak LDAPS 연결이 복구되기까지 거친 네 단계와, OS·JVM·Keycloak 신뢰 저장소가 각각 무엇을 검증하는지 정리한 그림입니다.

5. Keycloak truststore에 Root CA를 등록하고 재시작

Keycloak 26.3.3은 Quarkus 기반이라 추가로 신뢰할 인증서를 Keycloak 설정으로 관리합니다. 등록 방법은 두 가지이고, 이번에는 둘 다 적용했습니다. Root CA를 conf/truststores에 PEM으로 두고, keycloak.conf의 truststore-paths에도 같은 파일을 지정한 뒤 재시작했습니다. LDAPS 연결은 그 뒤에 복구됐습니다.

# 1) conf/truststores 디렉터리에 PEM으로 두기 (설치 경로는 예시)
sudo mkdir -p /opt/keycloak/conf/truststores
sudo cp example-dc01-root.cer /opt/keycloak/conf/truststores/example-dc01-root.pem
# 2) keycloak.conf에도 같은 파일을 지정
truststore-paths=/opt/keycloak/conf/truststores/example-dc01-root.pem
# 3) 두 설정을 반영하도록 재시작 (서비스 이름은 예시)
sudo systemctl restart keycloak

문서에 적힌 형식은 PEM과 PKCS12(.p12, .pfx, .pkcs12)입니다. 확장자가 .cer여도 내용이 PEM이면 그대로 쓸 수 있습니다. DER 형식이라면 openssl x509 -inform DER -in example-dc01-root.cer -out example-dc01-root.pem으로 바꿔 둡니다.

이미 truststore-paths를 쓰고 있다면 기존 값을 덮어쓰지 말고 경로를 추가해야 합니다. 서비스 이름과 설치 경로는 환경에 맞게 바꿔야 합니다.

두 방법을 함께 적용했기 때문에 어느 쪽이 결정적이었는지는 구분되지 않습니다. Keycloak 문서에 따르면 conf/truststores에 둔 인증서도 신뢰 대상이 됩니다. 그러니 같은 파일을 truststore-paths로 다시 지정한 것은 중복일 수 있습니다. 한쪽만 남겨도 연결이 유지되는지는 다음 점검 때 확인해 볼 만합니다.

업그레이드가 무엇을 바꿨는지는 확인하지 못했다

장애는 dnf upgrade 직후에 시작됐지만, 그 트랜잭션에 어떤 패키지가 들어 있었는지는 확인하지 않았습니다. 그래서 업그레이드가 문제를 만들었는지, 원래 있던 문제를 드러냈을 뿐인지 구분할 수 없습니다. 생각해 볼 수 있는 경로는 아래와 같고, 어느 쪽도 이번에 검증하지는 않았습니다.

  • 예전에 keytool로 JDK의 cacerts에 직접 넣어 둔 CA가, JDK 패키지 업데이트로 파일이 교체되면서 사라졌을 수 있습니다.
  • Keycloak이 업그레이드 과정에서 재시작되면서, 그동안 메모리에 남아 있던 예전 truststore 대신 바뀐 파일을 새로 읽었을 수 있습니다.

AWS 보안 공지(CVE-2026-113470)에는 AL2023의 ca-certificates에서 eMudhra 루트를 제거하는 조치가 올라 있었습니다. 이번 문제는 사설 AD CA에 대한 신뢰였으므로 공개 CA 목록 변경과는 직접 관계가 없습니다. 다음에 같은 일이 생기면 업그레이드 트랜잭션부터 확인하려 합니다.

sudo dnf history
sudo dnf history info <ID>
rpm -qa | grep -Ei 'corretto|openjdk|ca-certificates|openssl'

정리

  • 포트와 389는 LDAPS를 보증하지 않습니다. OpenSSL로 TLS 협상과 인증서 검증을 나눠 보면, 원인이 프로토콜인지 신뢰 체인인지 바로 갈립니다.
  • 파일 이름보다 subject와 issuer를 봅니다. Root CA는 subject와 issuer가 같은 자체 서명 인증서입니다.
  • OS, JVM, Keycloak의 신뢰 저장소는 서로 다릅니다. Keycloak LDAPS 연결을 위해서는 Keycloak truststore에 Root CA를 두고 재시작해야 했습니다. Keycloak을 새 버전으로 옮길 때는 conf/truststores도 함께 옮겨야 합니다.
  • 389로 돌아가거나 검증을 끄는 것은 해결이 아닙니다. StartTLS 없이 389를 쓰면 바인드 계정의 비밀번호가 평문으로 오갑니다.

참고

관련 포스트:

참고 문서: Keycloak Configuring trusted certificates · OpenSSL s_client · AWS ALAS CVE-2026-113470

Enabling GitLab Container Registry over HTTPS with a Custom TLS Certificate

Kaniko 이미지를 둘 곳으로 GitLab Container Registry 선택

GitLab Container Registry 기능을 자체 관리형 Omnibus GitLab에서 HTTPS로 활성화한 작업 기록입니다. WordPress를 Kubernetes로 이전하면서 커스텀 이미지를 저장할 레지스트리가 필요했는데, 로컬 환경에 docker나 podman이 없어 클러스터 안에서 Kaniko로 이미지를 빌드하고 곧바로 푸시할 대상이 있어야 했습니다. 이미 사내에서 운영 중인 GitLab이 있었으므로 별도 레지스트리를 구축하는 대신 GitLab에 내장된 Container Registry를 켜는 방향을 선택했습니다.

Omnibus GitLab의 Container Registry는 기본적으로 비활성 상태이며 /etc/gitlab/gitlab.rb에서 직접 켜야 합니다. 이 과정에서 registry용 nginx 블록이 기존 커스텀 인증서 경로를 상속하지 않는 문제로 GitLab 전체 nginx가 기동 중지되는 상황을 겪었습니다. 레지스트리만 실패한 것이 아니라 GitLab 웹 UI까지 함께 접속 불가가 되었기 때문에, 같은 구성을 계획하고 계신 분이라면 미리 알아두실 만한 지점이라 판단해 정리합니다.

환경

항목 내용
GitLab Omnibus GitLab CE (Ubuntu, 192.168.x.x)
기존 도메인 gitlab.sierracloud.dev (HTTPS 서비스 중)
기존 TLS 인증서 Let’s Encrypt *.sierracloud.dev 와일드카드, /test/cert/에 배치
Registry 목표 주소 gitlab.sierracloud.dev:5050
이미지 사용처 Kubernetes 클러스터 (Kaniko 빌드 → 파드 imagePullSecrets)

호스트 방식 선택 — 포트 분리와 서브도메인 분리

GitLab Container Registry 주소는 두 가지 방식으로 구성할 수 있습니다. 어느 쪽을 선택하느냐에 따라 필요한 DNS 레코드와 인증서가 달라지므로 먼저 결정해야 합니다.

방식 주소 예시 DNS 인증서
단일 호스트 + 포트 gitlab.sierracloud.dev:5050 추가 불필요 기존 와일드카드 재사용
별도 서브도메인 registry.gitlab.sierracloud.dev 레코드 추가 필요 신규 발급 또는 SAN 추가 필요

이번에는 기존에 발급받아 둔 *.sierracloud.dev 와일드카드 인증서를 그대로 재사용할 수 있고 DNS 작업이 필요 없는 단일 호스트 + 포트 방식을 선택했습니다. 다만 와일드카드 인증서는 *.sierracloud.dev 형태로 한 단계 서브도메인만 포함하므로, 별도 서브도메인 방식을 택했다면 registry.gitlab.sierracloud.dev는 두 단계가 되어 기존 인증서로 커버되지 않는다는 점도 고려 대상이었습니다.

사전 확인

GitLab은 여러 사용자가 함께 쓰는 공유 시스템이고 gitlab-ctl reconfigure가 서비스 재기동을 동반하므로, 설정을 바꾸기 전에 현재 상태와 복구 지점을 먼저 확보했습니다.

# 설정 파일 백업 (가장 중요 - 되돌릴 지점 확보)
sudo cp /etc/gitlab/gitlab.rb /etc/gitlab/gitlab.rb.bak-$(date +%Y%m%d)

# 현재 registry 관련 설정 확인 (주석이 아닌 유효 설정만)
sudo grep -n -i 'registry|external_url|nginx\[.ssl' /etc/gitlab/gitlab.rb | grep -v '^\s*#'

# 레지스트리 저장 공간 확인 - 이미지가 쌓이는 경로
df -h /var/opt/gitlab

# 방화벽 상태 및 현재 리스닝 포트
sudo ufw status
sudo ss -tlnp | grep -E ':443|:80|:5050'

# 기존 인증서 위치 확인
sudo ls -la /etc/gitlab/ssl/
sudo find /etc/letsencrypt -maxdepth 3

이 단계에서 두 가지를 확인했습니다. 첫째로 /etc/gitlab/ssl/ 디렉터리에는 인증서가 없었고, GitLab이 /test/cert/의 커스텀 경로를 바라보도록 구성되어 있었습니다. 둘째로 registry 관련 설정은 모두 주석 처리된 기본 상태였습니다. 이 첫 번째 사실이 뒤에서 문제의 원인이 됩니다.

Container Registry 활성화

/etc/gitlab/gitlab.rb 하단에 registry 설정을 추가합니다. Omnibus GitLab은 이 파일을 읽어 nginx와 registry 서비스 설정을 생성하는 구조입니다.

# gitlab.rb에 추가할 내용
registry_external_url 'https://gitlab.sierracloud.dev:5050'
gitlab_rails['registry_enabled'] = true

설정을 반영합니다. reconfigure는 Chef 기반으로 설정 파일을 재생성하고 관련 서비스를 재기동하므로 GitLab 이용자에게 짧은 영향이 발생합니다.

sudo gitlab-ctl reconfigure

# 반영 후 서비스 상태 확인
sudo gitlab-ctl status | grep -iE "nginx|registry"

reconfigure 이후 nginx 전체 기동 실패

reconfigure는 오류 없이 끝났지만 5050 포트가 열리지 않았고, 서비스 상태를 확인하니 registry는 떠 있는데 nginx가 down 상태였습니다.

$ sudo gitlab-ctl status | grep -iE "nginx|registry"
down: nginx: 0s, normally up, want up; run: log: (pid 1249167) 3169106s
run: registry: (pid 2639512) 481s; run: log: (pid 2639249) 540s

nginx가 내려갔다는 것은 레지스트리뿐 아니라 GitLab 웹 UI 전체가 접속 불가라는 의미입니다. 실제로 외부에서 gitlab.sierracloud.dev에 접속되지 않는 상태였습니다.

원인 진단

nginx 설정 파일을 검증하려 했으나 nginx 명령이 PATH에 없었습니다. Omnibus GitLab은 nginx를 자체 번들로 포함하므로 임베디드 바이너리의 전체 경로를 지정해야 합니다.

# PATH에 없으므로 임베디드 바이너리 직접 호출
sudo /opt/gitlab/embedded/sbin/nginx -t -c /var/opt/gitlab/nginx/conf/nginx.conf

검증 결과 원인이 명확히 드러났습니다.

nginx: [emerg] cannot load certificate "/etc/gitlab/ssl/gitlab.sierracloud.dev.crt":
  BIO_new_file() failed (SSL: error:02001002:system library:fopen:
  No such file or directory)
nginx: configuration file /var/opt/gitlab/nginx/conf/nginx.conf test failed

존재하지 않는 /etc/gitlab/ssl/gitlab.sierracloud.dev.crt를 찾고 있었습니다. 이 서버는 인증서를 /test/cert/에 두고 사용하도록 구성되어 있었는데, registry용 nginx가 그 경로를 알지 못한 것입니다.

근본 원인 — registry_nginx는 인증서 설정을 상속하지 않습니다

Omnibus GitLab에서 registry_nginx는 기존 GitLab 웹용 nginx와 별개의 설정 블록입니다. nginx['ssl_certificate']에 커스텀 경로를 지정해 두었더라도 registry_nginx는 이를 물려받지 않고, Omnibus의 기본 명명 규칙인 /etc/gitlab/ssl/<호스트명>.crt를 찾습니다.

인증서를 기본 경로에 두고 쓰는 환경에서는 이 규칙이 우연히 맞아떨어지기 때문에 문제가 드러나지 않습니다. 반면 이번처럼 인증서를 별도 경로에서 관리하는 구성에서는 파일을 찾지 못하고, nginx는 설정 검증 실패 시 부분 기동이 아니라 전체 기동을 중단합니다. 레지스트리 하나를 추가하려다 GitLab 서비스 전체가 멈추는 이유가 여기에 있습니다.

Omnibus GitLab에서 GitLab Container Registry를 켤 때 registry_nginx가 커스텀 인증서 경로를 상속하지 않아 nginx 전체가 멈추고, 같은 경로를 명시해 복구하는 흐름을 그린 구성도입니다.
registry_nginx는 웹용 nginx의 인증서 설정을 물려받지 않아, 검증 실패 한 번에 웹 UI까지 함께 내려갑니다.

해결

registry_nginx에도 동일한 인증서 경로를 명시적으로 지정합니다.

# gitlab.rb에 추가
registry_nginx['ssl_certificate'] = "/test/cert/fullchain.pem"
registry_nginx['ssl_certificate_key'] = "/test/cert/privkey.pem"
sudo gitlab-ctl reconfigure

$ sudo gitlab-ctl status | grep -iE "nginx|registry"
run: nginx: (pid 2643061) 44s; run: log: (pid 1249167) 3170680s
run: registry: (pid 2639512) 2055s; run: log: (pid 2639249) 2114s

nginx가 정상 기동되었습니다. 커스텀 인증서 경로를 쓰는 환경이라면 registry_external_url과 registry_nginx의 인증서 설정을 처음부터 같이 추가하는 것이 안전합니다. 두 항목을 한 번에 넣고 reconfigure를 한 번만 수행하면 다운타임 없이 마칠 수 있었던 작업이었습니다.

401 응답이 정상인 이유와 파드에서의 도달 확인

외부에서 GitLab 본 사이트와 레지스트리 엔드포인트에 각각 요청해 응답 코드를 확인합니다.

$ curl -sk -o /dev/null -w "gitlab main site -> %{http_code}\n" https://gitlab.sierracloud.dev/
gitlab main site -> 302

$ curl -sk -o /dev/null -w "registry :5050/v2/ -> %{http_code}\n" https://gitlab.sierracloud.dev:5050/v2/
registry :5050/v2/ -> 401

여기서 401 Unauthorized는 오류가 아니라 정상 신호입니다. Docker Registry HTTP API V2 규격상 /v2/ 엔드포인트는 인증되지 않은 요청에 401을 반환하면서 WWW-Authenticate 헤더로 토큰 발급처를 안내합니다. 레지스트리가 살아 있고 인증 흐름이 동작한다는 뜻이므로, 200을 기대하고 401을 실패로 오인하지 않도록 주의합니다. 반대로 000이나 커넥션 거부가 나오면 포트가 열리지 않았거나 nginx가 내려간 상태입니다.

Kubernetes 파드에서도 같은 주소에 도달하는지 확인합니다. 노드에서는 되는데 파드에서 안 되는 경우가 있어 클러스터 내부 경로를 별도로 검증했습니다.

kubectl run regtest --image=curlimages/curl:8.5.0 --rm -i --restart=Never --command -- \
  sh -c "curl -sk -o /dev/null -w '%{http_code}\n' https://gitlab.sierracloud.dev:5050/v2/"

# 출력: 401  (클러스터 내부에서도 도달 확인)

Kubernetes에서 이미지 가져오기

파드가 레지스트리에서 이미지를 받으려면 인증 정보가 담긴 Secret이 필요합니다. 개인 계정 대신 프로젝트 단위 Deploy Token을 발급해 사용했습니다. GitLab 프로젝트의 Settings → Repository → Deploy tokens에서 read_registry 권한으로 생성합니다.

kubectl create secret docker-registry gitlab-registry-cred \
  -n wordpress-system \
  --docker-server=gitlab.sierracloud.dev:5050 \
  --docker-username='gitlab+deploy-token-1' \
  --docker-password='<REDACTED>'

# Deployment에서 참조
# spec:
#   template:
#     spec:
#       imagePullSecrets:
#       - name: gitlab-registry-cred

운영 시 참고 사항

  • 인증서 갱신 — registry_nginx가 기존 인증서와 같은 파일을 바라보므로, 갱신 스크립트가 해당 경로의 파일을 제자리에서 교체한다면 별도 작업이 필요 없습니다. 다만 갱신 후에는 nginx 재기동이 필요합니다.
  • 저장 공간 — 이미지는 /var/opt/gitlab/gitlab-rails/shared/registry에 누적됩니다. 태그를 계속 늘려가는 운영이라면 디스크 사용량 모니터링과 정리 정책을 함께 준비하는 편이 좋습니다.
  • 포트 개방 — 방화벽이나 앞단 프록시를 거치는 구조라면 5050 포트에 대한 규칙을 별도로 추가해야 합니다. GitLab 서버가 내부망에서 직접 라우팅되는지 먼저 확인하시기 바랍니다.
  • 변경 전 백업 — gitlab.rb는 3,500줄이 넘는 파일이라 수정 전 백업이 실질적인 안전장치가 됩니다. 이번에도 백업본 대비 줄 수를 비교해 설정이 실제로 반영되었는지 확인했습니다.

참고

관련 포스트:

참고 문서: GitLab — Container Registry administration · Omnibus GitLab — SSL settings · Docker Registry HTTP API V2

Migrating Ingress Controller: ingress-nginx EOL to Traefik v3.7.5 with Wildcard TLS Automation

ingress-nginx EOL과 Traefik을 고른 이유

kubernetes/ingress-nginx 프로젝트가 2026년 3월 31일부로 EOL(End of Life)을 선언하고 아카이브되었습니다. 이를 계기로 ingress-nginx → Traefik 마이그레이션 작업을 진행하여 자체 관리형 Kubernetes 클러스터의 Ingress 컨트롤러를 Traefik v3.7.5로 교체하고, 모니터링 스택(Prometheus / Alertmanager / Grafana) 도메인을 *.sierracloud.dev로 전환하였습니다. 아울러 HAProxy 서버에서 관리하는 Let’s Encrypt 와일드카드 인증서를 Kubernetes 클러스터에 자동 동기화하는 CronJob을 구성하였습니다.

ingress-nginx Traefik 마이그레이션 구성도로, HAProxy와 기존 MetalLB IP 뒤에서 ingress-nginx v1.10.1을 Traefik v3.7.5로 교체한 요청 경로와 certbot 인증서가 NAS, CronJob, Secret을 거쳐 TLSStore default로 적용되는 경로를 보여 줍니다.
ingress-nginx → Traefik 마이그레이션 구성도입니다. MetalLB IP는 그대로 두고 컨트롤러만 바꾼 요청 경로와, 인증서가 바뀔 때만 Secret을 갱신하는 동기화 경로를 함께 그렸습니다.

대안으로 Contour, Kong, HAProxy Ingress 등을 검토하였으나, Traefik을 선택한 이유는 다음과 같습니다. Helm chart가 잘 관리되고 있으며, TLSStore를 통한 와일드카드 인증서 중앙 관리가 가능합니다. 또한 CRD(IngressRoute, Middleware 등)를 통한 고급 라우팅 설정과 Kubernetes Ingress 표준 오브젝트와의 호환성을 동시에 지원합니다. HTTP → HTTPS 강제 리다이렉트도 values.yaml 설정 한 줄로 처리됩니다.


환경

  • Kubernetes v1.33.7 (HA: Control Plane 3대 + Worker 3대)
  • MetalLB — LoadBalancer IP 풀: 192.168.x.x/29
  • HAProxy (192.168.x.x:6443) — K8s API LB 및 HTTPS 리버스 프록시 겸용
  • NAS — Let’s Encrypt 인증서 원본 보관, NFS export
  • 교체 전: kubernetes/ingress-nginx v1.10.1 (EOL)
  • 교체 후: Traefik v3.7.5 (Helm chart 41.0.0)

기존 IP를 유지한 교체와 인증서 자동화

1. ingress-nginx 설정 백업

삭제 전에 기존 설정을 코드로 백업하여 재설치 시 활용할 수 있도록 보존합니다.

# IngressClass, ConfigMap, Ingress 리소스 백업
kubectl get ingressclass nginx -o yaml > backup/ingress-nginx/ingressclass-nginx.yaml
kubectl get cm ingress-nginx-controller -n ingress-nginx -o yaml > backup/ingress-nginx/configmap-controller.yaml
kubectl get ingress -n monitoring -o yaml > backup/ingress-nginx/ingress-monitoring.yaml

# Helm values 백업
helm get values ingress-nginx -n ingress-nginx -o yaml > backup/ingress-nginx/helm-ingress-nginx-values.yaml

# 삭제 절차 문서화 후 제거
helm uninstall ingress-nginx -n ingress-nginx

2. Traefik v3.7.5 설치

ingress-nginx가 사용하던 MetalLB IP를 그대로 유지하여 HAProxy의 백엔드 설정 변경 없이 전환합니다.

helm repo add traefik https://traefik.github.io/charts
helm repo update

helm upgrade --install traefik traefik/traefik \
  -f helm/traefik-values.yaml \
  -n traefik --create-namespace

helm/traefik-values.yaml 핵심 설정:

service:
  annotations:
    metallb.universe.tf/loadBalancerIPs: "192.168.x.x"   # 기존 IP 유지

ingressClass:
  enabled: true
  isDefaultClass: true

ports:
  web:
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true   # HTTP → HTTPS 전체 리다이렉트

3. 모니터링 도메인 변경

*.sierracloud.kro.kr에서 *.sierracloud.dev로 전환하고 ingressClassName을 교체합니다. Helm values 수정 후 upgrade를 적용합니다.

# helm/monitoring-values.yaml (변경 부분)
prometheus:
  ingress:
    ingressClassName: traefik    # nginx → traefik
    hosts:
      - prometheus.sierracloud.dev

grafana:
  ingress:
    ingressClassName: traefik
    hosts:
      - grafana.sierracloud.dev
  grafana.ini:
    server:
      domain: grafana.sierracloud.dev
      root_url: https://grafana.sierracloud.dev
      protocol: http             # Traefik이 TLS 종료, Grafana 내부는 HTTP
helm upgrade monitoring prometheus-community/kube-prometheus-stack \
  -f helm/monitoring-values.yaml -n monitoring

4. 와일드카드 TLS 인증서 자동화

HAProxy 서버에서 certbot이 Let’s Encrypt 인증서를 주기적으로 갱신하고 NAS에 복사합니다. Kubernetes CronJob이 이후 NAS NFS를 마운트하여 SHA256 비교 후 변경 시에만 Secret을 갱신합니다.

certbot (HAProxy)
  └─→ NAS (NFS)
        └─→ K8s CronJob (SHA256 비교)
              └─→ traefik/wildcard-sierracloud-dev Secret
                    └─→ Traefik TLSStore default → 모든 서비스 자동 적용

TLSStore를 사용하면 각 네임스페이스의 Ingress 리소스에 secretName을 지정할 필요 없이 모든 HTTPS 라우트에 와일드카드 인증서가 자동으로 적용됩니다.

# manifests/traefik/tls-store.yaml
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: traefik
spec:
  defaultCertificate:
    secretName: wildcard-sierracloud-dev

CronJob은 매주 갱신 주기에 맞춰 실행되며 SHA256 비교를 통해 인증서가 변경된 경우에만 Secret을 업데이트합니다.

# manifests/traefik/tls-secret-sync.yaml (핵심 부분)
schedule: "30 6 * * 1"    # 매주 월요일
timeZone: "Asia/Seoul"
concurrencyPolicy: Forbid

volumes:
  - name: certs
    nfs:
      server: 192.168.x.x
      path: /data/cert
      readOnly: true
# CronJob 컨테이너 스크립트 (요약)
CURRENT_SHA=$(kubectl get secret wildcard-sierracloud-dev -n traefik \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | sha256sum | cut -d' ' -f1)
NEW_SHA=$(sha256sum < /certs/sierracloud.dev/fullchain.pem | cut -d' ' -f1)

if [ "$CURRENT_SHA" != "$NEW_SHA" ]; then
  kubectl create secret tls wildcard-sierracloud-dev \
    --cert=/certs/sierracloud.dev/fullchain.pem \
    --key=/certs/sierracloud.dev/privkey.pem \
    -n traefik --dry-run=client -o yaml | kubectl apply -f -
fi

전환 중 만난 이미지 태그 삭제와 일시적 503

① bitnami/kubectl 이미지 태그 없음

증상: CronJob 컨테이너 이미지 bitnami/kubectl:1.33이 ImagePullBackOff 발생.

원인: 2025년 12월 이후 bitnami/kubectl Docker Hub 레포지토리에서 버전 태그가 삭제됨 (GitHub issue #88999). latest 태그만 존재.

해결: alpine/k8s:1.33.10으로 교체. Alpine 기반으로 kubectl + 기본 유틸리티(sha256sum, base64 등) 포함, K8s 1.33.x 버전과 동일 minor version으로 완전 호환.

image: alpine/k8s:1.33.10    # bitnami/kubectl:1.33 → 교체

② 도메인 변경 후 Traefik 503 (약 5초)

증상: Helm upgrade 직후 prometheus.sierracloud.dev, grafana.sierracloud.dev 접속 시 503 반환.

원인: Traefik이 새로운 Ingress 라우트를 동기화하는 데 약 5초 소요.

해결: 별도 조치 없이 자연 해소. Traefik의 정상적인 라우트 갱신 동작.


검증

# Traefik LoadBalancer IP 확인
kubectl get svc -n traefik

# TLSStore 적용 확인
kubectl get tlsstore -n traefik

# Secret 인증서 유효기간 확인
kubectl get secret wildcard-sierracloud-dev -n traefik \
  -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates

# HTTPS 접속 확인
curl -skI https://grafana.sierracloud.dev | head -3
curl -skI https://prometheus.sierracloud.dev | head -3
# 출력 예시
HTTP/2 302       ← Grafana 로그인 리다이렉트 (정상)
HTTP/2 405       ← Prometheus (정상)

subject=CN = *.sierracloud.dev
notAfter=Sep 17 13:39:09 2026 GMT

결론

ingress-nginx EOL 전환을 계기로 Traefik v3.7.5를 도입하고, Let’s Encrypt 와일드카드 인증서의 자동 갱신 파이프라인을 구성하였습니다. Secret을 traefik 네임스페이스에 단일 관리하고 TLSStore default로 노출함으로써 향후 신규 서비스 추가 시 Ingress에 secretName을 별도 지정할 필요 없이 자동으로 와일드카드 인증서가 적용됩니다.

기존 ingress-nginx와의 전환 과정에서 MetalLB LoadBalancer IP를 그대로 유지했기 때문에 HAProxy 백엔드 설정 변경 없이 완전한 무중단 전환이 가능하였습니다. Traefik v3 계열의 Kubernetes Gateway API 지원, 향상된 observability, 그리고 CRD 기반의 미들웨어 체인 설정은 장기적인 운영에서도 ingress-nginx 대비 유리한 점이 많습니다.

참고

관련 포스트:

참고 문서: Traefik TLSStore Default Certificate (공식 문서) · Traefik Helm Chart 설치 가이드