직접 만든 프로그램을 서버에서 계속 돌리려면 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.target이enable의 대상이다.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 myappType= — “시작됐다”의 뜻이 값마다 다르다
여기가 “성공했다는데 안 돈다”의 진짜 원인이다. Type=은 systemd가 언제 이 서비스를 ‘시작 완료’로 볼지를 정한다. 없는 실행 파일을 ExecStart=에 적어 두고 값만 바꿔 가며 재 봤다.
Type= | systemctl start의 종료 코드 | 실제 상태 |
|---|---|---|
simple | 0 (성공처럼 보인다) | 곧 failed, ExecMainStatus=203 |
exec | 1 + 즉시 에러 메시지 | failed |
forking (포그라운드 프로그램에 지정) | TimeoutStartSec까지 멈춰 있다 | Result=timeout |
oneshot + RemainAfterExit=yes | 0 | active (프로세스가 끝나도 유지) |
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()만 하면 시작으로 치기 때문이다. exec은 execve()까지 성공해야 시작으로 치므로 같은 상황에서 실패를 알려 준다.
참고로 종료 상태 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.conf의DefaultStartLimitIntervalSec=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 startedjournalctl -u myapp -f— 실시간journalctl -u myapp -n 50 --no-pager— 최근 50줄journalctl -u myapp --since "10 min ago"— 시간 범위
오래된 예제에 흔한 StandardOutput=syslog는 지금 필요 없다. 기본값이 이미 저널이다.
고장났을 때 보는 순서
systemctl status myapp -l --no-pager— 상태, 마지막 종료 코드, 최근 로그 몇 줄.journalctl -u myapp -n 50 --no-pager— 프로세스가 남긴 진짜 이유.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.ExecStart를 ExecStrat으로 잘못 적은 것도, always를 alwyas로 적은 것도 무시된 뒤 “그래서 실행할 게 없다”로 귀결됐다. 유닛 파일을 고칠 때마다 돌리면 된다.
남의 유닛은 고치지 말고 드롭인으로 덮는다
패키지가 설치한 유닛의 설정 하나만 바꾸고 싶을 때 쓴다. 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-flagCentOS 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.service—Type=값별로 “시작 완료”의 기준이 무엇인지,simple에서systemctl start가 실패를 보고하지 않는다는 서술을 확인했다.man 5 systemd.unit— 유닛 로드 경로의 우선순위 표,StartLimitIntervalSec=/StartLimitBurst=가[Unit]설정이며reset-failed로 카운터가 초기화된다는 설명을 확인했다.