태그 보관물: PHP

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

Migrating WordPress from a Standalone LAMP Server to Kubernetes with Traefik

CentOS 7 단일 서버를 떠나며 세운 네 가지 목표

레거시 CentOS 7 + Apache + PHP 7.4 단일 서버에서 운영하던 사이트를 자체 관리형 클러스터로 옮긴 WordPress Kubernetes 마이그레이션 기록입니다. 대상 서버는 두 개 도메인을 서빙하고 있었고, DB는 이미 클러스터 내 MariaDB를 외부 LoadBalancer IP로 접속하고 있어 데이터베이스 자체는 새로 구축할 필요 없이 접속 경로만 전환하면 되는 상태였습니다.

이번 작업에서는 ① 진입점을 Traefik Ingress로 전환 ② MariaDB 접속을 ClusterIP로 전환 ③ 다중 파드 확장을 고려해 Rook CephFS(ReadWriteMany) 볼륨 채택 ④ PHP 7.4(EOL)에서 PHP 8.4로 업그레이드, 이 네 가지를 목표로 설계했습니다. 로컬 환경에 docker/podman이 없어 커스텀 이미지를 클러스터 안에서 직접 빌드해야 했고, 그 과정에서 GitLab Container Registry를 신규로 활성화하는 작업도 함께 진행했습니다.

WordPress Kubernetes 마이그레이션 구성도로, CentOS 7 레거시 단일 서버 구성과 HAProxy에서 Traefik websecure 443, WordPress 파드 2개, CephFS RWX PVC, MariaDB ClusterIP로 이어지고 Kaniko와 GitLab Registry가 이미지를 공급하는 이전 후 구성을 비교합니다.
진입점은 Traefik 443으로, webroot는 CephFS RWX 볼륨으로, 이미지는 클러스터 안 Kaniko 빌드로 바뀐 전후 구성입니다.

환경

항목 기존 (192.168.x.x 레거시 서버) 신규 (Kubernetes)
OS / 웹서버 / PHP CentOS 7, Apache 2.4.6 (mod_php, mpm_prefork), PHP 7.4.33 Debian(컨테이너), Apache 2.4.68, PHP 8.4.23
진입점 HAProxy가 TLS 종료 후 백엔드로 프록시 Traefik Ingress (websecure 443)
DB 연결 MariaDB LoadBalancer 외부 IP MariaDB ClusterIP (Service DNS)
스토리지 로컬 디스크 단일 서버 Rook CephFS RWX PVC 5Gi, 파드 2개 공유
메일 발송 로컬 Postfix (direct-send) 이미지 내 Postfix 동일 구성
이미지 – GitLab Container Registry + Kaniko 빌드

WordPress Kubernetes 마이그레이션 설계와 이관

1. 소스 서버 조사 및 이관 설계

SSH로 레거시 서버에 직접 접속해 vhost 설정, wp-config.php, 활성 플러그인 목록, PHP 설정을 조사했습니다. DB 접속 정보(DB_HOST)가 이미 클러스터 내 MariaDB LoadBalancer 외부 IP로 되어 있다는 점을 확인했고, HAProxy IP 하나만 신뢰하도록 되어 있던 mod_rpaf 설정은 새 토폴로지에서는 의미가 없어 mod_remoteip + Pod 네트워크 대역으로 대체가 필요하다는 점도 함께 확인했습니다.

2. 스토리지 설계: webroot 전체를 RWX PVC로

WordPress는 FS_METHOD=direct로 동작해 .htaccess나 플러그인/코어 업데이트를 파일시스템에 직접 씁니다. 다중 파드 환경에서 모든 레플리카가 일관된 상태를 보게 하려면 wp-content만이 아니라 /var/www/html 전체를 공유 볼륨에 올려야 한다고 판단했습니다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wordpress-webroot
spec:
  accessModes: [ReadWriteMany]
  storageClassName: rook-cephfs
  resources:
    requests:
      storage: 5Gi

wp-config.php는 이 PVC 밖에 두었습니다. DB 접속 정보 등 비밀이 아닌 값은 ConfigMap으로, DB 비밀번호와 AUTH/SALT 키는 Secret으로 분리해 두 파일을 각각 subPath로 마운트하고, 본체 wp-config.php가 require_once로 Secret 쪽 파일을 불러오는 구조로 구성했습니다.

3. 커스텀 이미지 빌드: GitLab Container Registry + Kaniko

로컬 작업 환경에 docker/podman이 없었고, 클러스터도 이전까지 공개 이미지만 사용해 온 상태라 커스텀 이미지를 빌드할 경로가 필요했습니다. GitLab Container Registry가 비활성 상태였어서 이번에 신규로 활성화했습니다.

FROM wordpress:php8.4-apache

# 원본에 없던 zip 확장 추가
RUN apt-get update && apt-get install -y libzip-dev && docker-php-ext-install zip

# 원본과 동일한 Postfix(direct-send) 구성
RUN echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y postfix

# mod_rpaf 대체
RUN a2enmod remoteip headers rewrite expires
COPY security-headers.conf remoteip.conf /etc/apache2/conf-enabled/
CMD ["/usr/local/bin/start.sh"]

이미지는 로컬 빌드 없이 Kaniko Job으로 클러스터 안에서 빌드하고 바로 Registry에 푸시했습니다.

containers:
  - name: kaniko
    image: gcr.io/kaniko-project/executor:v1.23.2
    args:
      - --dockerfile=/workspace/Dockerfile
      - --context=dir:///workspace/
      - --destination=gitlab.sierracloud.dev:5050/infra/k8s-ops/wordpress:php8.4-2

4. 데이터 이관

PVC를 마운트하는 임시 헬퍼 파드를 띄우고 rsync로 레거시 서버의 /var/www/html을 통째로 옮겼습니다. 총 297MB, 13,822개 파일이었고 컷오버 직전에 증분 동기화를 한 번 더 실행해 다운타임을 최소화했습니다. 미사용 상태로 남아있던 두 번째 WordPress 설치본(운영 DB를 그대로 바라보지만 어떤 vhost에서도 서빙되지 않던 orphan 설치)은 이관 대상에서 제외했습니다.

rsync -az --exclude='wp-config.php' \
  -e 'ssh -i <migration-key>' \
  root@192.168.x.x:/var/www/html/ /mnt/webroot/

레지스트리·빌드·리버스 프록시에서 막힌 네 지점

GitLab Container Registry 활성화 중 nginx 전체 다운

현상: gitlab.rb에 registry_external_url을 추가하고 gitlab-ctl reconfigure를 실행하자 nginx가 기동에 실패했고, Registry뿐 아니라 GitLab 웹 UI 전체가 접속 불가 상태가 되었습니다.

원인: registry_nginx가 메인 사이트에 적용해 둔 커스텀 인증서 경로를 상속받지 않고, 존재하지 않는 기본 경로(/etc/gitlab/ssl/<fqdn>.crt)를 찾다가 nginx 설정 검증 자체가 실패했습니다.

해결: registry_nginx['ssl_certificate']/['ssl_certificate_key']를 메인 사이트와 동일한 인증서 경로로 명시적으로 지정한 뒤 재실행해 즉시 복구했습니다.

registry_external_url 'https://gitlab.sierracloud.dev:5050'
gitlab_rails['registry_enabled'] = true
registry_nginx['ssl_certificate'] = "/data/cert/sierracloud.dev/fullchain.pem"
registry_nginx['ssl_certificate_key'] = "/data/cert/sierracloud.dev/privkey.pem"

Kaniko 빌드 컨텍스트의 dangling symlink

현상: Dockerfile과 부속 설정 파일을 ConfigMap으로 만들어 Kaniko 빌드 컨텍스트로 바로 마운트했더니, COPY 단계에서 cannot operate on dangling symlink 에러로 빌드가 실패했습니다.

원인: Kubernetes ConfigMap 볼륨은 각 파일을 실제 파일이 아니라 ..data/<file>을 가리키는 심볼릭 링크로 마운트합니다. Kaniko의 COPY가 이 링크를 그대로 이미지에 복사하면서, 이미지 안에서는 가리키는 대상이 없는 깨진 링크가 되어버렸습니다.

해결: initContainer에서 cp -rL로 심볼릭 링크를 실제 파일 내용으로 역참조 복사한 emptyDir을 만들고, 이 디렉터리를 Kaniko의 빌드 컨텍스트로 사용하도록 변경했습니다.

initContainers:
  - name: prepare-context
    image: busybox:1.36
    command: ["sh", "-c", "cp -rL /configmap-src/. /workspace/"]

Really Simple Security가 리버스 프록시 뒤에서 무한 리다이렉트를 일으킴

현상: .htaccess의 HTTPS 강제 리다이렉트 규칙이 RewriteCond %{HTTPS} !=on 조건을 쓰고 있었는데, 이관 후에는 모든 요청이 계속 리다이렉트되어 파드가 readiness probe를 통과하지 못하고 CrashLoopBackOff 상태에 빠졌습니다.

원인: 원본 서버는 Apache가 직접 TLS를 종료했기 때문에 %{HTTPS}가 정확했지만, 이관 후에는 Traefik이 TLS를 종료하고 WordPress 컨테이너에는 평문 HTTP로 전달되어 %{HTTPS}가 항상 off로 평가되었습니다.

해결: 플러그인 소스를 직접 확인해, 리버스 프록시/로드밸런서 뒤에서 지원하는 방식인 RewriteCond %{HTTP:X-Forwarded-Proto} !https로 교체했습니다. wp-config.php에도 HTTP_X_FORWARDED_PROTO를 확인해 $_SERVER['HTTPS']를 보정하는 코드를 추가해 PHP 레벨의 is_ssl() 판단도 함께 맞췄습니다. 프로브 자체는 이 로직과 무관한 정적 파일(/healthz.html)로 분리해 안정성을 확보했습니다.

RewriteCond %{HTTP_USER_AGENT} !lscache_runner [NC]
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{REQUEST_URI} !^/healthz\.html$
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]

Traefik이 평문 HTTP 구간에서는 X-Forwarded-Proto를 신뢰하지 않음

현상: 위 수정 이후에도, HAProxy가 Traefik의 80(web) 엔트리포인트로 붙는 기존 패턴(다른 내부 서비스들과 동일한 구성)으로 연결하면 여전히 무한 리다이렉트가 재현되었습니다.

원인: Traefik은 보안상 클라이언트가 보낸 X-Forwarded-Proto 값을 그대로 신뢰하지 않고, 자신이 실제로 수신한 연결이 TLS인지 여부로 직접 값을 판단해 덮어씁니다. HAProxy와 Traefik 사이 구간이 평문 HTTP(80)이면 Traefik은 이 값을 항상 http로 기록합니다.

해결: HAProxy의 backend를 Traefik의 443(websecure) 엔트리포인트로 변경했습니다(ssl verify none). Traefik이 이 연결에서 직접 TLS를 종료하므로 X-Forwarded-Proto가 정확히 https로 설정됩니다. 마침 traefik 네임스페이스에 이미 로드되어 있던 *.sierracloud.dev 와일드카드 인증서가 Traefik의 기본 인증서로 쓰이고 있어서, 별도 인증서 작업 없이 그대로 유효한 인증서를 응답받을 수 있었습니다.

두 파드의 RWX 볼륨 공유와 접근 경로 검증

WordPress Kubernetes 마이그레이션 이후의 최종 상태를 다음과 같이 확인했습니다.

$ kubectl get pods -n wordpress-system -l app=wordpress
NAME                         READY   STATUS    RESTARTS   AGE
wordpress-xxxxxxxxxx-xxxxx   1/1     Running   0          91m
wordpress-xxxxxxxxxx-yyyyy   1/1     Running   0          91m

두 파드가 동일 PVC(RWX)를 실시간으로 공유하는지도 직접 검증했습니다. 한 파드에서 파일을 쓰고 다른 파드에서 즉시 동일한 내용을 읽어, 별도 볼륨이 아니라 완전히 같은 볼륨임을 확인했습니다.

  • 내부망 DNS(도메인 → Traefik IP 직접) 경로로 로그인 페이지, REST API, 실제 게시글 permalink까지 정상 응답 확인
  • 외부에서는 CDN을 경유해 HAProxy → Traefik → 파드로 이어지는 경로로 동일하게 정상 렌더링 확인
  • HAProxy IP로 CDN을 거치지 않고 직접 접근하면 403이 반환되는 것도 확인 — origin을 직접 노출하지 않도록 걸어둔 기존 접근 제어가 그대로 유지되고 있다는 뜻이라 정상 동작으로 판단했습니다

참고

관련 포스트:

참고 문서: Traefik — EntryPoints Forwarded Headers · Kaniko (GoogleContainerTools) · GitLab Container Registry Administration

CloudFront + Legacy Origin 연동

origin 직접 접근을 막아야 했던 이유

AWS CloudFront와 HAProxy를 활용한 CloudFront Legacy Origin 연동 구성 및 트러블슈팅을 정리합니다. legacy 서버를 보호하기 위해 CloudFront와 WAF 레이어를 구성하며 발생한 다양한 이슈를 기록한 내용입니다.
도메인 변경과 HAProxy를 origin으로 하는 구조에서 모든 트래픽이 반드시 CloudFront를 경유하도록 강제하고, origin의 직접 접근을 차단하는 것이 주요 목표입니다.

본문은 CloudFront 구성이 주요 목적이 아니므로 전체적인 과정은 생략하고 Legacy Origin 연동 과정만 다루도록 하겠습니다.

아키텍처

전체 트래픽 흐름은 다음과 같습니다.

Browser (HTTPS)
    → CloudFront (WAF, CDN, TLS 종료)
        → HAProxy :80 (X-CF-Secret 검증)
            → Apache :443

WAF bypass 방지 원리: CloudFront는 모든 origin 요청에 X-CF-Secret custom origin header를 추가합니다. HAProxy는 www.example.com로 들어오는 요청 중 이 헤더가 없는 경우 403을 반환합니다. 브라우저가 HAProxy IP로 직접 접근하더라도 CloudFront를 거치지 않으면 403으로 차단됩니다.

CloudFront Legacy Origin 연동 구성도로, CloudFront가 X-CF-Secret 헤더를 붙인 요청만 HAProxy를 지나 Apache origin에 도달하고 헤더 없는 직접 접근은 403으로 차단되는 흐름과 경로별 캐시 정책을 보여 줍니다.
HAProxy는 CloudFront가 붙인 X-CF-Secret 헤더로 경유 여부를 판별하고, CloudFront는 경로별로 캐시 정책을 나눕니다.

HAProxy 핵심 설정

frontend http_front
    bind *:80
    acl is_cf_www    hdr(host) -i www.example.com
    acl has_cf_secret hdr(X-CF-Secret) -i <SECRET_VALUE>

    # CloudFront 아닌 직접 접근 차단
    http-request deny deny_status 403 if is_cf_www !has_cf_secret

    # CF 경유 표시 후 backend 라우팅
    http-request set-var(req.cf_routed) str(yes) if is_cf_www has_cf_secret
    http-request set-header X-Forwarded-Proto https if is_cf_www has_cf_secret
    http-request redirect scheme https code 301 unless { var(req.cf_routed) -m found }
    use_backend www.example.com if { var(req.cf_routed) -m found }

frontend https_front
    bind *:443 ssl crt /etc/letsencrypt/live/complex_dev.pem
    acl is_www.example.com hdr(host) -i www.example.com
    acl is_www.example.com hdr(host) -i <MY_IP>
    acl has_cf_secret hdr(X-CF-Secret) -i <SECRET_VALUE>
    http-request deny deny_status 403 if is_www.example.com !has_cf_secret
    use_backend www.example.com if is_www.example.com

주의: HAProxy의 redirect 지시어는 use_backend보다 항상 먼저 처리됩니다. CloudFront 트래픽을 판별한 뒤 리다이렉트를 선택적으로 건너뛰려면 http-request redirect와 set-var를 조합해야 합니다.

CloudFront Behaviors 구성

우선순위 Path pattern Cache policy Origin request policy
0 /wp-admin/* CachingDisabled AllViewer
1 /wp-login.php CachingDisabled AllViewer
2 (Default) * UseOriginCacheControlHeaders-QueryStrings –

/wp-admin/*와 /wp-login.php는 세션·쿠키 의존 동적 페이지이므로 캐시하지 않고 AllViewer로 모든 요청 정보를 origin에 전달합니다. Default behavior는 공개 페이지 캐시에 origin의 Cache-Control 헤더를 그대로 사용합니다.

CloudFront 경유 후 깨진 네 가지

1. 로그인 페이지 스타일 깨짐 — CloudFront 쿼리스트링 미전달

현상: /wp-login.php 등의 페이지가 스타일 미적용 상태로 표시됨.

원인: CloudFront의 Behavior 기본 Cache policy는 UseOriginCacheControlHeaders는 쿼리스트링을 cache key에 포함하지 않고 origin에도 전달하지 않습니다. WordPress의 load-styles.php?c=0&dir=ltr&load[]=...가 파라미터 없이 호출되어 0바이트 빈 CSS를 반환했고, CloudFront가 이를 max-age=31536000(1년) TTL로 캐시했습니다.

# 진단: 쿼리스트링 유무에 따른 응답 차이

curl -s -o /dev/null -w "%{size_download}"   "http://<origin>/wp-admin/load-styles.php"
# → 0 bytes (파라미터 없으면 빈 응답)

curl -s -o /dev/null -w "%{size_download}"   "http://<origin>/wp-admin/load-styles.php?c=0&dir=ltr&load[chunk_0]=login,..."
# → 110,881 bytes (정상 CSS)

해결: Cache policy를 UseOriginCacheControlHeaders-QueryStrings로 변경하고 CloudFront invalidation(/*) 실행. 이후 정상 표시 확인.

2. 캐시 플러그인 충돌 — W3 Total Cache drop-in 잔존

현상: admin-ajax.php 500 오류, CSS 응답 0바이트.

원인: W3 Total Cache 플러그인 삭제 후에도 WordPress drop-in 파일 /wp-content/object-cache.php가 남아 모든 object cache 요청을 가로채 오류를 반환.

# 오류 메시지
W3 Total Cache Error: some files appear to be missing or out of place.
Please re-install plugin or remove /var/www/html/wp-content/object-cache.php.

# 해결
rm /var/www/html/wp-content/object-cache.php

교훈: 캐시 플러그인 삭제 시 drop-in 파일(object-cache.php, advanced-cache.php, db.php)은 자동 삭제되지 않습니다. CloudFront 등 외부 CDN을 사용하는 경우 서버 사이드 캐시 플러그인은 제거하는 것이 좋습니다.

3. 배경 이미지 미표시 — PHP 직렬화 데이터 손상

현상: 메인 페이지 배경 이미지가 외부에서 표시되지 않음.

원인 1: DB의 theme_mods_twentyfourteen에 배경 이미지 URL이 구 도메인(www.sierracloud.dev)으로 저장.

원인 2: SQL REPLACE()로 URL을 교체했으나 PHP 직렬화 문자열의 길이 prefix(s:N:)가 업데이트되지 않아 unserialize 실패 → 테마 설정 전체 소실.

-- 잘못된 방법 (직렬화 길이 불일치 발생)
UPDATE sc_options
SET option_value = REPLACE(option_value,
  'https://www.sierracloud.dev',
  'https://www.example.com')
WHERE option_name = 'theme_mods_twentyfourteen';
-- s:79:"https://www.example.com/..." → 실제 길이는 76 → unserialize 실패

-- 올바른 방법: 전체 직렬화 값을 정확히 재구성하여 UPDATE
UPDATE sc_options
SET option_value = 'a:10:{...s:16:"background_image";s:76:"https://www.example.com/wp-content/uploads/.../image.jpg";}'
WHERE option_name = 'theme_mods_twentyfourteen';

교훈: WordPress DB의 PHP 직렬화 데이터에 SQL REPLACE()를 사용하면 문자열 길이 prefix가 맞지 않아 반드시 손상됩니다. URL 교체 작업에는 wp search-replace CLI를 사용하세요.

4. HTML 30일 캐시 — .htaccess mod_expires 설정

현상: CloudFront가 메인 페이지 HTML을 30일간 캐시. 콘텐츠 업데이트 미반영.

# 수정 전
ExpiresByType text/html "access 1 month"
ExpiresDefault "access 1 month"

# 수정 후
ExpiresByType text/html "access 0 seconds"
ExpiresDefault "access 1 day"

구성 수정 요약

항목 설정
CloudFront → Origin 프로토콜 HTTP (port 80)
WAF bypass 방지 X-CF-Secret custom origin header 검증
기본 Cache policy UseOriginCacheControlHeaders-QueryStrings
wp-admin / wp-login 처리 CachingDisabled + AllViewer
HTML Cache-Control max-age=0 (캐시 제외)
정적 자산 Cache-Control max-age=31536000 (WordPress 자동 버전 관리)

참고

관련 포스트:

참고 문서: CloudFront Custom Origin Headers (AWS 공식) · HAProxy ACL 설정 가이드