카테고리 보관물: WEB

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

php2shell on a Containerized WordPress: Why the PHP Patches Never Arrived

php2shell 취약점 공지와 운영 중이던 WordPress의 상태

2026년 10월 21일 PHP 프로젝트가 인증 없이 원격 코드 실행이 가능한 php2shell 취약점 두 건(CVE-2026-109618, CVE-2026-109619)을 고친 긴급 보안 릴리스를 냈습니다. Kubernetes에서 운영하는 WordPress가 영향을 받는지 확인해 보니, 파드 안의 PHP는 8.4.24였고 실행 방식은 Apache mod_php였습니다. 패치 대상인 데다, PHP가 위험도를 가장 높게 매긴 구성이었습니다.

더 불편한 사실은 따로 있었습니다. 이 이미지는 8월 20일에 빌드된 뒤 그대로였고, 그사이 나온 8.4.25와 8.4.26 보안 릴리스도 들어 있지 않았습니다. 베이스 이미지는 상위에서 계속 갱신되고 있었지만 우리 이미지는 빌드한 순간의 PHP에 멈춰 있었습니다. 이 글은 취약 여부 판단, 침해 흔적 점검, 이미지 교체 전 이력 검토, 재빌드와 무중단 배포까지의 기록입니다.

환경

구성 요소 내용
Kubernetes v1.36.3 (kubeadm HA, control plane 3대 + worker 3대)
WordPress 7.1.3, 파드 2개. 웹 루트 전체가 CephFS(RWX) 공유 볼륨
이미지 베이스 공식 wordpress:php8.4-apache (Debian 13)
웹 서버 / SAPI Apache 2.4 + mod_php (mpm_prefork)
PHP 8.4.24 → 8.4.27
이미지 빌드 클러스터 안 Kaniko Job (v1.23.2), 사설 GitLab Container Registry
앞단 CloudFront → HAProxy → Traefik Ingress
워커 노드 allocatable 약 7.8 GiB, swap 없음

취약 여부를 판단한 근거

공지에서 확인한 두 가지 결함

PHP 보안 공지에 따르면 두 결함은 성격이 다릅니다. CVE-2026-109618은 메모리 부족(OOM) 처리 도중 반쯤 초기화된 요청 데이터를 정리 루틴이 유효한 값으로 다루는 문제입니다. CVE-2026-109619는 해제된 요청 상태가 남아 있다가 같은 워커가 처리하는 다음 요청에서 다시 쓰이는 문제입니다. 둘 다 CVSS v4.0 9.2이고, 수정 버전은 8.5.12, 8.4.27, 8.3.36, 8.2.35입니다.

판단에 결정적이었던 부분은 실행 방식별 위험도였습니다. 공지는 PHP-FPM, CGI, LiteSpeed 등 웹 요청을 처리하는 설치를 모두 영향 대상으로 보면서, mod_php를 가장 위험한 경우로 꼽았습니다. mod_php에서는 애플리케이션 코드가 실행되기 전에 요청 하나로 공격이 성립할 수 있어서, WordPress 코어와 플러그인을 최신으로 유지해도 막을 수 없습니다. 공개 나흘 전인 10월 17일부터 공격 시도가 관측됐다는 보고도 있었습니다.

실행 중인 파드에서 확인한 값

이미지 태그나 Dockerfile이 아니라 실제로 떠 있는 파드에서 확인했습니다. 태그 이름은 빌드 시점의 의도일 뿐이고, 공격을 받는 것은 지금 돌고 있는 바이너리이기 때문입니다.

kubectl exec -n wordpress deploy/wordpress -- php -v
kubectl exec -n wordpress deploy/wordpress -- ls /etc/apache2/mods-enabled | grep -E 'php|mpm'
kubectl exec -n wordpress deploy/wordpress -- \
  php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
항목 확인 값 판정
PHP 8.4.24 (2026-08-05 빌드) 취약. 8.4.27 필요
SAPI Apache mod_php (mpm_prefork + php.load) 공지상 가장 위험한 구성
session.upload_progress.enabled 1 (기본값) 임시 완화책 미적용
WordPress 코어 7.1.3 (최신 안정판) WordPress 쪽 조치는 불필요

PHP 패치가 두 달 동안 들어오지 않은 이유

공식 WordPress 이미지의 php8.4-apache 태그는 PHP 패치가 나올 때마다 상위에서 다시 빌드됩니다. 그래서 태그만 보면 늘 최신 PHP를 쓰는 것처럼 보입니다. 하지만 그 태그를 베이스로 우리가 빌드한 이미지는 빌드한 순간의 PHP를 담은 채 고정됩니다. 상위 태그가 몇 번 갱신되든, 다시 빌드하지 않으면 운영 파드에는 아무것도 들어오지 않습니다.

레지스트리에서 이미지 설정을 직접 읽어 보니 상황이 분명해졌습니다. 운영 중이던 php8.4-7은 8월 20일 빌드였고 환경 변수는 PHP_VERSION=8.4.24였습니다. 그 뒤 8월 27일 8.4.25, 9월 24일 8.4.26, 10월 21일 8.4.27이 나왔으니 보안 릴리스 세 번을 연달아 놓친 셈입니다. php2shell 취약점 공지가 아니었다면 이 차이는 계속 드러나지 않았을 것입니다.

php2shell 취약점 대응 전 PHP 버전 추이: 8월 20일 빌드한 WordPress 이미지가 PHP 8.4.24에 머무는 동안 8.4.25, 8.4.26, 8.4.27 보안 릴리스가 나온 타임라인
운영 이미지의 PHP와 php.net 최신 8.4 패치의 차이. 음영 구간이 놓친 보안 릴리스이고, 빨간 선은 첫 공격 시도가 보고된 10월 17일입니다.

docker가 없는 환경에서도 이미지를 내려받지 않고 PHP 버전을 확인할 수 있습니다. 레지스트리 API로 매니페스트에서 이미지 설정(config) 블롭의 다이제스트를 찾고, 그 블롭 안의 환경 변수를 읽는 방식입니다. 상위 베이스가 패치를 담았는지와 우리가 푸시한 이미지에 무엇이 들어갔는지를 모두 이 방법으로 확인했습니다. 아래는 Docker Hub의 공식 이미지를 조회하는 예입니다.

REPO=library/wordpress; TAG=7.1.3-php8.4-apache; REG=https://registry-1.docker.io/v2/$REPO
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$REPO:pull" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
MANIFEST=$(curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.index.v1+json" $REG/manifests/$TAG \
  | python3 -c 'import json,sys; print(next(m["digest"] for m in json.load(sys.stdin)["manifests"] if m["platform"]["architecture"]=="amd64"))')
CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.manifest.v1+json" $REG/manifests/$MANIFEST \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["config"]["digest"])')
curl -sL -H "Authorization: Bearer $TOKEN" $REG/blobs/$CONFIG \
  | python3 -c 'import json,sys; c=json.load(sys.stdin); print(c["created"]); print(*[e for e in c["config"]["Env"] if e.startswith("PHP_VERSION")])'

# 2026-10-21T16:46:55.959706349Z
# PHP_VERSION=8.4.27

패치 전에 침해 흔적부터 확인했습니다

공격 시도가 공개 전부터 관측됐으므로, 패치만 하고 끝내면 이미 들어온 것을 놓칠 수 있습니다. 첫 공격 시도가 보고된 10월 17일을 기준점으로 잡고 세 가지를 확인했습니다.

메모리 손상의 부산물이 로그에 남았는지

두 결함 모두 메모리 손상 계열이라, 공격이 실패하더라도 워커가 비정상 종료하거나 메모리 한도 초과 오류가 남을 가능성이 큽니다. 재기동 이전 컨테이너의 로그까지 포함해 10월 17일 이후 Apache와 PHP 로그를 검색했습니다.

for P in $(kubectl get pods -n wordpress -l app=wordpress -o name); do
  kubectl logs -n wordpress $P --previous 2>/dev/null
  kubectl logs -n wordpress $P
done | grep -iE "segmentation|exit signal|Allowed memory size|zend_mm|heap corruption"

결과는 0건이었습니다. 같은 기간의 PHP Fatal 오류는 wp-includes 아래 PHP 파일을 직접 호출하는 스캐너 요청에서만 나왔습니다. WordPress를 거치지 않고 내부 파일을 부르면 당연히 나는 오류이고, 메모리 계열과는 무관했습니다.

웹 루트에 새로 생긴 실행 파일이 있는지

웹 루트 전체가 공유 볼륨이라 한 파드에서 확인하면 두 파드에 같은 결과가 나옵니다. 처음에는 find -newermt로 최근 변경된 PHP 파일을 나열했는데, 플러그인 자동 업데이트로 바뀐 수천 개 파일에 묻혀 판단할 수 없었습니다. 디렉터리와 시간 단위로 묶어 세는 쪽이 훨씬 빨랐습니다.

kubectl exec -n wordpress deploy/wordpress -- sh -c '
  cd /var/www/html
  find . -type f -newermt "2026-10-15" ! -path "./wp-content/cache/*" \
       -printf "%TY-%Tm-%Td %TH:%TM|%h\n" \
  | awk -F"|" "{split(\$2,a,\"/\"); print substr(\$1,1,13), a[2]\"/\"a[3]\"/\"a[4]}" \
  | sort | uniq -c'

   2293 2026-10-16 00 wp-content/plugins/<plugin-a>
      2 2026-10-17 13 wp-content/<waf-data>/
     37 2026-10-19 00 wp-content/plugins/<plugin-b>
    454 2026-10-19 00 wp-content/plugins/<plugin-c>
   2009 2026-10-20 12 wp-content/plugins/<plugin-d>
     16 2026-10-21 16 wp-content/uploads/2026
      1 2026-10-22 00 wp-content/<waf-data>/
      1 2026-10-22 01 wp-content/<waf-data>/
      1 2026-10-22 04 wp-content/<waf-data>/
      2 2026-10-22 07 wp-content/<waf-data>/

플러그인 디렉터리의 묶음은 각 플러그인의 자동 업데이트 시각과 일치했고, WAF 디렉터리는 보안 플러그인이 규칙과 차단 기록을 저장하는 데이터 파일이었습니다. uploads의 16개는 같은 날 직접 올린 블로그 이미지와 그 썸네일로, file 명령으로 모두 PNG임을 확인했습니다. uploads 아래 PHP 확장자 파일은 플러그인이 디렉터리 목록 노출을 막으려고 만든 빈 index.php 두 개뿐이었습니다.

컨테이너 안에 남은 것이 있는지

마지막으로 볼륨 밖의 컨테이너 파일시스템과 /tmp, /dev/shm, 프로세스 목록을 확인했습니다. 10월 15일 이후 바뀐 파일은 컨테이너 기동 때 생기는 파일과 메일 스풀뿐이었고, 프로세스도 Apache와 Postfix 외에는 없었습니다.

이 점검은 로그와 파일 상태로 확인할 수 있는 범위에 한정됩니다. 컨테이너 로그는 재기동 이전 한 세대까지만 남아 있어 10월 17일 이전은 볼 수 없었고, 디스크에 흔적을 남기지 않는 침입까지 배제하지는 못합니다. 그래서 점검 결과와 관계없이 패치를 서둘렀습니다.

이미지를 바꾸기 전에 확인한 이력

이 WordPress는 독립 서버에서 Kubernetes로 옮기면서 몇 군데를 손으로 고쳐 둔 상태입니다. 이미지를 교체할 때 그중 무엇이 사라질 수 있는지 먼저 따져 봤습니다. 기준은 단순합니다. 이미지 안에 있는 것은 재빌드로 바뀌고, 공유 볼륨과 ConfigMap에 있는 것은 그대로 남습니다.

php2shell 취약점 패치를 위한 WordPress 이미지 재빌드 시 교체되는 항목(PHP, mod_php, php.ini, vhost 규칙)과 유지되는 항목(공유 볼륨의 코어와 .htaccess, ConfigMap의 wp-config) 구조도
WordPress 이미지 재빌드로 바뀌는 것(왼쪽)과 바뀌지 않는 것(가운데, 오른쪽).
이력 항목 위치 재빌드 영향
.htaccess의 HTTPS 리다이렉트 조건 수정 (X-Forwarded-Proto 기준) 공유 볼륨 없음
.htaccess의 보안 플러그인 WAF 연결 (<IfModule mod_php.c> 안의 auto_prepend_file) 공유 볼륨 없음. 같은 PHP 8 mod_php라 모듈 이름이 그대로 맞음
헬스체크 경로를 리다이렉트에서 빼는 vhost 규칙 이미지 (Dockerfile의 sed 주입) 있음. 새 베이스의 설정 파일 형식이 바뀌면 조용히 빠질 수 있어 배포 후 확인 필요
프록시 뒤 HTTPS 판별과 접속 IP 보정 ConfigMap (wp-config.php) 없음
php.ini 재정의 (memory_limit 256M 등) 이미지 같은 파일을 다시 넣으므로 유지
빌드 컨텍스트 ConfigMap 클러스터 저장소 파일과 비교해 다섯 파일 모두 일치

위험한 항목은 vhost 규칙 하나였습니다. Dockerfile이 기본 사이트 설정의 DocumentRoot 줄 뒤에 RewriteRule을 끼워 넣는 방식이라, 상위 이미지가 그 줄의 형식을 바꾸면 빌드는 성공하는데 규칙만 빠집니다. 다행히 readiness probe가 바로 이 경로를 보므로, 규칙이 빠지면 새 파드가 Ready가 되지 못하고 롤아웃이 멈춥니다.

확인 과정에서 빌드 Job 매니페스트가 저장소에 없다는 것도 드러났습니다. 이전 작업 때 한 번 만들어 쓰고 남기지 않은 탓에 이번에 다시 작성해야 했습니다. 이번 패치와 함께 저장소에 넣어, 다음 보안 릴리스 때는 태그만 올려 바로 쓸 수 있게 했습니다.

Kaniko로 다시 빌드하고 무중단으로 교체한 과정

1. 베이스 이미지를 버전 태그로 고정했습니다

기존 Dockerfile은 FROM wordpress:php8.4-apache였습니다. 버전이 드러나지 않는 태그라 어떤 베이스로 빌드했는지가 기록에 남지 않으므로, WordPress 버전까지 들어간 태그로 바꿨습니다. 푸시하는 쪽도 기존 관례였던 latest 동시 푸시를 없애고 버전 태그 하나만 남겼습니다.

# docker/wordpress/Dockerfile
FROM wordpress:7.1.3-php8.4-apache

바꾸기 전에 두 태그의 다이제스트를 비교했고, 둘 다 PHP 8.4.27을 담은 같은 amd64 매니페스트를 가리켰습니다. 다만 고정한다고 PHP 패치가 저절로 들어오는 것은 아닙니다. 이 태그 역시 PHP 패치 때마다 상위에서 다시 빌드되므로, 우리가 재빌드해야 반영된다는 점은 같습니다. 태그에 들어간 7.1.3도 런타임에는 영향이 없습니다. WordPress 코어는 공유 볼륨에 있고 WordPress가 스스로 업데이트하기 때문입니다.

2. 임시 완화 설정을 이미지에 함께 넣었습니다

공지는 두 가지 완화책을 제시합니다. CVE-2026-109619에는 session.upload_progress.enabled=0, CVE-2026-109618에는 memory_limit=-1과 낮은 post_max_size입니다. 업로드 진행률 기능은 WordPress가 쓰지 않으므로 끄는 데 부담이 없어, 패치와 별개로 계속 유지하기로 했습니다. 공지대로 ini_set()이나 .user.ini가 아니라 php.ini 단계에서 꺼야 효과가 있습니다.

; docker/wordpress/php-custom.ini
session.upload_progress.enabled = 0

memory_limit=-1은 적용하지 않았습니다. 이 클러스터의 워커는 swap 없이 allocatable 약 7.8 GiB로 운영되고, 노드 메모리 고갈로 kubelet이 멈춘 장애를 이미 두 번 겪었습니다. PHP의 메모리 상한을 없애면 요청 하나가 컨테이너 한도까지 메모리를 끌어갈 수 있으므로, 패치로 원인을 없애는 쪽을 택했습니다. post_max_size는 이미 공지 권고값인 8M이었습니다.

3. 메모리 여유가 가장 큰 노드에서 빌드했습니다

로컬에 docker가 없어 이미지는 클러스터 안의 Kaniko Job으로 빌드합니다. 빌드도 노드 메모리를 쓰므로 스케줄러의 requests 계산 대신 실제 여유를 먼저 봤습니다. Prometheus의 node_memory_MemAvailable_bytes 기준으로 워커 세 대의 여유는 2.43, 3.37, 4.70 GiB였고, 가장 넉넉한 노드에 Job을 고정했습니다.

apiVersion: batch/v1
kind: Job
metadata:
  name: wordpress-image-build
  namespace: wordpress
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      nodeSelector:
        kubernetes.io/hostname: <worker-node-3>
      initContainers:
        - name: copy-context
          image: busybox:1.38.0
          command: ["sh", "-c", "cp -L /context-src/* /workspace/"]
          volumeMounts:
            - { name: context-src, mountPath: /context-src }
            - { name: workspace, mountPath: /workspace }
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:v1.23.2
          args:
            - --context=dir:///workspace
            - --dockerfile=/workspace/Dockerfile
            - --destination=registry.example.com/infra/wordpress:php8.4-8
            - --digest-file=/dev/termination-log
          resources:
            requests: { cpu: 500m, memory: 1Gi }
            limits: { cpu: "2", memory: 2Gi }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
            - { name: registry-cred, mountPath: /kaniko/.docker, readOnly: true }
      volumes:
        - name: context-src
          configMap: { name: wordpress-build-context }
        - name: workspace
          emptyDir: {}
        - name: registry-cred
          secret:
            secretName: registry-cred
            items: [{ key: .dockerconfigjson, path: config.json }]

initContainer를 따로 둔 이유는 ConfigMap 볼륨의 파일이 ..data/를 가리키는 심볼릭 링크이기 때문입니다. 이를 그대로 빌드 컨텍스트로 쓰면 Kaniko의 COPY가 깨진 링크를 이미지에 넣으므로, 실파일로 복사한 emptyDir을 넘깁니다. 빌드는 47초 만에 끝났고, 푸시된 이미지 설정에서 PHP_VERSION=8.4.27을 확인한 뒤 배포로 넘어갔습니다.

4. 이미지 태그 한 줄만 바꿔 롤링 업데이트했습니다

Deployment는 maxUnavailable: 0, maxSurge: 1이고 readiness probe가 헬스체크 경로를 봅니다. 앞서 말한 vhost 규칙이 빠졌다면 새 파드가 Ready가 되지 못하고 기존 파드가 계속 서비스하므로, 배포 방식 자체가 안전장치 역할을 합니다. kubectl diff로 변경이 이미지 태그 한 줄뿐인 것을 확인하고 적용했고, 두 파드 교체는 약 1분 만에 끝났습니다.

kubectl diff -f manifests/wordpress/wordpress.yaml
-      - image: registry.example.com/infra/wordpress:php8.4-7
+      - image: registry.example.com/infra/wordpress:php8.4-8

kubectl apply -f manifests/wordpress/wordpress.yaml
kubectl rollout status deploy/wordpress -n wordpress

작업 중 만난 확인 거리

diff에 없던 리소스가 configured로 나왔습니다

apply 출력에 Deployment뿐 아니라 wp-config가 든 ConfigMap과 PodDisruptionBudget도 configured로 찍혔습니다. 사전 diff에는 이미지 태그만 있었으므로, 의도하지 않은 변경이 들어갔는지 바로 확인했습니다.

원인은 last-applied-configuration 주석이었습니다. 해당 ConfigMap은 10월 초 DB 서버 이전 때 kubectl patch로 값을 바꿨는데, patch는 이 주석을 갱신하지 않습니다. 저장소 파일도 같은 값으로 고쳐 두었으므로 실제 데이터는 이미 일치했고, apply는 낡은 주석만 갱신했습니다. managedFields를 보면 이 사실이 그대로 드러납니다.

kubectl get cm wordpress-config -n wordpress -o json --show-managed-fields \
  | python3 -c 'import json,sys; [print(m["manager"], m["time"], json.dumps(m["fieldsV1"], separators=(",",":"))) for m in json.load(sys.stdin)["metadata"]["managedFields"]]'

kubectl-patch 2026-10-10T16:08:36Z {"f:data":{"f:wp-config.php":{}}}
kubectl-client-side-apply 2026-10-22T08:21:50Z {"f:data":{},"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}}}

wp-config.php 필드의 소유자는 여전히 10월 10일의 kubectl-patch이고, 이번 apply가 가져간 것은 주석 필드뿐입니다. 저장소와 운영 중인 wp-config.php의 내용도 비교해 동일함을 확인했습니다. PDB 쪽은 spec이 8월 이후 그대로였고, 파드 교체에 따라 컨트롤러가 status를 갱신한 것이었습니다.

빌드 로그의 zip 경고

빌드 로그에 warning: zip (zip) is already loaded!가 찍혔습니다. 마이그레이션 때 원본 서버에 없던 zip 확장을 Dockerfile에서 직접 설치하도록 했는데, 공식 베이스 이미지에 zip이 이미 들어 있어서 나는 경고입니다. docker-php-ext-enable이 중복을 감지해 설정 파일을 하나만 남기므로 새 파드의 php -m에서도 경고가 없었고, 동작에는 영향이 없습니다. 다음 정리 때 Dockerfile에서 빼도 되는 단계입니다.

php2shell 취약점 조치 결과

항목 조치 전 조치 후
운영 파드의 PHP 8.4.24 (8월 20일 빌드 이미지) 8.4.27 (10월 22일 빌드 이미지)
session.upload_progress.enabled 1 0
베이스 이미지 태그 php8.4-apache 7.1.3-php8.4-apache
푸시 태그 php8.4-N + latest php8.4-N만
빌드 Job 매니페스트 저장소에 없음 저장소에 보관

배포 후에는 두 파드 각각에서 PHP 버전, ini 값, vhost 규칙, Apache 모듈을 확인했습니다. 헬스체크, 메인 페이지, REST API, 로그인 페이지 모두 내부 경로와 외부 경로에서 200을 반환했고, 인증된 REST 조회로 플러그인이 모두 활성 상태인 것도 확인했습니다. 롤아웃 이후 PHP 오류 로그는 없었습니다.

kubectl exec -n wordpress deploy/wordpress -- php -v | head -1
kubectl exec -n wordpress deploy/wordpress -- \
  php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
kubectl exec -n wordpress deploy/wordpress -- \
  grep -n healthz /etc/apache2/sites-enabled/000-default.conf
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/healthz.html

PHP 8.4.27 (cli) (built: Oct 21 2026 15:53:29) (NTS)
0
15:	RewriteRule ^/healthz\.html$ - [L,END]
200

검증하지 않은 것도 분명히 해 둡니다. 공개된 공격 코드를 이 사이트에 직접 실행해 보지는 않았고, 침해 흔적 점검은 10월 17일 이후 로그와 현재 파일 상태에 한정됩니다. 관리자 화면은 인증된 REST 조회까지만 확인했습니다.

이번 일에서 얻은 교훈은 php2shell 취약점 자체보다 운영 방식에 있었습니다. 컨테이너 이미지는 빌드한 순간의 런타임을 고정하므로, 베이스 태그가 아무리 부지런히 갱신돼도 재빌드하지 않으면 패치는 들어오지 않습니다. 이제 PHP 보안 공지가 나오면 레지스트리에서 베이스의 PHP_VERSION을 확인하고, 저장소에 둔 빌드 Job의 태그를 하나 올려 재빌드하는 것을 정해진 절차로 두었습니다.

참고

관련 포스트:

참고 문서: PHP Security Advisory (2026-10-21) · PHP 8.4.27 ChangeLog · Tenable php2shell FAQ · WordPress Docker Official Image · Kaniko · Kubernetes Field Management

Diagnosing a WordPress Plugin Update Notice That Never Clears

업데이트에 성공해도 다시 뜨는 알림

WordPress 관리 화면의 플러그인 업데이트 알림 문구가 새 버전을 설치한 뒤에도 사라지지 않고 계속 다시 뜨는 문제를 처리한 기록입니다. 업데이트는 성공했다고 표시되는데 화면을 새로고침하면 같은 플러그인의 업데이트 알림이 다시 떠 있었습니다.

처음에는 Kubernetes로 이전하면서 생긴 파일 권한이나 스토리지 문제로 의심했습니다. 확인해 보니 서로 무관한 두 가지 원인이 겹쳐 있었고, 두 번째 원인은 이쪽 환경 문제가 아니라 배포된 플러그인 패키지 자체의 문제였습니다. 같은 증상을 겪는 경우 진단 순서를 줄일 수 있도록 정리합니다.

플러그인 업데이트 알림 표시가 반복된 두 원인으로, limit 512Mi에 여유가 120Mi 남짓이던 파드 리소스와, 헤더 Version 2.11.2와 Stable tag 2.12.0이 어긋나 업데이트 후에도 비교가 일치하지 않는 구조를 나타낸 개념도입니다.
플러그인 업데이트 알림 표시가 반복된 두 원인: 리소스 여유분 부족과 Version 헤더 불일치.

환경

구성 요소 내용
플랫폼 Kubernetes 자체 관리형 클러스터
애플리케이션 WordPress (Apache + PHP 8.4, 커스텀 이미지)
스토리지 Rook CephFS (RWX), /var/www/html 전체 마운트
변경 전 리소스 request 100m / 256Mi, limit 500m / 512Mi
변경 전 PHP memory_limit = 128M (이전 서버에서 그대로 승계)

원인 ① — 업데이트 작업을 버틸 리소스 여유분 부족

먼저 파드의 실제 사용량을 확인했습니다.

kubectl top pod -n wordpress-system

아무 작업도 하지 않는 유휴 상태에서 이미 330~390Mi를 사용하고 있었습니다. 활성화된 플러그인이 여러 개인데 지속형 오브젝트 캐시를 쓰지 않는 구성이라 기본 사용량 자체가 높은 상태였습니다. 그런데 메모리 limit은 512Mi였습니다.

플러그인 업데이트는 순간적으로 부하가 몰리는 작업입니다. ZipArchive로 압축을 풀고 Plugin_Upgrader가 파일을 교체하는 동안 메모리와 CPU를 함께 사용합니다. 남은 여유가 120Mi 남짓인 상태에서는 이 작업을 안정적으로 끝내기 어렵습니다. 실제로 업데이트를 시도한 시점에 무관한 정적 자원 요청이 503으로 실패한 것이 같은 시간대에 관측되었습니다.

PHP 쪽도 이전 서버에서 memory_limit = 128M을 그대로 가져온 상태였습니다. 컨테이너의 cgroup 제한과 별개로 PHP 프로세스 자체가 자기 한도에 먼저 걸릴 수 있는 값입니다.

양쪽을 함께 올렸습니다.

# docker/wordpress/php-custom.ini
memory_limit = 256M
# manifests/wordpress/wordpress.yaml
resources:
  requests:
    cpu: 150m
    memory: 384Mi
  limits:
    cpu: "1"
    memory: 1Gi

PHP 설정이 이미지에 포함되어 있어 이미지를 다시 빌드해 태그를 올린 뒤 배포했습니다. 이후 업데이트 도중 503이 발생하는 현상은 사라졌습니다.

원인 ② — 업스트림 패키지의 버전 헤더 불일치

리소스를 확보한 뒤 업데이트는 깨끗하게 완료되었습니다. 그런데도 업데이트 알림은 여전히 다시 나타났습니다.

여기서부터는 이쪽 환경 문제가 아니었습니다. WordPress가 설치된 플러그인의 버전을 어디에서 읽는지 확인하면 원인이 드러납니다. WordPress 코어는 플러그인 메인 PHP 파일 상단의 헤더 주석에 적힌 Version: 값을 설치된 버전으로 인식합니다. readme.txt의 Stable tag가 아닙니다.

설치된 파일의 헤더와 readme.txt를 직접 비교했습니다.

# 플러그인 메인 파일의 헤더 확인
grep -m1 "Version:" wp-content/plugins/<plugin-dir>/<plugin>.php
# Version: 2.11.2        ← 설치된 버전으로 인식되는 값

# 같은 패키지의 readme.txt
grep -m1 "Stable tag:" wp-content/plugins/<plugin-dir>/readme.txt
# Stable tag: 2.12.0     ← 배포 저장소가 최신으로 인식하는 값

공식 배포 zip 안에서 두 값이 어긋나 있었습니다. 릴리스를 내면서 readme.txt의 Stable tag는 올렸는데 플러그인 파일의 헤더 주석은 이전 버전 문자열이 그대로 남아 있는 상태였습니다.

이 경우 알림은 구조적으로 사라질 수 없습니다. WordPress는 저장소가 알려주는 최신 버전과 헤더에서 읽은 설치 버전을 비교하는데, 업데이트를 아무리 반복해도 같은 헤더 문자열이 다시 설치되므로 두 값은 영원히 불일치합니다. 업데이트가 실패한 것이 아니라 성공해도 상태가 갱신되지 않는 것입니다.

공식 릴리스 내용으로 파일을 교체한 뒤 헤더의 버전 문자열만 실제 릴리스 버전에 맞게 직접 수정해 해소했습니다. 동작에는 영향이 없고 WordPress가 비교하는 문자열만 맞추는 일회성 조치입니다. 이 사례는 2026년 8월 시점에 특정 플러그인에서 관측한 것으로, 이후 배포본에서는 수정되었을 수 있습니다.

진단 순서

같은 증상을 만났을 때 확인 순서를 정리하면 다음과 같습니다.

단계 확인 내용 해당하면
1 kubectl top pod으로 유휴 사용량과 limit 사이 여유분 리소스 상향
2 업데이트 시점 전후의 503·타임아웃 로그 리소스 상향
3 설치된 플러그인의 헤더 Version: vs readme.txt의 Stable tag 업스트림 패키징 문제
4 파일 소유자·권한, 스토리지 쓰기 가능 여부 권한 문제

1~2단계가 정상인데 알림이 계속 반복된다면 3단계를 먼저 보시기를 권합니다. 권한이나 스토리지를 의심하며 시간을 쓰기 쉬운 지점인데, 헤더 한 줄만 비교하면 바로 판별됩니다.

리소스 반영과 알림 해소 확인

# 파드 리소스 반영 확인
kubectl get deploy -n wordpress-system wordpress \
  -o jsonpath='{.spec.template.spec.containers[0].resources}'

# 적용된 PHP memory_limit 확인
kubectl exec -n wordpress-system deploy/wordpress -- php -i | grep memory_limit

# 유휴 사용량과 limit 사이 여유분 재확인
kubectl top pod -n wordpress-system

리소스 상향 후 유휴 사용량 대비 여유가 확보되었고, 헤더 수정 후 관리 화면에 플러그인 업데이트 알림 문구가 더 이상 나타나지 않는 것을 확인했습니다.

정리

이전 작업에서 승계한 리소스 설정은 한 번 점검할 가치가 있습니다. 기존 서버에서 문제없이 돌던 값이라도 컨테이너 환경에서는 limit이 곧 하드 제한이라 여유분의 의미가 달라집니다. 특히 유휴 상태 사용량이 limit의 70%를 넘고 있다면 순간 부하를 견딜 여지가 거의 없는 상태입니다.

그리고 모든 증상이 내 환경 탓은 아닙니다. 이번처럼 배포된 패키지 자체의 메타데이터가 어긋난 경우, 인프라를 아무리 고쳐도 증상은 사라지지 않습니다. 재현되는 문제를 계속 자기 쪽에서만 찾고 있다면 업스트림이 보내온 것을 직접 열어보는 단계를 진단 순서에 넣어두는 편이 좋습니다.

참고

관련 포스트:

참고 문서: WordPress — Plugin Header Requirements · Kubernetes — Managing Resources for Containers · PHP — memory_limit

Preserving the Real Client IP Behind Traefik: externalTrafficPolicy and mod_remoteip

통계에 Traefik 파드 IP만 찍힌 문제

Kubernetes로 이전한 WordPress에서 방문자 IP가 실제 접속자가 아니라 리버스 프록시의 주소로 기록되는 문제를 추적해 클라이언트 IP 보존 설정을 바로잡은 기록입니다. 트래픽 통계 플러그인에 찍히는 방문자 IP가 모두 동일한 값이었고, 확인해 보니 Traefik 파드의 IP였습니다.

리버스 프록시 뒤에서 클라이언트 IP가 사라지는 것은 흔한 증상이라 mod_remoteip 설정만 점검하면 될 것으로 예상했지만, 실제로는 서로 다른 계층에서 두 가지 원인이 겹쳐 있었습니다. 하나는 Kubernetes Service의 SNAT 동작이고, 다른 하나는 mod_remoteip가 사설 IP 대역을 신뢰하지 않는 하드코딩된 동작이었습니다. 후자는 설정으로 우회할 수 없어 애플리케이션 계층에서 보정해야 했습니다.

클라이언트 IP 보존 경로에서 내부망 요청이 Traefik Service의 SNAT과 mod_remoteip의 사설 IP 거부라는 두 지점에서 IP를 잃고, externalTrafficPolicy: Local과 wp-config.php 보정으로 각각 해결되는 구조를 나타낸 개념도입니다.
클라이언트 IP 보존 경로에서 내부망 방문자의 IP가 사라지는 두 지점과 각각의 해결 방법입니다.

환경

구성 요소 내용
Ingress Traefik v3.7.5 (LoadBalancer, MetalLB 할당)
애플리케이션 WordPress (Apache + PHP 8.4), 파드 2개
앞단 프록시 HAProxy → Traefik (내부망) / CloudFront (외부)
내부망 대역 RFC1918 사설 대역
파드 네트워크 Calico, 172.16.0.0/16

증상

트래픽 통계 플러그인에 기록된 방문자 IP가 전부 같은 값이었고, 그 값은 Traefik 파드의 IP였습니다. 특이한 점은 모든 방문자가 아니라 내부망에서 접속한 방문자만 해당된다는 것이었습니다. CloudFront를 거쳐 들어오는 외부 인터넷 방문자의 IP는 정상적으로 기록되고 있었습니다.

이 차이가 원인을 좁히는 단서가 되었습니다. 프록시 설정이 전면적으로 잘못되었다면 외부 방문자도 같이 깨져야 하는데 그렇지 않았기 때문입니다. 내부망 방문자와 외부 방문자를 가르는 차이는 실제 클라이언트 IP가 사설 대역인지 공인 대역인지였습니다.

원인 ① — externalTrafficPolicy의 SNAT

먼저 Kubernetes 계층을 확인했습니다. Traefik의 Service가 기본값인 externalTrafficPolicy: Cluster로 동작하고 있었습니다.

이 정책에서는 트래픽을 받은 노드가 반드시 자기 노드의 파드로 전달하지 않습니다. 다른 노드의 파드로 라우팅할 수 있고, 이때 kube-proxy가 SNAT을 수행하면서 원본 클라이언트 IP를 자기 노드 주소로 바꿉니다. Apache가 요청을 받기도 전에 출발지 IP가 이미 손실되는 것입니다.

# helm/traefik-values.yaml
service:
  annotations:
    metallb.universe.tf/loadBalancerIPs: "192.168.x.x"
  spec:
    externalTrafficPolicy: Local

Local로 바꾸면 각 노드가 자기 노드의 파드로만 전달하므로 SNAT이 발생하지 않고 원본 IP가 보존됩니다. 다만 로컬 엔드포인트가 없는 노드로 트래픽이 가면 응답이 끊기는데, MetalLB가 로컬 엔드포인트가 없는 노드를 광고 대상에서 자동으로 제외하므로 이 구성에서는 안전합니다. Traefik 파드를 2개로 운영하고 있어 가용성 측면의 여유도 확보된 상태였습니다.

같은 이유로 mariadb-galera-primary Service에도 이미 동일한 설정을 적용해 둔 상태였습니다. MetalLB와 kube-proxy가 얽힌 같은 원인이므로, LoadBalancer로 노출하면서 출발지 IP가 의미 있는 서비스라면 함께 점검할 항목입니다.

원인 ② — mod_remoteip가 사설 IP를 무시함

Service 정책을 고친 뒤에도 내부망 방문자의 IP는 여전히 Traefik 파드 IP로 기록되었습니다. Apache 계층에 두 번째 원인이 있었습니다.

mod_remoteip 설정 자체는 정상이었습니다.

# remoteip.conf
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 172.16.0.0/16

모듈의 디버그 로그를 켜서 실제 동작을 확인했습니다.

# Apache 설정에 추가
LogLevel remoteip:trace8

# 로그 출력
"appears to be a private IP or nonsensical. Ignored"

mod_remoteip는 X-Forwarded-For 값이 사설 IP로 보이면 이를 신뢰하지 않고 버립니다. 스푸핑 방지를 위한 안전장치인데, 문제는 이 판단이 모듈에 하드코딩되어 있어 RemoteIPTrustedProxy나 RemoteIPInternalProxy로 우회할 수 없다는 점입니다. 두 지시자는 어떤 프록시를 신뢰할지 지정할 뿐, 전달받은 값이 사설 대역일 때의 거부 동작에는 관여하지 않습니다.

이 환경은 LAN과 파드 네트워크가 모두 RFC1918 사설 대역입니다. 따라서 내부망에서 접속하는 방문자의 실제 IP는 항상 사설 대역이고, mod_remoteip는 그 값을 예외 없이 버린 뒤 REMOTE_ADDR을 Traefik 파드 IP로 남겨둡니다. 외부 인터넷 방문자의 IP는 공인 대역이라 정상적으로 수용되므로, 앞서 관찰한 내부/외부 차이가 여기서 설명됩니다.

해결 — PHP 계층에서 보정

모듈 동작을 설정으로 바꿀 수 없으므로 클라이언트 IP 보존 처리는 애플리케이션 계층에서 맡았습니다. wp-config.php에서 REMOTE_ADDR이 신뢰하는 프록시 대역 안에 있고 X-Forwarded-For 헤더가 존재할 때만 직접 값을 덮어쓰는 방식입니다.

// mod_remoteip가 사설 IP로 보이는 X-Forwarded-For를 버리므로 PHP 레벨에서 보정
if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) && isset( $_SERVER['REMOTE_ADDR'] ) ) {
    $trusted_proxy_long = ip2long( '172.16.0.0' );
    $trusted_proxy_mask = -1 << ( 32 - 16 ); // /16
    $remote_addr_long   = ip2long( $_SERVER['REMOTE_ADDR'] );

    if ( false !== $remote_addr_long
        && ( $remote_addr_long & $trusted_proxy_mask ) === ( $trusted_proxy_long & $trusted_proxy_mask ) ) {
        $forwarded_ips = array_map( 'trim', explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
        $client_ip     = $forwarded_ips[0];

        if ( filter_var( $client_ip, FILTER_VALIDATE_IP ) ) {
            $_SERVER['REMOTE_ADDR'] = $client_ip;
        }
    }
}

조건을 두 개 건 이유가 있습니다. REMOTE_ADDR이 파드 네트워크 대역일 때만 덮어쓰므로, 프록시를 거치지 않은 직접 요청이 X-Forwarded-For를 위조해 보내더라도 값이 반영되지 않습니다. 또한 FILTER_VALIDATE_IP로 형식을 검증해 잘못된 값이 들어가는 것을 막습니다.

이 방식은 같은 파일에 이미 적용되어 있던 HTTP_X_FORWARDED_PROTO → $_SERVER['HTTPS'] 보정과 동일한 패턴입니다. 리버스 프록시 뒤에서 PHP가 요청 맥락을 잘못 인식하는 문제를 애플리케이션 진입점에서 한 번에 정리하는 구조입니다.

주의 — 이 보정이 적용되지 않는 경우

보정 코드가 wp-config.php에 있다는 점에서 오는 제약이 있습니다. wp-load.php를 거치지 않고 직접 호출되는 순수 PHP 스크립트는 이 보정을 보지 못합니다. Apache나 php.ini 레벨이 아니라 WordPress 부트스트랩 과정에서만 실행되기 때문입니다.

검증할 때 이 점 때문에 혼란을 겪을 수 있습니다. 테스트용 PHP 파일을 만들어 $_SERVER['REMOTE_ADDR']을 출력하면 보정 전 값이 나오므로 수정이 반영되지 않은 것처럼 보입니다. WordPress를 실제로 부트스트랩하는 경로에서 확인해야 정확합니다.

실제 단말 주소로 돌아온 방문자 기록

두 가지를 모두 적용한 뒤 내부망에서 접속해 통계 플러그인의 기록을 확인했습니다. 방문자 IP가 Traefik 파드 IP가 아니라 실제 접속 단말의 주소로 기록되는 것을 확인했습니다. 외부 방문자 기록은 기존과 동일하게 유지되었습니다.

# Service 정책 확인
kubectl get svc -n traefik traefik -o jsonpath='{.spec.externalTrafficPolicy}'
# 출력: Local

# Traefik 파드가 각 노드에 분산되어 있는지 확인
kubectl get pods -n traefik -o wide

# MetalLB 광고 상태 확인 (로컬 엔드포인트 없는 노드 제외 여부)
kubectl get svc -n traefik traefik -o wide

정리

리버스 프록시 뒤에서 클라이언트 IP가 사라질 때는 계층을 나눠서 확인하는 편이 빠릅니다. Kubernetes Service의 SNAT과 Apache 모듈의 거부 동작이 이번 사례에서는 겹쳐 있었고, 한쪽만 고쳐서는 증상이 사라지지 않았습니다.

특히 mod_remoteip가 사설 IP를 버리는 동작은 사내망이나 VPN 환경처럼 클라이언트 IP 자체가 사설 대역인 구성에서 문제가 됩니다. 인터넷에 공개된 서비스에서는 클라이언트 IP가 공인 대역이라 드러나지 않으므로, 같은 구성이라도 접속 경로에 따라 증상이 갈립니다. 내부망 접속만 IP가 이상하다면 이 동작을 의심해 볼 만합니다.

참고

관련 포스트:

참고 문서: Apache — mod_remoteip · Kubernetes — External Traffic Policy · MetalLB — Traffic Policies

Upgrading Kubernetes Add-ons: Calico, MetalLB, and Traefik

v1.36 승급과 함께 올린 애드온 4종

클러스터를 v1.33에서 v1.36까지 올리면서 Kubernetes 애드온 업그레이드 순서도 함께 정해야 했습니다. 클러스터 코어만 올리면 끝나는 작업이라 생각했는데, 실제로는 CNI·로드밸런서·Ingress·메트릭 수집기를 각각 어느 시점에 어떤 버전으로 올릴지가 전체 일정과 무중단 여부를 결정했습니다.

이 글에서는 Calico, MetalLB, metrics-server의 버전 업그레이드와 Traefik 이중화 작업을 정리합니다. 특히 업스트림 표준 매니페스트를 그대로 적용했을 때 로컬 커스터마이즈가 조용히 사라지는 문제와, 그 과정에서 실측한 서비스 단절 시간에 초점을 맞췄습니다.

결과부터 말하면 애드온 업그레이드 구간은 전부 무중단이었습니다. 다만 그렇게 만들기 위해 사전에 확인해야 했던 항목이 네 가지 있었고, 그중 두 개는 kubectl diff 출력을 눈으로 확인해야만 발견되는 것이었습니다.

환경

자체 관리형 Kubernetes 클러스터입니다. 컨트롤플레인 3대와 워커 3대로 구성되어 있고, 컨테이너 런타임은 Docker + cri-dockerd입니다.

구성요소 업그레이드 전 업그레이드 후 방식
Kubernetes v1.33.7 v1.36.3 kubeadm, 마이너 3단계
Calico v3.30.7 v3.32.1 매니페스트, 2단계 경유
MetalLB v0.14.5 v0.16.0 매니페스트 + nodeSelector 재적용
metrics-server v0.7.1 v0.9.0 이미지만 교체
Traefik v3.7.5 (replica 1) v3.7.5 (replica 2) Helm values 변경
CoreDNS v1.12.0 v1.14.2 kubeadm 자동

MetalLB는 L2 모드로 운영 중이며 VIP 3개를 할당하고 있습니다. Ingress는 Traefik이 담당하고, Calico는 IPIP 모드에 자체 IPPool을 사용합니다. 노드 IP와 호스트명은 이 글에서 일반화해 표기합니다.

버전 범위에 맞춘 순서 설계와 애드온별 적용

1. 애드온 업그레이드 순서 설계

가장 먼저 결정한 것은 순서였습니다. 애드온마다 지원하는 Kubernetes 버전 범위가 다르기 때문에, 아무 시점에나 올리면 중간 상태가 공식 테스트 매트릭스 밖으로 나갑니다.

Calico의 경우 v3.31은 Kubernetes 1.32~1.35를, v3.32는 1.34~1.36을 테스트합니다. 그래서 v3.30.7에서 v3.32.1로 한 번에 올리지 않고 두 단계로 나눴습니다.

K8s 1.33 상태 → Calico v3.31.6 적용   (1.32~1.35 지원)
K8s 1.34, 1.35 업그레이드
K8s 1.35 상태 → Calico v3.32.1 적용   (1.34~1.36 지원)
K8s 1.36 업그레이드

이렇게 하면 모든 중간 상태가 지원 범위 안에 들어옵니다. MetalLB와 metrics-server는 Kubernetes 버전 의존성이 느슨해서 클러스터 업그레이드를 모두 마친 뒤 마지막에 올렸습니다.

Kubernetes 애드온 업그레이드 순서를 v1.33.7부터 v1.36.3까지 클러스터 버전별로 나눠 Traefik 이중화, Calico 두 단계, MetalLB와 metrics-server 적용 시점과 diff 확인 항목을 보여 주는 개념도입니다.
Kubernetes 애드온 업그레이드 시점은 Calico 테스트 범위에 맞춰 나눴고, MetalLB와 metrics-server는 마지막에 올렸습니다.

2. Traefik 이중화 — 클러스터 업그레이드 전 필수 선행 작업

클러스터 업그레이드는 워커 노드를 순차적으로 drain합니다. 그런데 Traefik이 replica 1개로 떠 있었기 때문에, 그 노드를 drain하는 순간 모든 Ingress 호스트가 30~60초씩 끊기는 구조였습니다. 마이너 3단계 × 워커 3대면 이 단절이 반복됩니다.

Helm values에 replica 2개, PodDisruptionBudget, 노드 분산 anti-affinity를 추가했습니다.

deployment:
  replicas: 2

podDisruptionBudget:
  enabled: true
  minAvailable: 1

affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app.kubernetes.io/name: traefik
            app.kubernetes.io/instance: traefik-traefik
        topologyKey: kubernetes.io/hostname

anti-affinity를 required로 둔 이유는, 두 파드가 같은 워커에 몰리면 이중화 의미가 없어지기 때문입니다. PDB는 drain이 두 파드를 동시에 축출하지 못하게 막습니다.

적용은 helm upgrade로 했고, 차트의 롤아웃 전략이 이미 maxUnavailable: 0 / maxSurge: 1이라 기존 파드를 유지한 채 새 파드가 먼저 올라옵니다. 0.5초 간격 프로브로 278회를 측정했는데 전부 HTTP 200이었습니다.

3. Calico 업그레이드 — server-side apply

Calico는 매니페스트 방식으로 설치되어 있어 업스트림 calico.yaml을 적용합니다. 공식 문서가 --server-side --force-conflicts를 권장하는데, 일반 kubectl apply는 CRD 어노테이션 크기 제한(262144 bytes)에 걸릴 수 있기 때문입니다.

curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/calico.yaml

# 커스터마이즈가 사라지지 않는지 먼저 확인
kubectl diff -f calico.yaml | head -100

kubectl apply --server-side --force-conflicts -f calico.yaml
kubectl -n kube-system rollout status ds/calico-node --timeout=600s

diff에서 확인해야 할 것은 환경변수입니다. IPIP 모드나 IPPool CIDR이 매니페스트 기본값으로 덮이면 네트워크가 끊깁니다. 두 번의 Calico 업그레이드 모두 환경변수 변경이 없었고, 바뀐 것은 이미지 태그와 init 컨테이너 이름, CRD 스키마, ClusterRole의 가산적 권한뿐이었습니다.

한 가지 주의할 점은 v3.31부터 이미지 레지스트리가 docker.io에서 quay.io로 바뀐다는 것입니다. 노드에서 quay.io 도달이 안 되면 롤아웃이 중간에 멈춥니다.

DaemonSet은 노드 1대씩 롤링되며 6노드 기준 약 4분이 걸렸습니다. 이 구간에 노드 Ready 상태는 6/6을 유지했고, 기존 파드의 네트워크 경로는 Calico 재시작 중에도 끊기지 않았습니다.

4. MetalLB v0.16.0 — nodeSelector 재적용이 핵심

MetalLB는 표준 metallb-native.yaml을 적용합니다. 그런데 이 클러스터는 speaker DaemonSet과 controller Deployment에 nodeSelector: type=lb를 걸어 워커 3대에만 배치하도록 커스터마이즈해두었습니다. 표준 매니페스트에는 이 설정이 없습니다.

즉 그대로 적용하면 nodeSelector가 사라집니다. speaker DaemonSet은 control-plane taint에 대한 toleration을 가지고 있어서, 셀렉터가 없어지면 컨트롤플레인 노드에도 배치됩니다.

# 적용 전 반드시 확인 — 제거되는 줄(-)이 있는지
kubectl diff -f metallb-native.yaml | grep -E "^[+-].*(nodeSelector|type: lb)"
#   -        type: lb
#   -        type: lb        ← speaker, controller 두 곳에서 제거됨

kubectl apply -f metallb-native.yaml

# 적용 직후 즉시 복구 (창을 최소화)
kubectl -n metallb-system patch ds speaker \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"type":"lb"}}}}}'
kubectl -n metallb-system patch deploy controller \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"type":"lb"}}}}}'

IPAddressPool과 L2Advertisement 같은 CR은 별도 리소스이므로 매니페스트 적용으로 사라지지 않습니다. 이 부분은 아래 CRD와 CR 구분 항목에서 다시 설명하겠습니다.

적용과 patch를 이어서 실행하고 롤아웃을 기다린 결과, VIP 3개가 모두 유지되고 speaker는 워커 3대에만 배치됐습니다. 이 구간에도 서비스 단절은 없었습니다.

5. metrics-server v0.9.0 — 이미지만 교체

metrics-server는 표준 components.yaml을 적용하지 않았습니다. 현재 배포에는 --kubelet-insecure-tls가 의도적으로 빠져 있는데, 표준 매니페스트는 이 플래그를 포함하고 있어 적용하면 보안 수준이 낮아지기 때문입니다.

# 현재 인자 확인 — --kubelet-insecure-tls 가 없는 상태를 지켜야 함
kubectl -n kube-system get deploy metrics-server \
  -o jsonpath='{.spec.template.spec.containers[0].args}'

# 이미지만 교체
kubectl -n kube-system set image deploy/metrics-server \
  metrics-server=registry.k8s.io/metrics-server/metrics-server:v0.9.0

kubectl -n kube-system rollout status deploy/metrics-server --timeout=180s
kubectl top nodes

인자 5개가 그대로 유지되는지 적용 후 다시 확인했고, kubectl top nodes로 6노드 메트릭이 정상 수집되는 것을 검증했습니다.

diff와 로그에서 드러난 다섯 가지 함정

externalTrafficPolicy 때문에 VIP가 22초 사라진 문제

현상 — 워커 노드를 drain했을 때 데이터베이스 VIP가 22초간 응답하지 않았습니다. 그런데 해당 VIP가 가리키는 파드는 다른 노드에 있었고 정상 동작 중이었습니다. 데이터베이스 클러스터도 쿼럼을 유지하고 있었습니다.

원인 — MetalLB speaker 로그가 답을 줬습니다.

kubectl -n metallb-system logs -l component=speaker --since=5m \
  | grep -E "serviceAnnounced|serviceWithdrawn"

# serviceAnnounced  ips=[192.168.x.x]
# serviceWithdrawn  reason="notOwner"     ← 소유권 상실
# serviceAnnounced  ips=[192.168.x.x]     ← 22초 후 재광고

해당 Service의 externalTrafficPolicy가 기본값인 Cluster였습니다. 이 설정에서는 엔드포인트가 없는 노드도 VIP를 광고할 수 있습니다. 광고 중이던 노드를 cordon하자 MetalLB가 L2 소유권을 재선출했고, 그 사이 LAN에 VIP가 존재하지 않았던 것입니다.

즉 데이터베이스 계층의 문제가 아니라 로드밸런서 계층의 문제였습니다. 파드 상태만 확인했다면 원인을 엉뚱한 곳에서 찾았을 것입니다.

해결 — externalTrafficPolicy: Local로 변경했습니다. 이 값이면 엔드포인트를 가진 노드만 VIP를 광고하므로, 다른 노드를 drain해도 소유권이 흔들리지 않습니다.

kubectl -n <namespace> patch svc <service-name> \
  -p '{"spec":{"externalTrafficPolicy":"Local"}}'

kubectl -n <namespace> get svc <service-name> \
  -o jsonpath='{.spec.externalTrafficPolicy} {.spec.healthCheckNodePort}'
# Local 30797     ← healthCheckNodePort 가 새로 할당됨

변경 후 다른 워커를 drain해봤을 때 VIP 단절은 0초였습니다. 부수 효과로 SNAT가 사라져 클라이언트 소스 IP가 보존되는데, 접속 제한이나 접속 로그를 소스 IP 기준으로 운영한다면 이 변화도 함께 고려해야 합니다.

참고로 오퍼레이터가 관리하는 Service였는데, 커스텀 리소스에 이 필드를 넣어도 이미 존재하는 Service에는 반영되지 않았습니다. 매니페스트에 선언해두고 실행 중인 Service는 직접 patch하는 두 가지 작업이 모두 필요했습니다.

Helm values 병합이 만든 중복 키

현상 — 병합 요청에서 충돌 1건이 보고됐고, 충돌 자체는 같은 값에 주석만 다른 사소한 것이었습니다. 그런데 병합 결과를 검사해보니 traefik-values.yaml에 최상위 deployment: 키가 두 개 있었습니다.

14: deployment:
      replicas: 2                  # 이중화를 위해 추가한 설정

65: deployment:
      revisionHistoryLimit: 5      # 다른 브랜치에서 추가한 공통 정책

원인 — 두 변경이 파일의 다른 위치에 있었기 때문에 Git이 텍스트 기준으로 충돌 없이 병합했습니다. 그러나 YAML은 같은 키가 중복되면 뒤에 나온 값이 앞을 덮습니다. 결과적으로 replicas: 2가 무효화되어 Traefik이 다시 1개로 돌아갈 상황이었습니다.

업그레이드 무중단을 위해 일부러 넣은 설정이 병합 과정에서 조용히 사라지는 셈입니다. 다음 유지보수 때 원인 모를 Ingress 단절로 나타났을 것입니다.

해결 — 두 블록을 하나로 합쳤습니다. 그리고 이런 유형을 자동으로 잡기 위해 중복 키를 오류로 처리하는 YAML 로더로 브랜치 전체를 검사했습니다. 표준 파서는 중복 키를 조용히 허용하므로 일반 문법 검사로는 발견되지 않습니다.

python3 - <<'EOF'
import yaml
class Dup(yaml.SafeLoader): pass
def nodup(loader, node, deep=False):
    m = {}
    for k, v in node.value:
        key = loader.construct_object(k, deep=deep)
        if key in m:
            raise ValueError(f"중복 키: {key!r} (line {k.start_mark.line+1})")
        m[key] = loader.construct_object(v, deep=deep)
    return m
Dup.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, nodup)

for f in ['helm/traefik-values.yaml', 'helm/monitoring-values.yaml']:
    try:
        list(yaml.load_all(open(f), Loader=Dup))
        print(f"OK   {f}")
    except Exception as e:
        print(f"FAIL {f}  {e}")
EOF

Helm values는 여러 사람이 서로 다른 섹션을 건드리기 쉬운 파일입니다. 병합 후 이 검사를 한 번 돌리는 것만으로 상당한 위험을 줄일 수 있습니다.

메트릭 포트 변경이 관측성을 조용히 끊는 경우

현상 — MetalLB 0.16의 diff를 보다가 메트릭 포트가 바뀐 것을 발견했습니다.

-        prometheus.io/port: "7472"
+        prometheus.io/port: "9120"
-        - --port=7472
+        - --port=9120
-        - containerPort: 7472
-          name: monitoring
+        - containerPort: 9120
+          name: metricshttps

원인 — 0.16부터 kube-rbac-proxy가 네이티브 TLS로 교체되면서 포트 번호와 포트 이름이 함께 바뀌었습니다. ServiceMonitor는 보통 포트 이름으로 타깃을 지정하므로, monitoring을 참조하던 설정은 매칭에 실패합니다.

문제는 이 실패가 오류로 드러나지 않는다는 점입니다. 스크레이프 타깃이 사라져도 파드는 정상이고 애플리케이션도 정상이며, 그저 그래프가 빈칸이 됩니다.

해결 — 이 클러스터에는 MetalLB용 ServiceMonitor가 없어 실제 영향은 없었습니다. 다만 애드온을 올릴 때 스크레이프 설정 존재 여부를 먼저 확인하는 절차를 추가했습니다.

# 해당 애드온을 스크레이프하는 설정이 있는지 먼저 확인
kubectl get servicemonitor,podmonitor -A | grep -i metallb

# 있다면 diff 에서 포트 이름 변경을 확인하고 함께 수정
kubectl diff -f metallb-native.yaml | grep -E "^[+-].*(port|name: metric|name: monitoring)"

CRD와 CR을 grep으로 구분하려다 오판한 경우

현상 — 적용 전 안전성을 확인하려고 매니페스트에서 우리 CR이 정의되어 있는지 검색했습니다.

grep -cE "kind: (IPAddressPool|L2Advertisement)" metallb-native.yaml
#   2      ← "인스턴스가 2개 들어 있다"고 오판

원인 — 매칭된 것은 CustomResourceDefinition의 spec.names.kind 필드였습니다. CRD는 자신이 정의하는 리소스의 종류 이름을 이 필드에 담고 있어서, 들여쓰기를 무시한 검색으로는 실제 인스턴스와 구분되지 않습니다.

해결 — 행 시작 앵커를 붙여 최상위 문서의 kind:만 세면 됩니다.

grep -c "^kind: IPAddressPool" metallb-native.yaml
#   0      ← 인스턴스 없음. 우리 CR 은 안전

# 매니페스트에 포함된 리소스 종류 전체를 보는 편이 더 확실
grep -E "^kind:" metallb-native.yaml | sort | uniq -c | sort -rn

실제로 metallb-native.yaml에는 CRD 9개, RBAC, Deployment, DaemonSet만 들어 있고 IPAddressPool·L2Advertisement 인스턴스는 없습니다. 따라서 IP 풀 설정은 매니페스트 적용으로 사라지지 않습니다.

Git과 클러스터가 어긋난 것을 나중에 발견한 경우

현상 — 병합 후 매니페스트와 클러스터를 대조했더니 Traefik의 revisionHistoryLimit이 저장소에는 5, 클러스터에는 10으로 달랐습니다.

원인 — 다른 브랜치가 이 값을 저장소에 커밋했지만, 이 작업 브랜치에서 helm upgrade를 두 번 실행할 때 그 필드가 없는 버전의 values 파일을 사용했습니다. 결과적으로 클러스터를 차트 기본값으로 되돌린 셈이 됐습니다.

kubectl apply로 관리하는 리소스는 파일이 곧 선언이라 이런 드리프트가 잘 드러납니다. 반면 Helm은 실행 시점에 넘긴 values가 반영되므로, 저장소 파일이 최신이어도 마지막 helm upgrade가 구버전 파일이었다면 클러스터는 뒤처집니다.

해결 — 병합된 values로 helm upgrade를 다시 실행해 맞췄습니다. revisionHistoryLimit은 파드 템플릿이 아니라 Deployment 스펙 필드라 롤아웃이 발생하지 않고, 파드는 재시작조차 하지 않았습니다.

# 매니페스트 파싱값과 클러스터 실제값을 대조
python3 -c "import yaml; d=yaml.safe_load(open('helm/traefik-values.yaml')); \
  print('manifest:', d['deployment'])"
kubectl -n traefik get deploy traefik \
  -o jsonpath='cluster: replicas={.spec.replicas} rhl={.spec.revisionHistoryLimit}'

애드온 작업 후에는 파일만 확인하지 말고 클러스터 실제값과 한 번 대조하는 편이 안전합니다.

Kubernetes 애드온 업그레이드 최종 상태와 실측 단절 시간

애드온 4종의 최종 상태입니다. 모든 구성요소가 목표 버전에 도달했고 정상 동작합니다.

kubectl -n kube-system get ds calico-node \
  -o jsonpath='calico     : {.spec.template.spec.containers[0].image} {.status.numberReady}/{.status.desiredNumberScheduled}{"\n"}'
kubectl -n metallb-system get ds speaker \
  -o jsonpath='metallb    : {.spec.template.spec.containers[0].image} {.status.numberReady}/{.status.desiredNumberScheduled}{"\n"}'
kubectl -n kube-system get deploy metrics-server \
  -o jsonpath='metrics    : {.spec.template.spec.containers[0].image}{"\n"}'
kubectl -n traefik get deploy traefik \
  -o jsonpath='traefik    : {.spec.template.spec.containers[0].image} {.status.readyReplicas}/{.status.replicas}{"\n"}'

# calico     : quay.io/calico/node:v3.32.1  6/6
# metallb    : quay.io/metallb/speaker:v0.16.0  3/3
# metrics    : registry.k8s.io/metrics-server/metrics-server:v0.9.0
# traefik    : docker.io/traefik:v3.7.5  2/2

커스터마이즈 보존 여부도 함께 확인했습니다. Calico의 IPIP 모드와 IPPool CIDR, MetalLB의 nodeSelector와 IP 풀, metrics-server의 인자 5개가 모두 그대로입니다.

kubectl get ippools.crd.projectcalico.org \
  -o custom-columns=NAME:.metadata.name,CIDR:.spec.cidr,IPIP:.spec.ipipMode --no-headers
kubectl -n metallb-system get ds speaker \
  -o jsonpath='{.spec.template.spec.nodeSelector}{"\n"}'
kubectl get svc -A --field-selector spec.type=LoadBalancer \
  -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,IP:.status.loadBalancer.ingress[0].ip --no-headers

Kubernetes 애드온 업그레이드 구간의 실측 단절 시간은 다음과 같습니다. 외부에서 1초 간격으로 HTTP 응답과 VIP TCP 도달성을 측정했습니다.

작업 실측 단절 측정 방식
Traefik 이중화 (replica 1 → 2) 0초 0.5초 간격 278회, 전부 200
Calico v3.31.6 0초 174회, 노드 Ready 6/6 유지
Calico v3.32.1 0초 DB 클러스터 크기 이탈 0회
MetalLB v0.16.0 0초 VIP 3개 유지
metrics-server v0.9.0 0초 인자 보존 확인
Traefik values 정렬 0초 150회, 파드 재시작 없음

애드온 업그레이드 자체는 전부 무중단이었습니다. 실제 단절이 발생한 것은 클러스터 노드를 drain하는 구간이었고, 그중 22초는 위에서 다룬 externalTrafficPolicy 문제였습니다.

결론

이번 Kubernetes 애드온 업그레이드 과정에서 가장 유용했던 습관은 kubectl diff의 제거되는 줄을 눈으로 확인하는 것이었습니다. 업스트림 매니페스트는 우리 환경의 커스터마이즈를 알지 못하므로, 추가되는 내용보다 사라지는 내용이 더 위험합니다.

두 번째는 “성공했다는 신호”를 그대로 믿지 않는 것입니다. Git이 충돌 없이 병합했다고 결과가 올바른 것은 아니었고, YAML 파서가 통과시켰다고 설정이 의도대로 남은 것도 아니었습니다. 적용 후 실제 값을 다시 조회해 확인해야 발견되는 문제들이었습니다.

세 번째는 단절의 원인을 계층별로 분리해 보는 것입니다. VIP가 끊겼을 때 데이터베이스를 먼저 의심했지만 실제 원인은 로드밸런서의 소유권 재선출이었습니다. 파드 상태만 확인했다면 찾지 못했을 원인이고, speaker 로그가 결정적인 단서를 줬습니다.

애드온마다 지원하는 Kubernetes 버전 범위가 다르다는 점도 미리 확인할 가치가 있었습니다. Calico를 두 단계로 나눈 덕분에 모든 중간 상태가 공식 테스트 범위 안에 머물렀습니다.

참고

관련 포스트:

참고 문서: Calico Upgrade · MetalLB Release Notes · Kubernetes Source IP and externalTrafficPolicy · metrics-server

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