본문으로 건너뛰기

"운영" 태그로 연결된 33개 게시물개의 게시물이 있습니다.

모든 태그 보기

systemd 유닛 11종

· 약 10분

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

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

systemd의 트리거 유닛이 service와 mount 유닛을 깨우는 관계

.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=의 주요 7종입니다(man systemd.service, v253+는 notify-reload 추가).

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의 계보​

.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의 자리를 맡은 달력식입니다.

보조 키워드도 그대로 정확히 적어둡니다.

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/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
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(패트릭 볼커딩)의 2012년 인터뷰에 나옵니다.

"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(패트릭 볼커딩), 2012

"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입니다. 아래 링크는 모두 그곳을 가리킵니다.

리눅스 init 세대 연표

· 약 13분

30년 버틴 init, 5년 만에 갈아치워진 표준​

리눅스에서 PID 1로 부팅 직후 가장 먼저 실행되는 프로세스가 init이에요. 이 자리는 SysVinit이 30년 가까이 지켰지만 2010년대 초반부터 갑자기 풍경이 바뀌었어요. 2011년 Fedora 15에서 등장한 systemd가 5년 만에 거의 모든 메이저 배포판의 기본 init을 차지했고, 그 사이에는 Upstart가 잠깐 등장했다 사라졌죠.

이 글은 세 init 시스템을 기능 비교 대신 연표로 다룹니다.

  • 어떤 배포판이 언제 SysVinit을 버렸는가
  • Upstart는 왜 짧게 살았는가
  • systemd 채택을 둘러싼 Debian 투표, Devuan 분기, Ubuntu의 입장 변화는 어떻게 흘러갔는가
  • 지금도 systemd를 쓰지 않는 배포판은 어디인가

서버에 SSH로 들어가서 ps -p 1 -o comm=을 한 번 실행했을 때, 출력 한 줄이 어떤 역사 위에 서 있는지 정리해 보겠습니다.

세 세대, 한눈에​

세대이름등장부팅 모델설정 단위대표 배포판 (전환 시점)
1세대SysVinit1983 (System V)직렬 / 런레벨/etc/inittab + /etc/init.d/*.sh거의 모든 리눅스 (~ 2010s)
2세대Upstart2006이벤트 기반/etc/init/*.confUbuntu 6.10 ~ 14.04, RHEL 6
3세대systemd2010병렬 / 의존성 + cgroup*.service, *.socket, *.timer, *.targetFedora 15+, RHEL 7+, Debian 8+, Ubuntu 15.04+, Arch, openSUSE 12.1+

리눅스 init 세대와 주요 채택 연표

세 시스템의 본질적 차이는 한 줄로 줄일 수 있습니다. SysVinit은 셸 스크립트를 순서대로 돌리고, Upstart는 이벤트가 발생하면 잡(job)을 돌리며, systemd는 의존성 그래프를 따라 유닛(unit)을 병렬로 돌린 뒤로도 계속 지켜봅니다.

1세대: SysVinit (1983~)​

뿌리는 AT&T가 1983년에 낸 Unix System V입니다. 리눅스에는 1990년대 초 Miquel van Smoorenburg(미컬 반 스모렌부르흐)가 포팅한 sysvinit 패키지로 흘러들어왔고, 그 후 약 20년간 거의 모든 리눅스 배포판의 기본 init이 됐습니다.

핵심 구조는 단순합니다.

/etc/inittab → 어느 런레벨에 어떤 스크립트를 돌릴지 선언
/etc/init.d/* → 각 서비스의 start/stop/restart 스크립트
/etc/rc{0..6}.d/ → 런레벨별 심볼릭 링크 (S20foo, K80foo …)

런레벨(runlevel)은 시스템의 모드 번호입니다. 관습적으로 7개를 씁니다.

런레벨의미
0halt (종료)
1 (S)single user mode
2multi-user, 네트워크 없음 (Debian 계열은 네트워크 포함)
3multi-user, 네트워크 있음 (서버 기본값)
4미정의/사용자 정의
5multi-user + GUI (데스크톱 기본값)
6reboot

런레벨 진입 시 /etc/rcN.d/의 S로 시작하는 링크를 번호 순으로 실행하고, 빠져나갈 때 K 링크를 실행합니다. 부팅이란 곧 "셸 스크립트를 정해진 순서대로 한 줄씩 돌리는" 일이었습니다.

SysVinit의 한계​

SysVinit이 30년을 살아남은 건 단순함 덕이고, 5년 만에 밀려난 것도 그 단순함 탓입니다.

가장 큰 문제는 직렬 부팅이었습니다. S20foo가 끝나야 S21bar가 시작되니, CPU가 놀고 있어도 두 서비스가 서로 무관해도 순서를 바꿀 수 없었고 이게 부팅이 느린 결정적 이유였습니다. 의존성 표현이 약한 것도 여기 얽힙니다. "DB가 떠 있어야 web이 뜬다" 같은 관계를 번호(S20, S21)로만 나타냈고, LSB 헤더가 의존성 기술을 거들긴 했지만 여전히 선형 정렬이 전제였습니다.

나머지 둘은 프로세스 관리 쪽입니다. 스크립트가 &로 백그라운드에 넘긴 자식을 init이 직접 추적하지 않아 PID 파일이 진실의 원천이 되는데, 이게 어긋나면 service foo status가 거짓말을 하고 자식이 더블 fork로 도망가면 더 곤란해집니다. 서비스가 죽었을 때 되살리는 것도 init 밖의 일이라, respawn을 inittab에 직접 걸거나 monit/supervisord 같은 외부 도구를 끌어와야 했습니다.

이 한계들이 2000년대 후반 "부팅이 빨라야 하는 노트북"과 "의존성 많은 데스크톱 환경"의 시대와 충돌했습니다. 다음 두 세대는 모두 이 중 하나 이상을 풀려고 출발합니다.

2세대: Upstart (2006~)​

Canonical의 Scott James Remnant(스콧 제임스 렘넌트)가 만들었습니다. 첫 출시는 Ubuntu 6.10 "Edgy Eft"(2006-10)입니다. 이때부터 Ubuntu의 기본 init은 SysVinit이 아니라 Upstart였습니다(호환 모드로 SysV 스크립트도 돌렸습니다).

Upstart의 출발점은 "부팅은 단순한 순서가 아니라 일련의 사건들이다"라는 발상입니다. USB가 꽂히고 네트워크가 올라오며 디스크가 마운트되는 등, 이벤트가 발생할 때마다 그에 맞는 잡이 트리거되도록 모델을 짰습니다.

설정은 /etc/init/*.conf에 한 잡씩 둡니다.

description "Hello Web"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

respawn 한 줄로 자동 재시작이 해결되고, start on filesystem and net-device-up 식으로 이벤트 조합도 가능했습니다. SysVinit에 비하면 진짜 진보였습니다.

채택도 빨랐습니다. Ubuntu 9.10 "Karmic Koala"(2009-10)에서 SysV 호환 레이어 없이 native Upstart 부팅을 기본화했고, RHEL 6(2010-11)이 정식 채택했습니다. Google의 ChromeOS와 일부 Fedora 릴리스도 한때 Upstart를 썼습니다.

Upstart가 짧게 살았던 이유​

그런데 RHEL 6 이후 Upstart의 채택은 거기서 멈춥니다. 2010년대 초반 새로 등장한 systemd로 흐름이 갈아탔고 Ubuntu 본진마저 결국 같은 길을 갔는데, 단순히 "더 좋은 게 나왔다"로는 설명이 부족합니다.

가장 자주 꼽히는 건 Canonical의 CLA 정책입니다. Upstart 코드에 기여하려면 Canonical이 요구하는 Contributor License Agreement에 동의해야 했는데, 받은 코드를 상용으로 재배포할 권리를 회사에 양도하는 형태라 다른 진영이 부담스러워했습니다. 같은 시기 Linux 커널을 비롯한 여러 핵심 프로젝트가 CLA 없이 기여를 받던 흐름과 대조됐습니다.

기술적 한계도 겹쳤습니다. "A 이후 B" 같은 단순 의존이 이벤트 모델로는 어색했던 반면, systemd가 들고 나온 의존성 그래프와 Wants= / Requires= / After= 선언이 더 자연스럽다는 평가가 빠르게 자리 잡았습니다. 자식 프로세스 추적도 Upstart는 SysVinit과 비슷한 한계 안에 머물러, cgroup을 fork-bomb에도 끄떡없는 추적 수단으로 쓴 systemd에 비하면 약했습니다. 여기에 Fedora와 RHEL이 systemd를 적극적으로 밀면서 큰 생태계 플레이어 하나가 Upstart 진영에서 빠져나갔고, RHEL 6의 Upstart는 단명한 뒤 RHEL 7부터 systemd로 갔습니다.

결정타는 Debian과 Ubuntu가 차례로 systemd로 넘어간 시점입니다. 그 부분은 뒤에서 따로 정리합니다.

3세대: systemd (2010~)​

2010년 4월, Red Hat의 Lennart Poettering(레나르트 푀터링)과 Kay Sievers(카이 지버스)가 발표한 글 한 편("Rethinking PID 1")으로 등장합니다. 핵심 아이디어는 세 가지였습니다.

첫째는 의존성 기반 병렬 부팅입니다. 유닛(*.service, *.socket, *.target 등)이 의존성을 명시하고, 의존성이 풀린 것부터 병렬로 출발합니다. 둘째는 소켓 활성화(socket activation)로, 데몬을 미리 띄우는 대신 systemd가 먼저 listen 소켓을 열어두고 첫 연결이 들어올 때 데몬을 깨웁니다. macOS의 launchd에서 영감을 받은 모델입니다. 셋째는 cgroup 기반 프로세스 추적입니다. 서비스가 fork한 자식과 손자까지 cgroup에 묶여 정확히 추적/정리되므로 PID 파일 위조나 더블 fork로 도망갈 수 없습니다.

이 세 가지가 동시에 풀린 게 결정적이었습니다. 이전 세대가 풀지 못한 한계를 한꺼번에 정리했고, 부팅 시간 단축이라는 가시적 효과도 따라왔습니다.

다만 systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔고, "Unix 철학과 어긋난다"는 비판도 같은 시기에 나왔습니다. 이 글은 그 논쟁에 한 발 들이지 않고, 채택 흐름만 따라갑니다.

systemd 채택 연표​

발표 시점부터 메이저 배포판들의 채택 시점을 연도순으로 보면 흐름이 분명해집니다.

연/월사건
2010-04systemd 첫 발표 (Lennart Poettering, Kay Sievers)
2011-05Fedora 15: 메이저 배포판 중 첫 systemd 기본 채택
2011-11openSUSE 12.1: systemd 기본
2012-05Mageia 2: systemd 기본
2012-10Arch Linux: SysVinit에서 systemd로 전환
2013CoreOS: 출범부터 systemd가 핵심 (컨테이너 호스트 OS)
2014-02Debian Technical Committee 표결: systemd를 Jessie의 기본 init으로 결정
2014-06RHEL 7 / CentOS 7: 엔터프라이즈 표준이 바뀐 분기점
2014-11Debian General Resolution: init 결합(coupling) 여부 투표, 결과는 "GR 불필요"
2014-11-27Devuan 분기 발표: "Veteran Unix Admins" 명의
2015-04Debian 8 "Jessie" 정식 출시 (systemd 기본)
2015-04-23Ubuntu 15.04 "Vivid Vervet": Upstart에서 systemd로
2017-05-25Devuan 1.0 "Jessie": Debian 기반, systemd 없는 첫 안정판

약 5년(2010 발표~2015) 만에 Fedora, RHEL, Debian, Ubuntu, openSUSE, Arch가 모두 systemd로 정렬됩니다. 이 정도 속도로 init 같은 핵심 컴포넌트가 통일된 적은 리눅스 역사에 거의 없습니다.

Debian의 투표와 Devuan 분기​

Debian은 의사결정에 시간이 오래 걸리는 프로젝트입니다. systemd 채택도 예외가 아니어서 2013년 후반부터 Technical Committee 안에서 격론이 오갔고, 결국 2014년 2월 표결로 Debian 8 "Jessie"의 기본 init은 systemd로 결정됩니다.

표결 자체는 그것대로 정리됐지만, 남아 있던 또 다른 쟁점, 즉 "패키지가 systemd 의존성을 강제로 걸 수 있느냐"가 분기를 불렀습니다. 이 쟁점은 2014년 11월 init system coupling General Resolution으로 넘어갔고, "다른 init 시스템 지원은 권장되지만 의무는 아니다(recommended, but not mandatory)" 같은 선택지가 올라왔지만 결과는 "GR 불필요(General Resolution is not required)"였습니다. 패키지가 systemd 외에 안 돌게 만들어도 막는 규정을 따로 두지 않는다는 의미였습니다.

이 결과에 반발한 일부 Debian 사용자/개발자가 "Veteran Unix Admins" 이름으로 2014년 11월 27일 Devuan 분기를 발표합니다. 약 2년 반의 패키지 감사/수정 끝에 2017년 5월 25일 Devuan 1.0 "Jessie"가 나왔습니다. Debian 8을 베이스로 systemd 훅을 모두 들어내고 SysVinit(또는 OpenRC)을 기본 init으로 하는 버전입니다.

Devuan은 이후로도 Debian을 한 단계씩 따라가며 출시를 이어가고 있습니다. 규모는 작지만 "systemd 없이도 Debian 생태계를 쓰고 싶다"는 수요에 답하는 진영으로 살아남았습니다.

Ubuntu가 자존심을 접은 결정​

Ubuntu에게 init은 단순한 부품이 아니었습니다. Upstart는 Canonical의 자체 프로젝트였고, 거의 10년간 Ubuntu의 기본 init이었습니다. 그런데 Debian이 systemd로 결정한 직후, Mark Shuttleworth(마크 셔틀워스)는 "Ubuntu도 upstream(Debian)과 보조를 맞추겠다"고 발표합니다.

마이그레이션은 비교적 부드러웠습니다. Ubuntu 15.04 "Vivid Vervet"(2015-04-23)에서 기본 init이 systemd로 전환됐고, Ubuntu Touch(모바일)만 예외였습니다. 이후 15.04부터 16.10까지는 부팅 시 GRUB에서 Upstart와 systemd를 고를 수 있는 듀얼 부팅 기간을 유지했는데, 회귀가 생겼을 때 도망갈 길을 일정 기간 열어둔 운영적 선택이었습니다. 16.10 이후 Upstart 옵션이 제거되면서 이때부터 Ubuntu는 완전히 systemd 단독으로 갔습니다.

Canonical 입장에서는 자기 프로젝트를 접고 경쟁 프로젝트를 받아들인 결정이었지만, 그 무렵엔 systemd가 사실상 표준이 된 상태였고 Debian과 다른 init을 유지하는 비용이 더 커졌습니다.

systemd를 안 쓰는 배포판들​

2026년 현재도 systemd가 아닌 init을 기본으로 쓰는 배포판이 남아 있습니다. 컨테이너 베이스 이미지나 임베디드, 보수적 운영을 위한 선택지로 의외로 자주 등장합니다.

배포판기본 init비고
Alpine LinuxOpenRC컨테이너 베이스 이미지 점유율이 높음. musl + busybox + OpenRC 조합
Void Linuxrunit단순함과 빠른 부팅이 강점
GentooOpenRC (기본) / systemd 옵션profile 선택으로 둘 다 사용 가능
DevuanSysVinit / OpenRCDebian 8 분기 후 독자 노선
SlackwareBSD-style init15.0 (2022) 시점에도 SysV가 아닌 BSD 스타일 유지
Artix LinuxOpenRC / runit / s6 / dinitArch 기반의 systemd-free 분기

여기서 중요한 점이 하나 있습니다. Alpine은 컨테이너 이미지 시장에서 표준급 점유율을 가지고 있습니다. "제 노트북은 Ubuntu고 systemd만 만져봤다"고 해도, 컨테이너에 FROM alpine:... 한 줄을 넣는 순간 OpenRC 기반 시스템과 만납니다. 다만 컨테이너 안에서는 init이 거의 의미를 갖지 않는다는 별도의 이슈가 있는데, 그건 마지막에서 다시 봅니다.

같은 작업, 세 가지 표현​

세 init 시스템의 차이를 가장 빨리 느끼는 방법은 같은 서비스를 세 가지 형식으로 옆에 두고 보는 것입니다. hello-web이라는 가상의 HTTP 서버를 부팅 시 자동 기동하고 죽으면 자동 재시작하도록 등록한다고 해보겠습니다.

SysVinit: /etc/init.d/hello-web​

#!/bin/sh
### BEGIN INIT INFO
# Provides: hello-web
# Required-Start: $network $remote_fs
# Required-Stop: $network $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: hello web server
### END INIT INFO

DAEMON=/usr/local/bin/hello-web
PIDFILE=/var/run/hello-web.pid

case "$1" in
start)
start-stop-daemon --start --background \
--make-pidfile --pidfile $PIDFILE \
--exec $DAEMON
;;
stop)
start-stop-daemon --stop --pidfile $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop; sleep 1; $0 start
;;
status)
[ -f $PIDFILE ] && kill -0 $(cat $PIDFILE) 2>/dev/null \
&& echo "running" || echo "stopped"
;;
*)
echo "Usage: $0 {start|stop|restart|status}"; exit 1
;;
esac

그 후 update-rc.d hello-web defaults(Debian 계열) 또는 chkconfig hello-web on(RHEL 계열)으로 런레벨 링크를 만들어야 합니다. 자동 재시작은 별도 도구가 필요합니다.

Upstart: /etc/init/hello-web.conf​

description "hello web server"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

스크립트가 아니라 선언문입니다. start-stop-daemon, PID 파일, status 분기, 런레벨 링크가 전부 사라졌습니다. respawn 한 줄로 자동 재시작도 끝납니다.

systemd: /etc/systemd/system/hello-web.service​

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

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

[Install]
WantedBy=multi-user.target

등록은 systemctl enable --now hello-web 한 줄. 의존성을 After=로 명시하고, 재시작 정책도 Restart=on-failure로 명확합니다. 자식 프로세스 추적은 cgroup이 알아서 해줍니다.

같은 의도가 34줄 스크립트에서 9줄, 11줄 선언으로 줄어듭니다. 줄어든 만큼 init이 책임지는 부분과 사용자가 짜야 하는 부분의 경계가 옮겨갔다는 뜻입니다.

컨테이너 시대의 init: PID 1 문제​

여기까지가 "호스트 OS의 init" 이야기였습니다. 그런데 컨테이너 시대로 들어오면 init의 의미가 한 번 더 뒤집힙니다.

도커 컨테이너 안에서 PID 1은 실제 init 시스템 대신 사용자가 실행한 프로세스입니다. CMD ["node", "server.js"]면 node가 PID 1이 됩니다. 그런데 PID 1에는 두 가지 특별한 책임이 있습니다.

하나는 좀비 자식 프로세스 수확입니다. 자식이 죽으면 부모가 wait()으로 거둬야 좀비가 정리되는데, PID 1이 이걸 안 하면 좀비가 영구히 쌓입니다. 다른 하나는 시그널 처리입니다. 커널은 PID 1에게 기본 시그널 핸들러를 붙여주지 않아서, 명시적으로 처리하지 않으면 SIGTERM이나 SIGINT가 무시됩니다.

대부분의 애플리케이션은 이 두 가지를 신경 쓰고 만들어지지 않았습니다. 그래서 컨테이너 생태계에는 경량 init들이 등장했습니다.

tini는 Docker가 --init 플래그로 채택한 사실상 표준입니다. dumb-init은 Yelp가 만든 대안이고, s6-overlay는 컨테이너 안에서 멀티 프로세스를 다룰 때 씁니다.

Kubernetes는 이 문제를 한 단계 더 위에서 다룹니다. Pod 종료 시 컨테이너 PID 1에 SIGTERM을 보내고 grace period 후 SIGKILL로 가는데, 앱이 SIGTERM을 안 잡으면 매번 강제 종료가 발생합니다. (이 흐름은 SIGINT, SIGTERM, SIGHUP, SIGKILL: 쿠버네티스 시대의 유닉스 시그널에서 깊게 다뤘습니다.)

호스트의 init은 의존성과 부팅 속도를 고민하지만, 컨테이너의 PID 1은 시그널 전파와 좀비 수확이라는 더 원초적인 책임으로 돌아갑니다.

한 줄로​

SysVinit의 30년은 단순함이 호환성을 만든 시대였고, systemd의 5년은 의존성과 cgroup이 표준을 만든 시대였어요. 그 사이의 Upstart는 좋은 아이디어 한 가지로 잠깐 빛났지만 큰 생태계의 흐름을 못 이긴 사례로 남았고, PID 1 자리에 무엇이 앉느냐는 결국 그 시대 운영체제가 무엇을 가장 중요하게 여기는지를 비추는 거울이에요.


→ 다음 편: systemd 유닛이라는 언어: 11종을 한 바퀴. 이 글에서 살짝만 비춘 유닛 11종이 cron, fstab, inetd, autofs, inotify 같은 옛 도구를 어떻게 흡수했는지 한 바퀴 돕니다.

참고​

쿠버네티스 유닉스 시그널

· 약 8분

오래된 주제와 새로운 무대​

유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 다루고 지나가는 주제예요. 그런데 Docker, Kubernetes, systemd, PM2 같은 프로세스 오케스트레이터들이 일상이 된 지금, 이 네 개의 시그널은 오히려 운영 사고의 주요 원인이 되고 있어요.

  • "Docker stop을 했는데 왜 10초 뒤에야 죽나요?"
  • "K8s rolling update 중 request가 잘립니다"
  • "Ctrl+C가 안 먹히는데 뭐가 잘못됐죠?"
  • "nohup으로 돌렸는데 SSH 끊기니까 죽었어요"

전부 시그널 이해와 직결된 질문입니다. 한 번 제대로 정리해두면 두고두고 도움이 됩니다.

네 시그널 개요​

시그널번호기본 동작핸들 가능대표 발생 경로
SIGINT2프로세스 종료캐치 가능터미널 Ctrl+C
SIGTERM15프로세스 종료캐치 가능kill PID, docker stop, K8s pod 종료
SIGHUP1프로세스 종료캐치 가능터미널 세션 종료, 관용적으로 "config reload"
SIGKILL9즉시 종료불가kill -9, Docker grace period 초과, OOM killer

핵심 차이는 마지막 칼럼인 핸들 가능 여부입니다. SIGKILL만 프로세스가 가로챌 수 없습니다. 이것이 왜 중요한지는 뒤에서 자세히 살펴봅니다.

SIGINT​

SIGINT는 우리가 가장 자주 만나는 시그널입니다.

터미널에서 Ctrl+C를 누르면 현재 포그라운드 프로세스 그룹에 SIGINT가 날아갑니다. "사용자 인터럽트"의 약자로 interrupt에서 왔습니다.

프로세스는 이 시그널을 받으면 다음과 같이 동작합니다.

  • 기본적으로는 즉시 종료
  • 핸들러를 등록하면 정리 후 종료 가능 (열린 파일, DB 트랜잭션 롤백 등)
  • 완전히 무시할 수도 있음 (일부 REPL이 이렇게 동작)

Ctrl+C가 안 먹는 경우들​

  • 자식 프로세스가 별도 세션으로 분리돼 있을 때 (setsid)
  • 프로세스가 I/O 블록 상태(디스크, 네트워크)라 인터럽트 후에도 시스템 콜 복귀가 늦을 때
  • 핸들러가 SIGINT를 잡아놓고 일부러 안 끝낼 때
  • TUI 앱(vim, tmux 등)이 터미널 raw 모드로 Ctrl+C를 키 입력으로 받아먹을 때

Ctrl+C가 안 먹히면 보통 SIGTERM을 쏴보는 게 다음 수순입니다. kill PID (기본값이 SIGTERM).

SIGTERM​

SIGTERM은 정중한 종료 요청이며, 가장 중요한 시그널이자 현대 운영 환경의 기본 종료 경로입니다.

kill PID를 인자 없이 쓰면 기본이 SIGTERM입니다. docker stop CONTAINER는 컨테이너 PID 1에 SIGTERM을 전송하고, Kubernetes의 Pod 종료가 시작될 때도 각 컨테이너에 SIGTERM을 보냅니다. systemd가 unit을 멈출 때와 PM2, foreman, supervisor 등 프로세스 매니저가 stop할 때도 기본 시그널은 SIGTERM입니다.

SIGTERM은 "그만 종료해 주세요" 라는 의사 표현입니다. 프로세스가 이 시그널을 캐치할 수 있고, 원하는 시간 동안 정리할 수 있습니다.

Graceful shutdown 패턴 (Go 예제)​

프로덕션 서버가 SIGTERM을 받았을 때 해야 할 일은 대체로 다음과 같습니다.

  1. 신규 요청 수신을 중단
  2. 진행 중인 요청을 완료할 때까지 기다림
  3. DB 커넥션, 파일, 임시 자원 정리
  4. 종료

Go 1.16+에서 signal.NotifyContext를 쓰면 깔끔합니다.

package main

import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: ":8080"}

go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()

<-ctx.Done() // SIGINT/SIGTERM 대기

shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("bye")
}

25초 타임아웃은 K8s 기본 grace period(30초)보다 살짝 짧게 잡는 것이 관행입니다. grace period 안에 정리를 끝내고 자진 종료하기 위해서입니다.

Node.js 예제​

const server = app.listen(3000)

const shutdown = (signal) => async () => {
console.log(`${signal} 수신, 정리 중...`)
server.close(() => process.exit(0))
// 25초 지나도 종료 안 되면 강제
setTimeout(() => process.exit(1), 25_000).unref()
}

process.on('SIGTERM', shutdown('SIGTERM'))
process.on('SIGINT', shutdown('SIGINT'))

Python 예제​

import signal, sys, time

def shutdown(signum, frame):
print(f"signal {signum} 수신, 정리 중...")
# cleanup code here
sys.exit(0)

signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)

while True:
time.sleep(1)

SIGKILL​

SIGKILL을 아껴 써야 하는 이유가 있습니다. kill -9 PID는 개발자들의 오랜 습관입니다 — 하지만 프로세스에게 정리할 기회를 주지 않습니다.

  • 커널이 프로세스를 즉시 종료
  • 프로세스는 시그널을 캐치하거나 무시하거나 핸들러를 등록할 수 없다 (불가능)
  • lock 파일이나 임시 파일 같은 애플리케이션 수준 정리가 누락되고, 상대편 DB는 연결 끊김을 늦게 감지할 수 있음
  • 쓰기 중이던 데이터가 flush 안 된 상태로 증발

쓸 만한 때​

  • SIGTERM을 보냈는데 충분히 기다린 뒤에도 안 죽음 (핸들러 무한 루프 등)
  • Docker grace period 초과 → Docker가 자동으로 SIGKILL 전송
  • K8s grace period 초과 → kubelet이 SIGKILL
  • OOM killer가 메모리 확보 위해 희생자로 선정

즉 순서는 항상 SIGTERM → 대기 → (안 되면) SIGKILL입니다. 처음부터 kill -9로 가는 것은 게으름이거나 데이터 손상 리스크를 감수하는 행위입니다.

SIGHUP​

SIGHUP은 의미가 뒤집힌 시그널입니다. 이름 그대로 "HangUp"에서 왔습니다. 1970년대 전화 연결이 끊기면 모뎀이 보내주던 신호입니다. 지금은 그 용도가 거의 사라졌지만 두 가지로 남아 있습니다.

의미 1: 터미널 세션 종료​

SSH로 원격 서버에 접속해서 명령을 실행하고 접속을 끊으면, 그 자식 프로세스들에 SIGHUP이 갑니다. 기본 동작은 종료입니다.

그래서 이를 피하는 도구들이 생겼습니다. nohup은 SIGHUP 무시 플래그를 설정하고 실행하는 "no hup"이고, disown은 셸의 job 테이블에서 프로세스를 제거해 SIGHUP 전파를 끊습니다. setsid는 새 세션을 만들어 터미널과의 연결 자체를 분리합니다. tmux나 screen은 결이 조금 다른데, 세션을 원격 호스트의 상주 데몬이 잡고 있어 클라이언트 연결이 끊겨도 프로세스가 유지됩니다.

주의: nohup은 SIGHUP만 막습니다. SIGTERM, SIGKILL은 그대로 받습니다. 시스템 재부팅/종료 때도 보호하지 못합니다.

의미 2: 설정 리로드 관용​

현대 데몬들은 SIGHUP을 "설정 다시 읽어라"라는 신호로 재해석해서 씁니다. 공식 표준은 아니지만 사실상 관례입니다. 대표적인 예가 nginx입니다. kill -HUP이나 nginx -s reload를 받으면 마스터가 새 설정으로 워커를 띄우고 기존 워커는 현재 연결이 끝나면 종료합니다. rsyslog/syslog-ng도 같은 방식으로 설정을 리로드하고, haproxy도 리로드용으로 씁니다(최근 버전은 seamless-reload를 따로 둡니다). postgres 역시 pg_reload_conf()나 kill -HUP postmaster로 설정을 다시 읽습니다.

HUP의 원래 의미와는 전혀 상관없는 용도인데, 이제는 이쪽이 더 유명합니다.

쿠버네티스의 Termination 시퀀스​

K8s에서 Pod를 지우면 내부적으로 다음 순서로 돕니다.

  1. Pod의 status가 Terminating으로 변경
  2. 서비스의 endpoint에서 해당 Pod 제거 (신규 트래픽 차단)
  3. preStop hook 실행 (정의돼 있다면)
  4. 각 컨테이너의 PID 1에 SIGTERM 전송
  5. Termination grace period (기본 30초) 대기
  6. 그래도 살아 있으면 SIGKILL

쿠버네티스가 Pod를 정상 종료한 뒤 필요하면 강제 종료하는 흐름

여기서 3번 preStop과 4번 SIGTERM 사이의 순서가 중요합니다. 쿠버네티스는 preStop hook이 끝난 뒤에 컨테이너 PID 1로 SIGTERM을 보내며, preStop에 걸린 시간도 grace period에 포함됩니다. 동시에 진행되는 것은 endpoint 제거와 종료 시퀀스라서, preStop은 주로 "LB에서 빠질 시간을 벌기" 같은 용도로 사용합니다.

grace period 설정​

spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
# ...
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # LB가 endpoint 제거를 반영할 시간

앱 내부의 graceful shutdown 타임아웃은 이 값보다 짧게 잡습니다.

흔한 함정들​

Dockerfile의 shell form ENTRYPOINT​

# 나쁜 예 — /bin/sh가 PID 1이 되어 SIGTERM을 흡수
CMD node server.js

# 좋은 예 — node가 직접 PID 1
CMD ["node", "server.js"]

shell form(문자열)은 sh -c로 감싸져 실행되는데, sh는 기본적으로 시그널을 자식에게 전파하지 않습니다. docker stop이 SIGTERM을 PID 1(sh)에 보내지만 실제 앱은 그것을 받지 못합니다. 결국 10초를 기다렸다가 SIGKILL로 박살납니다.

Docker shell form과 exec form의 PID 1 신호 전달 비교

긴 preStop을 graceful shutdown 대체용으로 쓰지 말 것​

preStop: sleep 60 같은 것으로 "종료 시간을 버는" 것은 꼼수에 가깝습니다. 앱이 실제로 정리하는 것이 아니라 단지 종료를 늦출 뿐입니다. 제대로 된 해법은 앱에 SIGTERM 핸들러를 두는 것입니다.

Python의 KeyboardInterrupt는 SIGINT만​

try/except KeyboardInterrupt는 SIGINT만 잡습니다. SIGTERM은 안 잡힙니다. 컨테이너에서 돌리는 Python이면 반드시 signal.signal(signal.SIGTERM, ...)을 등록해야 합니다.

Node.js에서 동기 블로킹 작업​

이벤트 루프를 오래 점유하는 동기 작업(큰 파일 sync read, 무거운 crypto 연산 등)은 process.on('SIGTERM', ...) 핸들러조차 실행하지 못합니다. Node 프로세스가 시그널을 처리할 기회를 얻지 못하기 때문입니다. graceful shutdown이 의미 있으려면 이벤트 루프를 풀어주는 코드여야 합니다.

nohup 과신​

nohup long_task.sh &로 돌려놓았다고 "이제 안전하다"라고 여기기 쉽지만, SIGHUP만 막습니다. reboot나 SIGTERM 앞에서는 무력합니다. 장기 작업은 systemd 유닛이나 tmux 세션 쪽이 더 안전합니다.

한 줄로​

"SIGTERM을 잡을 줄 아는 코드"와 "SIGTERM을 쏠 줄 아는 운영자"가 만나면 SIGKILL이 필요 없는 날이 많아져요. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이에요.

참고​