Published on

systemd 서비스가 계속 재시작될 때 진단 체크리스트

Authors

서버에서 systemd 서비스가 계속 재시작되는 상황은 대개 두 가지가 겹쳐서 발생합니다. (1) 프로세스가 빠르게 종료(Exit)하거나 크래시(Crash)하고, (2) 유닛 파일의 Restart= 정책이 이를 즉시 재기동하도록 설정되어 “증상”이 반복됩니다. 문제는 재시작 그 자체가 아니라 왜 종료되었는지인데, systemd는 그 단서를 대부분 로그와 상태에 남깁니다.

이 글은 “재시작 루프”를 멈추고 원인을 좁혀가는 실전 진단 순서를 체크리스트 형태로 정리합니다.

1) 먼저: 재시작 루프를 안전하게 멈추기

재시작이 빠르면 로그가 쏟아지고, 서비스가 의존하는 DB/외부 시스템에도 불필요한 부하를 줍니다. 원인 분석을 위해 일단 루프를 멈추는 게 좋습니다.

# 즉시 중지 (Restart=가 있어도 stop은 멈춥니다)
sudo systemctl stop myapp.service

# 실수로 다시 start되는 것을 막고 싶다면 마스킹
sudo systemctl mask myapp.service
# 분석 후 해제
sudo systemctl unmask myapp.service

또는 유닛에 StartLimitIntervalSec/StartLimitBurst가 없다면 “무한 재시작”이 가능하므로, 원인 파악 전 임시로 제한을 두는 것도 방법입니다(아래 7절 참고).

2) systemctl status로 ‘종료 이유’와 ‘최근 로그 10줄’부터 확인

가장 먼저 볼 것은 systemctl status입니다. 여기에는 마지막 종료 코드, 시그널, 재시작 카운터, 최근 로그가 압축되어 나옵니다.

sudo systemctl status myapp.service -l --no-pager

여기서 특히 확인할 포인트:

  • Active: activating (auto-restart) / failed (Result: exit-code)
  • Main PID: ... (code=exited, status=1/FAILURE)
  • code=killed, status=9/KILL (SIGKILL)
  • status=143 (SIGTERM), status=137 (SIGKILL/OOM 의심)
  • RestartSec=가 너무 짧아(예: 100ms~1s) 로그가 유실/혼재되는지

status=137처럼 애매한 숫자가 보이면 OOMKill, cgroup 제한, 또는 외부에서 kill했을 가능성이 큽니다.

3) journalctl로 “이번 부팅의 해당 유닛 로그”를 시간순으로 보기

systemctl status는 일부만 보여주므로, 전체 로그를 시간순으로 확인합니다.

# 현재 부팅에서의 유닛 로그
sudo journalctl -u myapp.service -b --no-pager

# 최근 5분만
sudo journalctl -u myapp.service -S "5 min ago" --no-pager

# 가장 최근 크래시 주변만 꼼꼼히(역순)
sudo journalctl -u myapp.service -b -r --no-pager | head -n 200

로그에서 자주 나오는 패턴은 다음과 같습니다.

  • 설정 파일 경로 오류/파싱 실패: No such file or directory, invalid config
  • 포트 바인딩 실패: Address already in use
  • 권한/소유자 문제: Permission denied
  • 의존 서비스 미준비: Connection refused, timeout
  • 환경변수 미설정: KeyError, ENV not set

만약 애플리케이션이 DB 연결 실패로 즉시 종료한다면, DB 자체 문제일 수도 있습니다. 예를 들어 PostgreSQL 연결 수가 꽉 차서 애플리케이션이 시작 단계에서 실패하는 케이스도 흔합니다. 이 경우는 DB 측 지표도 함께 봐야 합니다: RDS PostgreSQL too many connections 원인·해결

4) Exit code/Signal을 해석해 “누가 죽였는지”를 구분

systemd 재시작 루프에서 핵심은 정상 종료(코드)인지, 시그널로 죽었는지를 가르는 것입니다.

4-1) 애플리케이션이 스스로 종료(Exit)

  • code=exited, status=1/FAILURE 같은 형태
  • 보통 설정 오류, 필수 의존성 실패, 마이그레이션 실패 등

이 경우는 애플리케이션 로그(표준출력/표준에러)와 설정 파일을 보면 답이 나옵니다.

4-2) SIGKILL(9) / status=137: OOMKill 또는 강제 종료

  • 커널 OOM Killer, cgroup 메모리 제한, 혹은 누군가 kill -9
  • journal에 Out of memory: Killed process ...가 남을 수 있음
# 커널 메시지에서 OOM 흔적 확인
sudo journalctl -k -b --no-pager | egrep -i "oom|killed process|out of memory"

# systemd가 기록한 종료 원인 상세
sudo systemctl show myapp.service -p ExecMainStatus -p ExecMainCode -p ExecMainStartTimestamp -p ExecMainExitTimestamp

4-3) Timeout: systemd가 시작/정지 타임아웃으로 죽임

  • Result: timeout 또는 Start operation timed out
  • TimeoutStartSec=가 너무 짧거나, readiness가 오래 걸리는 앱
sudo systemctl cat myapp.service
sudo systemctl show myapp.service -p TimeoutStartUSec -p TimeoutStopUSec

5) 유닛 파일을 점검: Restart= 정책과 타입(Type=)이 맞는가

재시작 루프는 종종 “앱은 정상인데 systemd가 오해”해서 생기기도 합니다. 대표적으로 Type= 불일치가 있습니다.

sudo systemctl cat myapp.service

5-1) Type=simple vs forking vs notify

  • Type=simple(기본): ExecStart 프로세스가 포그라운드로 계속 살아있어야 함
  • Type=forking: 데몬이 포크 후 부모가 종료하는 형태(전통적 데몬)
  • Type=notify: sd_notify(READY=1)를 보내 readiness를 알림

예를 들어 앱이 백그라운드로 포크하고 부모가 종료하는데 Type=simple이면, systemd는 “프로세스가 끝났네?”라고 판단하고 재시작할 수 있습니다. 반대로 포그라운드 프로세스인데 Type=forking이면 PID 추적이 꼬입니다.

5-2) Restart=가 과도한가

  • Restart=always는 어떤 종료든 재시작
  • Restart=on-failure는 실패(exit!=0, signal)만 재시작

원인 진단 중에는 Restart=on-failure가 더 안전합니다.

# /etc/systemd/system/myapp.service.d/override.conf
[Service]
Restart=on-failure
RestartSec=2s

적용:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

6) “환경 차이” 확인: 쉘에서 되는데 systemd에서만 실패하는 경우

로컬에서 같은 커맨드를 치면 잘 뜨는데 systemd로만 실패하면, 대부분 다음 중 하나입니다.

  • PATH/환경변수 차이 (Environment=, EnvironmentFile= 누락)
  • WorkingDirectory 차이
  • User/Group 권한 차이
  • 파일 디스크립터 제한(ulimit) 차이

6-1) systemd가 보는 환경 확인

sudo systemctl show myapp.service -p Environment -p EnvironmentFiles -p User -p Group -p WorkingDirectory

6-2) ExecStart를 그대로 복사해 “같은 사용자/디렉터리”로 실행

# 유닛이 myapp 사용자로 돈다면
sudo -u myapp -H bash -lc 'cd /opt/myapp && /opt/myapp/bin/myapp --config /etc/myapp/config.yml'

6-3) 자주 터지는 권한 케이스

  • 로그 디렉터리 쓰기 불가 (/var/log/myapp)
  • 소켓/포트 바인딩 권한(1024 미만 포트)
  • ExecStart 바이너리 실행 권한/SELinux 컨텍스트

7) StartLimit* 때문에 “재시작하다가 어느 순간 멈추는” 현상도 확인

계속 재시작되는 것처럼 보이다가 어느 순간 Start request repeated too quickly로 멈추는 경우가 있습니다.

sudo systemctl status myapp.service -l --no-pager
# ... Start request repeated too quickly.

이는 systemd의 rate limit에 걸린 것입니다.

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=2

원인 자체를 해결하는 게 먼저지만, 너무 공격적인 재시작은 장애를 키우므로 적절한 제한은 운영에 유리합니다.

8) 의존성(네트워크/DB/파일시스템) 문제: “준비되기 전에 시작”을 잡아내기

서비스가 외부 의존성에 민감하면, 부팅 직후 네트워크/마운트/DB가 준비되기 전에 시작했다가 실패하고 재시작 루프에 빠질 수 있습니다.

8-1) 네트워크 준비

[Unit]
After=network-online.target
Wants=network-online.target

8-2) 특정 마운트 필요

[Unit]
RequiresMountsFor=/data

8-3) DB 준비(간단한 pre-check)

[Service]
ExecStartPre=/usr/bin/bash -lc 'until nc -z 127.0.0.1 5432; do echo waiting for db; sleep 1; done'

의존성 문제가 네트워크 라우팅/보안그룹/방화벽처럼 인프라 레벨이라면, 애플리케이션 로그에는 단순히 타임아웃만 남는 경우가 많습니다. 이런 경우 “연결 경로”를 10분 안에 점검하는 접근이 도움이 됩니다: EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단

9) 코어 덤프와 스택 추적: 크래시(SEGFAULT 등)일 때

로그에 segfault, SIGSEGV, Aborted 등이 보이면 코어 덤프를 확인해야 합니다.

# 코어 덤프 목록
coredumpctl list myapp

# 가장 최근 코어 덤프 상세
coredumpctl info myapp

# gdb로 스택 트레이스(패키지에 디버그 심볼이 있으면 더 좋음)
sudo coredumpctl gdb myapp
# (gdb) bt

네이티브 바이너리(C/C++/Go)뿐 아니라, JVM도 치명적 오류 시 hs_err_pid 로그를 남깁니다. WorkingDirectory-XX:ErrorFile= 위치에 파일이 생성되는지 확인하세요.

10) 자주 나오는 “재시작 루프” 원인 Top 8

운영에서 반복적으로 만나는 원인을 빠르게 정리하면 다음과 같습니다.

  1. 설정 파일 경로/포맷 오류: 배포 후 경로가 바뀌었거나 템플릿 렌더링 실패
  2. 환경변수 누락: systemd 유닛에 EnvironmentFile= 미설정
  3. 권한 문제: 실행 사용자 변경 후 디렉터리 소유권 미조정
  4. 포트 충돌: 이전 프로세스가 남아있거나 다른 서비스가 점유
  5. Type 불일치: 포그라운드/포크 모델과 Type= 불일치
  6. 의존성 미준비: DB/캐시/네트워크 준비 전 기동
  7. 리소스 한계: OOMKill, LimitNOFILE 부족, CPUQuota 제한
  8. 타임아웃: TimeoutStartSec가 너무 짧아 초기화 중 kill

11) 재현 가능한 진단 템플릿(명령 모음)

아래 순서대로 실행하면 대부분의 케이스는 원인이 드러납니다.

# 1) 상태 요약
systemctl status myapp.service -l --no-pager

# 2) 유닛 파일 확인
systemctl cat myapp.service

# 3) 최근 로그(현재 부팅)
journalctl -u myapp.service -b --no-pager

# 4) 커널 로그에서 OOM/kill/segfault
journalctl -k -b --no-pager | egrep -i 'oom|killed process|segfault|out of memory'

# 5) systemd가 기록한 종료 코드/시그널
systemctl show myapp.service -p ExecMainCode -p ExecMainStatus -p Result

# 6) 코어 덤프(있다면)
coredumpctl list myapp

12) 마무리: “재시작”이 아니라 “종료”를 잡아라

systemd 재시작 루프는 증상이고, 본질은 프로세스가 왜 죽었는지입니다. systemctl status로 종료 형태(Exit/Signal/Timeout)를 구분하고, journalctl -u로 직전 로그를 확보한 뒤, 유닛의 Type/Restart/Timeout/환경/권한/의존성을 순서대로 제거해 나가면 대부분 30분 내에 결론이 납니다.

특히 운영에서는 “로그가 없어서”가 아니라 “로그를 보는 위치가 달라서” 놓치는 경우가 많습니다. systemd 환경에서의 표준출력/표준에러, 커널 OOM 로그, 그리고 코어 덤프까지 한 번에 묶어 보는 습관이 재발 방지에 가장 효과적입니다.