직접 만든 프로그램을 서버에서 계속 돌리려면 nohup이나 screen이 아니라 서비스로 등록한다. 재부팅 후 자동 시작, 죽었을 때 재시작, 로그 수집이 그때부터 공짜로 딸려온다.

등록 자체는 텍스트 파일 하나다. 실제로 시간을 잡아먹는 건 그다음이다 — systemctl start가 성공했다고 했는데 프로세스는 없는 상태. 아래는 유닛 파일을 쓰는 법과, 그 “성공했는데 안 도는” 상황이 왜 생기고 어떻게 알아채는지다.

유닛 파일은 어디에 두나

systemd.unit(5)가 정의하는 로드 경로는 여러 개고, 같은 이름이 여러 곳에 있으면 우선순위가 높은 것 하나만 쓰인다. 자주 마주치는 건 셋이다.

경로용도우선순위
/etc/systemd/system관리자가 만든 유닛 — 내가 쓰는 곳높음
/run/systemd/system런타임 유닛(재부팅하면 사라진다)중간
/lib/systemd/system (= /usr/lib/systemd/system)배포판 패키지가 설치한 유닛낮음

패키지가 깐 파일은 고치지 않는다. 패키지를 업데이트하면 덮어써진다. 남의 유닛을 바꾸고 싶으면 아래 “드롭인” 항목을 쓴다.

root 권한이 없거나 내 계정에서만 돌리면 되는 프로그램이라면 사용자 단위를 쓸 수 있다. 파일은 ~/.config/systemd/user/에 두고 명령마다 --user를 붙인다. 이 글의 실측도 전부 사용자 단위로 했다.

최소 유닛 파일

/etc/systemd/system/myapp.service:

[Unit]
Description=My application
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
ExecStart=/usr/local/bin/myapp --config /etc/myapp.toml
WorkingDirectory=/var/lib/myapp
Environment=MYAPP_ENV=production
User=myapp
Group=myapp
Restart=on-failure
RestartSec=5s
 
[Install]
WantedBy=multi-user.target

읽을 때 걸리는 항목만 짚는다.

  • After=Wants=는 다른 일을 한다. After=는 순서만 정하고(그 유닛이 켜진다면 그 뒤에), Wants=는 같이 켜지게 만든다. 네트워크가 올라온 뒤 시작해야 한다면 둘 다 필요하다.
  • ExecStart=는 셸이 아니다. 절대경로여야 하고 파이프·리다이렉션·&&가 안 된다. 정말 필요하면 ExecStart=/bin/sh -c "..."로 감싼다.
  • WantedBy=multi-user.targetenable의 대상이다. enable은 이 타깃의 .wants 디렉터리에 심링크를 만드는 일이고, 그래서 부팅 때 함께 올라온다.
  • Restart=on-failure는 0이 아닌 종료·시그널 사망일 때만 되살린다. 정상 종료까지 되살리려면 always다.

daemon-reload · enable · start는 서로 다른 일을 한다

세 명령을 같은 것으로 착각해 “등록했는데 안 돈다”가 나온다. 실제로 확인한 결과다.

$ systemctl --user enable demo.service
Created symlink .../default.target.wants/demo.service → .../demo.service.
$ systemctl --user is-enabled demo.service
enabled
$ systemctl --user is-active demo.service
inactive          ← enable은 "부팅 때 켜라"일 뿐, 지금 켜지 않는다
$ systemctl --user start demo.service
$ systemctl --user is-active demo.service
active
  • daemon-reload — 유닛 파일을 새로 읽는다. 파일을 고친 뒤 이걸 빼먹으면 옛 내용으로 계속 돈다. systemd가 알아채고 경고는 해 준다.

    Warning: The unit file, source configuration file or drop-ins of demo.service changed
    on disk. Run 'systemctl daemon-reload' to reload units.
    
  • enable — 부팅 시 자동 시작 등록(심링크 생성). 지금 실행하지는 않는다.

  • start — 지금 실행. 재부팅 후에도 뜨게 하려면 enable이 따로 필요하다.

둘 다 하려면 systemctl enable --now myapp이다. 새 유닛을 놓은 직후의 순서는 늘 이렇다.

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp

Type= — “시작됐다”의 뜻이 값마다 다르다

여기가 “성공했다는데 안 돈다”의 진짜 원인이다. Type=systemd가 언제 이 서비스를 ‘시작 완료’로 볼지를 정한다. 없는 실행 파일을 ExecStart=에 적어 두고 값만 바꿔 가며 재 봤다.

Type=systemctl start의 종료 코드실제 상태
simple0 (성공처럼 보인다)failed, ExecMainStatus=203
exec1 + 즉시 에러 메시지failed
forking (포그라운드 프로그램에 지정)TimeoutStartSec까지 멈춰 있다Result=timeout
oneshot + RemainAfterExit=yes0active (프로세스가 끝나도 유지)

simple이 성공을 보고하는 건 버그가 아니라 정의다. systemd.service(5)가 그대로 적어 두었다 — “systemctl start command lines for simple services will report success even if the service’s binary cannot be invoked successfully (for example because the selected User= doesn’t exist, or the service binary is missing)”. fork()만 하면 시작으로 치기 때문이다. execexecve()까지 성공해야 시작으로 치므로 같은 상황에서 실패를 알려 준다.

참고로 종료 상태 203은 “실행 파일을 실행하지 못했다” 는 뜻이다. 경로 오타, 실행 권한 없음, 없는 User= 지정이 다 여기로 모인다.

고르는 기준은 단순하다.

  • 포그라운드로 계속 도는 프로그램 → simple(기본값). 시작 실패를 systemctl start에서 바로 알고 싶으면 exec.
  • 스스로 데몬화해서 부모가 빠지는 전통적 프로그램 → forking + PIDFile=.
  • 한 번 실행하고 끝나는 초기화 작업 → oneshot. 끝난 뒤에도 “완료됨”으로 남기려면 RemainAfterExit=yes.

포그라운드 프로그램에 forking을 주면 부모가 안 죽으니 systemd는 시작이 안 끝났다고 보고 타임아웃까지 기다린다. 위 표의 세 번째 줄이 그것이다.

Restart=와 재시작 폭주

Restart=always만 걸어 두면 무한히 살아날 것 같지만 아니다. 시작 속도 제한이 따로 있다.

[Unit]
StartLimitIntervalSec=30
StartLimitBurst=3
 
[Service]
Restart=always
RestartSec=0
ExecStart=/bin/sh -c "exit 1"

이 유닛을 시작하면 3번 재시작한 뒤 멈춘다.

$ systemctl --user show demo-crash.service -p Result -p NRestarts
Result=exit-code
NRestarts=3
$ journalctl --user -u demo-crash.service
... demo-crash.service: Start request repeated too quickly.
$ systemctl --user start demo-crash.service   # 이후엔 수동 시작도 거부된다
(종료 코드 1)
  • 기본값은 /etc/systemd/system.confDefaultStartLimitIntervalSec=10s, DefaultStartLimitBurst=5다. 즉 아무 설정도 안 해도 10초 안에 5번 죽으면 더 안 살아난다.
  • 제한에 걸린 상태는 systemctl reset-failed <유닛>으로 푼다.
  • 이 두 설정은 [Unit] 섹션에 둔다(systemd.unit(5)). 옛 예제에는 [Service] 안에 StartLimitInterval=로 적힌 것들이 있는데, systemd 249에서 그대로 넣어 봐도 systemd-analyze verify는 아무 말도 하지 않았다 — 검사기가 안 잡아 주니 눈으로 옮겨야 한다.

재시작이 정답이 아닌 경우도 있다. 설정 오류로 죽는 프로세스는 몇 번을 되살려도 똑같이 죽는다. RestartSec=를 넉넉히 주고(예: 5s), 폭주 제한을 그대로 두는 편이 로그를 읽을 여지를 남긴다.

실행 사용자를 지정한다

User=/Group=을 비우면 root로 돈다. 웹에서 입력을 받는 프로그램이라면 그 자체가 사고 대기다. 서비스 전용 계정을 만들어 지정한다.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp

주의할 점은 없는 사용자를 적어도 Type=simple에서는 조용히 실패한다는 것이다(위의 203). 계정을 먼저 만들고 유닛을 올린다.

로그는 저널로 모인다

ExecStart=가 돌린 프로세스의 표준 출력·표준 에러는 아무 설정 없이도 저널에 들어간다. 실측 로그를 보면 systemd가 남긴 줄과 프로세스가 찍은 줄이 같이 있다.

$ journalctl --user -u demo.service -n 3 --no-pager
systemd[898]: Started 검증용 데모 서비스.
sh[954]: demo started
  • journalctl -u myapp -f — 실시간
  • journalctl -u myapp -n 50 --no-pager — 최근 50줄
  • journalctl -u myapp --since "10 min ago" — 시간 범위

오래된 예제에 흔한 StandardOutput=syslog는 지금 필요 없다. 기본값이 이미 저널이다.

고장났을 때 보는 순서

  1. systemctl status myapp -l --no-pager — 상태, 마지막 종료 코드, 최근 로그 몇 줄.
  2. journalctl -u myapp -n 50 --no-pager — 프로세스가 남긴 진짜 이유.
  3. systemd-analyze verify /etc/systemd/system/myapp.service — 파일 자체의 문법.

3번이 중요한 이유가 있다. systemd는 모르는 키를 조용히 무시한다. 오타든, 이 버전에 없는 새 설정이든 똑같이 무시하고 넘어간다. 검사기를 돌려야 보인다.

$ systemd-analyze verify demo-typo.service
demo-typo.service:3: Unknown key name 'ExecStrat' in section 'Service', ignoring.
demo-typo.service:4: Failed to parse service restart specifier, ignoring: alwyas
demo-typo.service: Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing.
Unit demo-typo.service has a bad unit file setting.

ExecStartExecStrat으로 잘못 적은 것도, alwaysalwyas로 적은 것도 무시된 뒤 “그래서 실행할 게 없다”로 귀결됐다. 유닛 파일을 고칠 때마다 돌리면 된다.

남의 유닛은 고치지 말고 드롭인으로 덮는다

패키지가 설치한 유닛의 설정 하나만 바꾸고 싶을 때 쓴다. systemctl edit myapp을 실행하면 /etc/systemd/system/myapp.service.d/override.conf가 만들어지고, 원본은 그대로 둔 채 값만 덮인다.

$ systemctl --user cat demo.service
# ~/.config/systemd/user/demo.service
[Service]
Environment=DEMO_ENV=original
ExecStart=/bin/sleep 60
 
# ~/.config/systemd/user/demo.service.d/override.conf
[Service]
Environment=DEMO_ENV=override
 
$ systemctl --user show demo.service -p Environment
Environment=DEMO_ENV=override

단, ExecStart=처럼 여러 번 쌓이는 설정은 그냥 다시 적으면 안 된다. 덮이는 게 아니라 두 개가 된다.

$ systemd-analyze verify t-drop.service
t-drop.service: Service has more than one ExecStart= setting,
which is only allowed for Type=oneshot services. Refusing.

빈 값으로 먼저 지운 뒤 새로 적는다. 이러면 통과한다.

[Service]
ExecStart=
ExecStart=/usr/local/bin/myapp --new-flag

CentOS 7 / RHEL 7에서 다른 점

절차와 명령은 같지만 systemd 버전이 낮아 걸리는 게 있다.

  • 먼저 systemctl --version을 본다. 최신 문서에 있는 설정이 그 버전에 없을 수 있고, 앞서 봤듯 없는 키는 조용히 무시된다. Type=exec처럼 나중에 들어온 값이 대표적이다. 적용 여부는 systemd-analyze verify로 확인한다.
  • SELinux가 enforcing이면 /usr/local/bin 같은 곳의 바이너리 실행이나 파일 접근이 막힐 수 있다. 서비스는 failed인데 로그에 이유가 안 보이면 sudo ausearch -m avc -ts recent로 AVC 거부를 확인한다.
  • 방화벽(firewalld)은 서비스 등록과 별개다. 포트를 여는 건 유닛 파일이 하는 일이 아니다.

이 글의 명령과 출력은 WSL2의 우분투 22.04(systemd 249) 에서 systemctl --user로 직접 확인했다. /etc/systemd/system·User=·multi-user.target처럼 root가 필요한 부분과 CentOS 7 항목은 man 페이지와 아래 참고 자료를 근거로 적었다.

참고

  • CentOS7 systemd 에 서비스 등록하기 — CentOS 7 기준의 등록 절차와 [Service] 항목 구성(ExecStart, Restart, User, Environment)을 참고했다.
  • man 5 systemd.serviceType= 값별로 “시작 완료”의 기준이 무엇인지, simple에서 systemctl start가 실패를 보고하지 않는다는 서술을 확인했다.
  • man 5 systemd.unit — 유닛 로드 경로의 우선순위 표, StartLimitIntervalSec=/StartLimitBurst=[Unit] 설정이며 reset-failed로 카운터가 초기화된다는 설명을 확인했다.