태그 보관물: PHP

Diagnosing a WordPress Plugin Update Notice That Never Clears

개요

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

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

환경

구성 요소 내용
플랫폼 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.txtStable 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.txtStable tag는 올렸는데 플러그인 파일의 헤더 주석은 이전 버전 문자열이 그대로 남아 있는 상태였습니다.

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

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

진단 순서

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

단계 확인 내용 해당하면
1 kubectl top pod으로 유휴 사용량과 limit 사이 여유분 리소스 상향
2 업데이트 시점 전후의 503·타임아웃 로그 리소스 상향
3 설치된 플러그인의 헤더 Version: vs readme.txtStable 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

개요

WordPress Kubernetes 마이그레이션을 진행하여 레거시 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를 신규로 활성화하는 작업도 함께 진행했습니다.

환경

항목 기존 (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 빌드

단계별 절차

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.phprequire_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.rbregistry_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의 기본 인증서로 쓰이고 있어서, 별도 인증서 작업 없이 그대로 유효한 인증서를 응답받을 수 있었습니다.

결과 확인

이번 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 연동

개요

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으로 차단됩니다.

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 redirectset-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 헤더를 그대로 사용합니다.

트러블슈팅

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 설정 가이드