Published on

systemd 서비스가 계속 재시작될 때 원인 추적법

Authors

운영 중인 리눅스 서버에서 systemd 서비스가 "살아났다 죽었다"를 반복하면, 애플리케이션 자체 버그만 의심하기 쉽습니다. 하지만 실제로는 systemd의 재시작 정책(Restart=), 시작 제한(StartLimit*), 환경 변수/권한, 의존 서비스, 리소스 제한(MemoryMax=, LimitNOFILE=) 같은 런타임 조건이 원인인 경우가 많습니다.

이 글은 "왜 재시작되는지"를 감으로 추측하지 않고, 종료 코드와 타임라인을 중심으로 원인을 역추적하는 절차를 제공합니다. 특히 journalctl에서 핵심 신호를 뽑아내고, systemd 유닛 설정과 프로세스 종료 원인을 연결하는 방법에 집중합니다.

유사한 "계속 재시작" 문제를 Kubernetes에서 겪는 분이라면, 접근 방식이 거의 동일합니다. 컨테이너 환경의 재시작 원인 추적은 이 글도 함께 참고하세요: EKS CrashLoopBackOff 진단 - Pod 재시작 원인 추적

1) 먼저 "재시작"이 맞는지 상태를 확정

서비스가 재시작되는 것처럼 보여도 실제로는 다음 케이스일 수 있습니다.

  • 서비스는 정상 종료인데, 타이머나 다른 유닛이 다시 실행
  • Type=oneshot인데 계속 실행되는 것으로 착각
  • 서비스는 살아있지만 헬스체크/워처가 강제로 재시작

가장 먼저 아래로 상태를 확정합니다.

systemctl status myapp.service -l --no-pager
systemctl show myapp.service -p ActiveState,SubState,Result,ExecMainStatus,ExecMainCode,Restart,RestartUSec

여기서 특히 중요합니다.

  • ExecMainStatus: 메인 프로세스의 종료 코드(예: 1, 2, 137)
  • ExecMainCode: 종료 원인 유형(예: exited, killed, dumped)
  • Result: exit-code, signal, timeout 등 systemd가 판정한 결과
  • Restart: 재시작 정책이 켜져 있는지

ExecMainCode=killed 또는 Result=signal이면 애플리케이션 내부 exit(1)이 아니라, 시그널로 죽었을 가능성이 큽니다(예: OOM Kill, SIGKILL, SIGSEGV).

2) 재시작 타임라인을 로그로 재구성

원인 추적의 핵심은 "죽기 직전" 로그입니다. 다음 명령으로 해당 유닛 로그만 시간순으로 뽑습니다.

# 최신 부팅에서 해당 유닛 로그
journalctl -u myapp.service -b --no-pager

# 시간 범위를 좁혀 보기
journalctl -u myapp.service --since "2026-02-24 09:00" --until "2026-02-24 09:30" --no-pager

# 직전 200줄만
journalctl -u myapp.service -n 200 --no-pager

그리고 systemd가 남기는 대표적인 신호를 찾습니다.

  • Main process exited, code=exited, status=...
  • Failed with result 'exit-code' 또는 Failed with result 'signal'
  • Start request repeated too quickly.
  • Service RestartSec=... expired, scheduling restart.

Start request repeated too quickly가 보이면

이 메시지는 보통 짧은 시간에 너무 많이 죽어서 systemd가 시작을 막는 상황입니다. 즉, 근본 원인은 "죽는 이유"지만, 동시에 재시작 폭주를 제어해야 로그를 더 안정적으로 모을 수 있습니다.

systemctl show myapp.service -p StartLimitIntervalUSec,StartLimitBurst,RestartSec

3) 유닛 파일의 재시작 정책부터 확인

서비스가 자꾸 재시작되는 가장 흔한 이유는 단순합니다. 유닛에 재시작이 켜져 있기 때문입니다.

systemctl cat myapp.service

특히 아래 항목을 확인합니다.

  • Restart=always 또는 Restart=on-failure
  • RestartSec=...
  • SuccessExitStatus=... (특정 종료 코드를 성공으로 간주)
  • TimeoutStartSec=..., TimeoutStopSec=...

디버깅을 위해 일시적으로 재시작을 끄기

원인 파악 중에는 재시작이 로그를 덮어버리거나 상태를 불안정하게 만들 수 있습니다. 임시로 drop-in을 만들어 재시작을 꺼두면 편합니다.

sudo systemctl edit myapp.service

아래 내용을 입력합니다.

[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

원인 파악이 끝나면 반드시 원복하세요.

4) 종료 코드로 원인 범위를 1차 분류

ExecMainStatus 또는 로그의 status=...는 강력한 단서입니다.

자주 보는 종료 패턴

  • status=1 또는 status=2: 앱 설정/인자/환경 변수/권한 문제 가능성
  • status=127: 실행 파일을 못 찾음(경로, ExecStart 오타, 권한)
  • status=203/EXEC: systemd가 ExecStart 실행 자체 실패(파일 없음, 권한, shebang 문제)
  • status=217/USER: User=가 잘못되었거나 해당 유저로 실행 불가
  • status=137 또는 code=killed, status=9/KILL: OOM Kill 또는 강제 종료 의심

status=203/EXEC 같은 systemd 전용 코드는 특히 중요합니다. 앱 로그가 아니라 systemd가 실행을 못 한 것이라 앱 내부를 봐도 답이 안 나옵니다.

5) systemd가 실행을 못 하는 케이스(203/EXEC 등)

다음은 현장에서 매우 흔합니다.

  • ExecStart=/opt/myapp/bin/server 경로가 실제로 없음
  • 실행 권한 비트 누락
  • 스크립트 첫 줄 shebang이 잘못됨(예: #!/usr/bin/env bash 경로 불일치)
  • 바이너리가 동적 라이브러리를 못 찾아 즉시 종료

점검 순서:

# ExecStart 경로 확인
systemctl show myapp.service -p ExecStart

# 실제 파일 존재/권한
ls -al /opt/myapp/bin/server
file /opt/myapp/bin/server

# 동적 라이브러리 의존성
ldd /opt/myapp/bin/server

Node/Python 같은 런타임 기반이라면 WorkingDirectory=Environment=가 빠져도 즉시 종료할 수 있습니다.

6) 환경 변수/WorkingDirectory 문제를 재현 가능하게 만들기

서비스는 셸에서 수동 실행할 때와 환경이 다릅니다. 특히 PATH, HOME, 현재 디렉터리, .env 로딩 여부가 다릅니다.

유닛에서 다음을 확인하세요.

  • WorkingDirectory=
  • Environment= 또는 EnvironmentFile=
  • User= / Group=

디버깅용으로 환경을 출력하게 만들면 차이가 명확해집니다.

[Service]
Environment=SYSTEMD_DEBUG=1
ExecStartPre=/usr/bin/env

적용 후:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
journalctl -u myapp.service -b -n 200 --no-pager

ExecStartPre=/usr/bin/env 출력에서 기대한 환경 변수가 있는지 확인합니다.

7) 권한/파일 접근 문제(가장 흔한 운영 이슈)

서비스 유저가 바뀌면 다음이 즉시 터집니다.

  • 로그 디렉터리 쓰기 권한 없음
  • 소켓/포트 바인딩 권한 문제
  • 설정 파일 읽기 권한 없음

확인:

systemctl show myapp.service -p User,Group

# 해당 유저로 직접 실행해 보기
sudo -u myappuser -s
cd /path/that/WorkingDirectory
/opt/myapp/bin/server --config /etc/myapp/config.yml

포트가 1024 미만이면 CAP_NET_BIND_SERVICE가 필요할 수 있습니다. systemd에서는 다음처럼 부여합니다.

[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

8) OOM Kill, 리소스 제한, 파일 디스크립터 고갈

재시작 루프의 진짜 범인이 "메모리"인 경우가 많습니다.

OOM Kill 확인

# 커널 로그에서 OOM 흔적
journalctl -k -b --no-pager | grep -i -E 'oom|killed process|out of memory'

# systemd 관점의 종료 원인
systemctl status myapp.service -l --no-pager

status=137 또는 SIGKILL이면 OOM을 강하게 의심합니다.

systemd 리소스 제한 확인

systemctl show myapp.service -p MemoryMax,MemoryHigh,TasksMax,LimitNOFILE,CPUQuota

LimitNOFILE가 너무 낮으면 연결이 늘 때 EMFILE로 죽거나, 앱이 예외 처리 못 하고 종료할 수 있습니다.

운영 중 디스크/인오드 문제도 간접 원인이 됩니다(로그 쓰기 실패, 임시 파일 생성 실패). 디스크가 100퍼센트에 가까우면 이 글도 같이 보세요: 리눅스 디스크 100%? inode 고갈 진단·복구 실전

9) 크래시(세그폴트)라면 coredump로 확정

네이티브 바이너리나 일부 런타임에서 SIGSEGV가 나면 systemd는 재시작만 반복하고 원인이 숨겨질 수 있습니다.

# 최근 코어덤프 목록
coredumpctl list myapp.service

# 특정 덤프 상세
coredumpctl info myapp.service

# gdb로 바로 진입(서버에 gdb 필요)
sudo coredumpctl gdb myapp.service

coredumpctl infoSignal:Stack trace:만으로도 "어디서 죽는지" 윤곽이 나옵니다.

10) 의존 서비스/네트워크 준비 안 됨으로 인한 반복 종료

앱이 DB나 외부 API에 연결 실패하면 즉시 종료하도록 만들어진 경우가 있습니다. 이때 systemd는 "실패하니 재시작"을 반복합니다.

이런 경우는 유닛에 의존성을 명시하거나, 앱을 "재시도 후 정상 유지"하도록 바꾸는 게 정석입니다.

유닛 측면에서 최소한 아래를 점검합니다.

  • After=network-online.target
  • Wants=network-online.target
  • DB 서비스가 같은 호스트면 After=postgresql.service

예시:

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

[Service]
Restart=on-failure
RestartSec=5s

다만 네트워크 의존성은 완벽히 모델링하기 어렵습니다. 결국 앱 레벨에서 재시도(backoff)를 넣는 것이 재시작 루프를 줄입니다.

11) 타임아웃으로 죽는 경우(TimeoutStartSec/StopSec)

서비스가 "시작은 되는데" 준비가 늦어 TimeoutStartSec에 걸려 죽는 경우가 있습니다.

systemctl show myapp.service -p TimeoutStartUSec,TimeoutStopUSec,Type
journalctl -u myapp.service -b --no-pager | grep -i timeout

특히 Type=notify인데 앱이 sd_notify를 안 보내면 systemd는 준비 완료를 못 받아 타임아웃 처리할 수 있습니다. 이 경우 Type=simple로 바꾸거나, 앱이 notify를 제대로 보내도록 수정해야 합니다.

12) 실전: 원인 추적 체크리스트(10분 루틴)

재시작 루프를 만나면 아래 순서대로 하면 대부분 10분 내에 윤곽이 나옵니다.

  1. systemctl status ... -lExecMainStatus, Result 확인
  2. journalctl -u ... -b -n 200으로 "죽기 직전" 로그 확인
  3. systemctl cat ...Restart=, ExecStart=, User=, WorkingDirectory= 확인
  4. 203/EXEC, 217/USER 같은 systemd 코드면 유닛/권한부터 해결
  5. killed, 137, SIGKILL이면 OOM 및 MemoryMax, 커널 로그 확인
  6. 세그폴트 의심이면 coredumpctl로 확정
  7. 외부 의존성 문제면 After= 보강 및 앱 재시도(backoff) 검토

13) 재발 방지: 유닛에 최소한의 가드레일 추가

원인을 고쳤더라도 운영에서는 "또" 발생합니다. 다음 가드레일은 재시작 루프의 피해를 줄여줍니다.

  • 재시작 폭주 방지: StartLimitIntervalSec, StartLimitBurst
  • 재시작 간격: RestartSec
  • 로그 식별 강화: SyslogIdentifier=
  • 종료 코드 정책 정리: SuccessExitStatus=

예시:

[Unit]
Description=MyApp
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
User=myappuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server
Restart=on-failure
RestartSec=3s
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target

핵심은 "무조건 재시작"이 아니라, 실패 유형에 맞는 정책을 갖추는 것입니다. 앱이 설정 오류로 즉시 종료하는 케이스라면, 재시작이 문제를 숨기고 장애를 길게 끌 수 있습니다.

마무리

systemd 서비스 재시작 루프는 대부분 "앱이 죽는다"와 "systemd가 다시 살린다"의 합작입니다. 따라서 원인 추적도 두 축으로 나눠야 합니다.

  • 앱이 왜 죽는가: 종료 코드, 시그널, 코어덤프, 리소스
  • systemd가 왜 다시 살리는가: Restart=, 타임아웃, 시작 제한

위 절차대로 ExecMainStatusjournalctl 타임라인을 먼저 맞춰 보면, 감으로 디버깅하던 시간을 크게 줄일 수 있습니다.