IT/컨테이너

containerd image pull 흐름 — snapshotter와 unpack 단계 파헤치기

gfrog 2026. 5. 8. 03:13
반응형

kubectl describe pod에서 Pulling image "..."가 한참 머물러 있을 때, 그 안에서 무슨 일이 벌어지고 있는지 정확히 설명할 수 있는 사람이 의외로 적다. 나도 그랬다. "registry에서 layer 받아서 디스크에 풀고 mount한다" 정도가 내가 가진 모델의 전부였다. 근데 작년 말부터 ARC runner들이 콜드 스타트에서 한참 깔리는 문제를 디버깅하면서, 이 흐름을 좀 진지하게 들여다봐야겠다는 생각이 들었다. fetch가 느린 건지, unpack이 느린 건지, snapshotter가 느린 건지 분리해서 보지 못하면 튜닝 포인트가 없다.

이 글은 containerd 2.x 기준으로 image pull 한 번이 어떤 단계를 거치는지, 각 단계에서 무엇을 디스크에 쓰는지, 그리고 snapshotter라는 추상이 왜 거기에 있는지를 정리한 것이다. 코드 라인 단위 분석은 아니고, 실무자가 "어디를 어떻게 보면 되는지" 모델을 갖는 것을 목표로 했다.

pull은 fetch + unpack + snapshot, 세 단계다

containerd 입장에서 image pull은 단일 동작이 아니다. 크게 세 단계로 쪼개진다.

첫째, fetch. registry에서 image manifest를 읽고, 거기에 나열된 layer blob들을 /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/에 받아온다. 이 디렉터리가 흔히 말하는 content store다. 받아오는 단위는 layer 하나당 tar.gz blob 하나. 압축된 그대로 저장된다. 이 단계에서 디스크에 보이는 건 압축된 tarball들이고, 이건 그 자체로는 컨테이너 rootfs가 아니다.

둘째, unpack. content store에 들어온 tar.gz를 풀어서 실제 파일 시스템 트리로 만든다. 이게 어디로 풀리느냐가 snapshotter가 결정하는 지점이다. overlayfs snapshotter라면 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<id>/fs에 풀린다. erofs snapshotter라면 같은 tarball을 EROFS 포맷의 단일 이미지 파일로 변환해서 layer.erofs 같은 형태로 보관한다. 즉 fetch는 snapshotter와 무관한 공통 단계지만, unpack부터는 어떤 snapshotter를 쓰느냐에 따라 디스크 레이아웃이 완전히 달라진다.

셋째, snapshot 준비. 컨테이너를 띄우는 시점에, snapshotter는 위에서 만들어둔 layer들의 chain을 가지고 새 컨테이너용 r/w mount를 만든다. overlayfs면 lowerdir에 image layer들을 쌓고 upperdir/workdir을 새로 파서 overlay mount를 호출한다. 이 단계는 네트워크와 무관하고 거의 mount syscall 하나에 가깝다.

여기서 짚고 넘어가야 할 포인트. kubectl describe에 보이는 "Pulling image" 시간은 fetch와 unpack을 합친 시간이다. snapshot mount는 그 뒤 컨테이너 start 단계에서 일어나서 보통 따로 안 보인다. 그래서 image pull이 느리다고 느낄 때, 네트워크가 문제인지(fetch) 디스크가 문제인지(unpack) 분리하지 않으면 튜닝 방향이 엉뚱해진다.

snapshotter가 추상화하는 것

snapshotter는 "image layer들의 chain을 어떻게 디스크에 표현하고, 컨테이너에 어떻게 마운트할지"를 캡슐화한 인터페이스다. containerd 본체는 layer 데이터를 받아서 snapshotter에게 "이 layer를 풀어줘", "이 chain 위에 새 active snapshot 만들어줘"만 부탁하고, 실제로 디스크에 무엇을 쓰는지는 snapshotter 구현이 결정한다.

기본 구현은 overlayfs인데, 이건 가장 단순하다. layer마다 디렉터리를 하나 파고 거기에 tar를 풀어둔다. 컨테이너를 띄울 때 그 디렉터리들을 lowerdir로 묶어서 overlay mount를 한다. 단점은 레이어가 많아지면 lowerdir 수가 그대로 늘어나고, mount 인자 길이 제한에 걸리거나 first-access 시 page cache miss가 layer 수만큼 누적된다는 점.

stargz snapshotter는 다른 접근이다. 이미지를 eStargz라는 lazy-pullable 포맷으로 변환해두면, 컨테이너 start 시점에 layer 전체를 받지 않고 mount 후 접근하는 파일만 on-demand로 fetch한다. 이론적으로 startup이 빨라지지만, 베이스 이미지를 eStargz로 변환해서 push해야 하는 운영 부담이 있다. ARC runner처럼 콜드 스타트에 민감한 워크로드에서는 충분히 검토할 가치가 있다.

containerd 2.1부터 들어온 erofs snapshotter는 또 다른 방향이다. layer를 디렉터리로 풀지 않고 EROFS라는 read-only 파일시스템 포맷의 단일 이미지로 변환해서 저장한다. 컨테이너 start 시 이 EROFS 이미지를 loop mount해서 lowerdir로 쓴다. 2.2부터는 tar index 모드로 메타데이터만 빠르게 만들고, parallel unpack도 지원된다. 사실 내부적으로는 overlay mount를 쓰는 건 똑같은데, 그 아래 lower가 디렉터리가 아니라 EROFS 이미지인 셈이다. inode 수가 줄어드니 ls -laR 비용이 낮아지고, 파일 수가 많은 베이스 이미지에서 효과가 두드러진다.

unpack 단계가 느린 진짜 이유

운영하면서 흥미로웠던 건, "fetch는 빠른데 unpack이 길다"는 케이스가 생각보다 많다는 점이다. 100Mi 정도 layer를 푸는데 수 초가 걸리는 노드들을 종종 본다. 원인은 대개 두 가지다.

하나는 디스크 I/O. tar 푸는 과정은 작은 파일을 수만 개 만드는 작업이고, 노드의 EBS gp3 한 볼륨에서 여러 컨테이너가 동시에 unpack을 돌리면 IOPS가 갈린다. metric으로 잡으려면 노드의 node_disk_io_time_seconds_total을 보면 된다. 컨테이너 image pull과 IO util이 같이 올라가면 unpack 병목이 거의 확실하다.

다른 하나는 압축 해제 CPU. layer가 zstd나 gzip으로 압축돼 있고, unpack 시 이걸 단일 스레드로 풀면 한 코어가 풀로 박힌다. containerd 2.x에서는 일부 snapshotter에 parallel unpack이 들어가서 layer 단위로 병렬화되긴 하는데, layer가 적고 거대한 이미지에서는 여전히 한 layer 내부의 압축 해제가 직렬이다. 이게 왜 우리 팀에서 Wolfi 같은 distroless 베이스로 옮긴 이유 중 하나다 — layer가 작고 파일 수가 적으면 fetch와 unpack 둘 다 좋아진다.

조금 더 파보고 싶다면 containerd가 노출하는 metric 중 containerd_pull_image_duration_seconds를 본다. 이건 fetch + unpack을 합친 값이다. fetch만 따로 보고 싶으면 containerd_grpc_server_handled_total에서 Fetch 메소드 latency를 보거나, content store 디렉터리 du를 시간 따라 찍어보는 게 빠르다.

그래서 무엇을 바꿀 수 있나

내부 모델이 잡히고 나면 바꿀 수 있는 손잡이가 보인다. 베이스 이미지를 distroless나 Wolfi로 줄여 layer와 파일 수를 줄이는 게 가장 단순한 시작점이다. 그 다음이 콜드 스타트 빈도에 따라 이미지 prepull DaemonSet을 깔아서 unpack을 미리 끝내두는 패턴. 같은 노드 그룹 안에서 cluster-internal P2P 캐시(예: Spegel 같은)를 도입하면 fetch는 LAN 속도까지 떨어진다. 그리고 워크로드 특성에 따라 stargz나 erofs로 snapshotter를 바꾸는 옵션이 있는데, 이건 운영 비용을 충분히 평가하고 들어가야 한다. 단순 stateless API 서버 정도면 overlayfs 기본이 가장 단순하고 안정적이다.

지금 우리 팀은 ARC runner와 빌드 노드처럼 콜드 스타트에 민감한 곳만 erofs로 옮기는 실험을 돌리고 있다. 결론은 아직 안 났다. 좀 더 운영해보고 다음 글에서 다시 정리하려고 한다.

반응형