nginx proxy_pass의 함정
배포 두 개가 겹치자 도메인 전체가 502가 됐습니다. 몇 달째 멀쩡하던 한 줄이 원인이었습니다.
배포 스크립트를 손보고 두 개의 서비스를 거의 동시에 배포했습니다. 몇 분 뒤 도메인 전체가 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 failedproxy_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 usecompose 설정에 "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에 변수가 있는지 한 번 확인해보시길 권합니다. 없다면 언젠가 배포가 겹치는 날 같은 일을 겪게 됩니다.