요즘 팀 슬랙에 EKS 노드 스케일 아웃 후 파드가 Running까지 도달하는 데 걸리는 시간 얘기가 자주 올라온다. GPU 노드에 20GB짜리 추론 이미지를 올려야 하는데, 이미지 pull에만 3분 넘게 걸린다. 트래픽 스파이크가 오고 나서야 노드가 뜨기 시작하면 사실상 늦다.
이 문제 해결책으로 몇 년 전부터 계속 언급되는 게 lazy pulling이다. 이미지 전체를 미리 받지 않고, 컨테이너가 실제로 파일을 요청할 때만 해당 range를 원격 registry에서 가져다가 마운트해주는 방식. 개념은 단순한데 내부 동작이 꽤 재밌어서 이번에 SOCI(Seekable OCI) snapshotter 코드를 좀 뜯어봤다. 정리해둔다.
OCI 이미지가 어떻게 생겨먹었길래 lazy pulling이 되는가
우선 왜 이게 가능한지부터 짚고 가자. OCI 이미지는 결국 tar.gz로 압축된 layer들의 집합이다. 파일시스템 스냅샷 하나가 tar 아카이브 하나에 들어가고, gzip으로 압축된다. 컨테이너 런타임은 이걸 받아서 압축을 풀고, 스냅샷 레이어를 overlay로 쌓아서 rootfs를 만든다.
문제는 여기 있다. gzip은 스트림 압축이다. 파일 하나만 꺼내 쓰려고 해도 앞부분부터 순차적으로 복원해야 한다. 즉, bin/python만 필요해도 layer 전체를 다 받아서 다 풀어야 한다. 이게 이미지 pull이 느린 근본적인 이유다.
SOCI가 하는 일은 이 tar.gz에 "인덱스"를 하나 더 붙이는 것이다. 인덱스에는 두 가지 정보가 들어간다.
첫째, tar 안의 어떤 파일이 어떤 offset부터 어떤 offset까지에 있는지. 둘째, gzip 스트림의 어떤 지점에서든 decompression을 재시작할 수 있게 하는 checkpoint(zTOC라고 부른다). 이 두 개를 조합하면 "python 바이너리 하나 꺼내려면 layer의 offset 12.4MB부터 13.1MB까지만 range request로 받아서 특정 zTOC 시점부터 gunzip을 돌리면 된다"는 계산이 가능해진다.
인덱스 자체는 이미지 layer가 아니라 별도 OCI artifact로 registry에 올라간다. 즉, 원본 이미지를 재빌드하지 않고도 인덱스만 따로 만들어서 push 할 수 있다. 이게 SOCI가 다른 lazy pulling 접근(eStargz 계열)과 결정적으로 다른 지점이다. eStargz는 이미지를 재빌드해서 stargz 포맷으로 바꿔야 한다. SOCI는 원본 그대로 두고 옆에 인덱스만 붙이면 끝난다.
그래서 컨테이너 시작 시점에 뭐가 일어나는가
여기서부터가 재밌다. containerd가 이미지를 pull 하라는 요청을 받으면, remote snapshotter가 개입해서 registry에서 인덱스 존재 여부를 먼저 확인한다. 있으면 lazy 경로로 진입, 없으면 기존 방식대로 fallback.
lazy 경로로 들어가면 SOCI snapshotter가 하는 첫 작업은 layer 데이터를 받는 게 아니라, FUSE 마운트를 만드는 것이다. /var/lib/containerd/io.containerd.snapshotter.v1.soci/snapshots/N/fs 아래에 FUSE 파일시스템이 얹히고, 이 파일시스템은 read 요청이 올 때마다 registry에 HTTP range request를 날려서 해당 부분만 받아오는 방식으로 응답한다.
컨테이너 프로세스가 뜨고 나서 open("/usr/bin/python3")을 호출하면 커널이 이걸 VFS로 넘기고, overlayfs 상위 계층에는 없으니 lower 레이어로 내려오고, 그 레이어가 FUSE니까 결국 유저스페이스 데몬이 request를 받는다. 데몬은 인덱스를 보고 "이 파일은 layer X의 offset Y부터 Z까지"라는 걸 알아낸 다음, registry에 Range: bytes=Y-Z 헤더로 HTTP GET을 날린다. 응답이 오면 gzip을 특정 zTOC부터 복원해서 파일 내용을 만들고, 커널에 돌려준다.
한 번 읽은 부분은 로컬 캐시에 저장한다. 같은 파일이 재차 열리면 원격 요청 없이 캐시에서 서빙된다. 백그라운드에서는 별개의 워커가 "나중에 어차피 쓸 것 같은" 파일들을 미리 pull 해두는 prefetch도 돈다.
여기서 흥미로운 함정 하나. FUSE 데몬이 죽으면 그 마운트를 lower로 쓰고 있는 컨테이너의 모든 파일 접근이 EIO로 죽는다. 프로덕션에서 SOCI 데몬 자체의 안정성이 중요한 이유가 여기 있다. 워커 노드에서 데몬을 관리하는 systemd 유닛에 Restart 정책을 반드시 걸어야 하고, 재시작 후에도 진행 중이던 컨테이너가 살아남으려면 상태를 disk에 잘 남겨둬야 한다.
어떤 워크로드가 이득을 보는가
이론적으로는 이미지 사이즈가 클수록, 실제 컨테이너 시작 후 초기 몇 초간 접근하는 파일의 비중이 낮을수록 이득이 크다. 반대로 이미지 안의 거의 모든 파일을 초기부터 다 읽는 케이스(예를 들면 컨테이너 시작하자마자 전체 파일시스템을 tar로 압축해서 다른 곳으로 보내는 사이드카)라면 lazy pulling은 오히려 오버헤드다. FUSE 왕복 비용과 gzip 부분 복원 비용이 더해지기 때문이다.
실무적으로 lazy pulling이 확실한 답이 되는 케이스는 두 가지다. 하나는 ML 추론 이미지처럼 수 GB 모델 파일이 이미지에 박혀 있는데, 컨테이너 부팅 시점에는 모델 로딩 코드 몇 MB만 필요한 경우. 다른 하나는 CI/CD 러너 이미지처럼 다양한 도구가 다 들어있는데 실제 잡마다 쓰는 도구는 일부인 경우.
우리 팀에서 확인한 숫자를 대략 옮기면, 8GB짜리 모델 서빙 이미지로 GPU 노드 부팅 → 컨테이너 Ready까지 걸리던 시간이 165초에서 42초로 줄었다. 대부분은 이미지 pull 대신 lazy 마운트로 시간을 벌었고, 실제 첫 request가 들어오기 전까지 백그라운드 prefetch가 절반 이상을 채워놨다.
튜닝 포인트 몇 가지
기본 설정으로도 대충 동작하지만, 프로덕션에서 실제로 만져본 파라미터 몇 개를 남긴다.
max_concurrent_downloads_per_image를 기본 5에서 10 정도로 올리면 registry가 감당해주는 경우 병렬 pull이 눈에 띄게 빨라진다. 다만 registry(ECR, Harbor 등) 쪽 rate limit을 미리 확인해야 한다. 우리는 ECR에서 처음에 429를 맞고 나서야 이 값을 조정했다.
concurrent_download_chunk_size는 기본 8MB인데, layer가 크고 네트워크 대역이 넓으면 16MB로 올리는 게 낫다. 반대로 노드 메모리가 빡빡하면 오히려 chunk 사이즈를 줄여서 메모리 사용량을 낮춰야 한다. 이건 워크로드 프로파일 봐가면서 조정할 수밖에 없다.
FUSE 캐시 디렉토리는 기본적으로 노드의 루트 파일시스템에 잡히는데, 노드 디스크가 gp3 같은 EBS면 IOPS 한도에 걸리는 경우가 있다. 인스턴스 스토어(NVMe)가 있는 인스턴스 타입이라면 캐시를 그쪽으로 옮기는 게 좋다.
인덱스가 없는 이미지는 fallback으로 그냥 pull 하게 되는데, 이게 무음으로 일어나면 "왜 SOCI 켰는데 안 빨라지지?" 같은 착각이 생긴다. containerd 로그에서 no soci index found 같은 라인을 grep 해서 카운트를 대시보드에 올려두면 fallback률을 볼 수 있다. 이 지표가 낮게 유지되도록 CI에서 이미지 build 후에 SOCI 인덱스 push까지를 하나의 파이프라인으로 묶어두는 게 안전하다.
남은 의문들
여전히 확신이 없는 부분도 있다. 컨테이너가 시작하자마자 대량의 랜덤 파일 접근을 하는 워크로드(예를 들면 대용량 vector index를 mmap 하는 케이스)에서 FUSE 왕복 비용이 실제로 얼마나 되는지는 벤치마크로 확인해봐야 안다. 이론적으로는 mmap 페이지 폴트 하나당 FUSE 왕복 + range request + gzip 부분 복원이 다 걸리기 때문에, cold start 순간의 latency가 튈 가능성이 있다.
또 하나는 registry 쪽 부하다. 노드 수백 대가 동시에 range request를 쏘는 상황에서 ECR 같은 매니지드 registry가 얼마나 잘 버텨주는지는 벤더 SLA를 보고 판단해야 한다. 자체 registry(Harbor 등)를 쓴다면 앞단에 CDN 캐시를 두는 게 사실상 필수가 될 것 같다.
Prime Day 2025 때 Fargate가 하루 1800만 태스크를 SOCI로 돌렸다는 얘기가 있으니 스케일 자체는 검증됐다고 봐야 할 것 같은데, 우리 자체 트래픽 패턴에서 어디가 병목이 되는지는 결국 직접 계측해서 알아내는 수밖에 없다. 다음에 이 부분 벤치 돌려서 정리한 글을 하나 더 써볼 생각이다.

'IT > 컨테이너' 카테고리의 다른 글
| Docker BuildKit 캐시 마운트, CI에서 이렇게 쓰면 빌드가 확 빨라진다 (1) | 2026.08.06 |
|---|---|
| containerd snapshotter 내부 - overlay2에서 stargz로 넘어갈 때 실제로 무슨 일이 벌어지나 (0) | 2026.07.31 |
| 컨테이너 메모리 제한, 내부적으로는 뭘 어떻게 하는 걸까 (0) | 2026.07.22 |
| BuildKit cache mount 하나 잘못 붙였다가 CI가 두 배 느려진 이야기 (0) | 2026.07.14 |
| distroless 파드 디버깅, kubectl debug --profile 만 알면 된다 (0) | 2026.07.06 |