Linux Boot Sequence
서버를 부팅하면 firmware, bootloader, kernel, initramfs, systemd가 차례로 실행되고, 각 단계는 다음 단계가 실행될 환경을 만든 뒤 제어를 넘긴다. firmware는 memory와 장치를 준비한 뒤 bootloader를 실행하고, bootloader는 kernel과 initramfs를 memory에 올린 뒤 kernel로 점프한다. kernel은 initramfs 안의
/init을 실행하고, initramfs는 실제 root filesystem을 mount한 뒤 PID 1 자리를 systemd에게 넘긴다.
Intro
메인보드 firmware에는 BIOS 방식과 UEFI 방식이 있는데, 두 방식은 bootloader를 찾아 실행하는 부분만 다르고 그 뒤 과정은 같다. Linux Boot Sequence를 이해하기 위해 우리는 먼저 부팅을 다섯 단계로 나누어 각 단계가 무엇을 하는지 정리할 것이다.
Boot Stages
부팅은 다음 다섯 단계로 나뉜다.
flowchart LR
accTitle: Linux 부팅의 다섯 단계
accDescr: firmware, bootloader, kernel, initramfs, systemd가 차례로 실행되고, 각 단계가 다음 단계에 제어를 넘기는 순서를 보여준다.
F["Firmware<br/>Prepare hardware"] --> B["Bootloader<br/>Load kernel"]
B --> K["Kernel<br/>Manage hardware"]
K --> I["initramfs<br/>Mount root"]
I --> S["systemd<br/>Start services"]
- Firmware: 전원이 들어오면 가장 먼저 실행되어 CPU, memory, 디스크 같은 하드웨어를 쓸 수 있는 상태로 만들고, 디스크에서 bootloader를 찾아 실행한다.
- Bootloader: 디스크에서 kernel과 initramfs 파일을 읽어 memory에 올리고, kernel에 설정 값을 넘긴 뒤 kernel을 실행한다.
- Kernel: CPU, memory, 장치를 직접 관리하기 시작하고, initramfs 안에 있는 첫 번째 프로그램을 실행한다.
- initramfs: OS 파일이 들어 있는 실제 filesystem(root filesystem)을 찾아 mount하고, 그 안에 있는 init 프로그램에 제어를 넘긴다.
- systemd: network, 로그 수집, login 같은 서비스를 시작해서 machine을 사용할 수 있는 상태로 만든다.
firmware → bootloader → kernel → initramfs → systemd 순이다. 이건 외워두면 좋다.
왜 프로그램 하나가 부팅을 끝까지 맡지 않고 여러 단계로 나눌까? 각 단계에서 쓸 수 있는 자원이 서로 다르기 때문이다. firmware가 처음 실행될 때는 memory(DRAM)조차 쓸 수 없고, bootloader는 firmware가 제공하는 기능을 빌려서 디스크를 읽는다. kernel은 장치를 직접 다룰 수 있지만, OS 파일이 들어 있는 디스크를 바로 열지 못하는 경우가 많다.
그래서 각 단계는 자신이 쓸 수 있는 자원으로 다음 단계가 실행될 환경을 만든 뒤 제어를 넘긴다. 이 다섯 구간은 뒤에서 볼 systemd-analyze가 부팅 시간을 나누는 구간과도 같다.
Stage 1: Firmware
전원 버튼을 누르면 CPU는 왜 바로 OS를 실행하지 못할까? 전원이 막 들어온 machine에서는 memory(DRAM)조차 쓸 수 없고, OS가 저장된 디스크도 아직 인식되지 않았기 때문이다. firmware는 이런 하드웨어를 하나씩 초기화해서 bootloader가 실행될 수 있는 환경을 만든다.
firmware는 메인보드의 flash 칩에 저장되어 있다가 전원이 들어오면 가장 먼저 실행되는 프로그램이다. firmware에는 BIOS 방식과 UEFI 방식이 있으며, 하드웨어를 초기화하는 순서는 두 방식이 비슷하다.
BIOS와 UEFI: firmware가 하드웨어를 초기화하고 bootloader를 실행하는 방식을 가리키는 이름이다. BIOS는 IBM PC 시절부터 쓰인 방식이고, UEFI는 BIOS를 대체하려고 만든 표준 규격이다. 메인보드 설정 화면을 흔히 “BIOS 설정”이라고 부르지만, 실제로는 UEFI 방식의 firmware인 경우도 많다.
전원이 들어온 뒤 bootloader가 실행되기까지는 다음 순서로 진행된다.
firmware 내부 동작은 간략하게만 살펴보자.
Standby Power
전원 플러그가 연결되면 PSU(Power Supply Unit)가 대기 전원을 공급하고, 전원 버튼과 BMC 같은 일부 회로만 동작한다.
PSU: PSU(Power Supply Unit)는 콘센트의 교류 전원을 메인보드가 쓰는 직류 전압으로 바꾸는 전원 공급 장치다.
BMC: server 메인보드에 들어 있는 원격 관리용 보조 프로세서다. 대기 전원만으로 동작하기 때문에 machine이 꺼져 있어도 원격으로 전원을 켜고 끌 수 있다.
메인보드(motherboard, mainboard)를 모르는 사람들을 위해서 설명하자면, 부품을 꽂고 서로 연결하는 가장 큰 회로 기판이다. 기판 안의 배선들이 부품을 연결하고, 다음과 같은 부품들이 들어간다.
- CPU socket과 DIMM slot
- chipset과 PCIe slot
- firmware가 저장된 SPI Flash 칩
- CPU에 맞는 전압을 만드는 전압 조정 회로
- server의 경우 BMC 칩
프로세서란 단어가 조금 애매한데, 넓은 의미로는 memory에 저장된 명령어를 하나씩 읽고, 해석하고, 실행하는 회로이다. 좁은 의미로는 “Intel Core 프로세서”라고 하면 main CPU 칩 자체를 가리키는 경우도 있다. 이게 일상적으로는 더 많이 쓰이긴 한다.
CPU라는 이름도 Central Processing Unit의 약자로, 여러 프로세서 중에서 OS와 응용 프로그램을 실행하는 중심 프로세서라는 뜻이다. GPU, BMC, SSD controller에도 각자 프로세서가 들어 있고, 각 프로세서는 자기 firmware를 실행한다.
모르는 용어가 계속 나올 수 있지만, 이번 글에서 일일이 전부 설명하지는 않는다. 글을 이해하는 데 도움이 될 만한 용어만 다루고 넘어간다.
Power Good
전원 버튼을 누르면 주 전원이 켜지고, 전압이 안정되면 PSU가 Power Good 신호를 보낸다. CPU의 reset은 이 신호가 온 뒤에 해제된다.
Power Good: 전원을 켠 직후에는 전압이 안정되지 않으므로, PSU는 전압이 안정된 뒤에 Power Good 신호를 보낸다.
“PSU는 전원 공급 장치이고, 준비가 되면 Power Good 신호를 보낸다” 정도로 이해하면 되겠다.
CPU에는 reset 신호를 받는 입력 핀이 있다. 이 신호가 걸려 있는 동안 CPU는 아무 명령도 실행하지 않고 내부 상태를 초기 값으로 유지한다. 전압이 불안정할 때 명령어를 실행하면 잘못된 결과가 나올 수 있기 때문에, 전압이 안정될 때까지 reset 신호를 걸어 두고 Power Good 신호가 오면 그때 해제한다. “reset이 풀렸다”는 말은 이 신호가 해제되어 “CPU가 실행을 시작했다”는 뜻이다.
Intel ME / AMD PSP
main CPU의 reset이 해제되기 전에 보안용 보조 프로세서가 먼저 동작하면서 firmware 이미지를 검증하는 등의 초기 작업을 한다. Intel ME / AMD PSP도 BMC처럼 보조 프로세서이므로, main CPU와 별개로 자기 전용 firmware를 실행한다.
Intel ME와 AMD PSP: Intel ME(Management Engine)는 chipset 안에, AMD PSP(Platform Security Processor)는 CPU 안에 따로 들어 있는 작은 보조 프로세서다.
Reset Vector
reset이 해제된 CPU는 항상 같은 주소에서 첫 명령어를 읽는데, 이 주소가 reset vector이다. x86 CPU에서는 물리 주소 0xFFFFFFF0이며, 이 주소는 firmware가 저장된 flash 칩(SPI Flash)으로 연결된다. 또 core가 여러 개인 CPU에서도 처음에는 core 하나만 실행을 시작하고, 나머지 core는 나중에 firmware가 깨운다.
Memory Initialization
memory controller와 DRAM을 초기화해서 DRAM을 쓸 수 있게 만든다. memory controller는 CPU core와 DRAM 사이에서 읽기와 쓰기 요청을 처리하는 회로이다. CPU가 “이 주소를 읽어라”라고 요청하면 memory controller가 그 요청을 DRAM이 이해하는 명령으로 바꿔서 보낸다.
DRAM(Dynamic Random Access Memory)이 흔히 RAM이라고 부르는 main memory다. 메인보드의 slot에 꽂는 DIMM 모듈이 바로 DRAM이다. 참고로 DRAM은 transistor 하나와 capacitor 하나가 1비트를 저장하고, capacitor에 전하가 있는지 없는지로 0과 1을 구분한다. capacitor의 전하는 시간이 지나면 조금씩 빠져나가기 때문에, DRAM은 주기적으로 값을 다시 써 주는 refresh가 필요하다.
SRAM도 있는데, CPU cache로 쓰는 SRAM은 transistor 6개로 1비트를 저장하고, refresh가 필요 없으며 훨씬 빠르다. 대신 비싸다.
Memory training과 Cache-as-RAM: DRAM은 memory controller가 신호 타이밍을 맞추는 memory training을 마쳐야 쓸 수 있다. 그전까지 firmware는 CPU cache(SRAM)를 임시 memory로 쓰는데, 이 방식을 Cache-as-RAM이라고 한다.
Device Initialization
PCIe, 저장 장치, USB, network 장치를 초기화한다. firmware는 이 작업에 firmware 안에 들어 있는 driver를 쓴다.
driver: 특정 장치를 다루는 방법을 담은 코드다. 장치마다 명령을 받는 방식이 다르기 때문에, CPU에서 실행되는 프로그램은 driver를 통해 장치를 초기화하고 읽고 쓴다.
Boot Device Selection
설정된 부팅 순서에 따라 bootloader를 찾아 memory에 올리고 실행한다.
Finding the Bootloader
firmware가 마지막에 bootloader를 찾는 방법은 firmware 방식에 따라 다르다. BIOS 방식에서는 firmware가 부팅 디스크의 첫 sector(MBR)에 들어 있는 부트 코드를 실행하고, 이 코드가 GRUB의 나머지 부분을 불러온다.
sector는 디스크가 한 번에 읽고 쓰는 최소 단위(전통적으로 크기는 512바이트)이다. sector에는 0번부터 차례로 번호(LBA, Logical Block Address)가 붙으며, 여기서 말하는 “첫 sector”는 0번을 말한다. BIOS 방식에서는 이것을 MBR(Master Boot Record)라고 부른다.
| 위치 | 크기 | 내용 |
|---|---|---|
| 0~445 | 446바이트 | 부트 코드 |
| 446~509 | 64바이트 | partition table(16바이트 entry 4개) |
| 510~511 | 2바이트 | 부팅 가능 표시 0x55 0xAA |
BIOS firmware는 디스크의 filesystem을 이해하지 못한다. 그래서 파일을 이름으로 찾지 않고, 0번 sector 512바이트를 그대로 memory에 복사해 놓고 마지막 2바이트가 0x55 0xAA이면 맨 앞의 부트 코드를 실행한다.
UEFI 방식에서는 firmware가 EFI System Partition(ESP)에서 .efi 실행 파일을 찾아 실행한다. 어느 방식이든 이 시점부터 제어권이 firmware에서 bootloader로 넘어간다.
ESP: ESP(EFI System Partition)는 UEFI firmware가 읽을 수 있도록 FAT32로 포맷한 partition이며,
.efi형식의 bootloader가 이곳에 저장된다.
현재 machine이 어느 방식으로 부팅했는지는 /sys/firmware/efi 디렉터리로 확인할 수 있다. UEFI 방식으로 부팅하면 kernel이 이 디렉터리를 만들기 때문이다.
$ [ -d /sys/firmware/efi ] && echo UEFI || echo BIOS
UEFI
UEFI 방식에서는 부팅 순서가 NVRAM(전원이 꺼져도 내용이 유지되는 firmware 저장 공간)에 변수로 저장되므로, efibootmgr -v로 OS 안에서 boot entry를 확인할 수 있다. BIOS 방식에서는 부팅 순서를 firmware 설정 화면에서만 확인하고 바꿀 수 있다.
$ efibootmgr -v
BootCurrent: 0001
Timeout: 1 seconds
BootOrder: 0001,0000,0002
Boot0000* UEFI: PXE IPv4 Intel(R) Ethernet ...
Boot0001* ubuntu HD(1,GPT,3e2f...,0x800,0x100000)/File(\EFI\ubuntu\shimx64.efi)
Boot0002* UEFI: Built-in EFI Shell ...
BootOrder는 firmware가 시도할 entry의 순서이고, BootCurrent는 이번 부팅에서 실제로 선택된 entry다. Boot0001은 GPT 디스크의 첫 번째 partition(ESP)에 있는 \EFI\ubuntu\shimx64.efi를 실행하라는 entry다. 이 예시에서 firmware가 처음 실행하는 프로그램은 GRUB이 아니라 shim인데, shim이 왜 필요한지는 Stage 2의 Secure Boot 항목에서 설명한다.
Stage 2: Bootloader
firmware가 kernel을 직접 실행하면 안 될까? firmware가 읽을 수 있는 디스크 영역은 제한되어 있어서 그렇게 하기 어렵다. BIOS firmware는 filesystem을 이해하지 못하므로 디스크의 첫 sector만 읽어 실행하고, UEFI firmware는 기본적으로 FAT 계열 filesystem만 읽는다. 반면 kernel 파일은 보통 ext4나 xfs 같은 Linux filesystem으로 만든 /boot에 들어 있다.
FAT, ext4, xfs를 모두 알 필요는 없다. “firmware는 기본적으로 Linux filesystem에 있는 kernel을 읽을 줄 모른다”를 기억해두자.
bootloader는 Linux filesystem을 읽을 수 있는 작은 프로그램으로, /boot에서 kernel 파일을 찾아 memory에 올리고 실행한다. Linux에서는 GRUB을 가장 널리 쓰기 때문에, 이 글도 GRUB을 기준으로 설명한다.
EFI stub과 systemd-boot: UEFI 방식에서는 firmware가 kernel을
.efi파일처럼 직접 실행하게 하거나(EFI stub), GRUB 대신 systemd-boot를 쓰는 구성도 있다. 이런 구성에서는 kernel 파일을 보통 firmware가 읽을 수 있는 ESP에 둔다. 어느 구성이든 kernel, initramfs, 설정 값을 kernel에 넘긴다는 bootloader의 역할은 같다.
GRUB
GRUB은 /boot partition이나 ESP에서 설정 파일인 grub.cfg를 읽고 부팅 menu를 보여준다. 사용자가 entry를 고르거나 timeout이 지나면 GRUB은 세 가지 작업을 한다.
- kernel 이미지(
vmlinuz-*)를 memory에 올린다. - initramfs 이미지(
initrd.img-*또는initramfs-*.img)를 memory에 올린다. - kernel command line을 kernel에 넘기고 kernel을 실행한다.
initramfs: kernel이 실제 root filesystem을 열기 전에 임시로 쓰는 작은 filesystem 이미지다. 왜 필요한지는 Stage 4에서 설명한다.
grub.cfg의 entry는 대략 다음과 같은 형태다.
menuentry 'Ubuntu' {
search --no-floppy --fs-uuid --set=root 9b1c...
linux /vmlinuz-6.8.0-45-generic root=UUID=4f2a... ro quiet splash
initrd /initrd.img-6.8.0-45-generic
}
Handing Off to the Kernel
지금 우리는 bootloader를 통해 kernel을 실행하는 단계에 와 있다. “kernel을 실행한다”에 집중하자. kernel은 디스크에 vmlinuz라는 파일로 저장되어 있고, CPU가 해당 코드의 명령어를 읽기 시작해야 kernel이 동작한다.
“kernel이 실행된다”는 것은 결국 CPU가 memory에서 다음에 읽을 명령어의 주소를 kernel 코드의 시작 주소로 바꾼다는 뜻이다. 이를 위해 세 가지가 필요하다.
-
Load: CPU는 디스크에 있는 명령어를 직접 실행하지 못하고, memory에 있는 명령어만 읽을 수 있다. 그래서 bootloader는 먼저
/boot/vmlinuz-...파일의 내용을 읽어 memory의 정해진 위치에 복사한다. initramfs 파일도 같은 방식으로 memory에 복사한다. -
Boot protocol: kernel은 시작하자마자 몇 가지 정보를 알아야 한다.
- kernel command line
- initramfs를 memory의 어느 주소에 얼마만큼 올려 두었는지
- 사용할 수 있는 memory 영역의 목록
bootloader와 kernel은 이 정보를 어떤 형식으로 어디에 두는지 미리 약속해 두는데, 이를 boot protocol이라고 한다. 예를 들어 x86 Linux에서는 bootloader가
boot_params라는 구조체에 이 값들을 채워 두고, kernel은 시작할 때 그 구조체를 읽는다. -
Jump: 마지막으로 bootloader는 CPU의 instruction pointer(다음에 실행할 명령어 주소를 담는 register)를 kernel의 entry point 주소로 바꾸는 jump 명령을 실행한다. 이 순간부터 CPU는 kernel 코드를 읽어 실행한다.
우리가 shell에서
ls명령어를 실행하면 kernel이 프로세스를 만들고, 파일을 memory에 올리고, scheduler가 CPU 시간을 나눠 주고, 프로그램이 끝나면 정리한다. 근데 kernel을 실행할 땐 이런 일을 대신 해 줄 OS가 없다. 그래서 bootloader가 위의 세 단계를 직접 처리한 뒤 CPU를 kernel에 그대로 넘긴다. 실행된 kernel은 프로세스가 아니라, 이후 모든 프로세스를 관리하는 주체가 된다.
Kernel Command Line
kernel은 root filesystem이 어느 디스크에 있는지 어떻게 알까?
위에서 kernel이 시작하자마자 몇 가지 정보를 알아야 하고, 그중 하나가 kernel command line이라고 설명했다. bootloader가 kernel을 실행하면서 kernel command line이라는 설정 문자열을 함께 넘기고, 이 문자열에 root filesystem의 위치가 들어 있다. 위 grub.cfg에서는 linux 줄의 kernel 경로 뒤에 오는 root=UUID=4f2a... ro quiet splash가 kernel command line이다.
현재 부팅에서 kernel이 받은 값은 cat /proc/cmdline으로 확인할 수 있으며, 자주 쓰는 parameter는 다음과 같다.
| Parameter | 의미 |
|---|---|
root=UUID=... | 실제 root filesystem이 있는 block device를 지정한다. initramfs가 이 값을 보고 device를 찾는다. |
ro | root를 처음에 read-only로 mount한다. fsck 이후 systemd가 read-write로 다시 mount한다. |
init= | PID 1로 실행할 프로그램을 지정한다. 지정하지 않으면 /sbin/init을 실행한다. |
rd.break, rd.shell | dracut으로 만든 initramfs에서 특정 지점에 shell을 띄운다. |
systemd.unit= | 기본 target 대신 다른 target으로 부팅한다. (rescue.target 등) |
console= | kernel 메시지를 보낼 console을 지정한다. serial console을 쓸 때 사용한다. |
UUID: filesystem을 만들 때 부여되는 고유 식별자다.
/dev/sda1같은 장치 이름은 디스크가 인식되는 순서에 따라 바뀔 수 있지만 UUID는 바뀌지 않으므로,root=값으로 UUID를 많이 쓴다.fsck와 dracut: fsck는 filesystem에 오류가 있는지 검사하고 고치는 도구다. dracut은 initramfs 이미지를 만드는 도구다.
Secure Boot
firmware가 실행하는 bootloader나 kernel이 변조되지 않았다는 것은 어떻게 보장할까?
UEFI firmware는 이를 위해 Secure Boot 기능을 제공한다. Secure Boot를 켜면 firmware는 서명이 검증된 프로그램만 실행하므로, 변조된 bootloader나 kernel은 실행되지 않는다.
firmware에는 신뢰하는 인증서 목록(db)이 저장되어 있는데, 대부분의 PC와 server에는 Microsoft의 UEFI CA 인증서가 기본으로 등록되어 있다.
하지만 Linux 배포판이 GRUB과 kernel을 업데이트할 때마다 Microsoft의 서명을 받기는 어렵다. 그래서 배포판은 작은 1단계 bootloader인 shim만 Microsoft의 서명을 받고, 그 뒤의 검증은 shim에 맡긴다.
shim 안에는 배포판 자체의 인증서가 들어 있다. firmware는 Microsoft 서명으로 shim을 검증하고, shim은 내장된 배포판 인증서와 MOK 목록으로 GRUB을 검증한다. 그다음 GRUB은 shim이 제공하는 검증 기능을 호출해서 kernel 이미지의 서명을 확인하고, kernel은 module을 load할 때 module의 서명을 확인한다.
MOK: MOK(Machine Owner Key)는 machine 관리자가 직접 등록하는 신뢰 키 목록이다. 직접 빌드한 kernel이나 DKMS로 빌드한 module을 쓰려면, 자신의 키를
mokutil --import로 MOK에 등록해야 한다. DKMS는 kernel이 업데이트될 때 외부 module을 자동으로 다시 빌드하는 도구다.
flowchart LR
accTitle: Secure Boot 검증 사슬
accDescr: UEFI firmware가 Microsoft 인증서로 shim을 검증하고, shim이 GRUB을, GRUB이 shim을 통해 kernel을, kernel이 module을 차례로 검증하는 과정을 보여준다.
FW["UEFI firmware<br/>db: Microsoft UEFI CA"] -->|verifies| S["shimx64.efi<br/>distro cert"]
S -->|verifies| G["grubx64.efi"]
G -->|verifies via shim| K["vmlinuz"]
K -->|verifies| M["kernel modules"]
이처럼 각 단계는 다음 단계를 실행하기 전에 서명을 확인한다. 어느 한 단계라도 검증에 실패하면 부팅이 멈추므로, 출발점인 firmware의 db를 신뢰할 수 있다면 kernel까지 서명이 검증된 코드만 실행된다.
Stage 3: Kernel
bootloader가 jump하면 CPU는 kernel 코드를 실행하기 시작한다. kernel이 가장 먼저 하는 일은 CPU, memory, 장치를 자신이 직접 관리할 수 있는 상태로 만드는 것인데, 이 과정에서 firmware가 Stage 1에서 이미 초기화한 장치도 다시 초기화한다.
firmware가 이미 해 둔 일을 kernel은 왜 다시 할까?
먼저, Stage 1에서 firmware가 장치를 초기화할 때 쓴 driver는 bootloader를 불러오는 데 필요한 만큼만 장치를 다루는 단순한 driver여서, 여러 프로그램이 동시에 장치를 쓰는 OS 환경에는 맞지 않는다.
또한 kernel이 실행된 뒤에는 firmware의 driver를 더 이상 쓸 수 없다. UEFI 방식에서는 kernel이 시작 직후 firmware에 장치 제어를 끝내도록 요청하고(ExitBootServices()), BIOS 방식의 기능은 kernel이 동작하는 64비트 mode에서 호출할 수 없기 때문이다.
그래서 kernel은 자신의 driver로 장치를 다시 초기화하고, CPU와 memory를 여러 프로그램이 나눠 쓸 수 있도록 관리하기 시작한다.
메인보드 firmware에는 부팅에 필요한 장치를 다루는 driver만 들어 있고, kernel에는 그와 별개로 kernel용 driver가 있다. kernel은 자신의 driver로 장치를 다시 초기화해서 OS가 장치를 쓸 수 있게 만든다. 다만 memory training처럼 firmware가 이미 끝낸 작업은 그 결과를 그대로 이어받는다.
kernel 이미지는 압축되어 있으므로, 이미지 맨 앞에 있는 작은 코드가 먼저 나머지 kernel 코드의 압축을 memory에 푼다. 그다음 start_kernel() 함수가 실행되고, kernel은 시작하면서 다음 작업을 한다.
- page table을 만들고 memory 관리 기능을 초기화한다.
- interrupt controller, timer, scheduler를 초기화한다.
- firmware가 남긴 ACPI table을 읽어 CPU 구성과 장치 정보를 파악하고, 나머지 CPU core를 켠다.
- kernel에 내장된(built-in) driver로 장치를 초기화하고, PCIe bus를 다시 탐색한다.
- bootloader가 memory에 올려 둔 initramfs를 memory 기반 filesystem(rootfs)에 푼다.
- initramfs 안의
/init을 첫 번째 user space 프로세스, 즉 PID 1로 실행한다.
이 목록을 외울 필요는 없다. “kernel이 CPU, memory, 장치의 관리를 넘겨받고, initramfs의
/init을 PID 1로 실행한다”만 기억해 두자.
page table, interrupt, scheduler: page table은 프로그램이 쓰는 가상 주소를 실제 memory 주소로 바꾸는 데 쓰는 표다. interrupt는 장치가 처리할 일이 생겼다고 CPU에 알리는 신호다. scheduler는 여러 프로세스가 CPU를 번갈아 쓰도록 실행 순서를 정하는 kernel 기능이다.
ACPI table: firmware가 memory에 남겨 두는 하드웨어 정보 목록이다. CPU 개수, 장치 구성, 전원 관리 방법이 들어 있으며, kernel은 이 정보를 읽어 machine의 하드웨어 구성을 파악한다. firmware가 다음 단계에 하드웨어 정보를 넘기는 대표적인 방법이다.
user space와 PID 1: user space는 kernel 바깥에서 제한된 권한으로 일반 프로그램이 실행되는 영역이다. PID(Process ID)는 kernel이 프로세스마다 붙이는 번호이며, kernel이 처음 실행하는 user space 프로세스가 1번을 받는다.
이 시점에 kernel은 아직 실제 root filesystem을 mount하지 않았다. root filesystem을 찾아 mount하는 일은 다음 단계인 initramfs가 맡는다.
Stage 4: initramfs
kernel은 왜 root filesystem을 직접 mount하지 않을까?
root filesystem을 여는 데 필요한 driver가 root filesystem 안에 들어 있는 경우가 많기 때문이다.
예를 들어 root filesystem이 NVMe 디스크에 LVM을 구성하고 그 위를 LUKS로 암호화한 ext4라고 하자. 이 device를 열려면 NVMe driver, device-mapper, dm-crypt module이 필요하고, 사용자에게 암호도 물어봐야 한다.
그런데 배포판 kernel은 이런 driver를 대부분 module로 빌드하고, module 파일은 root filesystem 안의 /lib/modules에 있다. 즉 root를 열려면 module이 필요하고, module을 읽으려면 root가 필요하다.
initramfs는 이 순환 의존 문제를 해결하려고 root를 여는 데 필요한 module과 도구만 따로 묶어 둔 이미지다. bootloader가 kernel과 함께 initramfs를 memory에 올려 두기 때문에, kernel은 디스크를 열지 않고도 이 module과 도구를 쓸 수 있다.
LUKS, ext4도 알 필요 없다. initramfs는 “실제 root filesystem을 열기 위해 잠깐 쓰는 임시 root” 정도로 이해하면 되겠다.
- root filesystem:
/에 mount되는 filesystem이다./etc,/usr,/lib같은 OS 파일이 모두 이 안에 들어 있다. - LVM, LUKS, device-mapper: LVM(Logical Volume Manager)은 디스크를 논리 volume 단위로 나누고 묶어서 쓰게 해 주는 기능이다. LUKS(Linux Unified Key Setup)는 Linux에서 디스크를 암호화할 때 쓰는 표준 형식이다. 두 기능 모두 kernel의 device-mapper 위에서 동작하며, LUKS의 암호화와 복호화는 dm-crypt가 처리한다.
잠시 교통정리하자면, kernel 본체는 bootloader가 memory에 올리는 /boot/vmlinuz-... 같은 파일이다(Stage 2에서 GRUB이 읽어 오는 그 파일). 이 안에는 kernel이 동작하는 데 항상 필요한 기능들이 들어 있다. kernel module은 driver 같은 기능을 본체와 별도의 파일로 빌드한 것으로(e.g. nvme.ko, nvidia.ko), 필요할 때 실행 중인 kernel에 load된다.
initramfs에는 이런 module과 도구 외에, 도구들을 순서대로 실행하는 /init이라는 프로그램도 들어 있다. Stage 3에서 kernel이 PID 1로 실행한 프로그램이 바로 이 /init이다. /init은 다음 순서로 실제 root filesystem을 찾아 mount한다.
- kernel이 프로세스와 장치 정보를 보여 주는
/proc,/sys,/dev를 mount한다. - 장치를 감지하는 프로그램인
udev를 실행해서, kernel이 발견한 장치에 맞는 module을 load한다. - kernel command line의
root=값과 일치하는 디스크(block device)가 나타날 때까지 기다린다. - 앞의 예처럼 root가 암호화되어 있다면 LUKS 암호를 묻고, LVM을 활성화한다.
- 아직
/는 initramfs가 쓰고 있으므로, 찾은 디스크를/sysroot라는 임시 위치에 mount한다.
root 디스크를 끝내 찾지 못하면 initramfs는 사용자가 직접 문제를 고칠 수 있도록 명령을 입력받는 emergency shell을 띄운다. root= 값이 잘못되었거나 필요한 storage driver가 initramfs에 빠져 있을 때 보는 dracut:/# 또는 (initramfs) prompt가 바로 이 shell이다.
switch_root
root filesystem을 mount한 뒤에는 initramfs의
/init이 차지하던 PID 1 자리를 어떻게 실제 OS의 init 프로그램에 넘길까?
이 작업은 switch_root가 한다. switch_root는 initramfs가 쓰던 파일을 지워 memory를 비우고, /sysroot에 mount해 둔 실제 root filesystem을 /로 바꾼 뒤, 그 안의 /sbin/init을 exec으로 실행한다.
여기서 exec으로 실행한다는 점이 중요하다. exec은 새 프로세스를 만들지 않고, 현재 프로세스가 실행하던 프로그램을 다른 프로그램으로 바꾼다. 그래서 PID는 그대로 1로 유지되고, 실제 root에 있는 /sbin/init이 PID 1이 된다. 대부분의 배포판에서 /sbin/init은 /lib/systemd/systemd를 가리키는 symlink다.
switch_root는 “PID 1이라는 번호는 그대로 두고, 그 번호로 실행되는 프로그램만 initramfs의
/init에서 systemd로 바꾸는 작업”이라고 기억해 두자.
sequenceDiagram
accTitle: initramfs에서 systemd로 넘어가는 과정
accDescr: kernel이 initramfs의 /init을 PID 1로 실행하고, /init이 실제 root를 mount한 뒤 switch_root로 systemd를 실행해 PID 1을 넘기는 과정을 보여준다.
participant K as Kernel
participant I as initramfs /init (PID 1)
participant S as systemd (PID 1)
K->>I: Unpack initramfs, run /init
I->>I: Run udev, load modules, wait for root device
I->>I: Mount real root on /sysroot
I->>S: switch_root (clean up, make /sysroot the new /, exec /sbin/init)
S->>S: Start units up to default.target
Stage 5: systemd
switch_root가 끝나면 실제 root filesystem에 있는 systemd가 PID 1로 실행된다.
root filesystem까지 준비되었는데, 왜 프로그램이 하나 더 필요할까?
kernel은 프로그램이 실행될 환경을 제공할 뿐이고, 어떤 서비스를 어떤 순서로 시작할지는 정하지 않기 때문이다. network 설정, 로그 수집, 나머지 filesystem mount, login prompt 같은 작업은 PID 1로 실행된 init 프로그램이 순서에 맞게 시작해야 한다. 대부분의 배포판에서는 systemd가 이 역할을 맡는다.
PID 1은 특별한 프로세스다. PID 1이 종료되면 kernel은 panic을 일으키고, 부모 프로세스가 종료된 프로세스는 PID 1을 새 부모로 갖게 된다. systemd는 PID 1로 실행되면서 나머지 시스템 전체를 시작하고, machine이 꺼질 때까지 계속 실행된다.
kernel panic: kernel이 더 이상 실행을 계속할 수 없는 오류를 만나 시스템 전체를 멈추는 상태다.
systemd는 부팅할 때 default.target을 목표로 정하고, 이 target이 의존하는 unit을 거꾸로 따라가며 실행할 unit 목록을 만든다. server에서는 보통 multi-user.target이, desktop에서는 graphical.target이 default.target이다. 각 unit은 Requires=, Wants=, After=로 의존성과 순서를 선언하고, systemd는 순서 제약이 없는 unit을 동시에 시작한다.
unit과 target: unit은 systemd가 관리하는 작업 단위다. daemon을 실행하는 service unit, filesystem을 mount하는 mount unit 등이 있다. target은 여러 unit을 묶어 “이 상태까지 도달했다”는 지점을 나타내는 unit이다.
daemon: background에서 계속 실행되면서 요청을 처리하는 프로그램이다.
sshd와cron이 대표적인 예다.
systemd는 대략 다음 순서로 각 target에 도달한다.
flowchart LR
accTitle: systemd target 도달 순서
accDescr: local-fs.target, sysinit.target, basic.target, network.target, multi-user.target, graphical.target 순서로 부팅이 진행되는 모습을 보여준다.
A["local-fs.target<br/>fstab mounts"] --> B["sysinit.target<br/>udev, swap, journald"]
B --> C["basic.target<br/>sockets, timers, paths"]
C --> D["network.target<br/>network services"]
D --> E["multi-user.target<br/>sshd, cron, daemons"]
E --> F["graphical.target<br/>display manager"]
sysinit.target에 도달하기까지 systemd는 로그를 수집하는 journald와 장치를 관리하는 udevd를 시작하고, root filesystem을 read-write로 다시 mount하고, /etc/fstab에 적힌 나머지 filesystem을 mount한다. 이 작업들은 서로 순서 제약이 없으면 동시에 진행된다. 그 뒤 basic.target을 거쳐 각종 service를 실행하고, default.target에 도달하면 부팅이 끝난다. server라면 multi-user.target에 도달해서 getty가 terminal에 login prompt를 띄우는 시점이다.
target 이름은 필요할 때 찾아보면 된다. “systemd는
default.target에 도달할 때까지 unit을 의존 관계에 맞춰 시작한다”만 기억해 두자.
Measuring the Boot
systemd-analyze는 부팅에 걸린 시간을 이 글의 다섯 단계와 같은 구간으로 나누어 보여준다.
$ systemd-analyze
Startup finished in 8.412s (firmware) + 2.105s (loader) + 1.873s (kernel) + 3.920s (initrd) + 9.611s (userspace) = 25.923s
graphical.target reached after 9.588s in userspace.
| 구간 | 단계 | 측정 범위 |
|---|---|---|
| firmware | Stage 1 | reset vector부터 bootloader를 실행하기까지 |
| loader | Stage 2 | bootloader가 kernel로 점프하기까지 |
| kernel | Stage 3 | start_kernel()부터 initramfs의 /init 실행까지 |
| initrd | Stage 4 | root device를 찾아 switch_root하기까지 |
| userspace | Stage 5 | systemd가 default.target에 도달하기까지 |
firmware와 loader 구간은 firmware나 bootloader가 시간 정보를 OS에 넘겨줄 때만 표시된다. server에서는 firmware 구간이 수십 초씩 나오는 경우가 흔한데, 이 시간은 memory training과 장치 초기화에 걸리는 시간이라 OS 쪽에서 줄일 수 없다.
userspace 구간이 길다면 systemd-analyze blame으로 오래 걸린 unit을 찾는다.
$ systemd-analyze blame
5.204s NetworkManager-wait-online.service
1.811s snapd.service
902ms dev-nvme0n1p2.device
640ms systemd-journal-flush.service
...
blame은 unit마다 시작에 걸린 시간을 보여줄 뿐이며, 그 시간이 부팅 전체를 늦췄는지는 알려주지 않는다. 다른 unit과 동시에 실행된 unit은 오래 걸려도 전체 부팅 시간에 영향을 주지 않을 수 있다. 실제로 부팅을 늦춘 경로를 보려면 systemd-analyze critical-chain을 함께 실행하고, 의존 관계를 그림으로 보려면 systemd-analyze plot > boot.svg를 쓴다.
Summary
부팅 순서는 firmware → bootloader → kernel → initramfs → systemd를 기억하자.
그림은 아래에서부터 위로 올라간다고 생각하면 된다. 아래 단계는 자신이 쓸 수 있는 자원으로 바로 위 단계가 실행될 환경을 만들고, 화살표 옆에 적힌 방식으로 제어를 넘긴다. 맨 위의 systemd가 default.target에 도달하면 login prompt가 뜨고 부팅이 끝난다.