nginx proxy_pass의 함정

배포 두 개가 겹치자 도메인 전체가 502가 됐습니다. 몇 달째 멀쩡하던 한 줄이 원인이었습니다.

nginx proxy_pass의 함정

배포 스크립트를 손보고 두 개의 서비스를 거의 동시에 배포했습니다. 몇 분 뒤 도메인 전체가 502를 뱉고 있었습니다. 블로그도, 계산기도, 약국 찾기도 전부였습니다.

배포한 건 두 개인데 왜 나머지까지 죽었을까요. 원인은 nginx 설정에 몇 달째 조용히 잠들어 있던 한 줄이었습니다.

이 글에서는 proxy_pass에 호스트 이름을 그대로 쓰면 왜 위험한지, 변수와 resolver로 어떻게 장애를 격리하는지, 그리고 그 과정에서 만난 함정 하나를 정리합니다.

배포 두 개가 겹친 순간

제 홈서버는 Docker Compose로 여러 서비스를 띄우고, nginx 컨테이너가 앞단에서 리버스 프록시를 맡는 구조입니다. 각 서비스는 GitHub Actions로 배포되는데 스크립트는 대략 이렇게 생겼습니다.

pharmacy-finder-up:
	docker compose up -d pharmacy-finder
	docker compose restart nginx

서비스를 새로 띄운 뒤 nginx를 재시작해서 새 컨테이너 IP를 잡게 하는, 흔한 패턴입니다. 문제는 두 배포의 타이밍이 겹쳤을 때 벌어졌습니다.

  • A 배포 시작: docker compose stop A로 A 컨테이너가 사라집니다
  • 거의 동시에 B 배포 종료: docker compose restart nginx가 실행됩니다
  • nginx 재시작: 설정이 참조하는 A 컨테이너가 존재하지 않습니다
  • 기동 실패: nginx가 뜨지 못하고 전체 사이트가 내려갑니다

로그에는 이렇게 찍혀 있었습니다.

[emerg] host not found in upstream "pharmacy-finder" in
        /etc/nginx/conf.d/locations/pharmacy-finder.conf:2
nginx: configuration file /etc/nginx/nginx.conf test failed

proxy_pass는 시작할 때 딱 한 번 이름을 찾습니다

문제의 설정은 이랬습니다.

location /pharmacy-finder {
    proxy_pass http://pharmacy-finder:3001;
}

지극히 평범해 보입니다. 실제로 몇 달 동안 아무 문제 없이 돌았습니다. 하지만 여기엔 nginx의 중요한 동작 방식이 숨어 있습니다.

proxy_pass에 호스트 이름을 문자열 그대로 쓰면, nginx는 기동 시점에 딱 한 번 그 이름을 IP로 해석합니다. 실패하면 설정 오류로 간주하고 기동 자체를 거부합니다.

이 동작이 만드는 결과는 이렇습니다.

  • 서비스 하나가 잠깐 내려간 순간 nginx가 재시작되면: nginx 전체가 못 뜹니다
  • 관계없는 다른 서비스까지 함께 죽습니다: 장애가 격리되지 않고 전파됩니다
  • 평소에는 멀쩡합니다: 배포가 겹치는 순간에만 터지기 때문에 몇 달을 잠복했습니다

컨테이너를 재생성하면 IP가 바뀌는데도 잘 돌아간 이유는 매번 배포 끝에 restart nginx로 다시 해석하게 만들었기 때문입니다. 그 재시작이 하필 다른 서비스가 없는 순간에 걸린 것이 이번 사고였습니다.

죽은 틈에 다른 프로세스가 포트를 가져갔습니다

더 고약한 건 그다음이었습니다. nginx를 다시 띄우려 하자 이번엔 다른 에러가 났습니다.

failed to bind host port 0.0.0.0:443/tcp: address already in use

compose 설정에 "443:443" 매핑이 있었는데, 정작 nginx 설정에는 443 리스너가 없었습니다. TLS는 앞단 CDN에서 종단되고 내부는 80만 쓰는 구조라, 443 매핑은 아무 일도 하지 않는 유물이었습니다.

문제는 그게 정말로 "아무 일도 안 하는" 게 아니었다는 점입니다. nginx가 죽어서 443을 놓은 사이, 같은 서버에서 돌던 다른 데몬이 그 포트를 자기 용도로 잡아버렸습니다. 결국 nginx는 쓰지도 않는 포트 때문에 재기동에 실패했고, 장애 시간은 몇 분에서 십여 분으로 늘어났습니다.

쓰지 않는 포트 매핑은 중립이 아니라 부채였습니다. 평소엔 자리만 차지하다가 사고 순간에 복구를 막았습니다.

변수와 resolver로 격리하기

nginx는 proxy_pass에 변수가 포함되면 동작이 달라집니다. 기동 시점이 아니라 요청이 올 때마다 resolver로 이름을 찾습니다.

server {
    # Docker 내장 DNS. 컴포즈 네트워크 안에서 이름을 해석해줍니다
    resolver 127.0.0.11 valid=10s;

    location /pharmacy-finder {
        set $upstream http://pharmacy-finder:3001;   # 변수로 받아서
        proxy_pass $upstream;                        # 런타임에 해석
    }
}

이렇게 바꾸면 세 가지가 달라집니다.

  • 업스트림이 죽어 있어도 nginx는 정상 기동합니다: 해당 경로만 502가 되고 나머지는 살아 있습니다
  • 컨테이너가 살아나면 자동 복구됩니다: nginx를 재시작할 필요가 없습니다
  • 장애가 전파되지 않습니다: 서비스 하나의 문제가 전체 문제가 되지 않습니다

함정: 변수를 쓰면 경로가 잘리지 않습니다

여기서 한 번 더 걸렸습니다. 원래 이런 설정이 있었다면 어떨까요.

location /anki/ {
    proxy_pass http://anki-generator:8000/;   # 끝의 슬래시에 주목
}

끝에 슬래시가 붙은 proxy_pass/anki/foo 요청을 업스트림에 /foo로 넘깁니다. 경로 접두사를 잘라주는 기능입니다. 그런데 변수를 쓰면 이 URI 치환이 동작하지 않습니다. /anki/foo가 그대로 넘어가서 404가 납니다.

그래서 rewrite로 직접 잘라줘야 합니다.

location /anki/ {
    resolver 127.0.0.11 valid=10s;
    set $upstream http://anki-generator:8000;
    rewrite ^/anki/(.*)$ /$1 break;    # 접두사를 직접 제거
    proxy_pass $upstream;
}

그리고 쓰지 않던 포트 매핑도 지웠습니다.

ports:
  - "80:80"
  # - "443:443"   ← 삭제. 리스너도 없는데 죽은 틈에 선점당해 복구를 막았습니다

고쳤다고 말하기 전에

설정을 바꿨으면 사고 상황을 재현해서 확인해야 합니다. 그래서 일부러 같은 조건을 만들어봤습니다.

# 1. 서비스 하나를 죽입니다
docker stop pharmacy-finder

# 2. 그 상태에서 nginx를 재시작합니다 (사고 당시와 같은 조건)
docker restart nginx

결과는 이랬습니다. nginx는 정상 기동했고, 다른 서비스는 200을 반환했으며, 죽은 서비스만 502였습니다. 그리고 docker start pharmacy-finder로 컨테이너를 되살리자 nginx를 건드리지 않았는데도 그 경로가 다시 200이 됐습니다. 의도한 대로였습니다.

남은 교훈

  • "몇 달째 잘 돌던 설정"은 안전하다는 뜻이 아닙니다: 이번 설정은 배포가 겹치는 특정 타이밍에만 터지는 조건부 폭탄이었습니다. 잘 돌던 기간은 그 조건이 오지 않았다는 뜻일 뿐입니다
  • 장애 격리는 기본값이 아닙니다: 리버스 프록시 뒤에 여러 서비스를 두면 기본 설정에서는 하나의 장애가 전체 장애가 됩니다. 격리는 의도적으로 설계해야 얻어집니다
  • 안 쓰는 설정을 남겨두지 마세요: 443 매핑은 아무 일도 안 한다고 생각했지만 사고 순간에 복구를 막았습니다
  • 배포는 push로 끝나지 않습니다: 이번 장애를 10분 만에 잡을 수 있었던 건 배포 후 실제 응답을 확인하는 절차가 있었기 때문입니다

같은 구조로 서비스를 운영하고 있다면 지금 proxy_pass에 변수가 있는지 한 번 확인해보시길 권합니다. 없다면 언젠가 배포가 겹치는 날 같은 일을 겪게 됩니다.