커널 업데이트 재부팅, 실패해도 되돌릴 수 있게 — grub-reboot 와 스냅샷 순서

정기창·

지난 글에서 호스트의 자동 업그레이드가 서비스를 재시작하지 않도록 바꿨습니다. 그 대가로 숙제가 하나 남았습니다. 설치는 됐지만 아직 쓰이지 않는 새 커널과 라이브러리입니다. 이 글은 그 숙제, 커널 업데이트를 위한 재부팅을 실패해도 되돌릴 수 있게 진행한 과정의 기록입니다.

환경은 AWS EC2 의 ARM 인스턴스(Graviton) 한 대, Ubuntu 24.04 이고, 커널을 6.17 에서 7.0(linux-aws)으로 올렸습니다. 이 서버 한 대 위에서 셀프 호스팅 PaaS 가 웹, API, 워커, 개발 환경, 리버스 프록시를 모두 컨테이너로 돌립니다. GRUB 과 systemd 의 기본 개념은 알고 계신다고 가정하고 썼습니다.

설치된 패치는 재부팅 전까지 반영되지 않습니다

무인 자동 업그레이드(unattended-upgrades)는 보안 패치를 내려받아 설치합니다. 그런데 «설치됐다»와 «실행 중이다»는 다른 말입니다.

라이브러리부터 보겠습니다. 실행 중인 프로세스는 시작할 때 메모리에 올린 라이브러리를 계속 씁니다. 디스크의 libc 나 libssl 이 새 버전으로 바뀌어도, 이미 떠 있는 프로세스는 지워진 옛 파일을 그대로 붙잡고 있습니다. needrestart 는 이런 프로세스를 찾아 «재시작이 필요한 서비스»로 보여 줍니다. 지난 글에서 자동 재시작을 껐기 때문에 이 목록은 사람이 재시작할 때까지 줄지 않습니다. 이번에는 dbus, docker, systemd-logind 를 포함해 일곱 개가 쌓여 있었습니다.

커널은 더 분명합니다. 새 커널 이미지는 /boot 에 설치되지만, 돌고 있는 커널은 부팅할 때 올라온 옛 커널입니다. 새 커널에 들어 있는 보안 수정은 재부팅해서 그 커널로 올라오기 전까지 한 줄도 반영되지 않습니다. Ubuntu 는 이 상태를 /var/run/reboot-required 파일로 알려 주고, needrestart 의 배치 출력으로도 볼 수 있습니다.

$ sudo needrestart -b -r l
NEEDRESTART-KCUR: 6.17.0-1019-aws     ← 지금 돌고 있는 커널
NEEDRESTART-KEXP: 7.0.0-1013-aws      ← 설치돼 있는 새 커널
NEEDRESTART-KSTA: 3                   ← 1: 최신 · 2: ABI 호환 갱신 대기 · 3: 버전 갱신 대기
NEEDRESTART-SVC: dbus.service
NEEDRESTART-SVC: docker.service
...

컨테이너를 돌리는 서버라면 커널 패치의 무게가 더 큽니다. 컨테이너는 가상 머신과 달리 호스트의 커널을 함께 씁니다. 네임스페이스와 cgroup 으로 나뉘어 있을 뿐, 커널 하나가 모든 컨테이너의 격리 경계입니다. 커널에 권한 상승 취약점이 있으면 그 경계가 함께 약해집니다. 그래서 «패치는 설치했으니 괜찮다»는 생각이 컨테이너 호스트에서는 특히 위험하다는 생각이 들었습니다.

그렇다면 서비스만 하나씩 재시작하면 되지 않을까요. 이번 목록으로는 그럴 수 없었습니다. Docker 데몬은 live-restore 가 꺼져 있으면 재시작할 때 모든 컨테이너를 함께 재시작합니다. dbus 는 systemd 와 로그인 관리자가 기대는 버스라서 따로 재시작하는 것이 오히려 위험합니다. 그리고 커널은 재부팅 말고는 바꿀 방법이 없습니다. Canonical Livepatch 처럼 재부팅 없이 커널 일부를 고치는 방법도 있지만, 모든 수정이 대상은 아니고 이번처럼 커널 버전이 바뀌는 갱신은 결국 재부팅이 필요합니다. 그래서 한 번의 재부팅으로 전부 정리하기로 했습니다.

재부팅이 무서웠던 이유: 되돌릴 길이 없었습니다

재부팅 자체는 명령 한 줄입니다. 무서운 것은 새 커널이 뜨지 않는 경우였습니다. 6.17 에서 7.0 으로 올라가는 갱신은 패치 번호 하나가 바뀌는 것보다 변화가 큽니다. 그리고 이 서버를 살펴보니 그런 경우에 되돌릴 길이 사실상 없었습니다.

확인한 것 상태 뜻
부팅 메뉴 GRUB_TIMEOUT=0, 숨김 부팅 중에 다른 커널을 고를 수 없음
기본 부팅 항목 GRUB_DEFAULT=0 항상 가장 최근 커널로 부팅
EC2 직렬 콘솔 계정 단위로 꺼짐 부팅 화면에 들어갈 수 없음
디스크 백업 스냅샷 · 이미지 · 자동 백업 0개 복원 지점이 없음

새 커널이 뜨지 않으면 재부팅을 몇 번 해도 같은 커널로 다시 실패합니다. 고치려면 인스턴스를 멈추고, 루트 디스크를 떼어 다른 인스턴스에 붙이고, 부팅 설정을 고친 뒤 다시 붙여야 합니다. 그동안 이 서버 위의 모든 서비스가 수십 분 멈춥니다.

처음에는 «재부팅 전에 스냅샷만 떠 두면 되겠다»고 생각했습니다. 그런데 곰곰이 생각해보니 그 스냅샷도 같은 부팅 설정을 담고 있습니다. 스냅샷으로 복원한 디스크는 다시 최신 커널, 즉 뜨지 않는 그 커널로 부팅합니다. 스냅샷은 데이터는 지켜 주지만 이 실패를 되돌려 주지는 못합니다. 그래서 스냅샷을 뜨는 순서까지 정해야 했습니다.

새 커널은 한 번만 시험 부팅합니다

GRUB 에는 다음 부팅 한 번에만 다른 항목으로 부팅하는 기능이 있습니다. 기본 항목은 검증된 지금 커널로 두고, 새 커널은 다음 한 번만 시도합니다. 새 커널이 뜨지 않으면 다시 재부팅하기만 하면 기본 항목인 옛 커널로 돌아옵니다. 콘솔이 없어도 클라우드 API 로 재부팅을 한 번 더 요청하면 됩니다.

동작 원리는 GRUB 의 환경 블록(/boot/grub/grubenv)에 있습니다. Ubuntu 가 만드는 grub.cfg 는 부팅할 때 이 블록을 읽어, next_entry 가 있으면 그 항목으로 부팅하면서 곧바로 그 값을 지웁니다. 그래서 «한 번만»이 됩니다. next_entry 가 없으면, GRUB_DEFAULT=saved 일 때 saved_entry 를 씁니다.

# grub.cfg 안의 해당 부분(요약)
load_env
if [ "${next_entry}" ] ; then
   set default="${next_entry}"
   set next_entry=
   save_env next_entry       ← 부팅하면서 바로 지운다 = 한 번만
else
   set default="${saved_entry}"
fi

여기에는 전제가 하나 있습니다. 부팅 중에 GRUB 이 환경 블록을 고쳐 쓸 수 있어야 합니다. LVM, 소프트웨어 RAID, btrfs 위의 /boot 에서는 GRUB 이 쓰지 못해 next_entry 가 지워지지 않을 수 있습니다. 그러면 한 번이 아니라 매번 새 커널로 부팅합니다. 이 서버는 /boot 가 별도의 ext4 파티션이라 조건이 맞았습니다.

1단계: 기본 부팅을 지금 커널로 고정합니다

먼저 메뉴 항목의 정확한 ID 를 찾습니다. 개별 커널 항목은 «고급 옵션» 하위 메뉴 안에 있으므로 하위 메뉴 ID>항목 ID 형태로 가리킵니다.

grep -o -E "gnulinux-(advanced|[0-9.]+-[0-9]+-aws-advanced)-[0-9a-f-]{36}" /boot/grub/grub.cfg | sort -u
# 원래 설정 파일은 건드리지 않고, 덮어쓰는 파일을 하나 둔다
echo 'GRUB_DEFAULT=saved' | sudo tee /etc/default/grub.d/99-saved-default.cfg
sudo update-grub
sudo grub-script-check /boot/grub/grub.cfg && echo "문법 OK"

SUB="gnulinux-advanced-<UUID>"
OLD="gnulinux-6.17.0-1019-aws-advanced-<UUID>"
sudo grub-set-default "$SUB>$OLD"
sudo grub-editenv list      # saved_entry=…6.17… 가 보여야 한다

2단계: 그다음에 스냅샷을 뜹니다

순서가 핵심입니다. 기본 부팅을 지금 커널로 고정한 뒤에 스냅샷을 뜨면, 그 스냅샷으로 복원한 디스크도 검증된 커널로 부팅합니다. 스냅샷 직전에는 sync 로 쓰기 버퍼를 비웠습니다. 실행 중인 서버에서 뜬 스냅샷은 전원이 갑자기 꺼진 것과 같은 상태(crash-consistent)이므로, 데이터베이스는 복원 뒤 다시 켤 때 자체 복구 절차를 거친다는 점도 기억해 두어야 합니다.

sync
aws ec2 create-snapshot --volume-id <루트 볼륨 ID> \
  --description "before kernel reboot (default pinned to current kernel)"
aws ec2 wait snapshot-completed --snapshot-ids <스냅샷 ID>

그 디스크의 첫 스냅샷이라 사용 중인 13GB 를 모두 복사하느라 완료까지 44분이 걸렸습니다. 스냅샷은 만든 순간의 시점을 담으므로 진행 중에 재부팅해도 내용은 같습니다. 다만 복원이 필요한 상황에서 완료를 기다리고 싶지 않아 끝날 때까지 기다렸습니다.

3단계: 새 커널을 한 번만 걸고 재부팅합니다

NEW="gnulinux-7.0.0-1013-aws-advanced-<UUID>"
sudo grub-reboot "$SUB>$NEW"
sudo grub-editenv list      # saved_entry=…6.17…  next_entry=…7.0…
aws ec2 reboot-instances --instance-ids <인스턴스 ID>

재부팅은 서버 안의 reboot 명령 대신 클라우드 API 로 요청했습니다. 실패했을 때 다시 재부팅하는 수단도 같은 API 입니다. 서버 안에 들어가지 못하는 상황을 기준으로 절차를 맞춰 두고 싶었습니다.

재부팅 시각은 정시 작업을 피해서 고릅니다

시각을 정할 때 사용자가 적은 새벽인지만 보지 않았습니다. 이 서버의 워커는 매시 정각에 외부 채널에서 주문을 수집하고, 15분마다 채널의 OAuth 토큰을 갱신합니다. 특히 토큰 갱신이 문제였습니다. 갱신을 요청하면 채널은 새 토큰을 주면서 이전 토큰을 무효로 만들 수 있습니다. 새 토큰을 받은 직후, 저장하기 전에 프로세스가 끊기면 쓸 수 있는 토큰을 잃습니다. 그래서 정각과 15분 경계에서 몇 분 떨어진 구간(매시 :08~:13, :23~:28, :38~:43)을 골랐습니다.

재부팅 직전에는 진행 중인 수집 작업이 없는지 데이터베이스에서 확인했습니다. 배포가 진행 중이지 않은지, 열린 사용자 연결과 관리용 원격 세션이 없는지도 함께 봤습니다.

멈춘 시간은 숫자로 잽니다

재부팅하는 동안 바깥에서 1초마다 웹, API, 개발 환경의 상태 확인 주소를 불렀습니다. 상태 코드만 기록하는 단순한 루프입니다.

while :; do
  a=$(curl -s -o /dev/null -m 3 -w '%{http_code}' https://example.com/)
  b=$(curl -s -o /dev/null -m 3 -w '%{http_code}' https://example.com/api/health)
  echo "$(date +%H:%M:%S) $a $b" >> probe.log
  sleep 1
done
01:38:01  재부팅 요청
01:38:05  502 000 000    ← 프록시가 내려가는 중 / 연결 자체가 안 됨(000)
01:38:20  새 커널로 부팅
01:38:43  503 503 503    ← 프록시는 떴고 앱 컨테이너가 아직 준비 중
01:38:47  200 503 503
01:38:52  200 200 200

결과는 웹 39초, API 46초, 개발 환경 48초였습니다. 미리 2~4분으로 추정했는데 실제로는 1분이 안 됐습니다. 회복한 뒤 23분 동안 비정상 응답은 한 번도 없었습니다. 다만 감수해야 할 것도 있었습니다. 점검 안내 화면도 같은 서버에 있어서, 서버 전체가 재부팅되는 그 40여 초 동안은 안내 화면조차 보여 줄 수 없었습니다.

재부팅 뒤에 확인한 것

재부팅 전에 상태를 한 번 적어 두고, 뒤에 같은 항목을 다시 적어 대조했습니다. 재부팅 뒤의 값만 봐서는 무엇이 바뀌었는지 판단할 수 없기 때문입니다.

항목 재부팅 전 재부팅 후
커널 6.17.0-1019-aws 7.0.0-1013-aws
재시작이 필요한 서비스 7개 0개
재부팅 필요 표시 있음 없음
컨테이너 17개 동작 17개 동작 · 같은 이미지
컨테이너별 sysctl(tcp_retries2=8) 적용 유지
실패한 systemd 유닛 - 0개

오류 수준 로그는 두 줄이었고 둘 다 무해했습니다. 하나는 ARM 서버에서 새 커널이 흔히 남기는 PCI: OF: of_root node is NULL 이고, 다른 하나는 Docker 가 뜨기 전에 네트워크 도구가 docker0 인터페이스를 찾지 못했다는 안내였습니다. 처음 보는 오류 로그를 모두 문제로 읽지 않으려면 비교할 기준이 있어야 한다는 생각이 들었습니다.

마지막으로 다음 정시 수집이 새 커널에서 도는 것을 확인했습니다. 그런데 세 건 중 한 건이 실패했습니다. 애석하게도 처음에는 재부팅 탓인가 하는 생각부터 들었습니다. 로그를 열어 보니 외부 채널이 돌려준 «access token 만료»였습니다. 다른 시스템에서 옮겨 온 토큰이 마침 그 시각에 만료된 것이었고, 재부팅과는 상관이 없었습니다. 변경 직후에 일어난 실패라고 해서 변경이 원인이라는 보장은 없습니다. 반대로 변경 직후라서 원인을 확인하지 않고 넘겨서도 안 됩니다. 이번에는 로그가 남아 있었기 때문에 몇 분 만에 가를 수 있었습니다.

마지막: 부팅 설정을 원래대로 돌려놓습니다

새 커널이 정상이라는 것을 확인했으니 안전장치를 걷어냅니다. 덮어쓰는 파일을 지우고 환경 블록을 비우면 원래 설정, 즉 «가장 최근 커널이 기본»으로 돌아갑니다. 이 단계를 바로 하지 않고 다음 정시 작업까지 지켜본 이유는, 그 사이에 문제가 보이면 재부팅 한 번으로 옛 커널로 돌아갈 수 있게 하려는 것이었습니다.

sudo rm /etc/default/grub.d/99-saved-default.cfg
sudo update-grub
sudo grub-script-check /boot/grub/grub.cfg
sudo grub-editenv /boot/grub/grubenv unset saved_entry next_entry
grep -c 'set default="0"' /boot/grub/grub.cfg   # 1 이면 최신 커널이 기본

덮어쓰는 파일을 남겨 두면 다음 자동 업그레이드가 설치한 커널도 기본이 되지 않습니다. 그 자체로는 안전해 보이지만, 패치를 설치해도 반영되지 않는 처음의 문제로 조용히 돌아가는 셈이라 지웠습니다.

정리

단계 하는 일 막는 것
기본 고정 GRUB_DEFAULT=saved + grub-set-default 뜨지 않는 새 커널로 계속 부팅되는 것
스냅샷 기본 고정 «뒤»에 뜬다 디스크 손상, 복원본이 실패하는 커널로 부팅되는 것
한 번 부팅 grub-reboot 콘솔 없이도 재부팅 한 번으로 복귀
시각 선택 정각과 토큰 갱신 주기를 피함 작업이 끊겨 토큰이나 데이터를 잃는 것
1초 측정 바깥에서 상태 코드 기록 «잠깐 멈췄다»를 추정으로 남기는 것
원상 복구 덮어쓰기 제거, 환경 블록 비우기 다음 커널 갱신이 반영되지 않는 것

돌이켜 생각해보면 이번 작업에서 가장 오래 걸린 것은 재부팅이 아니라 «실패하면 어떻게 돌아오나»에 답하는 일이었습니다. 콘솔도 백업도 없는 서버에서는 재부팅 명령의 무게가 생각보다 무겁습니다. 다만 장치 두 개와 순서 하나만으로 그 무게를 꽤 덜 수 있었습니다. 패치를 설치하는 일만큼, 설치한 패치가 실제로 실행되게 하는 일도 운영의 일부라는 생각이 들었습니다.

LinuxUbuntu커널GRUBAWS EC2운영트러블슈팅