태그 보관물: Job

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

CKAD Cronjob 문제 후기 – 20250618

개요

CKAD 시험에서 출제된 Kubernetes CronJob 문제 풀이를 기록합니다. 특정 스케줄로 동작하는 CronJob을 작성하고, 성공/실패 history 보관 수, activeDeadlineSeconds, restartPolicy 등 세부 옵션을 지정하며, 테스트용 Job도 별도로 생성합니다.

출제 문제

※ 기억에 의존해 복기하는 문제라 오류가 있을 수 있습니다.
참고만 부탁 드립니다.

  • 30분 마다 job을 스케쥴 처리할 수 있는 cronjob 생성
  • cronjob 이름은 grep, namespace는 devops 사용할 것
  • 성공 history는 64개, 실패 history는 160개 보관
  • job이 실행되고 8초 이내에 완료 되지 못하면 중단할 것
  • pod가 중단 되더라도 재실행 되지 않을 것
  • container 이름: busybox
    image: busybox:stable,
    command: [“grep”, “-i”, “NAMESERVER”, “/etc/resolv.conf”]
  • cronjob 테스트를 위해 job을 별도로 생성해볼 것
    job 이름: grep-test
    namespace: devops

해결 방법

# cronjob-grep.yaml 생성

apiVersion: batch/v1
kind: CronJob
metadata:
  name: grep
  namespace: devops
spec:
  schedule: "*/30 * * * *"
  successfulJobsHistoryLimit: 64
  failedJobsHistoryLimit: 160
  jobTemplate:
    spec:
      activeDeadlineSeconds: 8
      template:
        spec:
          containers:
          - name: busybox
            image: busybox:stable
            imagePullPolicy: IfNotPresent
            command: ["grep", "-i", "NAMESERVER", "/etc/resolv.conf"]
          restartPolicy: Never
# job-grep-test.yaml 생성

apiVersion: batch/v1
kind: Job
metadata:
  name: grep-test
  namespace: devops
spec:
  activeDeadlineSeconds: 8
  template:
    spec:
      containers:
      - name: busybox
        image: busybox:stable
        imagePullPolicy: IfNotPresent
        command: ["grep", "-i", "NAMESERVER", "/etc/resolv.conf"]
      restartPolicy: Never
# cronjob 생성 및 상태 확인
$ k apply -f cronjob-grep.yaml
$ k get cronjob -n devops
$ k describe cronjob -n devops grep

# job 생성 및 실행 상태 확인
$ k apply -f job-grep-test.yaml
$ k get job -n devops
$ k describe job -n devops grep-test
$ k get po -n devops # pod의 Completed 상태 확인

CronJob 주요 필드 설명

CronJob은 주기적으로 Job을 생성하는 Kubernetes 리소스입니다. spec.schedule은 Linux cron 문법(*/분 시 일 월 요일)을 따르며, */30 * * * *는 30분마다 실행을 의미합니다. 시험에서는 schedule 문법과 Job/Pod 레벨 옵션을 조합하는 문제가 자주 출제됩니다.

주요 필드와 역할은 다음과 같습니다. successfulJobsHistoryLimitfailedJobsHistoryLimit는 보관할 완료/실패 Job 수를 제한해 클러스터 리소스 낭비를 방지합니다. jobTemplate.spec.activeDeadlineSeconds는 Job 실행 시작 후 제한 시간이며, 이 시간을 초과하면 실행 중인 Pod를 강제 종료합니다. restartPolicy: Never는 Pod 실패 시 재시작하지 않고 새 Pod를 생성하는 방식으로, 반복 실행 가능한 Job에 적합합니다.

# CronJob 상태 및 다음 실행 시간 확인
kubectl get cronjob grep -n devops

# CronJob이 생성한 Job 목록 확인
kubectl get jobs -n devops

# 특정 Job이 생성한 Pod 확인 및 로그 조회
kubectl get pods -n devops --selector=job-name=grep-test
kubectl logs -n devops -l job-name=grep-test

CronJob을 즉시 테스트하려면 kubectl create job --from=cronjob/grep grep-test -n devops로 기존 CronJob 스펙을 기반으로 Job을 수동 실행할 수 있습니다. 이 방법이 YAML 파일을 별도로 작성하는 것보다 간결하고 오타 위험이 없어 시험 환경에서 유용합니다.

concurrencyPolicy와 startingDeadlineSeconds

CronJob에는 이전 Job이 아직 실행 중일 때 새 Job을 어떻게 처리할지 결정하는 concurrencyPolicy 필드가 있습니다. Allow(기본값)는 동시 실행을 허용하고, Forbid는 이전 Job이 완료될 때까지 새 Job 생성을 건너뜁니다. Replace는 실행 중인 Job을 취소하고 새 Job으로 교체합니다. Job 실행 시간이 스케줄 간격보다 길어질 수 있는 경우 Forbid를 설정해야 중복 실행으로 인한 리소스 낭비를 방지할 수 있습니다.

startingDeadlineSeconds는 스케줄된 시간을 놓쳤을 때 허용하는 지연 시간입니다. 클러스터가 일시적으로 중단되었다가 복구될 때, 누락된 스케줄을 이 시간 안에서 복구 실행합니다. 이 값을 설정하지 않으면 100번 이상 스케줄을 놓친 경우 CronJob 컨트롤러가 Job 생성을 포기하므로, 장기 유지보수 후 복구 시 Jobs가 실행되지 않는 상황에 주의해야 합니다.

참고

참고 문서: Kubernetes Job 공식 문서 · kubectl create cronjob