단일 EC2 한 대에 systemd로 서비스 6개를 띄워 두고 운영자는 나 혼자다. 심사 전까지 시연 서버가 상시로 떠 있어야 했는데, 내가 막고 싶었던 건 서비스가 죽는 상황이 아니었다. 죽은 걸 아무도 모른 채 몇 시간이 흐르는 상황이었다
유닛 파일을 열어 보니 Restart=on-failure가 이미 있었다. 자동 재시작은 끝난 문제라고 생각했다. 그게 아니었다. SIGSTOP으로 얼린 프로세스는 그 설정으로 영원히 살아나지 않는다. 결국 크래시·좀비·통지를 각각 맡는 3겹 구조를 만들었고, 응답이 멈춘 서비스가 스스로 복구되기까지 실측 4분 30초가 걸렸다
되어 있다고 믿은 것부터 확인했다
제약이 먼저 있었다. 메모리를 SerialGC까지 손봐서 겨우 맞춘 서버라 상주 모니터링 에이전트는 올릴 수 없었다. 기존 기동 방식도 갈아엎으면 안 됐다. systemd와 타이머 + curl 수준에서 끝내야 했다
코드를 먼저 읽었다. 기동은 systemd 템플릿 유닛 하나로 6종을 돌리고 있었고 Restart=on-failure와 RestartSec=5는 이미 있었다. 배포 스크립트가 배포마다 systemctl enable을 실행하고 있어서 부팅 자동 기동도 사실상 되어 있었다. 문제는 세 번째였다. 헬스 엔드포인트가 어디에도 없었다. 배포 스크립트조차 TCP 포트가 열렸는지만 보고 있었다
요구사항에는 “각 서비스의 헬스 엔드포인트를 curl로 확인”이라고 적혀 있었다. 확인할 엔드포인트가 없었다. 여기서부터가 실제 설계였다
actuator를 넣는 게 정답인 줄 알았다
처음 떠올린 건 spring-boot-starter-actuator 추가였다. 6개 서비스에 의존성 한 줄씩 넣고 재배포하면 /actuator/health가 생긴다
두 가지가 걸렸다. 하나는 메모리다. GC까지 바꿔 가며 아낀 서버에 JVM 6개 × 수십 MB를 다시 얹는 건 방향이 반대였다. 다른 하나가 더 컸다. actuator의 기본 health는 DataSource를 함께 본다. DB가 잠깐 흔들리면 6개가 동시에 DOWN을 반환하고, 워치독은 그걸 보고 6개를 전부 재시작한다. DB는 그대로인데 재시작만 연타로 돈다
다음 후보는 TCP 포트 열림 확인이었다. 배포 스크립트가 이미 쓰고 있으니 가져다 쓰면 된다. 이건 더 빨리 기각됐다. 이 작업의 목적이 좀비를 잡는 건데, JVM이 힙 고갈로 멈춰도 리스닝 소켓은 그대로 열려 있다. 포트만 보면 좀비가 건강해 보인다
결국 생존 판정을 이렇게 바꿨다. HTTP 응답이 오기만 하면 살아 있는 것으로 본다
# 상태 코드는 보지 않는다. 응답이 오는지만 본다.
curl -s -o /dev/null -m 5 "http://127.0.0.1:${port}${PROBE_PATH}"
PROBE_PATH는 어느 서비스에도 매핑되지 않은 경로다. 404가 돌아오면 실패가 아니라 성공이다. 라우팅까지 도달해서 “그런 경로 없다”고 답할 수 있다는 뜻이니 HTTP 스택이 살아 있다. 연결 거부나 5초 타임아웃이면 실패로 센다
404를 성공으로 치기 전에 서비스 로그부터 열었다. 1분마다 없는 경로를 때리면 WARN이 쌓일 것 같아서 예외 처리 advice의 스코프를 먼저 확인했다. 전부 @RestControllerAdvice(basePackages = ...)로 패키지가 지정되어 있었다. 미매핑 404는 프레임워크가 처리하고 서비스 로그에는 남지 않는다. 확인하고 나서 채택했다. 대신 PROBE_PATH를 설정 파일 상수로 뺐다. actuator로 갈아탈 일이 생기면 이 한 줄만 바꾸면 된다
기본 StartLimit은 한 번도 발동하지 않았다
크래시 루프 차단은 systemd가 기본으로 해 주는 줄 알았다. 기본값이 StartLimitIntervalSec=10s, StartLimitBurst=5니까 10초 안에 5번 죽으면 멈춘다고 읽었다
RestartSec=5와 같이 놓고 계산하고 나서야 알았다. 재시작 간격이 5초면 10초 창 안에 시도할 수 있는 횟수는 많아야 2회다. 5회에 도달할 방법이 없다. 이 조합에서 크래시 루프 차단은 영원히 발동하지 않는다. 잘못된 설정이 켜져 있던 게 아니라, 켜져 있다고 믿은 안전장치의 조건이 애초에 도달 불가능했다
유닛에 넣은 건 세 줄이다
[Unit] StartLimitIntervalSec=600 StartLimitBurst=5 [Service] OOMPolicy=kill
창을 10분으로 옮기면 크래시 루프가 5회에서 failed로 끊긴다. OOMPolicy=kill은 커널 OOM 킬러가 프로세스를 잡았을 때 그걸 유닛 실패로 확정시킨다. 여기에 JVM 옵션 -XX:+ExitOnOutOfMemoryError를 붙였다. 힙이 마른 JVM은 죽지 않고 GC만 계속 돌면서 아무 응답도 못 하는 상태로 남는데, 이 옵션이 그 상태를 프로세스 종료로 바꾼다. 종료되면 systemd가 다시 띄운다
기존 기동 방식은 손대지 않았다. 세 줄과 JVM 옵션 하나가 1겹의 전부다
3겹으로 나눈 건 실패 모드가 서로 다르기 때문이다
최종 구조는 세 겹이다. 각 겹이 맡는 실패 모드가 다르다
1겹 — 크래시 systemd Restart=on-failure (기존)
+ StartLimit 5회/10분 → 크래시 루프 차단
+ OOMPolicy=kill, -XX:+ExitOnOutOfMemoryError
2겹 — 좀비 1분 주기 타이머 → 워치독 스크립트
127.0.0.1:포트 HTTP 프로브
연속 3회 무응답 → 해당 서비스만 systemctl restart
쿨다운 10분 · 기동 유예 3분 · inactive는 불개입
3겹 — 통지 ⚠️ 워치독 재시작 🚨 재시작 실패
🔁 systemd 자동 재시작 감지 (NRestarts 증가)
1겹은 프로세스가 사라지는 실패를 잡는다. 2겹은 프로세스가 살아 있는데 응답을 못 하는 실패를 잡는다. 3겹은 잡는 게 아니다. 앞의 두 겹이 조용히 일했다는 사실 자체를 나에게 알린다
3겹에서 고민한 건 발신원이었다. 유닛에 OnFailure= 훅을 달면 systemd가 직접 알림을 쏘게 만들 수 있다. 그런데 그건 완전 실패 시점만 잡는다. systemd가 조용히 되살려서 아무 일 없이 지나간 크래시에는 알림이 오지 않는다. 이 작업에서 제일 중요한 케이스가 바로 그거였다. 그래서 훅 대신 워치독이 매 회차 NRestarts를 읽고 지난 회차보다 늘었으면 🔁 알림을 보내게 했다. 알림 발신원은 워치독 하나로 유지된다
배포와 재부팅에 대한 방어도 여기 들어갔다. 재시작 직후 3분은 판정을 보류하고, 타이머는 OnBootSec=10min으로 잡았다. 재부팅 직후에는 JVM 6개가 vCPU를 두고 경합하느라 어차피 느린데, 그때 워치독이 끼어들면 복구가 아니라 방해가 된다
검증 명령이 자기 자신을 죽였다
여기까지는 로컬에서 가짜 systemctl과 curl을 PATH에 꽂아 시나리오 6종 21개 assert를 전부 통과시킨 상태였다. 서버에 올리고 실제로 죽여 볼 차례였다
ssh host 'sudo pkill -9 -f jars/tour.jar; sleep 15; systemctl status ...'
2분이 지나도 출력이 없었다. SIGSTOP으로 바꾼 두 번째 리허설은 7분이 넘도록 ssh 세션이 아예 돌아오지 않았다. 명령을 세 번 다시 읽었다
원인은 pkill -f였다. -f는 전체 커맨드라인을 매칭한다. ssh로 넘긴 이 명령은 원격에서 bash -c 'sudo pkill -9 -f jars/tour.jar; ...'로 실행되고, 그 셸의 커맨드라인에는 패턴 문자열이 그대로 들어 있다. pkill이 자기를 실행한 셸과 sudo까지 같이 잡는다. -9면 셸이 즉사해 뒤에 붙인 sleep과 systemctl이 통째로 증발한다. -STOP이면 셸이 얼어붙어 ssh가 영원히 안 돌아온다. 서버에는 STAT T 상태의 셸·sudo가 두 쌍 남아 있었다
교정은 패턴 매칭 자체를 없애는 쪽이었다
# 유닛의 main PID만 정확히 겨눈다 systemctl kill -s KILL --kill-who=main seniorhub@tour
잔류 프로세스는 pkill -CONT -f "jars/[t]our.jar"로 풀었다. 대괄호가 자기 커맨드라인과의 매칭을 끊어 준다. ssh 원라이너에 pkill -f를 쓰지 않는다는 건 이제 규칙으로 적어 뒀다
로그가 0줄인 건 동작하지 않았다는 뜻이 아니었다
사고를 수습하고 워치독 저널을 tail -25로 봤다. 판정 로그가 한 줄도 없었다. 워치독이 안 돈다는 결론으로 갈 뻔했다
그전에 숫자 하나가 걸렸다. 첫 리허설에서 1이 됐어야 할 NRestarts가 0이었다. 이 값은 systemd가 자동 재시작할 때 올라가고 systemctl restart 같은 수동형 재시작에서는 리셋된다. 0이라는 건 그 사이에 수동형 재시작이 있었다는 뜻이고, 이 서버에서 그럴 수 있는 건 워치독뿐이었다. 일을 안 한 게 아니라 다 하고 지나간 다음이었다
tail -25가 담고 있던 건 정확히 조용한 구간이었다. 창을 13분 넓히자 전 과정이 나왔다
| 시각(UTC) | 사건 |
|---|---|
| 09:42:55 | 설치 직후 첫 실행 — NRestarts 기준값 저장 |
| 09:43:57 | tour: systemd 자동 재시작 감지 (NRestarts 0 → 1) → 🔁 발송 |
| 09:45:18 | 리허설 B — tour 동결(SIGSTOP). 기동 유예로 두 회차 침묵 |
| 09:47:32 | tour: 무응답 (연속 1/3) |
| 09:48:42 | tour: 무응답 (연속 2/3) |
| 09:49:53 | 3/3 → 재시작 실행 → ⚠️ 발송, 4분 30초 만에 자동 복구 |
부수 발견도 있었다. 동결된 프로세스는 SIGTERM을 못 받으니 90초 타임아웃 후 SIGKILL까지 기다릴 줄 알았는데 1초에 끝났다. systemd는 유닛을 정지할 때 SIGTERM에 SIGCONT를 동반해 보낸다. SIGSTOP으로 얼린 프로세스도 즉시 깨어나 종료된다
이 “0건”은 하루 뒤에 한 번 더 나왔다. 알림 발송 이력 조회가 0건이었다. 이번에도 발송이 안 된 게 아니라 알림 서비스의 캐시 보존 기간이 12시간이었고, 발송분의 만료가 조회 시각보다 3시간 먼저였다. epoch로 계산해서 확정했다
그 조사에서 진짜 결함이 하나 나왔다. 알림 발송 함수의 curl에 -f가 없었다
# HTTP 4xx/5xx를 받아도 exit code는 0이다 curl -s -d "$message" "$CHANNEL_URL"
curl은 -f 없이는 서버가 500을 돌려줘도 전송 자체는 성공했으니 exit 0을 반환한다. “알림 발송 실패” 로그가 구조적으로 남을 수 없는 상태였다는 뜻이다. 실패 로그가 없는 걸 정상 동작의 근거로 쓰고 있었는데, 애초에 실패가 로그로 남을 수 없었다. -sf로 고쳤다
통하지 않는 조건과, 한 줄 요약
이 구조가 못 잡는 게 있다. 워치독은 “HTTP 응답이 오는가”만 본다. 커넥션 풀이 말라서 응답은 200으로 오는데 기능은 죽어 있는 상태는 이 프로브를 그대로 통과한다. 기동 유예 3분도 공짜가 아니다. 재시작 직후에 진짜 장애가 나면 판정이 한 사이클 늦어진다. 배포 오탐을 막는 대가로 받아들인 트레이드오프다
서버가 여러 대가 되거나 서비스가 두 배로 늘면 이 구조는 맞지 않는다. 그때는 상주 에이전트를 올리는 게 옳다. 이건 단일 서버, 운영자 1명, 메모리 여유 없음이라는 조건에서 나온 답이다
한 줄로 요약하면, 자동 복구는 “죽으면 다시 띄운다”가 아니라 “무엇을 죽은 것으로 볼지”를 정하는 일이다
다음에 같은 장치를 만든다면 순서를 이렇게 잡을 것이다. 먼저 생존 판정 기준을 정하고, 그 기준이 못 잡는 실패 모드를 적어 본다. 프로세스 소멸, 응답 불가, 응답은 오지만 기능이 죽은 상태가 나온다. 각 줄에 담당자를 붙이면 겹이 몇 개 필요한지가 보인다. 마지막으로 하나만 확인한다. 이 장치가 실패했을 때 그 실패가 로그로 남는 구조인지 본다