직접 만든 프로그램을 서버에서 계속 돌리려면 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번 죽으면 더 안 살아난다. - 이 두 설정은
[Unit]섹션에 둔다(systemd.unit(5)). 옛 예제에는[Service]안에StartLimitInterval=로 적힌 것들이 있는데, systemd 249에서 그대로 넣어 봐도systemd-analyze verify는 아무 말도 하지 않았다 — 검사기가 안 잡아 주니 눈으로 옮겨야 한다.
StartLimitIntervalSec=은 간격이 아니라 창이다
위 유닛을 “30초 간격으로 3번 재시작한다”로 읽기 쉽다. 아니다. 간격을 정하는 건 RestartSec=이고, StartLimitIntervalSec=은 “몇 초짜리 창 안에서 시작 횟수를 세느냐”다. RestartSec=0이니 30초가 아니라 16밀리초 만에 세 번을 다 쓴다.
$ journalctl --user -u demo-crash.service --no-pager -o short-precise
00:22:20.622932 systemd[950]: Started restart burst demo.
00:22:20.623847 systemd[950]: demo-crash.service: Scheduled restart job, restart counter is at 1.
00:22:20.624332 systemd[950]: Started restart burst demo.
00:22:20.625694 systemd[950]: demo-crash.service: Scheduled restart job, restart counter is at 2.
00:22:20.626146 systemd[950]: Started restart burst demo.
00:22:20.638644 systemd[950]: demo-crash.service: Scheduled restart job, restart counter is at 3.
00:22:20.638789 systemd[950]: demo-crash.service: Start request repeated too quickly..622에서 .638까지, 전부 같은 1초 안이다. 그러니까 30초는 폭주를 감지하는 관찰 구간이고, 실제 상황은 그 앞머리에서 끝난 뒤 남은 30초 가까이를 잠긴 채 보내는 것이다.
창 설정은 그대로 두고 RestartSec=2로만 바꾸면 간격이 2초씩 벌어진다.
00:23:38.246 systemd[913]: Started restart gap demo.
00:23:40.402 systemd[913]: gap-demo.service: Scheduled restart job, restart counter is at 1.
00:23:42.631 systemd[913]: gap-demo.service: Scheduled restart job, restart counter is at 2.
00:23:44.883 systemd[913]: gap-demo.service: Scheduled restart job, restart counter is at 3.
00:23:44.883 systemd[913]: gap-demo.service: Start request repeated too quickly.StartLimitIntervalSec=30은 손대지 않았는데 소요가 16밀리초에서 6.6초로 늘었다. 두 설정은 서로 다른 것을 정한다. 그리고 6.6초는 30초 창을 못 넘었으므로 결국 똑같이 제한에 걸린다 — RestartSec=이 창보다 짧으면 폭주 제한은 언젠가 걸린다.
정말 “30초 간격으로”를 원한다면 RestartSec=30이다. 다만 간격을 창만큼 벌리면 폭주 제한이 통째로 무력해진다 — 창 하나에 시작이 하나씩만 들어가기 때문이다. 둘 다 3초로 줄여(창 3초, RestartSec=3) 20초를 지켜봤다.
$ journalctl --user -u eq-demo.service | grep -c "Scheduled restart"
6
$ journalctl --user -u eq-demo.service | grep -c "repeated too quickly"
0
$ systemctl --user show eq-demo.service -p ActiveState -p NRestarts
ActiveState=activating
NRestarts=6여섯 번을 되살렸는데 한 번도 안 걸렸다. 계속 죽는 프로그램이면 이대로 영원히 돈다. RestartSec=은 창보다 짧게 둬야 안전장치가 살아 있다.
걸린 제한은 창이 지나면 저절로 풀린다
reset-failed가 유일한 해제 수단은 아니다. 창이 지나가면 아무것도 안 해도 다시 시작된다. 창을 5초로 줄여 재 봤다.
$ systemctl --user start win-demo.service # 한도에 걸린 직후
Job for win-demo.service failed because the control process exited with error code.
(종료 코드 1)
$ # 5초 창이 지나기를 기다린 뒤, reset-failed 없이 그대로
$ systemctl --user start win-demo.service
(종료 코드 0)그래서 systemctl reset-failed <유닛>은 창이 지나기를 기다리지 않고 당장 풀고 싶을 때 쓴다.
재시작이 늘 정답은 아니다
설정 오류로 죽는 프로세스는 몇 번을 되살려도 똑같이 죽는다. 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 --user show demo.service -p StandardOutput -p StandardError
StandardOutput=journal
StandardError=inheritStandardError=inherit은 “표준 출력과 같은 곳으로”라는 뜻이다. 그래서 출력 방향을 바꿀 때 StandardOutput= 하나만 고치면 에러도 따라간다.
/bin/sh -c로 감싸면 로그 태그가 뭉개진다
위 실측 출력을 다시 본다. 프로세스가 찍은 줄의 태그가 sh[954]다. 저널은 실행 파일 이름으로 줄에 태그를 단다. 그런데 앞에서 ExecStart=는 셸이 아니니 파이프가 필요하면 /bin/sh -c로 감싸라고 했다 — 그렇게 감싼 서비스는 전부 sh로 찍힌다. 그런 유닛이 여럿이면 저널에서 서로 구분이 안 된다.
SyslogIdentifier=가 그 태그를 바꾼다. 같은 유닛에 이 한 줄만 넣고 다시 시작한 결과다.
$ journalctl --user -u tag-demo.service -n 1 --no-pager # SyslogIdentifier 없음
sh[957]: demo started
$ journalctl --user -u tag-demo.service -n 1 --no-pager # SyslogIdentifier=myapp
myapp[967]: demo started태그가 붙으면 유닛 이름 대신 태그로도 뽑을 수 있다 — journalctl -t myapp.
파일로 뺄 때 — file:을 쓰면 옛 로그가 섞인다
저널 말고 파일에 남겨야 할 때가 있다. 값이 셋인데 셋의 차이가 로그를 망가뜨린다.
| 값 | 파일을 열 때 하는 일 |
|---|---|
file:/경로 | 처음부터 덮어쓴다. 자르지는 않는다 |
append:/경로 | 끝에 이어 붙인다 |
truncate:/경로 | 열면서 자른다 |
file:이 함정이다. 이름과 달리 truncate가 아니라 파일 앞에서부터 덮어쓰기라서, 이번 실행의 출력이 지난 실행보다 짧으면 뒤에 옛 내용이 그대로 남는다. 긴 줄을 찍는 유닛으로 파일을 만든 뒤, 같은 파일에 짧은 줄을 찍는 유닛을 걸어 봤다.
$ cat /tmp/ovr.log # 1) 긴 출력 — 29바이트
AAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ cat /tmp/ovr.log # 2) file:로 짧은 출력 — 여전히 29바이트
bb
AAAAAAAAAAAAAAAAAAAAAAAAA
$ cat /tmp/ovr.log # 3) 같은 자리에 truncate: — 3바이트
bb2번이 이번 실행 로그도, 지난 실행 로그도 아닌 잡탕이다. tail로 들여다보면 지난 실행의 찌꺼기를 이번 로그로 읽게 된다. 그래서 파일로 뺄 거면 거의 항상 append: 다. truncate:는 매 실행이 독립적이고 직전 것을 안 봐도 되는 경우에나 맞는다.
파일로 빼면 딸려오는 게 둘 더 있다.
-
그 출력은 저널에 안 남는다. 같은 서비스의 저널에는 systemd가 찍은 줄만 남는다.
$ journalctl --user -u out-file.service -n 2 --no-pager systemd[918]: Starting out-file.service... systemd[918]: Finished out-file.service.journalctl -u로 찾던 습관이 있으면 이게 “로그가 사라졌다”로 보인다. -
회전을 직접 챙겨야 한다. 저널은 크기 상한이 있어 알아서 지운다(
journald.conf의SystemMaxUse=, 기본값은 파일시스템의 10%).append:파일에는 그런 게 없으니 logrotate를 따로 걸어야 한다.
고장났을 때 보는 순서
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로 카운터가 초기화된다는 설명을 확인했다.man 5 systemd.exec—StandardOutput=의 값별 동작을 확인했다. 특히file:이 “파일 처음부터 열되 자르지 않는다”,append:는 추가 모드,truncate:는 열면서 자른다는 서술.man 5 journald.conf— 저널이 쓰는 디스크 상한(SystemMaxUse=)의 기본값이 파일시스템의 10%라는 것을 확인했다.