Published on

systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘

Authors

서버에서 systemd 서비스가 “떴다 죽었다”를 반복하면, 표면 증상은 단순합니다. Active: activating (auto-restart) 혹은 failed가 번갈아 보이고, 재시작 카운터가 빠르게 증가합니다. 하지만 원인은 크게 세 갈래로 갈립니다.

  • 로그가 말해주는 즉시 원인: 포트 점유, 권한, 파일 누락, 설정 파싱 실패, OOM 등
  • 유닛(Unit) 설정이 만든 재시작 루프: Restart=/RestartSec=/StartLimit*=/Type=/Timeout*= 조합
  • 쉘/실행 환경 문제: ExecStart가 기대한 환경변수·작업 디렉터리·PATH·권한을 못 받거나, 스크립트가 잘못된 종료 코드를 내는 경우

이 글은 “로그 → 유닛 → 쉘” 순서로 최단 시간에 재시작 루프를 끝내는 진단 루틴을 제공합니다.

관련해서 더 넓은 관점의 원인 추적은 systemd 서비스가 계속 재시작될 때 원인 추적도 함께 참고하면 좋습니다.

1) 증상 고정: 지금 무슨 상태인지 먼저 캡처

재시작 루프는 시간이 지나면 로그가 밀리고, StartLimit에 걸리면 또 양상이 바뀝니다. 먼저 아래 3개를 캡처해 “현 상태”를 고정하세요.

systemctl status myapp.service -n 200 --no-pager
systemctl show myapp.service -p ActiveState -p SubState -p NRestarts -p Result
systemctl show myapp.service -p ExecMainStatus -p ExecMainCode -p MainPID
  • ExecMainStatus가장 중요한 힌트입니다. 일반적으로 0이면 정상 종료, 1 이상이면 오류 종료입니다.
  • ExecMainCodeexited인지 killed인지 구분하세요. killed면 OOM 또는 시그널 종료 가능성이 큽니다.

재시작 루프를 잠깐 멈추는 방법(진단용)

원인 분석 중 로그가 폭주하면 잠시 멈추는 게 좋습니다.

sudo systemctl stop myapp.service
sudo systemctl reset-failed myapp.service

서비스를 “아예 다시 못 뜨게” 막고 싶다면 마스킹도 가능합니다.

sudo systemctl mask myapp.service
# 해제
sudo systemctl unmask myapp.service

2) 로그 진단: journalctl을 ‘정확히’ 보는 법

systemctl status는 요약만 보여줍니다. 루프의 직접 원인은 대부분 journalctl에 있습니다.

최근 부팅부터 서비스 로그만 보기

journalctl -u myapp.service -b --no-pager -n 300

시간 범위를 좁혀 재시작 직전/직후를 보기

journalctl -u myapp.service --since "10 min ago" --no-pager

재시작 루프에서 특히 유용한 옵션

  • -o short-precise: 밀리초 단위 타임스탬프
  • -o cat: 메타데이터 없이 메시지만
journalctl -u myapp.service -b -o short-precise --no-pager

자주 나오는 로그 패턴과 의미

1) status=203/EXEC

ExecStart에 지정한 바이너리/스크립트를 실행하지 못했다는 뜻입니다.

대표 원인:

  • 경로 오타
  • 실행 권한 없음(chmod +x 누락)
  • 인터프리터 경로 문제(예: shebang이 잘못됨)
  • No such file or directory지만 실제로는 동적 링커/라이브러리 누락

확인:

systemctl cat myapp.service
ls -al /path/to/exec
file /path/to/exec
ldd /path/to/exec 2>/dev/null | sed -n '1,120p'

2) status=1/FAILURE 혹은 애플리케이션 자체 에러

이 경우는 서비스가 “실행은 됐는데 바로 죽는” 상황입니다. 애플리케이션 로그(표준출력/표준에러)가 journalctl에 남는지부터 확인하세요.

만약 출력이 없으면 StandardOutput/StandardError 설정 문제일 수도 있습니다(뒤에서 다룹니다).

3) start-limit-hit

너무 빨리/자주 재시작되어 systemd가 더 이상 시도하지 않는 상태입니다.

systemctl status myapp.service --no-pager | sed -n '1,80p'

이 상태에서는 원인을 고친 후에도 아래를 해줘야 다시 시도합니다.

sudo systemctl reset-failed myapp.service
sudo systemctl start myapp.service

4) Killed process ... out of memory

커널 OOM으로 죽은 케이스입니다.

dmesg -T | grep -i -E "oom|killed process" | tail -n 50
journalctl -k -b --no-pager | grep -i -E "oom|killed process" | tail -n 80

메모리 압박이 디스크/인오드 문제와 함께 터지는 경우도 자주 있습니다. 디스크가 꽉 차면 로그/임시파일/소켓 생성 실패로 연쇄 장애가 나기도 하니 리눅스 디스크 100%? inode 고갈 진단·복구 실전도 같이 점검해보세요.

3) 유닛(Unit) 진단: 재시작을 만든 설정을 찾아라

로그에서 애플리케이션 오류가 명확하면 그걸 고치면 되지만, 많은 경우 유닛 설정이 상황을 악화시킵니다.

유닛 원문 확인: drop-in까지 포함해서 보기

systemctl cat myapp.service

운영 환경에서는 /etc/systemd/system/myapp.service.d/override.conf 같은 drop-in이 숨어 있는 경우가 많습니다.

핵심 필드만 빠르게 덤프

systemctl show myapp.service \
  -p FragmentPath -p DropInPaths \
  -p Type -p Restart -p RestartSec \
  -p StartLimitIntervalUSec -p StartLimitBurst \
  -p TimeoutStartUSec -p TimeoutStopUSec \
  -p ExecStart -p WorkingDirectory -p User -p Group \
  -p Environment -p EnvironmentFile \
  --no-pager

Restart=가 루프를 만드는 전형적인 경우

  • Restart=always로 되어 있고
  • 애플리케이션이 설정 오류로 즉시 종료하며
  • RestartSec=0 또는 매우 짧음

이면 로그 폭주 + CPU/IO 낭비가 발생합니다.

진단 중에는 임시로 재시작 정책을 완화할 수 있습니다.

sudo systemctl edit myapp.service

아래처럼 drop-in을 추가합니다.

[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

원인 해결 후 원복하세요.

Type= 불일치로 “정상인데 죽었다고 판단”하는 케이스

  • Type=forking인데 실제로는 포그라운드 프로세스
  • Type=simple인데 실제로는 데몬화(포크 후 부모 종료)

이 경우 systemd가 메인 PID를 잘못 추적해 서비스가 죽었다고 보고 재시작할 수 있습니다.

확인 포인트:

systemctl show myapp.service -p Type -p MainPID -p ControlPID -p GuessMainPID --no-pager

대부분의 현대 서버 프로세스는 포그라운드 실행 + Type=simple 또는 준비 신호를 쓰면 **Type=notify**가 안정적입니다.

TimeoutStartSec= 때문에 “느린 기동”이 실패로 처리되는 케이스

DB 마이그레이션, 캐시 워밍업, 대형 설정 로딩 등으로 기동이 길어지면 TimeoutStartSec에 걸려 강제 종료되고 재시작 루프가 됩니다.

systemctl show myapp.service -p TimeoutStartUSec --no-pager

증상은 로그에 start operation timed out 비슷한 메시지로 남습니다.

StartLimit*=로 인해 “어느 순간부터 아예 안 뜨는” 케이스

재시작 루프가 심하면 결국 start-limit-hit로 굳습니다.

  • StartLimitBurst가 작고
  • RestartSec가 짧으면

금방 막힙니다. 진단 중에는 값을 늘려 원인 파악 시간을 벌 수도 있지만, 근본적으로는 즉시 종료를 유발하는 원인을 제거해야 합니다.

4) 쉘/실행 환경 진단: 같은 명령을 수동 실행해도 재현되나

서비스는 로그인 쉘과 환경이 다릅니다.

  • PATH가 다름
  • HOME이 비어 있거나 다름
  • WorkingDirectory가 다름
  • 권한/그룹/umask가 다름
  • EnvironmentFile이 로드 실패해도 조용히 망가지는 경우가 있음

4-1) ExecStart를 “그대로” 뽑아서 수동 실행

systemctl show myapp.service -p ExecStart --no-pager

출력된 커맨드를 복사해 수동 실행해보되, 반드시 같은 사용자로 실행해야 의미가 있습니다.

sudo -u myapp -H bash -lc '/path/to/exec --flag1 --flag2'
  • -H는 HOME을 대상 사용자로 맞추는 데 도움이 됩니다.
  • bash -lc는 로그인 쉘처럼 초기화를 약간 더 흉내냅니다. 단, 서비스 환경과 완전히 같지는 않습니다.

4-2) WorkingDirectory, 상대경로 의존성 확인

유닛에서 WorkingDirectory가 없으면 기본은 /입니다. 애플리케이션이 상대경로로 설정 파일을 읽는다면 바로 죽을 수 있습니다.

systemctl show myapp.service -p WorkingDirectory --no-pager

해결은 보통 유닛에 명시합니다.

[Service]
WorkingDirectory=/opt/myapp

4-3) EnvironmentFile 로드 실패/값 누락

EnvironmentFile이 가리키는 파일이 없거나 권한이 없으면 환경변수가 비어 서비스가 즉시 종료할 수 있습니다.

systemctl show myapp.service -p EnvironmentFile -p Environment --no-pager

파일 존재/권한:

sudo ls -al /etc/myapp/myapp.env
sudo -u myapp cat /etc/myapp/myapp.env

4-4) 스크립트 ExecStart의 흔한 함정: 종료 코드와 파이프

ExecStart=/bin/bash -c 'cmd1 | cmd2' 형태에서 cmd1이 실패해도 파이프 때문에 성공처럼 보이거나, 반대로 set -e 때문에 의도치 않게 즉시 종료할 수 있습니다.

권장 패턴:

[Service]
ExecStart=/bin/bash -lc 'set -euo pipefail; /opt/myapp/bin/myapp --config /etc/myapp/config.yml'
  • set -euo pipefail로 실패를 빠르게 드러내고
  • -l로 최소한의 로그인 환경을 맞추되
  • 가능한 한 최종적으로는 “스크립트가 아니라 바이너리 직접 실행”으로 단순화하는 게 가장 좋습니다.

4-5) 표준출력/표준에러가 어디로 가는지 확인

로그가 비어 보이면 출력이 다른 곳으로 가는 중일 수 있습니다.

systemctl show myapp.service -p StandardOutput -p StandardError --no-pager

진단 단계에서는 journal로 모으는 편이 편합니다.

[Service]
StandardOutput=journal
StandardError=journal

5) 재현과 격리: systemd-run으로 “서비스 환경”에 가깝게 테스트

수동 실행이 애매하면 systemd-run으로 유사 환경을 만들어 재현성을 높일 수 있습니다.

sudo systemd-run --unit myapp-debug \
  --property=User=myapp \
  --property=WorkingDirectory=/opt/myapp \
  --property=Environment="ENV=prod" \
  /opt/myapp/bin/myapp --config /etc/myapp/config.yml

journalctl -u myapp-debug -b --no-pager -n 200

이 방식은 “유닛으로 돌릴 때만 실패”하는 문제(권한, 경로, 환경)를 잡는 데 특히 효과적입니다.

6) 실전 체크리스트: 15분 안에 끝내는 순서

아래 순서대로 하면 대부분의 재시작 루프는 빠르게 방향이 잡힙니다.

  1. systemctl statusExecMainStatus, Result, NRestarts 확인
  2. journalctl -u -b로 재시작 직전 로그 확인
  3. status=203/EXEC면 경로/권한/인터프리터/라이브러리부터
  4. OOM 의심이면 journalctl -kdmesg -T 확인
  5. systemctl cat로 drop-in 포함 유닛 전체 확인
  6. Restart=/Type=/TimeoutStartSec=/WorkingDirectory/EnvironmentFile 점검
  7. sudo -u로 동일 사용자 수동 실행 재현
  8. 필요하면 systemd-run으로 유사 환경 재현
  9. 진단 중에는 Restart=no drop-in으로 루프를 잠깐 끊고 원인 해결

7) 쿠버네티스 CrashLoopBackOff와의 공통점

systemd 재시작 루프는 쿠버네티스의 CrashLoopBackOff와 구조가 유사합니다.

  • “프로세스가 빨리 죽는다”는 사실은 같고
  • 차이는 “런타임과 선언(유닛/매니페스트)이 무엇을 재시작 조건으로 보느냐”입니다.

컨테이너 환경의 관점으로 원인 분류를 확장하고 싶다면 K8s CrashLoopBackOff 원인 10가지·즉시 진단법도 도움이 됩니다.

8) 예시: 재시작 루프를 줄이고 원인 노출을 강화한 유닛

아래는 흔한 웹/워커 계열 서비스를 가정한 예시입니다.

[Unit]
Description=MyApp API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp

EnvironmentFile=/etc/myapp/myapp.env

ExecStart=/opt/myapp/bin/myapp --config /etc/myapp/config.yml

# 진단/운영 안정화 포인트
Restart=on-failure
RestartSec=3s
StartLimitIntervalSec=60
StartLimitBurst=10

TimeoutStartSec=60
TimeoutStopSec=30

StandardOutput=journal
StandardError=journal

# 필요 시 리소스 제한(OOM/폭주 방지)
# MemoryMax=500M
# CPUQuota=200%

[Install]
WantedBy=multi-user.target

적용 커맨드:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service --no-pager -n 200

마무리

systemd 재시작 루프는 “로그만 보면 될 것 같은데” 실제로는 유닛 설정과 실행 환경이 원인을 숨기거나 증폭시키는 경우가 많습니다. journalctl로 직접 원인을 잡고, systemctl show로 유닛의 재시작 조건과 타입/타임아웃을 점검한 뒤, 마지막으로 sudo -usystemd-run으로 환경 차이를 좁히면 대부분의 케이스는 짧은 시간 안에 해결 방향이 나옵니다.