Nginx 액티브 헬스체크 모듈 패치 설치 (Amazon Linux 2)

6/02/2025 ·impact

Nginx 액티브 헬스체크 모듈 패치 설치 (Amazon Linux 2)

Nginx 오픈소스 버전이 제공하는 장애 감지는 max_failsfail_timeout을 쓰는 패시브 방식입니다. 실제 요청이 실패해야 그 서버가 후보에서 빠집니다. 주기적으로 백엔드를 직접 찔러보는 health_check 지시어는 ngx_http_upstream_hc_module에 들어 있는데, 공식 문서는 이 모듈이 "상용 구독의 일부로 제공된다"고 못 박아 뒀습니다. 오픈소스 빌드에서 같은 일을 시키려면 서드파티 모듈인 nginx_upstream_check_module이 필요하고, 이 모듈은 동적 모듈로 붙지 않습니다. Nginx 소스에 패치를 대고 함께 컴파일해야 합니다. Amazon Linux 2를 기준으로 패키지 설치부터 패치, 컴파일, upstream 설정까지 순서대로 정리했습니다.

패시브 감지가 놓치는 구간

max_fails=3 fail_timeout=30은 30초 안에 3번 실패한 서버를 30초 동안 후보에서 빼두라는 뜻입니다. 공식 문서 ngx_http_upstream_modulemax_fails를 "서버를 사용 불가로 판정하기까지 fail_timeout이 정한 기간 안에 일어나야 하는 통신 실패 횟수"로 설명하고, 기본값은 1입니다. 여기서 걸리는 대목은 그 판단의 근거가 사용자 요청이라는 점입니다. 요청이 뜸한 시간대에는 실패 카운트가 쌓이지 않아 백엔드가 이미 죽었어도 그 사실이 드러나지 않고, 배포로 잠깐 내려갔다 올라온 서버는 fail_timeout이 끝날 때까지 제외된 채 남습니다. 같은 문서에는 그룹에 서버가 하나뿐이면 max_failsfail_timeout이 무시되고 그 서버는 절대 사용 불가로 처리되지 않는다는 단서도 붙어 있습니다.

액티브 헬스체크는 사용자 요청과 무관하게 정해진 주기로 각 백엔드에 요청을 보내고, 그 결과만으로 up/down을 판정합니다. 트래픽이 없는 시간에도 상태를 압니다.

빌드 환경 준비

Amazon Linux 2는 yum으로 빌드 도구를 올립니다. Development Tools 그룹에 gcc, make, patch가 들어 있고, 나머지는 Nginx가 링크할 라이브러리와 그 헤더입니다.

sudo yum update -y
sudo yum groupinstall "Development Tools" -y
sudo yum install pcre pcre-devel zlib zlib-devel openssl openssl-devel git wget -y

-devel을 같이 받는 이유는 컴파일 시점에 헤더가 필요해서입니다. 어느 라이브러리가 왜 필요한지는 nginx 공식 문서 Building nginx from Sources에 적혀 있습니다. PCRE는 "location 지시어의 정규식 지원과 ngx_http_rewrite_module에 필요한 라이브러리", zlib은 "ngx_http_gzip_module에 필요한 라이브러리"이고, --with-http_ssl_module은 "기본으로 빌드되지 않으며 빌드와 실행에 OpenSSL 라이브러리가 필요"합니다. 빠진 게 있으면 ./configure가 그 자리에서 멈추고, 무엇을 찾지 못했는지 에러 메시지에 그대로 나옵니다.

소스와 모듈은 같은 디렉터리 아래 나란히 둡니다. 뒤에 나올 patch--add-module이 모두 ../ 상대 경로를 쓰기 때문에, 위치가 어긋나면 두 군데를 같이 고쳐야 합니다.

wget http://nginx.org/download/nginx-1.26.1.tar.gz
tar -zxvf nginx-1.26.1.tar.gz
git clone https://github.com/yaoweibin/nginx_upstream_check_module

패치 파일은 모듈 저장소에 Nginx 버전대별로 들어 있습니다. 저장소 파일 목록에서 가장 나중 것이 check_1.20.1+.patch이고, 최근 커밋은 2022년 11월에 멈춰 있습니다. 그 뒤에 나온 버전대는 이 패치로 맞춰 볼 수밖에 없습니다.

패치 적용과 컴파일

왜 패치부터 대야 하는지는 패치가 건드리는 파일을 보면 드러납니다. check_1.20.1+.patch의 대상은 src/http/ngx_http_upstream_round_robin.c와 그 헤더, 그리고 hash·ip_hash·least_conn 밸런서 모듈까지 다섯 개로 모두 Nginx 코어입니다. 코어 소스를 고쳐야 하는 이상 .so 파일 하나로 떼어낼 수 없습니다. 모듈 저장소의 config 스크립트도 HTTP_MODULES에 이름을 직접 붙이는 구식 형식이라, 개발 가이드가 설명하는 ngx_module_type 설정과 auto/module 호출이 없습니다. --add-dynamic-module이 아니라 --add-module로 가는 이유입니다.

패치는 압축을 푼 소스 루트 안에서 실행합니다.

cd nginx-1.26.1
patch -p1 < ../nginx_upstream_check_module/check_1.20.1+.patch

GNU patch 문서의 옵션 설명대로 -p는 패치에 적힌 파일 이름에서 떼어낼 앞 경로 수를 정합니다. -p1이니 소스 루트에서 돌려야 src/http/... 경로가 맞아떨어집니다. 파일 이름의 1.20.1+는 이 패치가 1.20.1 이상 버전대를 대상으로 만들어졌다는 표시고, 버전 차이가 벌어지면 일부 hunk가 실패합니다. 실패한 hunk는 원본 파일 이름에 .rej를 붙인 파일로 남으니, 그때는 그 파일을 열어 어느 부분이 어긋났는지 직접 봐야 합니다.

./configure --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --user=nginx --group=nginx --with-http_ssl_module --with-http_v2_module --with-http_realip_module --with-http_addition_module --with-http_sub_module --with-http_dav_module --with-http_flv_module --with-http_mp4_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_auth_request_module --with-http_random_index_module --with-http_secure_link_module --with-http_slice_module --with-http_stub_status_module --with-mail --with-mail_ssl_module --with-stream --with-stream_ssl_module --add-module=../nginx_upstream_check_module
make
sudo make install

마지막 --add-module=../nginx_upstream_check_module이 모듈 소스를 빌드에 넣는 옵션입니다. 공식 문서는 이 옵션을 "외부 모듈을 활성화한다"는 한 줄로 설명합니다. 앞의 옵션들은 배포판 패키지가 잡아주던 경로와 모듈 구성을 맞춘 것입니다. 이미 패키지로 깔아 쓰던 Nginx를 대체하는 상황이라면 기존 옵션을 그대로 옮겨 적어야 하는데, 그 목록은 nginx -V가 찍어줍니다. 공식 문서의 명령행 파라미터 설명대로 -V는 nginx 버전, 컴파일러 버전, configure 파라미터를 함께 출력합니다. 옵션 하나가 빠지면 잘 돌던 설정 파일이 unknown directive 에러를 냅니다.

make install이 설정 파일을 어떻게 다루는지는 nginx 소스의 auto/install 스크립트에 그대로 있습니다. nginx.conf는 대상 경로에 파일이 없을 때만 복사하고(test -f ... || cp ...), nginx.conf.default는 조건 없이 매번 복사합니다. mime.typesfastcgi_params도 같은 방식입니다. 기존 설정이 지워지지는 않지만, 손대기 전에 복사본은 떠 둡니다.

upstream 블록의 check 지시어

빌드가 끝나면 upstream 블록 안에서 check 계열 지시어를 쓸 수 있습니다. 원문 설정은 이렇습니다.

upstream loadbalancer {
 server localhost:8000;
 server 1.1.1.1:8000 weight=3 max_fails=3 fail_timeout=30;

 check interval=3000 rise=2 fall=5 timeout=4000 type=http;
 check_http_send "GET / HTTP/1.1\r\nHost: \$server_addr\r\nConnection: close\r\n\r\n";
 check_http_expect_alive http_2xx http_3xx;
}

check의 시간 값은 밀리초입니다. 모듈 README가 적어둔 기본값은 interval=30000 fall=5 rise=2 timeout=1000 default_down=true type=tcp이니, 위 설정은 주기를 30초에서 3초로 당기고, timeout을 1초에서 4초로 늘리고, 타입을 http로 바꾼 셈입니다. 늘어난 timeout이 줄어든 interval보다 커졌다는 게 뒤에서 문제가 됩니다. default_down은 손대지 않았습니다. README가 이 값을 백엔드의 초기 상태로 설명하므로, 기동 직후에는 rise 횟수를 채우기 전까지 서버가 down 쪽에서 시작합니다.

README에는 위 설정이 쓰지 않은 파라미터도 있습니다. port는 체크를 보낼 포트를 백엔드 포트와 다르게 지정할 때 쓰고, 기본값 0은 원래 백엔드 포트를 그대로 쓴다는 뜻입니다.

weightmax_fails는 패시브 설정 그대로 남아 있습니다. 두 방식은 배타적이지 않으니, 액티브 체크를 넣었다고 기존 값을 지울 이유는 없습니다. 업스트림 목록 자체가 자주 바뀌는 자리, 예를 들어 쿠버네티스 컨트롤 플레인 앞단이라면 노드를 늘릴 때 이 블록도 같이 손봐야 합니다. 그 절차는 kubeadm으로 쿠버네티스 클러스터에 마스터·워커 노드 추가하기에 정리해 뒀습니다. 반대로 컨트롤 플레인 여러 대가 한꺼번에 down으로 떨어졌다면 nginx보다 클러스터 쪽을 먼저 봅니다. 쿠버네티스 인증서 만료 확인과 kubeadm certs renew 갱신 방법이 그런 경우입니다.

감지까지 걸리는 시간

fall=5는 연속 5회 실패해야 down으로 내리고, rise=2는 연속 2회 성공해야 다시 up으로 올린다는 의미입니다. 아래 숫자는 모듈 문서에 적힌 값이 아니라 interval과 두 카운트로 직접 계산한 값입니다. 백엔드가 곧바로 거절 응답을 준다고 놓고 3초 주기로 따지면 이렇습니다.

  • 배제까지: 첫 실패 체크에서 다섯 번째 실패 체크까지 네 주기, 12초. 장애가 체크 직후에 시작되면 첫 실패까지 한 주기가 더 붙어 최대 15초
  • 복귀까지: 두 번의 성공 사이가 한 주기, 3초. 복구 시점이 어긋나면 최대 6초

응답이 아예 오지 않는 유형이면 얘기가 달라집니다. 체크 한 번이 timeout 4초를 다 쓰는데, 이 값이 interval 3000보다 큽니다. 모듈 소스의 ngx_http_upstream_check_begin_handler를 보면 타이머는 check_interval / 2마다 다시 걸리고, 연결이 이미 잡혀 있거나 타임아웃 이벤트가 살아 있는 peer는 그대로 건너뜁니다. 체크가 겹쳐 돌지는 않는다는 뜻입니다. 대신 진행 중인 체크가 끝나야 다음 체크가 시작되므로, timeoutinterval보다 크면 실제 체크 간격이 벌어지고 감지 시간도 위 계산보다 늘어집니다. 주기를 기준으로 감지 시간을 예측하려면 timeoutinterval보다 작게 잡는다.

check_http_send는 문자열을 그대로 보낸다

원문 표기는 Host: \$server_addr로 백슬래시가 붙어 있습니다. 이 문자열이 실제로 어떻게 나가는지는 두 군데에서 정해집니다.

먼저 설정 파서입니다. Nginx의 ngx_conf_read_token은 따옴표 안에서 \", \', \\, \t, \r, \n 여섯 가지만 escape로 처리합니다. 백슬래시 뒤에 그 밖의 문자가 오면 어느 case에도 걸리지 않아 백슬래시까지 그대로 복사됩니다. \r\n은 실제 CR LF로 바뀌지만 \$는 두 글자 그대로 남습니다.

다음은 모듈입니다. check_http_send의 설정 핸들러는 모듈 소스에서 ucscf->send = value[1]; 한 줄이 전부입니다. 변수를 컴파일하는 단계가 없고, 저장한 문자열이 체크 요청 버퍼에 그대로 실립니다.

둘을 합치면 백엔드가 받는 Host 헤더 값은 \$server_addr라는 문자열 자체입니다. 이게 문제가 되는지는 백엔드에 달렸습니다. Host를 따지지 않고 응답하는 서버라면 200이 돌아오고 체크는 통과합니다. 이름 기반 가상 호스트로 라우팅하는 서버라면 매칭에 실패해 기본 서버 블록으로 떨어지거나 4xx를 돌려줄 수 있습니다. 환경마다 갈리는 부분이니 이 설정을 옮겨 쓸 때는 백엔드가 Host를 어떻게 처리하는지 직접 확인하세요.

Host를 특정해야 한다면 실제 호스트명을 문자열에 박습니다. 필요 없다면 README 기본값인 "GET / HTTP/1.0\r\n\r\n"처럼 보내 Host 헤더를 생략할 수 있습니다. 아래 두 줄은 원문 설정이 아니라 대안입니다.

# 대안 1 - 호스트명을 직접 적는다
check_http_send "GET /healthz HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\r\n";

# 대안 2 - README 기본값과 같은 형태
check_http_send "GET / HTTP/1.0\r\n\r\n";

연결 재사용도 짚어둘 만합니다. check_keepalive_requests의 README 기본값은 1이고, README는 이 값이면 요청 하나를 보낸 뒤 nginx가 연결을 반드시 닫는다고 적었습니다. 체크마다 새 연결이 열린다고 보면 됩니다.

어떤 응답을 살아 있음으로 볼지

check_http_expect_alive http_2xx http_3xx는 README가 적어둔 기본값과 같습니다. 나열할 수 있는 값은 http_2xx, http_3xx, http_4xx, http_5xx 네 가지입니다. 헬스체크 전용 경로를 따로 뒀다면 그 경로가 리다이렉트를 돌려줄 이유는 없으니 http_2xx만 남깁니다. 3xx를 열어두면 로그인 페이지로 튕겨내는 302까지 정상으로 잡힙니다.

체크가 찌르는 경로는 가볍게 유지합니다. DB나 외부 API까지 확인하는 경로를 지정하면 의존 대상 하나가 흔들릴 때 백엔드 전체가 한꺼번에 down으로 떨어집니다. 체크 요청은 백엔드 액세스 로그에도 그대로 쌓이므로, 로그에서 제외할지도 같이 정해둡니다.

체크 타입

README가 열거하는 type 값은 여섯 가지입니다.

  • tcp: 소켓을 연결하고 1바이트를 peek
  • ssl_hello: 클라이언트 SSL Hello를 보내고 서버 SSL Hello를 받음
  • http: HTTP 요청을 보내고 응답을 파싱
  • mysql: MySQL 서버에 접속해 greeting 응답을 받음
  • ajp: AJP Cping을 보내고 Cpong 응답을 파싱
  • fastcgi: FastCGI 요청을 보내고 응답을 파싱

fastcgi로 체크할 때 보낼 파라미터는 check_fastcgi_param으로 정합니다. README가 적어둔 기본값은 REQUEST_METHOD "GET", REQUEST_URI "/", SCRIPT_FILENAME "index.php" 셋입니다.

상태 페이지

check_status를 location에 걸면 각 서버의 up/down을 HTTP로 볼 수 있습니다. README 기준 형식은 html, csv, json 세 가지에 기본은 html이고, /status?format=json처럼 인자로 바꿔 부릅니다. status 인자를 붙이면 /status?format=csv&status=up처럼 같은 상태인 서버만 추려 볼 수 있습니다.

location /status {
 check_status;

 access_log off;
 allow SOME.IP.ADD.RESS;
 deny all;
}

README는 체크 대상이 수천 대면 헬스체크용 공유 메모리가 모자랄 수 있다며 check_shm_size로 늘리라고 안내합니다. 기본값은 1M입니다.

소스로 깔아서 남는 일

패키지가 대신 해주던 일이 그대로 남습니다. --user=nginx --group=nginx로 빌드했다면 그 계정이 서버에 있어야 합니다. 공식 문서는 --user를 "worker 프로세스가 실행 권한으로 사용할 비특권 사용자 이름"으로 설명하고 기본값을 nobody로 적어뒀는데, 설치 뒤에는 nginx.conf의 user 지시어로 언제든 바꿀 수 있다는 설명도 함께 붙어 있습니다.

원문의 마지막 단계는 sudo systemctl start nginx 한 줄인데, 이 명령이 통하려면 systemd 유닛 파일이 이미 있어야 합니다. 앞서 본 auto/install에는 유닛 파일이나 init 스크립트를 설치하는 규칙이 없습니다. 바이너리와 설정 파일, 문서만 깔립니다. 패키지로 Nginx를 깐 적 없는 서버라면 기동 전에 유닛부터 만듭니다.

make install로 깐 바이너리는 rpm 데이터베이스에 등록되지 않습니다. yum 입장에서는 존재하지 않는 파일이고 업데이트 대상도 아닙니다. 문제는 rpm으로 설치한 nginx 패키지가 같은 서버에 함께 남아 있을 때입니다. --sbin-path=/usr/sbin/nginx는 패키지가 쓰는 경로와 겹치므로, 패키지 쪽이 업데이트되면 같은 자리의 바이너리가 패치 없는 것으로 바뀔 수 있습니다. 그 상태에서는 check가 unknown directive가 되고 기동이 막힙니다. 패키지가 함께 깔린 서버라면 nginx를 yum 업데이트 대상에서 뺀다.

Nginx 버전을 올릴 때마다 새 소스를 받아 다시 패치하고 다시 컴파일하는 절차는 반복됩니다. 그때 확인할 것은 두 가지입니다. 저장소에 있는 패치가 새 버전대에도 hunk 실패 없이 붙는지, 그리고 nginx -V로 뽑은 기존 configure 옵션을 빠짐없이 옮겼는지. 옮긴 뒤에는 nginx -t로 통과시키고 기동합니다. 공식 문서는 -t를 설정 파일의 문법을 검사하고 설정에서 참조하는 파일을 열어보는 옵션으로 설명합니다.

패시브로 버틸지, 컴파일을 떠안을지

사용자 요청 몇 건이 실패로 소진되는 것을 감수할 수 있으면 max_fails/fail_timeout으로 충분합니다. 요청이 뜸한 시간에도 죽은 백엔드를 빼둬야 한다면 상용 구독의 health_check이나 이 패치인데, 패치를 고르면 동적 로드를 포기하고 버전 업마다 재패치·재컴파일을 떠안습니다.

timeout=4000은 그대로 옮겨 쓸 값이 아닙니다. interval보다 커서 응답 없는 백엔드에서는 체크 간격이 벌어지고, 계산한 12~15초도 그만큼 어긋납니다. 모듈 README의 기본값 1000으로 되돌릴지부터 정합니다.

timeoutinterval 아래로 낮춘 설정으로 올린 뒤, check_status에서 배제와 복귀가 12~15초·3~6초 안에 들어오는지 재보세요.