집 NAS에는 Gitea, Jenkins, 문서 웹앱, VPN 같은 서비스가 스무 개쯤 돌고 있다. 이걸 *.sejong.dev 서브도메인으로 밖에 내보내는 리버스 프록시로 NPMplus(Nginx Proxy Manager 포크)를 쓰고 있었는데, 가끔 사이트가 이유 없이 안 열렸다. 안 열리는 서비스의 컨테이너 로그에는 단서가 없어서, 요청이 지나오는 길을 거슬러 리버스 프록시 로그를 열었다. 거기에 이런 줄이 쌓여 있었다.
accept4() failed (24: No file descriptors available)다행히 에러 메시지가 꽤 구체적이었다. 남은 질문은 FD가 왜 모자라느냐였다.
원인 추적: 소켓을 받을 파일 디스크립터가 없다
리눅스에서는 소켓도 파일이다. 새 연결을 accept()하려면 프로세스가 파일 디스크립터(FD)를 하나 더 열어야 하는데, 프로세스마다 열 수 있는 수에 한도가 있다. 에러 번호 24(EMFILE)는 바로 그 한도에 닿았다는 뜻이다.
그래서 nginx 워커마다 지금 FD를 몇 개 열고 있는지 직접 셌다.
# 워커별 한도와 현재 FD 수
for p in $(pgrep nginx); do
printf "%s limit=%s fds=%s\n" $p \
"$(grep 'open files' /proc/$p/limits | awk '{print $4}')" \
"$(ls /proc/$p/fd | wc -l)"
done
# 워커 하나가 연 FD가 무엇인지 종류별로
for f in /proc/$WORKER/fd/*; do readlink $f; done | sort | uniq -c | sort -rn | head결과가 예상 밖이었다. 연결이 많아서가 아니라, 워커 하나가 평소에도 FD를 1,500개 가까이 열고 있었다. 종류별로 묶어 보니 대부분이 소켓이 아니라 일반 파일이었다. NPMplus가 호스트·location마다 디렉터리 목록 모듈(fancyindex)용 파일을 붙여 두는데, 워커마다 이걸 전부 열어 두는 구조였다. 당시 워커의 열린 파일 한도는 1,024였으니, 연결이 조금만 몰려도 새 소켓을 받을 자리가 없었던 것이다.
한도를 올릴까, 구조를 바꿀까
가장 빠른 해결은 worker_rlimit_nofile을 올리는 것이다. 하지만 그건 쓰지도 않는 파일을 수천 개 열어 두는 구조를 그대로 두고 숫자만 키우는 일이었다. 마침 NPMplus 관리 화면에서 호스트를 하나씩 클릭해 관리하는 것도 불편했고, 설정이 DB에 들어 있어 Git으로 이력을 남기기도 어려웠다.
그래서 Caddy로 옮기기로 했다. 고른 이유는 세 가지다.
- 설정이 텍스트 파일 하나다. Git으로 관리하고, 바꾼 내용을 diff로 볼 수 있다.
- HTTPS가 기본이다. 인증서 발급과 갱신을 알아서 한다. Cloudflare DNS 플러그인을 넣으면
*.sejong.dev와일드카드 인증서도 DNS-01로 받을 수 있다. - 무중단 reload가 된다. 설정을 바꿔도 연결을 끊지 않는다.
Caddy 구성
공식 이미지에는 DNS 플러그인이 없어서 xcaddy로 Cloudflare 모듈을 넣어 빌드했다.
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy서비스가 스무 개라 같은 블록을 반복하지 않도록 스니펫을 만들었다. 호스트 하나를 추가하는 일이 이제 한 줄이다.
{
acme_dns cloudflare {env.CF_API_TOKEN}
}
# 사용법: import p <서브도메인> <업스트림>
(p) {
@{args[0]} host {args[0]}.sejong.dev
handle @{args[0]} {
reverse_proxy {args[1]} {
# 업스트림이 재시작하는 동안 5초까지 재시도
lb_try_duration 5s
}
}
}
*.sejong.dev {
import p git <NAS-IP>:13330
import p jenkins <NAS-IP>:8085
import p office <NAS-IP>:13001
# … 나머지 호스트
# 정의하지 않은 서브도메인은 응답 없이 끊는다
handle {
abort
}
}컨테이너는 매일 새벽 Watchtower가 자동으로 업데이트한다. 그동안 업스트림이 몇 초 내려가면 프록시는 바로 502를 돌려준다. 그래서 lb_try_duration 5s를 넣어, 그 사이 들어온 요청은 바로 실패하지 않고 5초까지 다시 시도하게 했다.
전환: 다운타임 3초
바로 갈아끼우지 않고 먼저 Caddy를 다른 포트로 띄워서 모든 호스트가 NPMplus와 같은 응답 코드를 내는지 비교했다. 그다음 전환은 스크립트 하나로 했다. 실패하면 자동으로 되돌아가도록 만든 게 핵심이다.
# 1) 설정 검증 — 틀리면 여기서 멈춘다
caddy validate --config Caddyfile.new
# 2) NPMplus 중지 → Caddy를 실제 포트로 기동
(cd npmplus && docker compose stop)
docker compose up -d || rollback
# 3) 실제로 응답할 때까지 최대 20초 확인, 아니면 원래대로
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' https://git.sejong.dev/)
[ "$code" = 200 ] && break; sleep 1
done
[ "$code" = 200 ] || rollback # Caddy 내리고 NPMplus 다시 시작실제 다운타임은 약 3초였다. 전환 직후 호스트 20개의 응답 코드가 모두 이전과 같은지 다시 확인했다. NPMplus는 지우지 않고 멈춰만 둬서, 문제가 생기면 명령 두 줄로 되돌릴 수 있게 했다.
지금은
- Caddy 프로세스 전체가 연 FD는 21개다. 예전에는 nginx 워커 하나가 1,500개를 열고 있었다.
- 호스트를 추가할 때는 Caddyfile에 한 줄을 넣고 reload 스크립트를 돌린다. 스크립트가 설정을 올리고
caddy reload를 실행하는데, 실패하면 이전 설정으로 자동 복원한다. - 설정 변경 이력이 전부 Git 커밋으로 남는다.
돌아보며
처음에는 안 열리는 서비스의 컨테이너부터 봤다. 로그를 보고 설정도 뒤져 봤지만 별다른 에러가 없었다. 그래서 요청이 들어오는 흐름을 처음부터 다시 따라가 봤다. 인터넷에서 공유기를 거쳐 리버스 프록시, 그다음이 앱이다. 앱에 흔적이 없다면 요청이 그 앞에서 막혔을 수 있었다. 프록시 로그를 열자 accept4() failed (24)가 쌓여 있었고, 거기서부터 원인을 좁혀 조치할 수 있었다. 증상이 보인 곳과 원인이 있는 곳이 달랐던 셈이다.
숫자를 키우기 전에 그 숫자가 왜 그렇게 큰지부터 보는 게 맞았다. 한도를 올렸다면 당장은 조용해졌겠지만, 설정을 파일로 관리하고 무중단으로 바꿀 수 있는 지금 구조는 얻지 못했을 것이다.