서버 nginx 없이 WordPress 운영하기: NPG + Docker 이전 가이드

들어가며

최근 PHP2SHell과 같은 보안 이슈로 인하여 서버에 NPG를 설치했습니다.

NPG는 Nginx에 보안을 강화한 프로그램인데요. 앞단에 Nginx Proxy Guard(NPG)를 두고 있다면, 그 뒤의 서버 nginx와 PHP-FPM, MariaDB는 모두 Docker 컨테이너로 옮기고 서버에서 지울 수 있다. 실제로 서버 두 대에서 WordPress 5개와 앱 몇 개를 옮겼고, 사이트는 몇 초 이상 멈춘 적이 없었으며 속도도 느려지지 않았다.

처음 구조는 흔한 형태였다. NPG가 80/443을 받아 SSL과 WAF를 처리하고, 그 뒤에서 서버에 직접 설치한 nginx가 다시 요청을 받아 PHP-FPM으로 넘기는 이중 구조다. 같은 일을 두 nginx가 나눠 하다 보니 SSL 인증서도 NPG와 Certbot 양쪽에 있었고, 설정을 바꿀 때마다 어디를 고쳐야 하는지 헷갈렸다.

이 글은 그 중간 단계를 걷어내는 방법을 정리한 것이다. 특정 서버가 아니라 비슷한 구성이라면 그대로 따라 할 수 있도록 일반화했다.

  • WordPress를 컨테이너로 옮기는 기본 절차
  • Redis, 외부 DB, 서버 로컬 DB를 쓰는 경우의 처리
  • WordPress가 아닌 앱(프록시, 정적 파일 서빙)을 함께 정리하는 방법
  • 실제로 겪은 함정과 속도 측정 결과
  • 서버 패키지를 안전하게 지우는 순서

한눈에 보기

이전에는 NPG가 받은 요청을 서버 nginx가 한 번 더 받아 PHP-FPM으로 넘겼다. 이후에는 NPG가 컨테이너로 바로 넘기고, 서버에 설치했던 nginx·PHP·DB는 지운다. WordPress 파일은 옮기지 않고 기존 폴더를 컨테이너에 연결하므로, NPG의 대상 포트만 바꾸면 전환이고 되돌리면 복구다.

1. 시작 전 현황 파악

옮기기 전에 서버 nginx가 실제로 무슨 일을 하고 있는지부터 확인한다. 아래 명령은 모두 읽기만 하므로 서버에 영향이 없다.

awpu03
# nginx가 맡은 사이트, 파일 경로, 포트
sudo nginx -T 2>/dev/null | grep -E "^\s*(listen|server_name|root|proxy_pass) "

# WordPress별 버전, DB, 플러그인, 소유자
for WP in /var/www/사이트1 /var/www/사이트2; do
  echo "== $WP =="
  sudo head -3 $WP/wp-config.php
  grep "wp_version =" $WP/wp-includes/version.php
  grep -nE "DB_HOST|DB_NAME|table_prefix|That's all|WP_REDIS|WP_CACHE" $WP/wp-config.php
  ls $WP/wp-content/plugins
  ls -la $WP/.htaccess $WP/wp-content/object-cache.php 2>&1
  du -sh $WP; ls -ld $WP
done

# 실제로 도는 PHP-FPM 버전과 바꿔 둔 설정
systemctl list-units --type=service | grep fpm
grep -hE "^(upload_max_filesize|post_max_size|memory_limit|max_execution_time)" /etc/php/*/fpm/php.ini

# NPG가 host 네트워크 모드인지
docker inspect npg-proxy --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'

결과에서 챙길 것은 다섯 가지다.

확인할 것왜 필요한가
사이트마다 root 경로컨테이너에 그대로 연결할 폴더
PHP-FPM 버전과 php.ini 변경값같은 버전의 이미지 태그, 같은 업로드·메모리 제한
DB_HOST 값외부 DB인지, 서버 로컬 DB인지에 따라 연결 방법이 다름
object-cache.php, WP_REDISRedis를 쓰면 컨테이너용 Redis가 필요
NPG 네트워크 모드host 모드면 컨테이너 이름으로 연결할 수 없음

nginx -T 결과에 proxy_pass만 있는 사이트는 nginx가 다른 앱으로 넘겨주기만 하는 곳이다. 이런 사이트는 NPG에서 그 앱 포트로 바로 연결하면 된다. alias로 서버 폴더의 파일을 직접 내보내는 사이트는 따로 처리해야 하는데, 7장에서 다룬다.

2. WordPress 컨테이너 만들기

핵심은 WordPress 파일을 복사하지 않고 기존 서버 폴더를 그대로 컨테이너에 연결하는 것이다. 파일이 한 벌뿐이라 1GB가 넘는 사이트도 바로 옮길 수 있고, 문제가 생기면 NPG의 대상 포트만 되돌리면 원래대로 돌아간다.

이미지는 공식 wordpress:phpX.Y-apache를 쓴다. Apache와 PHP가 함께 들어 있어서 nginx + PHP-FPM 조합을 컨테이너 하나로 대신한다. PHP 버전은 서버의 PHP-FPM과 맞춘다.

# ~/wordpress-docker/docker-compose.yml
x-wordpress: &wordpress
  image: wordpress:php8.5-apache
  restart: unless-stopped
  labels:
    com.centurylinklabs.watchtower.enable: "false"
  environment:
    TZ: Asia/Seoul

services:
  site-a:
    <<: *wordpress
    ports:
      - "127.0.0.1:8091:80"
    volumes:
      - /var/www/site-a:/var/www/html
      - ./php.ini:/usr/local/etc/php/conf.d/custom.ini:ro
      - /etc/localtime:/etc/localtime:ro

  site-b:
    <<: *wordpress
    ports:
      - "127.0.0.1:8092:80"
    volumes:
      - /var/www/site-b:/var/www/html
      - ./php.ini:/usr/local/etc/php/conf.d/custom.ini:ro
      - /etc/localtime:/etc/localtime:ro
# ~/wordpress-docker/php.ini  (서버 php.ini에서 바꿔 둔 값을 그대로)
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 256M
max_execution_time = 300

설정마다 이유가 있다.

  • 127.0.0.1:포트로만 연다. NPG가 host 네트워크 모드면 Docker 네트워크의 컨테이너 이름을 찾지 못한다. 서버의 127.0.0.1에만 포트를 열면 NPG는 연결할 수 있고, 바깥에서는 이 포트로 직접 들어올 수 없다.
  • 포트는 서버 nginx와 겹치지 않게 고른다. 기존 nginx가 8081, 8082를 쓰고 있다면 8091부터 쓴다. sudo ss -lntp | grep -E ':(8091|8092) '로 비어 있는지 먼저 확인한다.
  • 파일 소유자는 그대로 둔다. Debian 계열 서버의 www-data와 공식 이미지의 www-data는 둘 다 UID 33이라 권한을 바꿀 필요가 없다.
  • watchtower 제외 라벨은 선택이다. 자동 업데이트를 쓰고 있다면, PHP와 Apache가 언제 바뀔지 직접 정하고 싶을 때 붙인다. 이 경우 docker compose pull && docker compose up -d로 직접 업데이트한다.
  • **TZ와 /etc/localtime**은 로그 시간을 한국 시간으로 맞춘다. WordPress의 글 작성 시간에는 영향이 없다.

3. 꼭 넣어야 할 두 가지

컨테이너만 띄우면 메인 화면은 열리지만, 이 두 가지를 빼먹으면 전환하는 순간 사이트가 깨진다.

HTTPS 인식 코드

SSL은 NPG에서 끝나고, NPG는 컨테이너로 HTTP로 넘긴다. WordPress는 자신이 HTTP로 접속받았다고 보고 HTTPS 주소로 다시 보내는데, 이 요청이 다시 HTTP로 들어오면서 리다이렉트가 무한 반복된다. NPG가 붙여 주는 X-Forwarded-Proto 헤더를 보고 HTTPS로 인식하게 하면 된다.

if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

wp-config.php의 /* That's all, stop editing! Happy publishing. */ 줄 바로 위에 넣는다. 헤더가 있을 때만 동작하므로 기존 nginx로 서비스 중일 때 미리 넣어도 안전하다. 손으로 편집하기 번거롭다면 넣을 내용을 파일에 써 두고 한 군데에만 붙인다.

cat > /tmp/wp-block.php <<'EOF'
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

EOF

WP=/var/www/site-a
sudo cp -p $WP/wp-config.php $WP/wp-config.php.bak
sudo -u www-data sed -i '/Happy publishing/{e cat /tmp/wp-block.php
}' $WP/wp-config.php
grep -c HTTP_X_FORWARDED_PROTO $WP/wp-config.php   # 1이면 한 번만 들어간 것
ls -l $WP/wp-config.php                            # 소유자가 www-data인지

sudo -u www-data로 실행하는 데는 이유가 있다. sudo sed -i는 파일을 새로 만들어 바꿔치기해서 소유자가 root로 바뀐다. 그러면 WP Rocket 같은 플러그인이 wp-config.php를 고치지 못한다.

.htaccess

서버 nginx는 글 주소(퍼머링크)를 nginx 설정으로 처리했지만, Apache는 .htaccess로 처리한다. nginx만 쓰던 사이트에는 이 파일이 없는 경우가 대부분이고, 없으면 메인만 열리고 글 페이지는 전부 404가 난다.

sudo -u www-data tee /var/www/site-a/.htaccess > /dev/null <<'EOF'
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
EOF

서버 nginx는 .htaccess를 읽지 않으므로 이 파일도 미리 만들어 둬도 된다. 방문자 실제 IP는 따로 설정할 필요가 없다. 공식 WordPress 이미지에 사설 IP에서 온 X-Forwarded-For를 믿는 설정이 기본으로 들어 있다.

4. 테스트, 전환, 되돌리기

NPG를 바꾸기 전에 컨테이너에 직접 요청을 보내서 확인한다. 이 단계까지는 방문자가 계속 기존 nginx로 접속하므로 사이트에 영향이 없다.

cd ~/wordpress-docker && docker compose up -d site-a

H=example.com; P=8091
for PATH_ in / /feed/ /sitemap_index.xml /wp-json/ /wp-admin/; do
  printf "%-20s " "$PATH_"
  curl -s -o /dev/null -w "%{http_code}\n" -H "Host: $H" -H "X-Forwarded-Proto: https" http://127.0.0.1:$P$PATH_
done
주소정상 응답다르게 나오면
/200301이면 HTTPS 인식 코드가 빠짐
/feed/, /sitemap_index.xml200404면 .htaccess가 없음
/wp-json/200Elementor, 폼 플러그인이 쓰는 주소
/wp-admin/302로그인 화면으로 보내는 정상 동작

기존 nginx와 결과가 다를 때는 응답 헤더를 나란히 비교한다. 예를 들어 /feed/가 301로 나왔는데 기존 nginx도 같은 주소로 보낸다면 사이트의 원래 퍼머링크 설정이다.

curl -sI -H "Host: $H" -H "X-Forwarded-Proto: https" http://127.0.0.1:$P/feed/ | grep -iE "^(HTTP|location)"
curl -sI -H "Host: $H" -H "X-Forwarded-Proto: https" http://127.0.0.1:8081/feed/ | grep -iE "^(HTTP|location)"

전환은 NPG 화면에서 해당 프록시 호스트의 대상 포트만 바꾸면 끝난다. 대상 호스트는 127.0.0.1, 스킴은 http 그대로다. www 주소가 별도 프록시 호스트라면 함께 바꾼다.

전환됐는지는 로그로 확인한다. 이 명령을 켜 둔 상태에서 시크릿 창으로 접속했을 때 기록이 올라오고, 맨 앞에 실제 방문자 IP가 찍히면 성공이다.

docker compose logs -f --tail 0 site-a

되돌리기는 NPG의 대상 포트를 원래 값(예: 8081)으로 돌리는 것으로 끝난다. 파일을 공유하고 있어서 데이터 차이가 생기지 않는다. 서버 nginx는 모든 사이트를 옮기고 며칠 지켜본 뒤에 끈다.

전환 후에는 브라우저에서 메인, 글 몇 개, 이미지, 관리자 로그인을 확인하고, 캐시 플러그인이 있다면 관리자에서 설정을 한 번 저장한다. WP Rocket이나 W3 Total Cache는 이때 Apache용 규칙을 .htaccess에 새로 쓴다.

5. Redis 객체 캐시를 쓰는 사이트

wp-content/object-cache.php가 있고 wp-config.php에 Redis 주소가 없다면, 플러그인이 기본값인 127.0.0.1:6379로 서버 Redis에 붙고 있는 것이다. 컨테이너 안의 127.0.0.1은 컨테이너 자신이라 서버 Redis에 닿지 않는다. 같은 compose에 Redis 컨테이너를 하나 추가하는 게 가장 간단하다.

  site-b:
    <<: *wordpress
    environment:
      TZ: Asia/Seoul
      WP_REDIS_HOST: redis
    depends_on:
      - redis
    # ports, volumes 생략

  redis:
    image: redis:7-alpine
    restart: unless-stopped

wp-config.php에는 환경 변수가 있으면 그걸, 없으면 기존 값을 쓰도록 넣는다. 이렇게 하면 미리 넣어 둬도 기존 nginx 쪽 사이트가 깨지지 않고, NPG 포트만 바꾸면 전환이 끝난다.

define('WP_REDIS_HOST', getenv('WP_REDIS_HOST') ?: '127.0.0.1');
define('WP_REDIS_PREFIX', 'site_b_');
  • WP_REDIS_PREFIX는 사이트마다 다르게 붙인다. 여러 사이트가 Redis 하나를 같이 쓰면 캐시가 섞일 수 있다.
  • 컨테이너로 붙었는지는 키로 확인한다. docker compose exec redis redis-cli --scan --pattern 'site_b_*' | head -3에 키가 보이면 된다.
  • 환경 변수는 x-wordpress 공통 부분과 합쳐지지 않는다. 서비스에 environment를 따로 쓰면 공통 부분의 environment를 통째로 덮어쓴다. TZ처럼 공통으로 쓰던 값은 서비스 쪽에도 다시 적는다.
  • 되돌릴 때는 서버 Redis에 전환 전의 오래된 캐시가 남아 있을 수 있다. redis-cli --scan --pattern 'site_b_*' | xargs -r redis-cli del로 그 사이트 캐시만 비운다.

서버 Redis를 끄기 전에는 다른 프로그램이 쓰고 있지 않은지 꼭 본다. sudo ss -tnp 'dport = :6379'에 celery 같은 이름이 나오면 그 앱이 작업 대기열로 쓰고 있는 것이다.

6. DB 처리

DB_HOST 값에 따라 세 가지 경우로 나뉜다. 어느 쪽이든 DB 데이터 자체는 옮기지 않아도 컨테이너 전환은 할 수 있다.

DB_HOST방법바꿀 것
다른 서버 IP그대로 사용없음. 컨테이너에서 나가는 연결은 서버 IP로 바뀌어 나가서 DB 권한도 그대로
localhost (서버에 MariaDB)소켓 폴더를 컨테이너에 연결compose 볼륨 1줄, php.ini 2줄
localhost, DB도 정리하고 싶음DB까지 컨테이너로 이전덤프·가져오기, DB_HOST를 db로

외부 DB 서버

따로 할 일이 없다. 다만 DB 서버가 공인 IP로 열려 있다면 DB 서버 방화벽에서 웹서버 IP만 허용하는지 확인한다. 같은 클라우드 안이라면 사설 IP로 연결하는 편이 안전하다.

서버 로컬 MariaDB를 그대로 쓸 때

DB_HOST가 localhost면 PHP는 TCP가 아니라 소켓 파일로 접속한다. 소켓 폴더를 같은 경로로 연결하고, PHP에 소켓 위치를 알려주면 wp-config.php와 DB 계정을 하나도 안 바꾸고 붙는다.

    volumes:
      - /run/mysqld:/run/mysqld
mysqli.default_socket = /run/mysqld/mysqld.sock
pdo_mysql.default_socket = /run/mysqld/mysqld.sock

DB까지 컨테이너로 옮길 때

서버 MariaDB를 WordPress만 쓰고 있다면 DB도 같은 compose에 넣을 수 있다. DB 포트를 밖으로 열지 않아도 되고, 서버의 MariaDB 패키지를 통째로 지울 수 있다. 다른 앱이 같은 DB를 쓰는지 먼저 확인한다. sudo mariadb -e "SHOW DATABASES;"와 각 앱의 DB 설정을 본다.

  db:
    image: mariadb:11.4          # 서버와 같은 버전으로 고정
    restart: unless-stopped
    labels:
      com.centurylinklabs.watchtower.enable: "false"   # 큰 버전 자동 업그레이드 방지
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}  # .env에 무작위 값
    volumes:
      - ./db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      retries: 10

WordPress 쪽에는 depends_on: { db: { condition: service_healthy } }를 넣어 DB가 준비된 뒤에 켜지게 한다. 순서는 다음과 같다.

  1. 안전 백업을 받는다: sudo mariadb-dump --single-transaction --routines --triggers --databases db1 db2 | gzip > before.sql.gz
  2. root 비밀번호를 만든다: umask 077; echo "MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)" > .env
  3. DB 컨테이너만 띄우고 healthy가 될 때까지 기다린다.
  4. WordPress 컨테이너를 멈춘다. 여기서부터 몇 분간 사이트가 멈춘다. 그 사이에 쓰인 댓글이 빠지지 않게 하려는 것이다.
  5. 덤프를 다시 받고, DB 계정은 비밀번호 대신 저장된 해시로 꺼낸다. 컨테이너끼리는 localhost가 아니므로 접속 위치를 %로 바꾼다.sudo mariadb -N -r -e "SHOW CREATE USER 'wpuser'@'localhost'; SHOW GRANTS FOR 'wpuser'@'localhost';" \ | sed 's/`localhost`/`%`/g; s/$/;/' > users.sql
  6. 두 파일을 컨테이너에 넣고, 양쪽의 테이블 수가 같은지 비교한다.docker compose exec -T db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"' < migrate.sql docker compose exec -T db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"' < users.sql
  7. wp-config.php의 DB_HOST를 localhost에서 db로 바꾸고 WordPress 컨테이너를 다시 만든다.
  8. 서버 MariaDB를 꺼 보고도 사이트가 열리는지 확인한다. 페이지 캐시가 있으면 메인은 DB 없이도 열리므로 /wp-login.php나 /?nocache=1로 확인한다.
  9. users.sql은 지운다. 계정 해시가 들어 있다.

%로 바꿔도 DB 포트가 밖으로 열려 있지 않아서 외부에서는 접속할 수 없다. 컨테이너 DB에 접속할 때는 docker compose exec db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"'를 쓴다.

7. WordPress가 아닌 사이트

서버 nginx가 WordPress 말고도 일을 하고 있었다면, 그 사이트들을 먼저 다른 곳으로 옮겨야 nginx를 끌 수 있다. nginx -T 결과를 보면 두 종류로 나뉜다.

다른 앱으로 넘기기만 하는 사이트

location /에 proxy_pass http://127.0.0.1:포트만 있는 경우다. n8n, 자체 Python 앱 등이 여기에 속한다. NPG의 대상을 그 앱 포트로 바로 바꾸면 된다.

  • nginx에만 있던 설정을 옮긴다. proxy_read_timeout 300s 같은 긴 대기 시간, 업로드 크기 제한은 NPG의 해당 프록시 호스트에도 넣어야 한다. 화면에 항목이 없으면 사용자 정의 Nginx 설정 칸에 넣는다.
  • WebSocket이 필요한 앱(n8n 편집 화면 등)은 NPG에서 WebSocket 지원을 켠다.
  • 앱 포트가 0.0.0.0으로 열려 있지 않은지 본다. Docker의 "5678:5678"은 인터넷 전체에 연다. "127.0.0.1:5678:5678"로 바꿔야 NPG의 WAF와 봇 차단을 거친다.
  • 헤더를 옮길지는 실제 응답으로 판단한다. nginx는 location 안에 add_header가 하나라도 있으면 바깥 server의 add_header를 무시한다. 설정 파일에 적혀 있어도 실제로는 안 나가는 헤더가 있다. curl -sI https://도메인/과 curl -sI http://127.0.0.1:앱포트/를 비교해서 실제로 나가던 헤더만 옮긴다.

서버 폴더의 파일을 직접 내보내는 사이트

Django처럼 앱은 뒤에서 돌고, /static/이나 /media/는 nginx가 alias로 서버 폴더에서 직접 내보내는 경우다. NPG는 컨테이너 안에 있어서 서버 폴더를 볼 수 없다. 앱이 직접 내보내게 하면 큰 영상이나 HLS 스트리밍을 감당하기 어렵다.

이럴 때는 그 사이트 부분만 떼어낸 작은 nginx 컨테이너를 만든다. 기존 설정을 거의 그대로 가져가니 동작이 달라지지 않는다.

# ~/app-nginx/docker-compose.yml
services:
  app-nginx:
    image: nginx:stable-alpine
    restart: unless-stopped
    network_mode: host                # 서버의 127.0.0.1:앱포트에 바로 붙기 위해
    volumes:
      - ./default.conf:/etc/nginx/conf.d/default.conf:ro   # 기본 80 포트 설정을 반드시 덮어쓰기
      - ./cloudflare-realip.conf:/etc/nginx/cloudflare-realip.conf:ro
      - /home/user/app/static:/home/user/app/static:ro     # 서버와 같은 경로로 연결 → alias 그대로
      - /home/user/app/media:/home/user/app/media:ro
# ~/app-nginx/default.conf (핵심만)
map $http_x_forwarded_proto $real_scheme {
    default $http_x_forwarded_proto;
    ""      $scheme;
}
set_real_ip_from 127.0.0.1;
include /etc/nginx/cloudflare-realip.conf;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

server {
    listen 127.0.0.1:8093;
    server_name app.example.com;
    client_max_body_size 3G;

    location /static/ { alias /home/user/app/static/; expires 30d; }
    location /media/  { alias /home/user/app/media/; }

    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $real_scheme;
        proxy_pass http://127.0.0.1:8180;
        proxy_request_buffering off;
        proxy_read_timeout 1000s;
    }
}
  • host 네트워크 모드에서는 이미지의 기본 설정이 80 포트를 잡는다. default.conf를 덮어쓰지 않으면 NPG와 충돌한다.
  • $real_scheme 같은 변수가 서버 nginx의 다른 설정 파일에서 정의돼 있었다면 컨테이너 설정에도 직접 넣는다.
  • 서버 nginx 폴더의 파일을 빌려 쓰지 않는다. Cloudflare IP 목록처럼 필요한 파일은 컨테이너 폴더로 복사해 둔다. 나중에 nginx 패키지를 지우면 그 파일도 같이 사라진다.
  • 먼저 docker compose run --rm app-nginx nginx -t로 설정을 검사하고, 같은 경로를 새 포트와 기존 nginx 포트에 보내 응답 코드가 같은지 비교한 뒤 NPG를 바꾼다.
  • NPG에서도 큰 업로드를 받을 수 있게 최대 요청 크기, 타임아웃, 요청 버퍼링 끄기를 맞춘다. NPG에서 막히면 뒤의 설정은 소용없다.

8. 실제로 겪은 함정들

대부분 옮기기 전부터 있던 문제가 컨테이너로 바꾸면서 드러난 경우였다.

증상원인해결
모든 페이지 맨 앞에 /* That's all, stop editing! */ 글자가 출력됨wp-config.php에서 <?php 앞에 줄이 잘못 들어가 있었음. 서버 PHP 설정 덕에 티가 안 났을 뿐<?php 앞 줄 삭제. Docker 이미지는 출력 버퍼링이 꺼져 있어 로그인·쿠키가 깨질 수 있음
“That’s all” 기준으로 코드를 넣었더니 두 군데 들어감위와 같은 줄이 파일 맨 위에 하나 더 있었음Happy publishing처럼 유일한 문구를 기준으로 넣고, grep -c로 1인지 확인
WP Rocket이 “wp-config.php에 쓸 수 없음” 경고sudo sed -i로 고친 뒤 소유자가 root로 바뀜sudo chown www-data:www-data wp-config.php. 처음부터 sudo -u www-data sed -i로
All-in-One WP Migration이 백업 폴더를 못 만듦서버 시절 절대 경로(/var/www/사이트/...)가 DB 설정에 저장돼 있었음. 컨테이너 안에서는 /var/www/htmlWP-CLI로 옵션 값 변경. 0777 권한은 필요 없음
메인만 열리고 글은 404.htaccess 없음3장의 기본 규칙 생성
브라우저가 도메인:8081로 이동예전 리다이렉트를 브라우저가 기억시크릿 창에서 주소를 직접 입력해 확인
캐시 플러그인 두 개가 흔적을 남김이전 캐시 플러그인의 object-cache.php, 캐시 폴더가 남음플러그인 삭제 후 드롭인과 그 플러그인 폴더만 정리. WP_CACHE 줄은 새 플러그인도 쓰므로 유지
서비스 중지 후 백업이 실패옛 백업 스크립트에 DB root 비밀번호가 평문으로 있었고, 실행 권한(x)도 없어서 2년간 실행되지 않았음백업 폴더 날짜부터 확인. 새 스크립트는 비밀번호 없이 컨테이너나 WP-CLI로 덤프

옛 경로가 DB에 남아 있는지는 한 번에 찾을 수 있다. WP-CLI 컨테이너는 서버에 PHP가 없어도 쓸 수 있다.

wpcli() { docker run --rm --user 33:33 --network wordpress-docker_default -e WP_REDIS_HOST=redis -v "$1":/var/www/html wordpress:cli wp "${@:2}"; }

wpcli /var/www/site-a db query "SELECT option_name, LEFT(option_value, 120) FROM wp_options WHERE option_value LIKE '%/var/www/%';"
wpcli /var/www/site-a option update ai1wm_backups_path /var/www/html/wp-content/ai1wm-backups

결과 중 recently_edited(테마 편집 기록)나 오류 로그는 그냥 둬도 되고, _transient_로 시작하는 임시 캐시는 지우면 새로 만들어진다. upload_path가 나오면 이미지 업로드 경로가 고정된 것이니 꼭 고친다.

9. 속도는 느려질까

느려지지 않았다. Docker 컨테이너는 가상머신이 아니라 서버 CPU를 그대로 쓰는 칸막이라 PHP 실행 속도가 같다. 속도를 실제로 가른 것은 페이지 캐시였다.

측정은 두 경로로 나눠서 한다. 컨테이너에 직접 보낸 값은 순수한 PHP 처리 시간이고, 실제 주소로 보낸 값과의 차이는 Cloudflare, HTTPS, NPG를 오가는 시간이다.

H=example.com; P=8091

# 컨테이너 직접 = PHP 처리 시간
for i in 1 2 3; do
  curl -s -o /dev/null -w "%{time_starttransfer}s\n" -H "Host: $H" -H "X-Forwarded-Proto: https" http://127.0.0.1:$P/
done

# 실제 경로 (Cloudflare 거쳐서)
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "첫 응답 %{time_starttransfer}s / 전체 %{time_total}s\n" https://$H/
done

# 어디서 걸리는지: Cloudflare 없이 NPG에 직접
curl -s -o /dev/null --resolve $H:443:127.0.0.1 \
  -w "연결 %{time_connect} / HTTPS %{time_appconnect} / 첫 응답 %{time_starttransfer}\n" https://$H/

같은 서버(ARM 2코어)의 WordPress 세 개를 컨테이너로 옮긴 직후와, 페이지 캐시(WP Rocket)를 붙인 뒤의 첫 응답 시간이다.

사이트컨테이너 직접, 캐시 전 (초)컨테이너 직접, 캐시 후 (초)실제 경로, 캐시 전 (초)실제 경로, 캐시 후 (초)
가벼운 블로그 (원래 캐시 사용)0.0020.0020.180.18
일반 블로그0.120.002~0.0110.300.16
Elementor + 게시판 사이트0.260.002~0.0120.430.17
  • 세 사이트 모두 실제 경로가 0.17초 정도 더 걸렸다. 사이트와 상관없이 일정한 이 시간은 서버에서 Cloudflare를 오가는 시간이었다. Cloudflare 없이 NPG에 직접 붙였을 때 WAF 검사를 포함한 NPG 처리 시간은 0.002초였다.
  • 다른 서버에서는 같은 측정이 0.07~0.08초로 나왔다. 서버 처리 속도는 같았으니, Cloudflare까지 가는 길의 차이다.
  • 서버에서 잰 값은 매번 DNS 조회와 HTTPS 연결을 새로 해서 실제 방문자 체감보다 느리게 나온다.
  • 캐시 플러그인은 로그인한 관리자에게는 캐시를 안 쓰는 게 기본이라, 브라우저 체감은 시크릿 창으로 본다.
  • 게시판(KBoard 등)이 있는 사이트는 새 글이 목록에 바로 반영되도록 게시판 페이지를 캐시 제외 URL에 넣는다.
awpu01

10. 서버 정리와 보안 점검

모든 사이트를 옮겼으면 서버 프로그램을 중지 → 며칠 관찰 → 자동 시작 해제 → 삭제 순서로 정리한다. 각 단계마다 “정말 아무도 안 쓰는지”를 확인하는 게 핵심이다.

끄기 전 확인

# 서버 nginx로 아직 들어오는 요청이 있는지 (테스트용 127.0.0.1 말고)
sudo tail -n 20 /var/log/nginx/access.log | awk '{print $1, $4, $7}'

# 서버 Redis, PHP를 쓰는 다른 프로그램이나 예약 작업
sudo ss -tnp 'dport = :6379'
sudo crontab -l; crontab -l; ls /etc/cron.d/

# systemd로 끌 대상과 NPG의 nginx 구분 (host 모드 NPG도 ss에는 "nginx"로 보임)
systemctl show nginx -p MainPID
sudo ss -lntp | grep nginx

# NPG가 인증서를 직접 관리하는지 (Certbot 날짜와 다르면 Certbot 불필요)
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate
sudo certbot certificates

host 모드의 NPG 컨테이너 안 nginx도 서버에서는 그냥 nginx로 보인다. 프로세스 번호가 systemctl show nginx의 MainPID와 같은 묶음인지 보고, systemctl stop nginx가 서버 nginx만 끄는지 먼저 확인한다.

중지, 해제, 삭제

sudo systemctl stop nginx php8.5-fpm          # 1. 중지 (재부팅하면 다시 켜짐)
sudo systemctl disable nginx php8.5-fpm       # 2. 며칠 뒤 자동 시작 해제

# 3. 지워질 목록을 먼저 보고 (docker, python3, ffmpeg 등이 있으면 중단)
sudo apt purge -s 'php*' 'nginx*' 'libnginx-mod-*' certbot 'python3-certbot*' | grep "^Purg"
sudo apt purge -y 'php*' 'nginx*' 'libnginx-mod-*' certbot 'python3-certbot*'
sudo apt autoremove -y
  • MariaDB를 컨테이너로 옮겼다면 'mariadb-*' 'mysql-*' 'galera-*'도 함께 지운다. phpMyAdmin은 MariaDB를 끈 상태에서 지우면 DB 삭제 오류가 뜨는데, ignore를 고르고 나중에 정리하면 된다.
  • 지운 뒤 command -v php가 여전히 경로를 보여주면 터미널이 예전 위치를 기억하는 것이다. hash -r 후 다시 확인한다.
  • 예약 작업의 Certbot 갱신 줄도 지운다. 오라클 무료 인스턴스에서 흔히 쓰는 stress-ng 줄(유휴 회수 방지)은 남긴다.
  • 서버에 직접 띄운 앱(gunicorn, celery 등)은 --daemon이나 그냥 실행된 상태면 재부팅 후 안 켜진다. systemd 서비스로 등록해 둔다.

보안 점검 목록

옮기는 과정에서 기존 구성의 보안 문제가 꽤 드러났다. 같은 구성이라면 한 번씩 확인해 볼 만하다.

  • sudo ss -lntp에서 0.0.0.0으로 열린 DB(3306), 앱 포트가 없는지
  • DB 계정의 접속 위치가 %(어디서든)로 열려 있지 않은지
  • 스크립트에 DB root 비밀번호가 평문으로 있지 않은지. 서버 MariaDB를 계속 쓴다면 root를 unix_socket 방식으로 바꿔 sudo mariadb로만 접속
  • wp-config.php 권한이 640 이하인지 (666인 경우도 있었다)
  • NPG에 SSL이 없는 도메인(Cloudflare Flexible)이 있는지. 인증서를 발급하고 Cloudflare를 Full (strict)로
  • 백업 스크립트가 실제로 돌고 있는지. 백업 폴더의 날짜로 확인
  • 자동 업데이트(watchtower) 대상에 DB가 들어 있지 않은지. 큰 버전으로 올라가면 데이터가 안 열릴 수 있다

마치며

정리하고 나니 서버에 직접 설치된 웹 프로그램은 거의 남지 않았고, 사이트마다 docker-compose.yml 한 파일로 구성이 다 보이게 됐다. 핵심은 세 가지였다.

  1. 파일은 그대로 두고 실행 환경만 바꾼다. 기존 폴더를 연결하면 NPG 포트 하나로 전환과 복구가 끝난다.
  2. HTTPS 인식 코드와 .htaccess를 먼저 넣는다. 둘 다 기존 사이트에 영향이 없어서 미리 넣어 둘 수 있다.
  3. 지우기 전에 누가 쓰는지 확인한다. 서버 Redis를 다른 앱이 쓰고 있거나, 컨테이너가 서버 nginx 폴더의 파일을 빌려 쓰는 경우가 실제로 있었다.

한 번에 모든 사이트를 바꾸기보다, 가장 작은 사이트 하나로 전 과정을 끝까지 해 보고 나머지를 같은 순서로 옮기는 편이 안전했다.

설치하고 얼마 되지도 않았는데 벌써 차단이 600개나 된다. 해킹 진짜 많이 들어온다 ㅠ

awpu04

  이 사이트는 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Leave a Comment