“유닛"이라는 단어가 묶고 있는 것

SysVinit → Upstart → systemd: 리눅스 init 세대 연표 의 마지막 단락은 이렇게 끝났다.

systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔다.

그 한 줄을 펼치는 글이다. 시스템 영역에서 systemd 가 흡수해 간 것 중 가장 가시적인 흔적이 유닛(unit) 이라는 단일 언어로 흩어진 옛 도구들이다. cron, fstab, inetd, autofs, inotify, runlevel, cgroup — 다 다른 시대에 다른 사람이 다른 이유로 만든 도구들이 *.service, *.timer, *.mount 같은 같은 모양의 파일 안으로 모여 들어왔다.

서버에 들어가서 systemctl --type=help 한 번 쳐보면 출력이 짧다. 11줄.

service
socket
target
device
mount
automount
timer
swap
path
slice
scope

이 11종이 무엇을 흡수했는지를 한 표로 먼저 보여주고, 그 다음에 한 종류씩 풀어 본다.

유닛흡수한 옛 도구
.service/etc/init.d/* 셸 스크립트
.socketinetd / xinetd / launchd
.timercron / anacron / at
.mountfstab
.automountautofs
.pathinotify 사용처 (디렉토리 감시 데몬)
.targetrunlevel
.slicecgroup 트리의 그룹 이름
.scope외부에서 만든 프로세스를 cgroup 으로 묶는 래퍼
.deviceudev 와의 다리
.swapswapon

.service — 옛 init.d 스크립트의 자리

가장 익숙한 유닛이다. 1편의 hello-web.service 9줄 예제를 다시 떠올리면 충분하다.

[Unit]
Description=hello web server
After=network.target

[Service]
ExecStart=/usr/local/bin/hello-web --port 8080
Restart=on-failure

[Install]
WantedBy=multi-user.target

핵심은 Type= 의 6종이다 (man systemd.service).

Type언제 쓰나
simple기본값. ExecStart 가 즉시 메인 프로세스
execsimple + 자식 exec 까지 끝나야 활성화 처리
forking옛 데몬 스타일 — fork 후 부모는 종료, 자식이 데몬
oneshot일회성 작업 (마이그레이션, 셸 스크립트 등)
notify자식이 sd_notify(3) 로 “준비됨” 통지
dbusDBus 이름 등록 시점에 활성화 처리
idlesimple 변형 — 다른 잡이 콘솔 출력 끝낼 때까지 대기

옛 init.d 시절의 가장 큰 골칫거리였던 “데몬이 fork 한 자식을 init 이 추적하지 못한다” 는 cgroup 으로 깔끔하게 해결됐다 (1편의 §3 참조). PID 파일 위조도 더블 fork 도 cgroup 트리에서 도망갈 수 없다. Restart=on-failure 한 줄이 monit/supervisord 의 자리를 흡수한 것도 이 추적 위에서 가능해진 일이다.

.service 한 종류만 따로 깊게 다룰 글을 다음에 한 편 더 쓸 수 있을 만큼 옵션이 많다. 이 글에서는 여기까지.


.socket — inetd → xinetd → launchd → systemd, 21년의 계보

.socket 이 가져온 모델을 흔히 “socket activation” 이라 부른다. 처음 들으면 systemd 의 발명 같지만 사실 40년짜리 계보의 마지막 단계다.

연도도구한 줄
1980년대 초inetd (4.3BSD)슈퍼 서버 — 소켓을 listen 하다가 연결 들어오면 데몬을 fork
1990년대 후반xinetdinetd 를 보안성 강화로 대체 (Panagiotis Tsirigotis(파나기오티스 치리고티스))
2005-04-29launchdMac OS X 10.4 Tiger 도입. Dave Zarzycki(데이브 자지키) 설계. 데몬을 미리 안 띄우고 첫 연결로 깨운다
2010systemd socket activationlaunchd 에서 영감, Linux 로

1편이 “macOS launchd 에서 영감을 받았다” 한 줄로 끝낸 부분의 21년짜리 사연이다. 흥미롭게도 launchd 의 데뷔일이 정확히 21년 전 오늘이다.

systemd 가 자식에게 listen 소켓을 어떻게 넘기는지는 sd_listen_fds(3) man page 에 정확히 적혀 있다.

The first file descriptor may be found at file descriptor number 3 (i.e. SD_LISTEN_FDS_START), the remaining descriptors follow at 4, 5, 6, …

세 개의 환경변수가 따라온다.

  • $LISTEN_PID — 이 fd 들이 자기 것인지 확인용 (PID 일치 검사)
  • $LISTEN_FDS — 넘어온 소켓 개수
  • $LISTEN_FDNAMES — 각 소켓의 라벨 (FileDescriptorName=)

자식 입장에서는 accept() 만 하면 되고, listen 은 systemd 가 부팅 직후 미리 해 둔 상태다. 결과:

  • 부팅 시 데몬을 미리 안 띄워도 된다 (첫 연결로 깨움)
  • 데몬을 재시작해도 listen 소켓이 살아 있어 연결이 잘리지 않는다
  • 의존성 — A 가 B 의 소켓을 두드리면 systemd 가 B 를 알아서 깨운다

가벼운 예 — sshd.socket:

[Unit]
Description=OpenSSH Server Socket

[Socket]
ListenStream=22
Accept=no

[Install]
WantedBy=sockets.target

Accept=yes 면 연결마다 인스턴스화된 sshd@.service 가 깨어난다. 이게 inetd 의 원래 모델이다.


.timer — cron / anacron / at 의 후계

cron 표현식은 “분 시 일 월 요일” 5컬럼이다. systemd 의 OnCalendar= 는 같은 일을 하지만 표현법이 다르다.

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=300

트리거 키워드는 6종이다 (man systemd.timer).

  • OnActiveSec= — 타이머가 활성화된 시점 기준
  • OnBootSec= — 부팅 후 N초
  • OnStartupSec= — systemd 가 뜬 후 N초
  • OnUnitActiveSec= — 짝꿍 유닛이 마지막으로 활성화된 시점 기준 (반복 잡)
  • OnUnitInactiveSec= — 짝꿍 유닛이 마지막으로 멈춘 시점 기준
  • OnCalendar= — 달력식 (cron 자리)

보조 키워드도 verbatim 으로 정확히 적어둔다.

  • Persistent=true — 시스템이 꺼져 있던 시간을 보정해 부팅 직후 한 번 실행 (anacron 의 자리)
  • RandomizedDelaySec= — N초 범위 내 무작위 지연. cron 으로 풀기 어려운 thundering herd 회피
  • AccuracySec= — 1분 단위로 묶어 깨워서 노트북 배터리/디스크 절약
  • OnClockChange=, OnTimezoneChange= — 시계가 점프했을 때 트리거

타이머는 자기가 일을 하지 않는다. .timer 는 짝꿍 .service 를 한 번씩 깨울 뿐이다. 잡 본체는 .service 에 적는다.

# /etc/systemd/system/certbot-renew.timer
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
# /etc/systemd/system/certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet

cron 대비 이득은 분명하다 — journald 에 자동 로깅, Wants= / After= 로 의존성 표현, 실패 시 OnFailure= 로 알림 잡 트리거.


.mount / .automount — fstab 의 그림자, autofs 흡수

리눅스를 오래 만진 사람도 잘 모르는 사실 — fstab 은 부팅 시점에 그대로 쓰이지 않는다. systemd-fstab-generator(8) 가 끼어들어 fstab 의 각 줄을 .mount.swap 유닛으로 변환한 다음, systemd 의 의존성 그래프에 끼워 넣는다.

systemd-fstab-generator is a generator that translates /etc/fstab into native systemd units… instantiating mount and swap units as necessary.

이름 규칙도 정해져 있다 (man systemd.mount 의 예시).

Mount units must be named after the mount point directories they control. Example: the mount point /home/lennart must be configured in a unit file home-lennart.mount.

따라서 /var/logvar-log.mount 가 된다. 슬래시는 하이픈으로, 첫 슬래시는 떨어뜨리고. 이 변환 로직은 systemd-escape 명령으로 직접 돌려볼 수 있다.

$ systemd-escape -p --suffix=mount /var/log
var-log.mount

.automount 는 autofs 의 자리를 흡수했다. 마운트 지점에 첫 접근이 들어오면 그제서야 mount 하고, 일정 시간 idle 하면 unmount 한다. NFS 같은 큰 볼륨을 lazy mount 하고 싶을 때 유용하다.

# /etc/systemd/system/mnt-bigdata.automount
[Unit]
Description=Lazy mount for /mnt/bigdata

[Automount]
Where=/mnt/bigdata
TimeoutIdleSec=600

[Install]
WantedBy=multi-user.target

짝꿍 .mount 가 같이 있어야 한다 (mnt-bigdata.mount). .automount 는 트리거, .mount 는 실제 마운트.


.path — inotify 를 유닛 언어로

“파일이 생기면 / 바뀌면 / 사라지면 잡을 깨운다” 는 패턴은 옛날부터 있었다. cron 폴링, inotifywait 스크립트, 파일을 감시하는 데몬을 따로 띄우기. .path 가 이걸 유닛 언어로 흡수했다.

키워드트리거 조건
PathExists=경로가 존재하면
PathExistsGlob=글롭 패턴이 매치되면
PathChanged=파일/디렉토리가 변경되면 (close-after-write)
PathModified=매 write 마다
DirectoryNotEmpty=디렉토리에 항목이 있으면
# /etc/systemd/system/upload-watch.path
[Path]
PathChanged=/srv/upload
Unit=upload-process.service

[Install]
WantedBy=multi-user.target

cron 으로 1분마다 폴링하던 잡을 즉시 반응형으로 바꿀 수 있다. .timer 와 마찬가지로 .path 도 자기가 일하지 않고 짝꿍 .service 를 깨우는 모델이다.


.target — runlevel 의 후계

1편의 런레벨 표를 그대로 가져와 매핑하면 이렇다.

런레벨systemd target
0poweroff.target
1rescue.target
3multi-user.target
5graphical.target
6reboot.target

init 3 자리에 systemctl isolate multi-user.target 이 들어왔다.

런레벨은 단순한 모드 번호였다. target 은 의존성 그래프 위의 동기화 지점이다. network-online.target 은 “네트워크가 실제로 도달 가능해질 때까지 기다리는 자리”, local-fs.target 은 “로컬 파일시스템이 모두 마운트된 자리”, sockets.target 은 “모든 socket activation listen 이 끝난 자리”.

/etc/systemd/system/default.target 은 심볼릭 링크다. 데스크톱이면 graphical.target, 서버면 보통 multi-user.target 으로 걸려 있다.

$ systemctl get-default
multi-user.target

.slice / .scope — cgroup 트리에 이름을 붙이는 두 형태

1편이 “cgroup 추적이 systemd 승리의 결정타였다” 고 적었다. 그 cgroup 트리를 시스템에서 직접 들여다볼 수 있는 게 .slice.scope 다.

$ systemd-cgls
Control group /:
├─user.slice
│ └─user-1000.slice
│   ├─user@1000.service
│   └─session-3.scope
│     └─sshd 와 자식 프로세스들
├─system.slice
│ ├─nginx.service
│ ├─postgresql.service
│ └─sshd.service
└─machine.slice
  └─runc 컨테이너들

세 개의 최상위 슬라이스로 나뉜다.

  • system.slice — 시스템 데몬
  • user.slice — 로그인 사용자 (login session)
  • machine.slice — VM, 컨테이너

.scope 는 systemd 가 직접 만들지 않은 프로세스 — 예: SSH 로그인 세션, runc 가 띄운 컨테이너 — 를 cgroup 으로 묶는 래퍼다. systemd 입장에서는 “외부에서 도착한 프로세스 무리에 이름표를 붙여 트리에 끼워 넣는” 도구.

자원 제한은 슬라이스에든 서비스에든 들어갈 수 있다.

[Slice]
MemoryMax=4G
CPUQuota=50%
TasksMax=200

/etc/systemd/system/heavy-jobs.slice 한 파일에 4GB / 50% / 200 task 캡을 걸어두고, 거기에 속한 .service 들이 그 한도를 공유하는 식.


.device / .swap — udev 와 swapon 의 다리

직접 작성하는 일이 거의 없는 두 종류다.

  • .device — udev 이벤트로 자동 생성된다. /dev/sda 가 인식되면 dev-sda.device 가 자동으로 활성화. 다른 유닛에서 BindsTo=dev-sda.device 같은 식으로 의존성만 거는 용도.
  • .swap — fstab 의 swap 항목을 위에서 본 systemd-fstab-generator 가 변환해 만든다. 직접 작성할 일이 거의 없고, 작성하더라도 Where= 가 아니라 What= 으로 디바이스를 지정한다.

정직하게 말하면 — 11종 중 운영자가 직접 작성하는 건 사실상 6~7종이다. .device.swap 은 자동 생성, .scope 도 외부 도구가 만든다. 직접 손으로 쓰는 건 .service, .socket, .timer, .mount, .automount, .path, .target, .slice 정도.


“init 비대화” 비판이 가리키는 것

여기까지 9개 섹션을 거친 독자가 자연스럽게 보게 되는 그림이 있다 — init 한 자리에 cron, fstab, inetd, autofs, inotify, runlevel, cgroup 도구가 모두 모여 있다. 1편이 비껴간 “Unix 철학과 어긋난다” 비판이 정확히 이 그림을 가리킨다.

비판 진영의 가장 또렷한 한 마디는 Slackware 창립자 Patrick Volkerding(패트릭 볼커딩) 의 2013 년 인터뷰다.

“I don’t spend all day rebooting my machine, and having looked at systemd config files it seems to me a very foreign way of controlling a system to me, and attempting to control services, sockets, devices, mounts, etc., all within one daemon flies in the face of the UNIX concept of doing one thing and doing it well.”

— Patrick Volkerding(패트릭 볼커딩), 2013

“services, sockets, devices, mounts, etc., all within one daemon” — 이 글이 §2 부터 §9 까지 보여준 11종 투어와 정확히 같은 그림을 묘사한다. Volkerding 은 그것을 부담으로 봤다.

반대편에서 Lennart Poettering(레나르트 푀터링) 은 2010 년 “Rethinking PID 1” 에서 정반대 입장을 폈다. 단일 데몬이 의존성 그래프와 cgroup 추적을 한 자리에서 가지고 있어야 socket activation, parallel boot, 정확한 프로세스 정리가 일관되게 동작한다는 논지였다.

이 글은 어느 쪽이 옳다고 말하지 않는다. 다만 두 진영이 가리키는 그림 자체 — init 한 자리에 옛 도구 7~8종이 흡수된 — 는 같다는 것을 보여주는 데까지가 이 글의 자리다. 그 그림을 어떻게 평가할지는 운영하는 시스템의 성격과 운영자의 취향에 달렸다.


한 줄로

1편이 “PID 1 자리에 누가 앉느냐"의 이야기였다면, 2편은 “그 자리에 앉은 것이 자기 영역을 어디까지 정의했느냐"의 이야기다. 유닛이라는 한 단어가 cron 부터 cgroup 까지 끌어안았다는 사실은, 좋은 평가든 나쁜 평가든 같은 그림 위에서 시작한다.

다음 편은 둘 중 하나로 더 깊이 들어갈 예정이다 — .service 한 종류만 끝까지 파고드는 운영자용 다이브, 또는 1편이 핵심으로 꼽았던 의존성 그래프(Wants= / Requires= / After=)가 실제로 풀리는 방식. 어느 쪽이 먼저 나올지는 다음 글에서 정한다.


참고

systemd 공식 man pages 는 freedesktop.org 가 upstream 이다. 아래 링크는 모두 거기를 가리킨다.