본문 바로가기

부팅 과정과 운영체제 적재

전원을 켠 뒤 펌웨어, 부트로더, 커널, initramfs, PID 1이 차례로 제어권을 넘기며 운영체제가 올라오는 과정을 따라가고, systemd로 서비스를 관리하는 방법과 부팅 문제를 단계별로 진단하는 방법을 살펴봅니다.

컴퓨터에 전원을 넣으면 제조사 로고가 잠깐 보이고, 곧 로그인 화면이 나타납니다. 그런데 전원이 막 들어온 순간의 메모리(RAM)에는 실행할 수 있는 운영체제가 들어 있지 않습니다. 운영체제는 디스크에 있고, 디스크에서 무언가를 읽으려면 디스크를 다루는 프로그램이 먼저 실행되어야 합니다. 프로그램을 실행하려면 운영체제가 필요하고, 운영체제를 올리려면 프로그램이 필요한 셈입니다.

이 순환은 아주 작은 프로그램에서 시작해 조금 더 큰 프로그램을 차례로 불러오는 방식으로 풉니다. 영어에서 자기 신발 끈(bootstrap)을 당겨 스스로를 들어 올린다는 표현에 빗대어 이 과정을 부트스트래핑(bootstrapping), 줄여서 부팅(booting)이라고 부릅니다. 이 글에서는 일반적인 Linux 컴퓨터가 부팅하는 과정을 따라가며, 각 단계에서 무엇이 준비되고 어떤 문제가 생길 수 있는지 살펴봅니다. 서버가 재부팅 뒤에 올라오지 않거나 컨테이너가 이상하게 종료될 때, 이 과정을 알고 있으면 원인을 훨씬 빨리 좁힐 수 있습니다.

다섯 번 넘겨지는 제어권

부팅은 이어달리기와 비슷합니다. 각 단계의 프로그램은 다음 단계의 프로그램을 찾아 메모리에 올리고 필요한 정보를 넘긴 뒤 실행을 넘깁니다. 아래 단계 개념도는 부트로더와 initramfs를 쓰는 일반적인 Linux 구성에서 제어권이 넘어가는 순서를 보여 줍니다.

일반적인 Linux 부팅의 제어권 인계

부팅 대상을 선택한다. PID 1을 유지하며 유닛을 준비한다. 각 단계의 값과 전환 이유를 비교합니다.

Linux 부팅 · 제어권 인계

커널 뒤에는 초기 사용자 공간이 실제 루트를 준비합니다

부트로더와 initramfs를 쓰는 일반적인 Linux 예시입니다. 초기 /init이 실제 루트로 전환하고 systemd 등으로 exec하면 PID 1은 유지됩니다.

부팅 실행 주체의 인계화살표는 다음 실행 주체로의 제어 인계입니다. 초기 사용자 공간의 init이 실제 루트로 전환하고 PID 1의 init 프로그램을 이어 실행합니다.펌웨어부팅 대상을 선택부트로더커널 · initramfs 적재커널하드웨어 · 초기 루트 준비초기 /initPID 1 · 실제 루트로 전환systemd 등 · PID 1 유지유닛 · 로그인 환경 준비
부팅 실행 주체의 인계화살표는 다음 실행 주체로의 제어 인계입니다. 초기 사용자 공간의 init이 실제 루트로 전환하고 PID 1의 init 프로그램을 이어 실행합니다.펌웨어부팅 대상을 선택부트로더커널 · initramfs 적재커널하드웨어 · 초기 루트 준비초기 /initPID 1 · 실제 루트로 전환systemd 등PID 1 유지유닛 · 로그인 환경 준비

부트로더와 initramfs를 사용하는 예시입니다. EFI stub·통합 커널 이미지나 initramfs 없는 구성 등에서는 중간 단계가 달라질 수 있습니다. 서명 검증 범위는 실제 Secure Boot 구성에 따릅니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 펌웨어 선택부터 로그인 환경까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목일반적인 Linux 부팅의 제어권 인계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
펌웨어 선택

부팅 대상을 선택한다.

  • 부팅·제어권 1: 펌웨어 실행
  • 부트로더에 인계
커널 적재

부트로더가 커널과 initramfs를 준비한다.

  • 부팅·제어권 1: 커널·initramfs 적재
  • 커널로 제어 인계
커널 초기화

하드웨어와 초기 루트를 준비한다.

  • 부팅·제어권 1: 커널 실행
  • 부팅·제어권 2: 초기 /init PID 1
  • 초기 사용자 공간 시작
실제 루트 준비

초기 /init이 실제 루트로 전환한다.

  • 부팅·제어권 1: 실제 루트 준비
  • 부팅·제어권 2: PID 1
  • systemd 등으로 exec
로그인 환경

PID 1을 유지하며 유닛을 준비한다.

  • 부팅·제어권 1: systemd 등 PID 1
  • 부팅·제어권 2: 유닛·로그인 환경
  • 사용 환경 준비; 다른 부팅 구성은 별도

일반적인 Linux 부팅의 제어권 인계

부팅 대상을 선택한다. PID 1을 유지하며 유닛을 준비한다. 각 단계의 값과 전환 이유를 비교합니다.

Linux 부팅 · 제어권 인계

커널 뒤에는 초기 사용자 공간이 실제 루트를 준비합니다

부트로더와 initramfs를 쓰는 일반적인 Linux 예시입니다. 초기 /init이 실제 루트로 전환하고 systemd 등으로 exec하면 PID 1은 유지됩니다.

부팅 실행 주체의 인계화살표는 다음 실행 주체로의 제어 인계입니다. 초기 사용자 공간의 init이 실제 루트로 전환하고 PID 1의 init 프로그램을 이어 실행합니다.펌웨어부팅 대상을 선택부트로더커널 · initramfs 적재커널하드웨어 · 초기 루트 준비초기 /initPID 1 · 실제 루트로 전환systemd 등 · PID 1 유지유닛 · 로그인 환경 준비
부팅 실행 주체의 인계화살표는 다음 실행 주체로의 제어 인계입니다. 초기 사용자 공간의 init이 실제 루트로 전환하고 PID 1의 init 프로그램을 이어 실행합니다.펌웨어부팅 대상을 선택부트로더커널 · initramfs 적재커널하드웨어 · 초기 루트 준비초기 /initPID 1 · 실제 루트로 전환systemd 등PID 1 유지유닛 · 로그인 환경 준비

부트로더와 initramfs를 사용하는 예시입니다. EFI stub·통합 커널 이미지나 initramfs 없는 구성 등에서는 중간 단계가 달라질 수 있습니다. 서명 검증 범위는 실제 Secure Boot 구성에 따릅니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 펌웨어 선택부터 로그인 환경까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목일반적인 Linux 부팅의 제어권 인계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
펌웨어 선택

부팅 대상을 선택한다.

  • 부팅·제어권 1: 펌웨어 실행
  • 부트로더에 인계
커널 적재

부트로더가 커널과 initramfs를 준비한다.

  • 부팅·제어권 1: 커널·initramfs 적재
  • 커널로 제어 인계
커널 초기화

하드웨어와 초기 루트를 준비한다.

  • 부팅·제어권 1: 커널 실행
  • 부팅·제어권 2: 초기 /init PID 1
  • 초기 사용자 공간 시작
실제 루트 준비

초기 /init이 실제 루트로 전환한다.

  • 부팅·제어권 1: 실제 루트 준비
  • 부팅·제어권 2: PID 1
  • systemd 등으로 exec
로그인 환경

PID 1을 유지하며 유닛을 준비한다.

  • 부팅·제어권 1: systemd 등 PID 1
  • 부팅·제어권 2: 유닛·로그인 환경
  • 사용 환경 준비; 다른 부팅 구성은 별도

펌웨어가 부팅할 대상을 고르고 부트로더에 넘기면, 부트로더는 커널과 initramfs를 메모리에 올리고 커널에 넘깁니다. 커널은 하드웨어를 준비한 뒤 initramfs 안의 /init을 첫 사용자 프로세스로 실행하고, 이 프로세스는 실제 루트 파일 시스템을 준비해 systemd 같은 init 프로그램으로 자신을 바꿉니다. 마지막으로 init 프로그램이 서비스와 로그인 환경을 띄웁니다. 이어달리기와 다른 점은, 앞 주자가 달리기를 마치고 쉬는 것이 아니라 대부분 메모리에서 사라진다는 것입니다. 모든 시스템이 이 단계를 똑같이 거치지는 않으며, 임베디드 기기나 일부 클라우드 가상 머신은 단계를 줄이거나 다르게 구성합니다.

펌웨어: 전원이 들어온 뒤 처음 실행되는 코드

전원이 들어오면 CPU는 하드웨어에 정해진 주소의 코드부터 실행합니다. 이 주소를 리셋 벡터(reset vector)라고 하며, x86 CPU에서는 물리 주소 공간의 끝 근처인 0xFFFFFFF0입니다. 이 주소에는 메인보드의 플래시 메모리에 저장된 펌웨어(firmware)가 연결되어 있습니다.

처음 실행되는 코드가 RAM이 아니라 플래시 메모리에 있어야 하는 이유는 간단합니다. RAM은 전원이 꺼지면 내용을 잃는 휘발성 메모리이므로, 전원이 막 들어온 시점에는 실행할 수 있는 코드가 들어 있지 않습니다. 게다가 RAM을 쓰려면 메모리 컨트롤러를 먼저 초기화해야 하는데, 이 초기화도 펌웨어가 합니다. 플래시 메모리는 전원이 꺼져도 내용이 남으므로 첫 코드를 둘 수 있습니다.

전통적인 BIOS와 MBR

예전 PC의 펌웨어인 BIOS(basic input/output system)는 먼저 하드웨어를 점검하는 POST(power-on self-test)를 했습니다. CPU와 메모리, 주요 장치가 제대로 동작하는지 확인하고, 문제가 있으면 화면을 켜기 전이라도 삐 소리의 패턴(beep code)으로 어느 부분이 문제인지 알렸습니다. 점검이 끝나면 설정에 저장된 부팅 순서에 따라 장치를 하나씩 살피며, 각 장치의 첫 섹터 512바이트를 읽었습니다.

이 첫 섹터를 MBR(master boot record)이라고 하며, 내용은 다음과 같이 나뉩니다.

위치(바이트)크기내용
0–445446바이트부트로더의 첫 단계 코드
446–50964바이트파티션 표(16바이트 항목 4개)
510–5112바이트서명 0x55, 0xAA

BIOS는 마지막 2바이트에 정해진 서명이 있는 장치를 부팅할 수 있는 장치로 보고, 첫 단계 코드를 메모리에 올려 실행했습니다. 다만 서명이 있다는 것은 형식을 갖추었다는 뜻일 뿐, 파티션 표와 부트 코드가 올바르다는 뜻은 아닙니다. 446바이트는 운영체제를 올리기에 너무 작으므로, 이 코드는 디스크의 다른 곳에 있는 부트로더의 나머지 부분을 불러오는 일만 합니다. 또 MBR의 파티션 표는 섹터 번호를 32비트로 기록하므로, 512바이트 섹터를 쓰면 약 2TiB까지만 다룰 수 있습니다. MBR과 뒤에서 볼 GPT의 형식은 UEFI 명세의 해당 장에 정리되어 있습니다.

오늘날의 UEFI

오늘날의 컴퓨터는 BIOS 대신 UEFI(Unified Extensible Firmware Interface)를 씁니다. UEFI 펌웨어는 FAT 파일 시스템을 직접 읽을 수 있어서, 512바이트 섹터에 코드를 억지로 담을 필요가 없습니다. 디스크에는 ESP(EFI system partition)라는 FAT32 파티션이 있고, 펌웨어는 여기에 저장된 EFI 실행 파일(부트로더)을 찾아 실행합니다. 어떤 파일을 실행할지는 펌웨어에 등록된 부팅 항목이 정합니다.

UEFI와 함께 쓰이는 GPT(GUID partition table)는 섹터 번호를 64비트로 기록하므로 MBR의 2TiB 한계가 없습니다. UEFI와 GPT는 함께 쓰이는 경우가 많지만 서로 다른 것이어서, 한쪽은 펌웨어의 규격이고 다른 쪽은 디스크를 나누는 형식입니다.

UEFI의 보안 부팅(Secure Boot)은 실행하려는 EFI 파일에 신뢰할 수 있는 키의 서명이 있는지 확인합니다. 서명이 없거나 차단된 파일은 실행하지 않으므로, 부트로더를 몰래 바꿔치기하는 공격을 막을 수 있습니다. 다만 펌웨어가 직접 확인하는 것은 자신이 실행하는 EFI 파일까지입니다. 그 뒤의 커널과 커널 모듈까지 확인할지는 부트로더와 배포판의 설정에 따라 달라집니다. 이 밖에 UEFI는 네트워크에서 부트로더를 받아 부팅하는 기능도 갖추고 있어, 데이터센터에서 많은 서버에 운영체제를 한꺼번에 설치할 때 쓰입니다.

부트로더: 커널과 initramfs를 메모리에 올린다

펌웨어가 실행한 부트로더(boot loader)는 커널 이미지를 찾아 메모리에 올리고, 커널에 넘길 설정을 준비한 뒤 커널로 실행을 넘깁니다. Linux에서 가장 널리 쓰이는 부트로더는 GRUB(GRand Unified Bootloader)입니다. 여러 운영체제가 설치되어 있으면 GRUB은 어느 것을 부팅할지 고르는 메뉴를 보여 줍니다.

GRUB은 커널에 커널 매개변수(kernel parameter)를 넘깁니다. 예를 들어 root=UUID=...는 실제 루트 파일 시스템이 어느 파티션에 있는지 알려 주고, quiet는 부팅 중 커널 메시지를 줄이라는 뜻입니다. 지금 실행 중인 커널이 어떤 매개변수로 부팅되었는지는 /proc/cmdline 파일에서 확인할 수 있습니다.

initramfs가 필요한 이유

GRUB은 커널과 함께 initramfs(initial RAM file system)라는 작은 파일 시스템 이미지도 메모리에 올립니다. 이것이 왜 필요한지는 커널의 처지에서 생각해 보면 알 수 있습니다. 커널은 실제 루트 파일 시스템을 마운트해야 하는데, 그러려면 그 디스크를 다룰 드라이버가 있어야 합니다. 그런데 배포판의 커널은 수많은 하드웨어를 지원하려고 많은 드라이버를 모듈로 빼 두며, 그 모듈 파일은 루트 파일 시스템 안에 있습니다. 루트를 읽으려면 드라이버가 필요하고, 드라이버를 읽으려면 루트가 필요한 순환이 생기는 것입니다.

initramfs는 이 순환을 끊습니다. 부팅에 꼭 필요한 드라이버와 도구만 담은 작은 파일 시스템을 부트로더가 미리 메모리에 올려 두면, 커널은 디스크 없이도 이것을 루트로 삼아 실행을 시작할 수 있습니다. initramfs 안의 프로그램은 필요한 드라이버를 적재하고, 암호화된 디스크라면 암호를 묻고, 실제 루트 파일 시스템을 찾아 마운트합니다. 그다음 실제 루트로 옮겨 가며 initramfs의 역할을 마칩니다.

Windows는 자체 부트로더인 Windows 부팅 관리자(Windows Boot Manager)를 쓰며, 부팅 설정은 BCD(boot configuration data)라는 저장소에 두고 bcdedit 명령으로 고칩니다.

커널의 초기화와 첫 사용자 프로세스

Linux 커널 이미지 파일은 보통 vmlinuz라는 이름을 가지며, 압축되어 있습니다. 이미지의 맨 앞에는 압축을 푸는 작은 코드가 있어서, 부트로더가 실행을 넘기면 커널은 먼저 스스로 압축을 풀고 본래의 커널 코드로 넘어갑니다. 그다음 커널은 대략 다음과 같은 일을 합니다. 세부 순서는 아키텍처와 설정에 따라 다릅니다.

준비하는 것하는 일
CPU의 실행 환경x86-64라면 64비트 모드와 페이지 테이블을 설정하고, 세그먼트와 인터럽트 서술자 테이블(IDT)을 채운다
메모리펌웨어가 넘겨준 메모리 지도를 보고 쓸 수 있는 물리 메모리를 파악해 페이지 할당기를 초기화한다
인터럽트와 예외IDT에 처리 코드를 등록해 앞 글에서 본 인터럽트와 예외를 처리할 준비를 한다
장치PCI 같은 버스를 살펴 장치를 찾고, 커널에 들어 있는 드라이버를 초기화한다
첫 사용자 프로세스initramfs를 임시 루트로 삼아 그 안의 /init을 프로세스 ID 1로 실행한다

이 과정에서 커널이 남긴 메시지는 부팅이 끝난 뒤 dmesg나 journalctl -k로 볼 수 있습니다. 어떤 장치가 인식되었는지, 어떤 드라이버가 적재되었는지가 기록되어 있어 하드웨어 문제를 진단할 때 먼저 확인합니다.

커널이 처음 실행하는 사용자 프로세스는 프로세스 ID(PID)가 1이며, 흔히 init 프로세스라고 부릅니다. initramfs를 쓰는 구성에서 PID 1은 처음에 initramfs의 /init입니다. 이 프로그램이 실제 루트 파일 시스템을 마운트하고 그쪽으로 루트를 바꾼 뒤, 실제 루트에 있는 systemd 같은 init 프로그램을 exec로 실행합니다. exec는 프로세스를 새로 만드는 것이 아니라 지금 프로세스의 프로그램을 바꾸는 것이므로, systemd는 PID 1을 그대로 이어받습니다.

PID 1에는 다른 프로세스와 다른 몇 가지 규칙이 있습니다. 첫째, 부모 프로세스가 먼저 종료되어 고아가 된 프로세스는 PID 1이나 따로 지정된 프로세스의 자식으로 옮겨지므로, PID 1은 이들이 끝났을 때 종료 상태를 거두어야 합니다. 둘째, 시스템 전체의 PID 1이 종료되면 커널은 더 이상 진행할 수 없어 커널 패닉을 일으킵니다. 셋째, 모든 프로세스가 PID 1의 자손인 것은 아닙니다. 커널이 내부 작업을 위해 만드는 커널 스레드는 PID 2인 kthreadd의 자식입니다. 고아를 입양하는 규칙은 pid_namespaces(7)에 정리되어 있고, 시스템 전체의 PID 1이 끝날 때 커널 패닉을 일으키는 코드는 커널 소스의 kernel/exit.c에 있습니다. 뒤에서 볼 컨테이너에서도 PID 1이 고아를 떠안는 규칙은 같지만, 컨테이너의 PID 1이 끝나면 커널 패닉 대신 그 네임스페이스의 프로세스가 모두 SIGKILL로 종료됩니다.

SysV init에서 systemd로

예전 UNIX와 Linux에서는 SysV init이 PID 1이었습니다. SysV init은 /etc/inittab에서 런레벨(runlevel)을 읽고, 그 런레벨에 해당하는 초기화 스크립트를 차례로 실행했습니다. 런레벨은 시스템의 동작 상태를 숫자로 나타낸 것으로, 0은 종료, 1은 관리 작업을 위한 단일 사용자 모드, 3은 여러 사용자의 텍스트 모드, 5는 그래픽 모드, 6은 재부팅을 뜻하는 경우가 많았습니다.

SysV init의 약점은 스크립트를 하나씩 순서대로 실행한다는 점이었습니다. 서비스 A의 스크립트가 끝나야 서비스 B의 스크립트가 시작되므로, 서로 상관없는 서비스도 앞의 서비스를 기다려야 했습니다. 서비스가 많아질수록 부팅이 느려졌고, 서비스 사이의 의존 관계는 스크립트 이름의 번호 순서로만 표현되었습니다.

오늘날 대부분의 Linux 배포판은 2010년에 레나르트 푀테링(Lennart Poettering) 등이 시작한 systemd를 PID 1로 씁니다. systemd는 서비스와 마운트 지점, 타이머, 소켓 같은 관리 대상을 모두 유닛(unit)이라는 단위로 다루고, 유닛마다 설정 파일을 둡니다. 유닛 파일에 의존 관계를 선언하면, systemd는 서로 의존하지 않는 유닛들을 동시에 시작합니다.

systemd의 소켓 활성화(socket activation)는 이 병렬화를 한 걸음 더 밀고 나갑니다. 서비스를 미리 띄우는 대신 systemd가 그 서비스의 소켓만 먼저 열어 두고, 누군가 소켓에 연결하면 그때 서비스를 시작해 소켓을 넘겨줍니다. 그러면 서비스 B가 서비스 A의 소켓에 연결하는 순간 A가 아직 준비되지 않았더라도 연결 요청은 소켓에 쌓여 기다리므로, 두 서비스를 동시에 시작해도 됩니다. 또 systemd는 서비스마다 별도의 cgroup을 만들어 넣으므로, 서비스가 만든 자식 프로세스까지 묶어서 추적하고 메모리나 CPU 사용량을 제한할 수 있습니다. cgroup은 13장에서 다룹니다.

systemctl로 서비스 다루기

systemd가 관리하는 서비스는 systemctl 명령으로 다룹니다. 자주 쓰는 명령은 다음과 같습니다. 서비스의 상태를 바꾸는 명령은 root 권한이 필요하므로 보통 sudo를 붙입니다.

systemctl status nginx          # 상태, 주 프로세스 ID, 최근 로그
sudo systemctl start nginx      # 지금 시작
sudo systemctl stop nginx       # 지금 중지
sudo systemctl restart nginx    # 중지 후 다시 시작
sudo systemctl enable nginx     # 부팅할 때 자동으로 시작되도록 연결
sudo systemctl disable nginx    # 자동 시작 연결을 해제
systemctl list-units --failed   # 시작에 실패한 유닛 목록
journalctl -u nginx -f          # 이 서비스의 로그를 계속 따라 읽기
systemd-analyze blame           # 유닛별 시작에 걸린 시간

가장 자주 헷갈리는 것은 start와 enable의 차이입니다. start는 지금 서비스를 시작하라는 요청이고, 다음 부팅과는 상관이 없습니다. enable은 다음 부팅부터 서비스가 자동으로 시작되도록 연결을 만드는 것이고, 지금 서비스를 시작하지는 않습니다. 둘을 한 번에 하려면 systemctl enable --now nginx를 씁니다. 서비스를 start만 해 두고 enable하지 않으면, 서버를 재부팅한 뒤 서비스가 올라오지 않는 일이 생깁니다.

유닛 파일 읽기

서비스 유닛 파일은 배포판이 설치한 것은 보통 /usr/lib/systemd/system/에, 관리자가 직접 만들거나 덮어쓰는 것은 /etc/systemd/system/에 둡니다. 다음은 데이터베이스를 쓰는 애플리케이션을 서비스로 등록하는 예입니다.

/etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/start.sh
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

[Unit] 부분의 두 줄은 비슷해 보이지만 하는 일이 다릅니다. Requires=postgresql.service는 이 서비스를 시작할 때 PostgreSQL 서비스도 함께 시작하라는 요구입니다. After=는 함께 시작되는 유닛 사이의 순서만 정하므로, 이 줄 덕분에 PostgreSQL의 시작 작업이 끝난 뒤에 myapp이 시작됩니다. Requires=만 쓰고 After=를 빼면 두 서비스가 동시에 시작될 수 있습니다. 두 줄을 함께 쓰면, PostgreSQL이 시작에 실패했을 때 myapp도 시작되지 않습니다.

[Service] 부분의 Restart=on-failure는 프로세스가 0이 아닌 종료 코드로 끝나거나 신호를 받아 비정상적으로 끝났을 때 다시 시작하라는 설정이고, RestartSec=5는 다시 시작하기 전에 5초를 기다리라는 뜻입니다. 기다리지 않고 바로 다시 시작하면 같은 원인으로 곧바로 다시 실패하기를 되풀이할 수 있기 때문입니다. [Install]의 WantedBy=multi-user.target은 enable할 때 어디에 연결할지를 정하며, multi-user.target은 일반적인 서버가 부팅을 마쳤을 때 도달하는 상태입니다. 각 설정의 정확한 의미는 systemd.unit과 systemd.service 문서에서 확인할 수 있습니다.

더 깊이: 시작되었다와 준비되었다는 다르다

After=postgresql.service가 지켜졌다고 해서 myapp이 시작될 때 PostgreSQL이 연결을 받을 준비가 끝났다는 뜻은 아닙니다. systemd가 "시작이 끝났다"고 판단하는 시점은 서비스 유형(Type=)에 따라 다릅니다. 예를 들어 Type=simple인 서비스는 프로세스를 띄운 직후에 시작된 것으로 처리되므로, 그 프로세스가 아직 초기화 중일 수 있습니다. 서비스가 준비를 마친 뒤 systemd에 직접 알리는 Type=notify 방식을 쓰면 이 간격이 줄지만, 그 서비스가 이 방식을 지원해야 합니다.

네트워크도 비슷한 함정이 있습니다. After=network.target은 네트워크 관리 서비스가 시작된 뒤라는 뜻일 뿐, IP 주소가 설정되어 외부와 통신할 수 있다는 뜻이 아닙니다. 네트워크가 실제로 연결된 뒤에 시작해야 한다면 network-online.target을 Wants=와 After=에 함께 적어야 하며, 이 방법은 systemd의 네트워크 대상 안내에 설명되어 있습니다. 어떤 경우든 애플리케이션은 시작할 때 데이터베이스나 네트워크가 잠시 준비되지 않았을 수 있다고 보고, 다시 시도하는 코드를 갖추는 것이 안전합니다.

부팅 문제를 단계로 나누어 보기

부팅은 여러 단계가 차례로 다음 단계를 불러오는 구조이므로, 한 단계가 실패하면 그 뒤의 단계는 모두 실행되지 않습니다. 그래서 부팅 문제를 만나면 먼저 "어느 단계까지 성공했는가"를 확인하는 것이 출발점입니다.

보이는 증상마지막으로 성공한 단계먼저 확인할 것
화면도 안 켜지고 삐 소리만 난다펌웨어의 하드웨어 점검 중메모리와 하드웨어 연결
부팅할 장치가 없다는 펌웨어 메시지펌웨어부팅 순서, 디스크 인식, ESP와 부팅 항목
GRUB 메뉴나 명령 프롬프트에서 멈춘다부트로더 시작GRUB 설정, 커널과 initramfs 파일
커널 메시지 뒤 루트를 마운트하지 못했다는 패닉커널 시작root= 매개변수, initramfs의 드라이버
부팅은 됐는데 서비스 하나가 안 올라온다PID 1과 대부분의 유닛systemctl status, journalctl -u, enable 여부

예를 들어 커널 메시지가 한참 나오다가 "VFS: Unable to mount root fs"라는 커널 패닉으로 멈추었다면, 펌웨어와 부트로더는 제 역할을 했고 커널도 실행되었습니다. 커널이 루트 파일 시스템을 찾지 못한 것이므로, 커널 매개변수의 root= 값이 맞는지, initramfs에 그 디스크의 드라이버가 들어 있는지를 먼저 봅니다. 반대로 로그인까지 되는데 특정 서비스만 없다면, 부팅 과정이 아니라 그 유닛의 설정과 로그를 봐야 합니다.

부팅이 느릴 때는 systemd-analyze blame으로 유닛마다 시작에 걸린 시간을 볼 수 있습니다. 다만 유닛들은 동시에 시작되므로 오래 걸린 유닛이 곧 부팅을 늦춘 원인이라고 할 수는 없습니다. 실제로 부팅을 붙잡고 있는 의존 경로는 systemd-analyze critical-chain이 보여 주므로, 두 결과와 로그를 함께 보고 판단합니다. 서버에 필요 없는 서비스를 찾았다면 systemctl disable로 자동 시작을 해제할 수 있습니다.

커널 패닉으로 시스템이 멈추었다면, 재부팅한 뒤 직전 부팅의 커널 메시지를 확인합니다. 저널을 디스크에 보관하도록 설정된 시스템에서는 journalctl -k -b -1이 직전 부팅의 커널 로그를 보여 줍니다. 원인은 메모리 같은 하드웨어 오류, 손상된 커널 모듈, 드라이버의 버그 등 다양합니다.

디스크 파티션을 바꾸거나 다른 운영체제를 설치한 뒤 GRUB이 망가져 부팅이 안 될 때는, 설치용 USB로 부팅해 원래 시스템의 루트를 마운트하고 chroot로 그 안에 들어가 GRUB을 다시 설치하는 방법을 씁니다. 이때 쓰는 명령은 배포판과 부팅 방식에 따라 다릅니다. Debian 계열은 grub-install과 update-grub을, Fedora 계열은 grub2-mkconfig를 쓰며, UEFI 시스템이라면 ESP도 마운트해야 합니다. 대상 디스크를 잘못 고르면 다른 디스크의 부팅 영역을 덮어쓸 수 있으므로, 실행하기 전에 디스크와 마운트 상태를 반드시 확인합니다.

컨테이너 안의 PID 1

Docker 같은 컨테이너는 자기만의 PID 네임스페이스를 가지므로, 컨테이너에서 처음 실행된 프로세스가 그 안의 PID 1이 됩니다. 그래서 앞에서 본 PID 1이 고아를 떠안는 규칙이 컨테이너 안의 애플리케이션에도 적용됩니다. 이 프로세스가 종료되면 컨테이너 안의 다른 프로세스도 모두 종료되고 컨테이너가 멈춥니다.

여기서 두 가지 문제가 자주 생깁니다. 하나는 신호입니다. PID 네임스페이스의 PID 1에게는 그 프로세스가 처리 함수를 등록해 둔 신호만 전달되며, 바깥 네임스페이스에서 보낸 신호도 SIGKILL과 SIGSTOP을 빼고는 마찬가지입니다. 그래서 SIGTERM을 따로 처리하지 않는 애플리케이션이 PID 1이면, docker stop이 보낸 SIGTERM이 무시되고, Docker는 기다리다가 결국 SIGKILL로 강제 종료합니다. 다른 하나는 좀비 프로세스입니다. 애플리케이션이 자식 프로세스를 만들고 그 종료 상태를 거두지 않으면, 고아가 된 프로세스까지 떠안은 PID 1 아래에 좀비가 쌓입니다. 이 두 문제 때문에 tini 같은 작은 init 프로그램을 PID 1로 두고 애플리케이션을 그 자식으로 실행하기도 하며, Docker의 docker run --init 옵션이 이 방식을 씁니다. 좀비 프로세스는 3장에서 자세히 다룹니다.

정리

  • 부팅은 펌웨어, 부트로더, 커널, initramfs의 /init, init 프로그램이 차례로 다음 단계를 불러오며 제어권을 넘기는 과정입니다.
  • 전원이 들어오면 CPU는 리셋 벡터에서 플래시 메모리의 펌웨어를 실행합니다. BIOS는 MBR의 446바이트 코드로, UEFI는 ESP의 EFI 실행 파일로 부트로더를 시작합니다.
  • 부트로더는 커널과 initramfs를 메모리에 올리고 커널 매개변수를 넘깁니다. initramfs는 루트 파일 시스템을 읽는 데 필요한 드라이버를 미리 제공해 순환을 끊습니다.
  • 커널은 CPU와 메모리, 인터럽트, 장치를 준비한 뒤 첫 사용자 프로세스를 PID 1로 실행합니다. PID 1은 고아 프로세스를 거두는 일을 맡고, 종료되면 커널 패닉이 일어납니다.
  • systemd는 유닛 파일의 의존 관계를 보고 서비스를 동시에 시작합니다. start는 지금 시작, enable은 다음 부팅부터 자동 시작이며, After=는 순서만, Requires=는 함께 시작하라는 요구만 나타냅니다.
  • 부팅 문제는 어느 단계까지 성공했는지부터 확인하고, 그다음 단계를 살펴보는 순서로 진단합니다.

연습 문제

1. enable만 한 경우와 start만 한 경우

myapp 서비스가 꺼져 있고 자동 시작도 설정되어 있지 않습니다. 운영자 A는 systemctl enable myapp만 실행했고, 운영자 B는 systemctl start myapp만 실행했습니다. 각 경우에 지금 서비스가 실행되고 있는지와 서버를 재부팅한 뒤 서비스가 자동으로 시작되는지를 답하세요. 다른 유닛이나 소켓, 타이머가 이 서비스를 시작하는 일은 없다고 가정합니다.

해설 보기

A의 경우 enable은 다음 부팅부터 자동으로 시작되도록 연결을 만들 뿐 지금 시작하라는 요청은 하지 않으므로, 지금은 서비스가 실행되고 있지 않고 재부팅한 뒤에는 시작됩니다. B의 경우 start는 지금 시작을 요청하지만 자동 시작 연결은 만들지 않으므로, 지금은 실행 중이지만 재부팅한 뒤에는 시작되지 않습니다. 두 가지를 모두 원하면 systemctl enable --now myapp을 씁니다. 또 시작을 요청했다는 것과 서비스가 실제로 잘 실행되고 있다는 것은 다르므로, Requires=로 묶인 PostgreSQL이 실패했거나 애플리케이션에 오류가 있다면 systemctl status와 로그로 실제 상태를 확인해야 합니다.

2. 루트를 찾지 못한 커널

새 서버에 NVMe SSD를 달고 기존 디스크의 시스템을 옮겼더니, 부팅 중 커널 메시지가 나온 뒤 "VFS: Unable to mount root fs"라는 패닉으로 멈춥니다. 이 시스템의 NVMe 드라이버는 커널에 내장되지 않고 모듈로만 들어 있으며, initramfs는 옛 서버에서 만든 그대로입니다. 어느 단계까지 성공했고, 무엇이 원인일 가능성이 높으며, 어떻게 고칠 수 있을지 설명하세요.

해설 보기

커널 메시지가 나왔으므로 펌웨어와 부트로더는 성공했고, 부트로더가 커널을 올려 커널이 실행되었습니다. 실패한 것은 커널이 실제 루트 파일 시스템을 마운트하는 단계입니다. NVMe 드라이버가 모듈로만 있으므로 커널은 initramfs에 그 모듈이 들어 있어야 NVMe SSD를 읽을 수 있는데, 옛 서버에서 만든 initramfs에는 그 서버에 필요 없던 NVMe 모듈이 빠져 있었을 가능성이 높습니다. 루트 파일 시스템을 읽으려면 드라이버가 필요하고, 드라이버는 루트 파일 시스템 안에 있는 순환이 initramfs로 끊기지 않은 것입니다. 설치용 USB로 부팅해 시스템에 chroot로 들어간 뒤 NVMe 모듈을 포함하도록 initramfs를 다시 만들고, root= 매개변수가 새 디스크의 파티션을 가리키는지도 함께 확인합니다.

3. 10초 뒤에야 멈추는 컨테이너

Python으로 만든 웹 서버를 CMD ["python", "server.py"]로 실행하는 컨테이너가 있습니다. docker stop을 실행하면 매번 약 10초가 지나서야 컨테이너가 멈추고, 종료 코드는 137입니다. 서버 코드에는 신호를 처리하는 부분이 없습니다. 무엇이 일어나고 있는지 PID 1의 규칙으로 설명하고, 해결 방법 두 가지를 제안하세요.

해설 보기

CMD를 exec 형식으로 적었으므로 python 프로세스가 컨테이너의 PID 1입니다. docker stop은 먼저 SIGTERM을 보내는데, PID 네임스페이스의 PID 1에게는 처리 함수를 등록한 신호만 전달되므로, 처리 함수가 없는 이 서버에는 SIGTERM이 무시됩니다. Docker는 기본 대기 시간인 10초를 기다린 뒤 SIGKILL로 강제 종료하며, 신호 9로 끝난 프로세스의 종료 코드가 128 + 9 = 137로 보고됩니다. 첫째 해결 방법은 서버 코드에서 signal 모듈로 SIGTERM 처리 함수를 등록해 연결을 정리하고 스스로 종료하게 하는 것입니다. 둘째 방법은 docker run --init이나 tini로 작은 init 프로그램을 PID 1로 두는 것으로, 이 프로그램이 받은 신호를 서버 프로세스에 전달하므로 서버는 일반 프로세스처럼 SIGTERM의 기본 동작에 따라 종료됩니다.

다음 장의 프로세스의 개념에서는 부팅이 끝난 뒤 실행되는 프로그램들을 운영체제가 프로세스라는 단위로 어떻게 표현하고 관리하는지 살펴봅니다.