“유닛"이라는 단어가 묶고 있는 것
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/* 셸 스크립트 |
.socket | inetd / xinetd / launchd |
.timer | cron / anacron / at |
.mount | fstab |
.automount | autofs |
.path | inotify 사용처 (디렉토리 감시 데몬) |
.target | runlevel |
.slice | cgroup 트리의 그룹 이름 |
.scope | 외부에서 만든 프로세스를 cgroup 으로 묶는 래퍼 |
.device | udev 와의 다리 |
.swap | swapon |
.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 가 즉시 메인 프로세스 |
exec | simple + 자식 exec 까지 끝나야 활성화 처리 |
forking | 옛 데몬 스타일. fork 후 부모는 종료하고 자식이 데몬으로 동작 |
oneshot | 일회성 작업 (마이그레이션, 셸 스크립트 등) |
notify | 자식이 sd_notify(3) 로 “준비됨” 통지 |
dbus | DBus 이름 등록 시점에 활성화 처리 |
idle | simple 변형. 다른 잡이 콘솔 출력을 끝낼 때까지 대기 |
옛 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년대 후반 | xinetd | inetd 를 보안성 강화로 대체 (Panagiotis Tsirigotis(파나기오티스 치리고티스)) |
| 2005-04-29 | launchd | Mac OS X 10.4 Tiger 도입. Dave Zarzycki(데이브 자지키) 설계. 데몬을 미리 안 띄우고 첫 연결로 깨운다 |
| 2010 | systemd socket activation | launchd에서 영감을 받아 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의 자리를 맡은 달력식입니다.
보조 키워드도 그대로 정확히 적어둡니다.
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/fstabinto 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/lennartmust be configured in a unit filehome-lennart.mount.
따라서 /var/log는 var-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 |
|---|---|
| 0 | poweroff.target |
| 1 | rescue.target |
| 3 | multi-user.target |
| 5 | graphical.target |
| 6 | reboot.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입니다. 아래 링크는 모두 그곳을 가리킵니다.
- systemd.unit(5): 유닛 일반
- systemd.service(5)
- systemd.socket(5)
- systemd.timer(5)
- systemd.mount(5)
- systemd.path(5)
- systemd.slice(5)
- sd_listen_fds(3): socket activation 인터페이스
- systemd-fstab-generator(8)
- Rethinking PID 1 — Lennart Poettering (2010)
- launchd — Wikipedia
- inetd — Wikipedia
- LinuxQuestions Interviews Slackware Founder Patrick Volkerding (2012)