[CS300 #223] systemd — 서비스를 띄우고, 지키고, 가두는 init
컴퓨터공학 300 주제 시리즈의 223번째 글이다. 전체 지도는 여기.
한 줄 요약
systemd 는 PID 1 로 떠서 “유닛” 이라는 선언적 설정 파일을 읽고, 의존 순서대로 서비스를 시작하고, 죽으면 다시 살리고, cgroup 으로 자원을 묶는 시스템·서비스 관리자다.
왜 필요한가
서버에 프로그램을 하나 띄우는 일은 쉽다. 어려운 것은 그다음이다.
- 부팅할 때 자동으로 떠야 한다. 단, 네트워크가 올라온 뒤에.
- 죽으면 다시 떠야 한다. 단, 무한 재시작 폭주는 막아야 한다.
- 로그는 한곳에 모여야 한다.
- 메모리를 폭식하면 다른 서비스를 해치기 전에 막아야 한다.
예전에는 셸 스크립트(SysV init)와 nohup, cron 으로 이 일을 했다. systemd 는 이 요구를 선언적 설정 몇 줄로 해결한다. 대부분의 주요 배포판이 기본 init 으로 systemd 를 쓰므로, 리눅스 서버를 운영하면 피할 수 없다.
핵심 개념
유닛(unit)
systemd 가 관리하는 대상은 모두 유닛이다. 파일 확장자가 유닛 종류를 나타낸다.
| 종류 | 예 | 하는 일 |
|---|---|---|
.service |
nginx.service |
프로세스(데몬) |
.timer |
backup.timer |
시간에 맞춰 다른 유닛을 실행(cron 대체) |
.socket |
sshd.socket |
소켓을 먼저 열고 연결이 오면 서비스 기동 |
.target |
multi-user.target |
유닛 묶음, 동기화 지점 |
.mount |
home.mount |
마운트 지점 |
.slice |
user.slice |
cgroup 계층의 가지(자원 분배 단위) |
시스템 유닛은 보통 /etc/systemd/system/(관리자가 쓰는 곳)과 배포판 패키지가 설치하는 경로에 있다. 관리자 파일이 패키지 파일보다 우선한다.
서비스 유닛 해부
[Unit]
Description=Sample web app
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=app
WorkingDirectory=/opt/app
ExecStart=/opt/app/bin/server --port 8080
Restart=on-failure
RestartSec=5
MemoryMax=512M
CPUQuota=50%
[Install]
WantedBy=multi-user.target
[Unit]의Wants=/Requires=는 의존(함께 시작할 것)이고After=/Before=는 순서다. 둘은 독립적이다.Wants=만 쓰고After=를 빼면 함께 시작되긴 하지만 동시에 시작된다. 흔한 실수다.Type=simple은ExecStart프로세스가 곧 메인 프로세스라는 뜻이다. 데몬이 스스로 fork 해서 부모가 끝나는 전통 방식이면Type=forking, 준비 완료를 알려 주는 프로그램이면Type=notify를 쓴다.Restart=on-failure는 비정상 종료(0 이 아닌 종료 코드, 신호로 죽음, 타임아웃 등)일 때만 재시작한다. 무한 재시작은[Unit]의StartLimitIntervalSec=,StartLimitBurst=로 제한된다. 한도를 넘으면 유닛은 실패 상태로 남는다.MemoryMax=,CPUQuota=는 cgroup 자원 제한이다. 메모리 한도를 넘으면 해당 cgroup 안에서 OOM 이 일어나고, 다른 서비스는 보호된다.[Install]의WantedBy=는systemctl enable을 했을 때 어느 target 에 매달릴지 정한다. enable 은 “부팅 시 자동 시작” 이고, start 는 “지금 시작” 이다. 별개다.
상태 기계
유닛은 inactive → activating → active → deactivating → inactive 를 오가고, 실패하면 failed 에 머문다. systemctl status 가 보여 주는 Active: 줄이 이것이다. failed 는 자동으로 풀리지 않는다. systemctl reset-failed 나 다음 start 로 지운다.
자주 쓰는 명령
systemctl daemon-reload # 유닛 파일을 고친 뒤 반드시
systemctl enable --now app.service # 자동 시작 등록 + 즉시 시작
systemctl status app.service
systemctl restart app.service
journalctl -u app.service -f # 해당 유닛 로그 따라 보기
systemctl list-timers # 타이머 목록과 다음 실행 시각
systemd-analyze blame # 부팅 시 유닛별 소요 시간
타이머: cron 대체
# backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
같은 이름의 backup.service 를 실행한다. Persistent=true 는 꺼져 있던 동안 놓친 실행을 켜진 직후 한 번 수행한다. cron 대비 장점은 로그가 journald 에 남고, 실행 이력과 다음 실행 시각이 list-timers 에 보이고, 서비스 유닛의 자원 제한을 그대로 쓸 수 있다는 점이다.
사용자 단위 systemd
systemctl --user 로 root 권한 없이 내 계정의 서비스를 띄울 수 있다. 유닛은 ~/.config/systemd/user/ 에 둔다. 로그아웃 후에도 유지하려면 그 사용자에 대해 lingering 을 켜야 한다(loginctl enable-linger).
직접 해 보기
Restart=on-failure 와 StartLimitBurst 가 만드는 동작을 흉내 내 보자. 실제 systemd 의 기본값은 배포판·버전마다 다를 수 있으므로 여기서는 값을 명시해서 시뮬레이션한다.
def simulate(exits, restart_sec=5, burst=5, interval=10):
"""exits: 프로세스가 매번 몇 초 뒤 어떤 코드로 끝나는지 [(runtime, code), ...]"""
t, starts = 0, []
for runtime, code in exits:
starts = [s for s in starts if t - s < interval]
if len(starts) >= burst:
print(f"t={t:3}s start-limit-hit -> failed (stop retrying)")
return
starts.append(t)
print(f"t={t:3}s start (attempt {len(starts)} in last {interval}s)")
t += runtime
if code == 0:
print(f"t={t:3}s exited 0 -> inactive (no restart)")
return
print(f"t={t:3}s exited {code} -> restart in {restart_sec}s")
t += restart_sec
print("case A: crash once, then OK")
simulate([(30, 1), (100, 0)])
print("\ncase B: crash loop, restart every 1s")
simulate([(0, 1)] * 10, restart_sec=1)
print("\ncase C: crash loop, RestartSec=5")
simulate([(0, 1)] * 10, restart_sec=5)
결과는 다음과 같다.
case A: crash once, then OK
t= 0s start (attempt 1 in last 10s)
t= 30s exited 1 -> restart in 5s
t= 35s start (attempt 1 in last 10s)
t=135s exited 0 -> inactive (no restart)
case B: crash loop, restart every 1s
t= 0s start (attempt 1 in last 10s)
t= 0s exited 1 -> restart in 1s
t= 1s start (attempt 2 in last 10s)
t= 1s exited 1 -> restart in 1s
t= 2s start (attempt 3 in last 10s)
t= 2s exited 1 -> restart in 1s
t= 3s start (attempt 4 in last 10s)
t= 3s exited 1 -> restart in 1s
t= 4s start (attempt 5 in last 10s)
t= 4s exited 1 -> restart in 1s
t= 5s start-limit-hit -> failed (stop retrying)
case C: crash loop, RestartSec=5
t= 0s start (attempt 1 in last 10s)
t= 0s exited 1 -> restart in 5s
t= 5s start (attempt 2 in last 10s)
t= 5s exited 1 -> restart in 5s
t= 10s start (attempt 2 in last 10s)
...
C 는 시작 한도에 걸리지 않는다. 10초 창 안에 시작이 두 번뿐이기 때문이다. 시뮬레이션은 입력 10회에서 멈추지만, 실제 서비스라면 끝없이 재시작한다. 설정 오류로 절대 뜰 수 없는 서비스라면 이것은 로그만 쌓는다. 재시작 간격과 시작 한도는 함께 설계해야 한다.
현업에서는
- “설정 바꿨는데 반영이 안 돼요”. 유닛 파일을 고친 뒤
daemon-reload를 빠뜨린 경우가 가장 흔하다. 패키지가 설치한 유닛을 직접 고치지 말고systemctl edit app.service로 drop-in(app.service.d/override.conf)을 만들면 업그레이드에도 살아남는다. - 자원 격리. 한 서버에 여러 서비스가 있을 때 하나가 메모리를 다 먹으면 전체가 죽는다.
MemoryMax=를 걸거나, 무거운 일회성 작업을systemd-run --scope -p MemoryMax=2G 명령처럼 별도 칸에서 돌리면 피해가 그 칸 안에 갇힌다. - 쿠버네티스 노드의 기반. kubelet 과 컨테이너 런타임도 보통 systemd 서비스로 뜬다. k3s 도 설치 스크립트가 systemd 유닛을 만든다. 노드가 NotReady 가 되면 그 노드에서 kubelet(또는 k3s) 유닛의 상태와 journal 을 가장 먼저 본다.
- 컨테이너와의 관계. 컨테이너 안에서는 보통 systemd 를 쓰지 않는다. 컨테이너 하나에 프로세스 하나가 원칙이고, 재시작과 자원 제한은 바깥(컨테이너 런타임, 쿠버네티스)이 맡는다. 같은 역할이 계층을 옮겨 간 것이다.
확인 문제
Wants=와After=의 차이를 설명하라.systemctl enable과systemctl start는 각각 무엇을 하는가?- 유닛 파일을 수정한 뒤 반드시 실행해야 하는 명령은?
- cron 대신 systemd 타이머를 쓸 때의 장점 두 가지를 들라.
- 계속 죽는 서비스가 어느 순간부터 재시작하지 않고 failed 로 남았다. 무엇 때문일 가능성이 큰가?
풀이
Wants=는 함께 시작하라는 의존 관계,After=는 시작 순서다. 순서까지 보장하려면 둘 다 써야 한다.- enable 은
[Install]에 따라 target 에 링크를 걸어 부팅 시 자동 시작되게 한다. start 는 지금 바로 시작한다. systemctl daemon-reload.- 로그가 journald 에 유닛 단위로 남는다. 다음 실행 시각과 이력을
list-timers로 본다. 놓친 실행을Persistent=로 보정한다. 자원 제한을 걸 수 있다. (이 중 두 가지) - 시작 횟수 제한(
StartLimitIntervalSec/StartLimitBurst)에 걸렸다.
더 읽을거리 (References)
- systemd, systemd.service(5)
- systemd, systemd.unit(5)
- systemd, systemd.timer(5)
- systemd, systemd.resource-control(5)
- The Linux Kernel documentation, Control Group v2