컴퓨터공학 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 를 쓰지 않는다. 컨테이너 하나에 프로세스 하나가 원칙이고, 재시작과 자원 제한은 바깥(컨테이너 런타임, 쿠버네티스)이 맡는다. 같은 역할이 계층을 옮겨 간 것이다.

확인 문제

  1. Wants= 와 After= 의 차이를 설명하라.
  2. systemctl enable 과 systemctl start 는 각각 무엇을 하는가?
  3. 유닛 파일을 수정한 뒤 반드시 실행해야 하는 명령은?
  4. cron 대신 systemd 타이머를 쓸 때의 장점 두 가지를 들라.
  5. 계속 죽는 서비스가 어느 순간부터 재시작하지 않고 failed 로 남았다. 무엇 때문일 가능성이 큰가?

풀이

  1. Wants= 는 함께 시작하라는 의존 관계, After= 는 시작 순서다. 순서까지 보장하려면 둘 다 써야 한다.
  2. enable 은 [Install] 에 따라 target 에 링크를 걸어 부팅 시 자동 시작되게 한다. start 는 지금 바로 시작한다.
  3. systemctl daemon-reload.
  4. 로그가 journald 에 유닛 단위로 남는다. 다음 실행 시각과 이력을 list-timers 로 본다. 놓친 실행을 Persistent= 로 보정한다. 자원 제한을 걸 수 있다. (이 중 두 가지)
  5. 시작 횟수 제한(StartLimitIntervalSec/StartLimitBurst)에 걸렸다.

더 읽을거리 (References)