맥미니 재부팅 후 502 — 범인은 시작 순서였다

어제 맥미니를 재부팅했습니다. 평범한 업데이트였습니다.

재부팅 후 블로그에 접속해보니 502 Bad Gateway.

“금방 뜨겠지” 하고 기다렸는데, 안 뜹니다. docker ps를 확인하니 컨테이너들은 다 healthy입니다. 그런데 사이트는 죽어있습니다.

이 글은 원인이 하나가 아니었던 502 사태와, 고치고 나서야 “아 이런 거였구나” 했던 순간들의 기록입니다.


아키텍처 전체 그림

먼저 yndl.dev의 요청 흐름입니다.

인터넷 사용자
  └─ Cloudflare CDN
       └─ Cloudflare Tunnel (cloudflare-tunnel 컨테이너)
            └─ http://caddy:80 (Caddy 리버스 프록시)
                 └─ http://wordpress:80 (WordPress 컨테이너)
                      ├─ MariaDB
                      └─ Redis

포트를 외부에 노출하지 않는 구조입니다. cloudflared가 Cloudflare 서버와 아웃바운드 터널을 만들고, 외부 트래픽이 이 터널을 통해 들어옵니다. Caddy는 HTTP만 서빙하고, SSL은 Cloudflare가 처리합니다.

이 체인에서 어느 한 고리라도 끊어지면 사용자에게는 502입니다.

그리고 오늘 세 고리가 동시에 문제였습니다.


원인 1: PHP JIT가 Apache를 죽이고 있었다

증상

WordPress 컨테이너가 (unhealthy) 상태입니다. 로그를 보니:

$ docker logs yndldev-wordpress-1 2>&1 | grep -i fault
[core:notice] AH00052: child pid 1514 exit signal Segmentation fault (11)
[core:notice] AH00052: child pid 1515 exit signal Segmentation fault (11)
[core:notice] AH00052: child pid 1516 exit signal Segmentation fault (11)

Apache worker 프로세스가 계속 죽고 있습니다. 무한 반복으로.

원인

성능 최적화를 위해 config/php/opcache.ini에 PHP JIT를 켜뒀습니다.

opcache.jit=1255
opcache.jit_buffer_size=64M

PHP 8.x + Apache + WordPress 조합에서 JIT가 특정 워크로드에서 Segmentation Fault를 발생시키는 버그가 있습니다. 평소에는 괜찮다가 재부팅 후 첫 요청들이 몰릴 때 터지는 패턴입니다.

해결

; opcache.jit=1255        ← 주석 처리
; opcache.jit_buffer_size=64M
opcache.jit=0             ← 비활성화

교훈: JIT는 PHP 8 공식 기능이지만, WordPress + Apache 환경에서는 아직 불안정합니다. 실제로 속도 차이도 WordPress에서는 JIT보다 OPcache만으로도 충분합니다. JIT는 CLI 스크립트나 연산 집약적인 코드에서 효과가 큽니다.


원인 2: Healthcheck가 항상 실패하고 있었다

증상

JIT를 끄고 컨테이너를 재시작했습니다. Segfault는 멈췄는데, 컨테이너가 여전히 (unhealthy)입니다.

$ docker inspect yndldev-wordpress-1 \
  --format='{{json .State.Health.Log}}' | python3 -m json.tool
{
  "ExitCode": 22,
  "Output": "curl: (22) The requested URL returned error: 400"
}

400 Bad Request. WordPress 자체는 살아있는데 healthcheck가 실패하고 있습니다.

원인

기존 healthcheck 설정:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:80/wp-admin/admin-ajax.php"]

/wp-admin/admin-ajax.php에 GET으로 접근하면 400을 반환합니다. 파라미터(action=...)가 없으면 유효하지 않은 요청이기 때문입니다. WordPress는 정상이지만 Docker는 계속 unhealthy로 판정하고, Caddy는 unhealthy로 표시된 upstream에 계속 트래픽을 보내다가 502를 반환합니다.

해결

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:80/"]

루트 경로(/)는 WordPress가 정상이면 200을 반환합니다.

한 가지 더: docker-compose restart로는 healthcheck 설정 변경이 적용되지 않습니다. 컨테이너를 재생성해야 합니다.

# ❌ 이렇게 하면 healthcheck 설정이 안 바뀜
docker-compose restart wordpress

# ✅ 이렇게 해야 적용됨
docker-compose up -d wordpress

원인 3: Cloudflare Tunnel이 너무 일찍 시작됐다

증상

JIT 끄고, healthcheck 고치고, 컨테이너들 다 healthy. 근데 여전히 외부에서 502입니다.

내부에서 직접 테스트:

$ curl -sI -H "Host: yndl.dev" http://localhost/
HTTP/1.1 200 OK
# 정상!

Caddy → WordPress 체인은 완벽합니다. 그런데 외부에서는 502.

Caddy 로그를 봅니다.

$ docker logs --since 5m caddy | grep -v "docker-proxy\|New Caddyfile\|New Config"
# (아무것도 없음)

외부 요청이 Caddy까지 도달하지 않고 있습니다. 문제는 Caddy 앞단, 즉 Cloudflare Tunnel입니다.

원인

재부팅 시 컨테이너 시작 순서 문제입니다.

재부팅
  ↓
Docker 컨테이너들 시작 (depends_on에 따라)
  ↓
cloudflare-tunnel 컨테이너 시작  ← 이 시점에 Caddy가 완전히 준비되지 않음
  ↓
cloudflared가 Cloudflare 서버와 터널 연결 수립
  ↓
origin: http://caddy:80 으로 설정됨
  ↓
이때 Caddy가 yndl.dev 컨테이너를 아직 감지 못한 상태
  ↓
터널이 연결된 채로 "오래된 caddy 상태"를 바라봄
  ↓
외부 요청 → tunnel → caddy → 404 or 연결 실패 → Cloudflare 502

핵심은 터널이 한번 연결되면 자동으로 재연결하지 않는다는 점입니다. Caddy가 나중에 정상화되어도, 터널은 이미 수립된 연결을 유지합니다. 자연 복구가 안 됩니다.

# 확인: tunnel을 재시작하면 즉시 해결됨
$ docker restart cloudflare-tunnel
# 5초 후 사이트 접속 → 정상

역시 tunnel 문제가 맞았습니다.

해결

cloudflare-tunnel의 depends_on에 caddy를 추가합니다.

# /Users/yoon/Projects/docker-compose.yml
services:
  tunnel:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel run
    depends_on:
      - caddy        # ← 추가: Caddy가 먼저 시작된 후에 tunnel 시작
    environment:
      - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}

이렇게 하면 재부팅 시 Caddy가 먼저 시작되고, Caddy가 준비된 후에 cloudflared가 터널을 수립합니다.


발견한 부작용: Caddy active health check는 쓰면 안 된다

이번에 Caddy에서 자체적으로 WordPress 헬스체크를 하도록 설정해볼까 했습니다.

# ❌ 이거 하면 안 됨
caddy.reverse_proxy.health_uri: "/"
caddy.reverse_proxy.health_interval: "10s"

WordPress 내부에서 HTTP로 /에 접근하면 FORCE_SSL_ADMIN이 활성화되어 있기 때문에 301 HTTPS 리다이렉트가 반환됩니다. Caddy active health check는 이 301을 “unhealthy”로 판정합니다. 그러면 Caddy가 upstream 전체를 차단해버립니다. 사이트 완전 차단.

Docker healthcheck만으로 충분합니다. Caddy의 active health check는 이 환경에서는 쓰지 않는 게 낫습니다.


디버깅 체크리스트

같은 상황이 또 생기면 이 순서로 확인합니다.

# 1단계: 컨테이너 상태
docker ps | grep -E "wordpress|caddy|tunnel"

# 2단계: WordPress segfault 확인
docker logs yndldev-wordpress-1 2>&1 | grep -i "fault\|error"

# 3단계: Healthcheck 상세 확인
docker inspect yndldev-wordpress-1 \
  --format='{{json .State.Health.Log}}' | python3 -m json.tool

# 4단계: Caddy에 요청이 도달하는지 확인
docker logs --since 2m caddy | grep -v "docker-proxy\|New Caddyfile\|New Config"
# → 로그가 없으면 tunnel 문제

# 5단계: Caddy → WordPress 직접 테스트
curl -sI -H "Host: yndl.dev" http://localhost/
# → 200이면 Caddy/WordPress는 정상

# 6단계: 터널 재시작으로 빠른 복구
docker restart cloudflare-tunnel

세 가지 에러가 동시에 터진 이유

재부팅이라는 하나의 이벤트가 세 가지 에러를 동시에 드러냈습니다.

원인 평소에 안 보인 이유 재부팅 후 드러난 이유
PHP JIT segfault 프로세스가 장시간 안정적으로 동작 재시작 후 JIT 재컴파일 중 충돌
Healthcheck 400 (unhealthy) 상태여도 Caddy는 요청 전달 여러 문제 겹치며 체인 전체 불안정
Tunnel 시작 순서 처음 세팅 후 재부팅 없이 운영 재부팅이 시작 순서 의존성을 드러냄

각각 독립적인 버그지만, 평소에는 서로 감추고 있던 겁니다. 재부팅이라는 “모두 초기화” 이벤트가 한꺼번에 노출시켰습니다.


결론: 인프라는 재부팅해봐야 안다

서비스를 처음 세팅할 때는 “잘 됩니다”로 끝납니다. 하지만 재부팅 후에도 잘 되는지는 별개의 문제입니다. 시작 순서 의존성, healthcheck 설정, 그리고 오래된 연결 상태 — 이런 것들은 장시간 운영하다 처음 재부팅할 때 드러납니다.

세팅이 끝나면 재부팅 테스트를 한 번 해봐야 한다는 걸 이번에 배웠습니다.

댓글 남기기