Split View: containerd 2.3의 EROFS 네이티브 레이어 — 언팩을 없애는 대신 무엇을 냈나
containerd 2.3의 EROFS 네이티브 레이어 — 언팩을 없애는 대신 무엇을 냈나
- 들어가며 — 이미지 풀이 느린 이유는 두 개다
- containerd 2.3에서 실제로 머지된 것
- differ 코드가 말해 주는 것 — 언팩이 사라지는 지점
- 레지스트리에서 직접 확인해 보기
- os.features — 하나의 태그로 두 세계를 서빙하기
- +zstd — 압축은 되찾았지만 언팩 회피는 절반
- 지연 로딩은 아직 없습니다
- dm-verity — 조용히 더 흥미로운 쪽
- 값을 치러야 하는 것들
- 표준은 어디에 있나
- 그래서, 지금 켜야 하나
- 마치며
- 참고 자료
들어가며 — 이미지 풀이 느린 이유는 두 개다
컨테이너 이미지 풀이 느리다고 할 때, 사실 두 개의 다른 문제가 한 단어에 뭉쳐 있습니다.
하나는 내려받는 시간입니다. 레지스트리에서 수 GB를 끌어오는 네트워크 비용이죠. 다른 하나는 푸는 시간입니다. tar+gzip을 스트리밍으로 해제하면서 수만 개 파일을 호스트 파일시스템에 하나씩 만들어 내는 비용입니다. 후자는 네트워크가 아니라 CPU와 파일시스템 메타데이터 저널의 문제라, 100Gbps 링크를 깔아도 그대로 남습니다.
지난 몇 년간 이 바닥의 화제는 대부분 첫 번째 문제였습니다. eStargz, SOCI, Nydus 같은 지연 로딩(lazy pull) 계열이 "전부 받지 말고 필요한 조각만 받자"고 했고, AWS의 SOCI는 아예 방향을 틀어 "그냥 병렬로 빨리 받자"는 모드를 붙였습니다. 이 계보는 컨테이너 런타임 비교 편에서 다룬 적이 있습니다.
2026년 4월 30일에 나온 containerd 2.3은 두 번째 문제를 정면으로 칩니다. 아이디어는 단순합니다 — tar를 풀어서 파일시스템을 만드는 대신, 애초에 파일시스템을 레이어로 배포하자. 이 글은 그게 실제로 어디까지 왔는지, 코드와 레지스트리 매니페스트로 확인해 본 기록입니다.
containerd 2.3에서 실제로 머지된 것
먼저 날짜와 PR을 못 박아 두겠습니다. EROFS 자체는 2.3에서 처음 나온 게 아닙니다.
| 시점 | 무엇 |
|---|---|
| containerd 2.1.0 (2025-05-07) | EROFS snapshotter와 differ 최초 탑재 (#10705, 2025-02-05 머지) |
| containerd 2.2.0 (2025-11-06) | mount manager 기반 개선, tar index 모드, 병렬 언팩 |
| containerd 2.3.0 (2026-04-30) | 네이티브 EROFS 레이어 — 레지스트리에서 EROFS blob을 그대로 수신 |
2.3에 들어간 조각들은 이렇습니다.
- #12567 (2026-01-06 머지) — media type
application/vnd.erofs.layer.v1추가. PR 설명 그대로 "containerd가 EROFS 네이티브 컨테이너 이미지를 직접 가져올 수 있도록" 하는 것. - #13091 (2026-04-01 머지) —
os.features에erofs가 있으면 transfer service가 EROFS snapshotter/differ를 고르도록. - #12502 (2026-04-01 머지) — EROFS snapshotter에 dm-verity 지원.
- #13185 (2026-04-12 머지) —
application/vnd.erofs.layer.v1+zstd, zstd로 감싼 EROFS 레이어.
참고로 2.3.0은 containerd 릴리스 정책이 바뀐 첫 릴리스이기도 합니다. 릴리스 노트의 표현으로는 쿠버네티스 일정에 맞춘 약 4개월 주기로 옮겼고, 2.3은 그 체계의 첫 연간 LTS입니다. endoflife.date가 독립적으로 같은 값을 보여 줍니다 — 2.3 릴리스 2026-04-30, EOL 2028-04-30, LTS 표시. 덧붙이면 같은 표에서 containerd 2.1은 이미 2026-07-03에 EOL이 지났습니다.
differ 코드가 말해 주는 것 — 언팩이 사라지는 지점
EROFS의 이득은 벤치마크 그래프가 아니라 differ 코드 몇 줄에서 가장 선명하게 보입니다. containerd 2.3.0의 plugins/diff/erofs/differ.go에는 세 갈래가 있습니다.
1. 일반 OCI tar 레이어를 받았을 때 — mkfs.erofs를 띄워 tar를 EROFS로 실시간 변환합니다. internal/erofsutils/mount.go의 ConvertTarErofs가 하는 일이 이겁니다.
args := append([]string{"--tar=f", "--aufs", "--quiet", "-Enoinline_data"}, mkfsExtraOpts...)
cmd := exec.CommandContext(ctx, "mkfs.erofs", args...)
cmd.Stdin = r
즉 tar 스트림을 mkfs.erofs의 표준입력으로 밀어 넣습니다. 파일을 하나씩 호스트 FS에 만드는 게 아니라 EROFS blob 하나로 찍어 내는 것이라, overlayfs snapshotter의 파일별 추출보다 메타데이터 저널 트래픽이 적고 GC 때 수만 개 파일을 지울 일도 없습니다. 이건 2.1부터 있던 이야기입니다.
2. 압축 없는 네이티브 EROFS 레이어를 받았을 때 — 2.3의 새 경로이고, 코드가 이렇게 끝납니다.
layerBlobPath := path.Join(layer, "layer.erofs")
// Allow copy file range when there is an uncompressed native EROFS layer
if fastcopy {
f, err := os.Create(layerBlobPath)
...
_, err = io.Copy(f, content.NewReader(ra))
f.Close()
...
return desc, nil
}
여기가 핵심입니다. mkfs.erofs도, tar 파서도, 파일별 생성도 없습니다. content store의 blob을 layer.erofs로 복사하고 끝냅니다. 언팩 단계 자체가 없어진 게 아니라, blob 복사 한 번으로 축약된 것입니다. 그다음 snapshotter가 layer.erofs를 fs/에 마운트하고 부모 레이어들과 함께 overlayfs 마운트를 돌려줍니다.
3. +zstd 레이어를 받았을 때 — 뒤에서 따로 보겠습니다.
레지스트리에서 직접 확인해 보기
문서만 읽고 넘어가면 재미가 없으니 실제 레지스트리를 찔러 봤습니다. EROFS 메인테이너가 테스트용으로 올려 둔 docker.io/hsiangkao/ubuntu 저장소에 관련 태그들이 있습니다. 아래는 2026-07-16에 registry-1.docker.io에서 직접 받아 본 결과입니다.
22.04-platforms 태그의 OCI index를 열면, amd64 매니페스트가 두 개 들어 있습니다.
{
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{ "digest": "sha256:1c4cc37c...",
"platform": { "architecture": "amd64", "os": "linux" } },
{ "digest": "sha256:06ae2788...",
"platform": { "architecture": "amd64", "os": "linux",
"os.features": ["erofs"] } }
]
}
같은 아키텍처, 같은 OS인데 os.features만 다릅니다. 각 매니페스트의 레이어를 열어 보면 media type이 갈립니다.
| 형식 | media type | 레이어 크기 |
|---|---|---|
| 표준 tar+gzip | application/vnd.oci.image.layer.v1.tar+gzip | 29,536,798 B (28.17 MiB) |
| EROFS 네이티브 (무압축) | application/vnd.erofs.layer.v1 | 45,289,472 B (43.19 MiB) |
| EROFS + zstd | application/vnd.erofs.layer.v1+zstd | 27,214,834 B (25.95 MiB) |
트레이드오프가 이 세 줄에 다 들어 있습니다. 무압축 EROFS는 tar+gzip 대비 와이어 바이트가 약 1.53배입니다 — 언팩을 없애는 값으로 네트워크를 53퍼센트 더 쓰는 셈입니다. zstd로 감싸면 오히려 tar+gzip보다 8퍼센트쯤 작아집니다.
정직하게 붙여 둘 단서가 있습니다. 이건 벤치마크가 아니라 매니페스트 한 개를 열어 본 것입니다. 이미지 하나(ubuntu 22.04 베이스, 단일 레이어), 그것도 EROFS 프로젝트 본인이 올린 테스트 아티팩트이고, EROFS blob의 내용이 tar 레이어와 바이트 단위로 동일한 rootfs인지는 제가 확인하지 않았습니다. 압축률은 이미지 내용에 따라 크게 흔들립니다. 여기서 읽어야 할 것은 정확한 비율이 아니라 부호 — 무압축 네이티브는 커지고, zstd를 씌우면 다시 작아진다는 방향입니다.
os.features — 하나의 태그로 두 세계를 서빙하기
위 index가 왜 우아한지 보겠습니다. 클라이언트가 EROFS를 못 쓰면 평범한 tar+gzip 매니페스트를 고르고, 쓸 수 있으면 EROFS 매니페스트를 고릅니다. 같은 태그, 같은 pull 명령입니다. ctr i pull --platform="linux(+erofs)" docker.io/hsiangkao/ubuntu:22.04-platforms 같은 문법이 PR 설명에 나옵니다.
그런데 os.features가 원래 이 용도였을까요? OCI image-spec의 image-index 문서를 읽어 보면 이렇게 정의돼 있습니다 — 이 OPTIONAL 속성은 필수 OS 기능의 문자열 배열이고, os가 windows일 때 구현체는 win32k 값을 이해해야 한다. 그리고 결정적인 한 줄이 이어집니다.
os가windows가 아닐 때, 값은 구현체 정의(implementation-defined)이며 표준화를 위해 이 명세에 제출되어야 한다(SHOULD).
즉 리눅스에서의 os.features는 스펙이 "각자 알아서 쓰되 표준화하러 오라"고 열어 둔 문이고, containerd는 그 문으로 들어간 겁니다. 아직 제출은 되지 않았습니다.
한 가지 오해하기 쉬운 부분 — os.features 지원은 자동 스위치가 아닙니다. PR #13091의 설명이 스스로 못 박습니다.
사용자는 EROFS 네이티브 이미지를 실행하려면 여전히 EROFS snapshotter를 명시적으로 지정해야 하며, 이는 설계상 그렇다. 이건 언팩 과정의 사용자 경험을 개선할 뿐이다.
기본 snapshotter로 돌리다가 EROFS 이미지를 만나면 overlayfs가 뱉는 알 수 없는 추출 에러 대신 명확한 에러를 보게 되는 것 — 개선의 실체는 딱 그만큼입니다.
+zstd — 압축은 되찾았지만 언팩 회피는 절반
무압축 네이티브 레이어의 53퍼센트 페널티는 아프고, 그래서 나온 게 +zstd입니다. 발상은 #12506 RFC에 잘 정리돼 있습니다 — 파일시스템 자체는 무압축으로 두고(런타임 읽기가 빠르도록), 전송할 때만 zstd로 감싸자(풀이 빠르도록).
그런데 2.3.0에 실제로 들어간 코드를 보면, +zstd 경로는 fastcopy 분기를 타지 않습니다. 표준 zstd 프로세서 체인을 통과한 뒤 layer.erofs로 복사됩니다.
processor := diff.NewProcessorChain(diffLayerType, content.NewReader(ra))
...
if native {
f, err := os.Create(layerBlobPath)
_, err = io.Copy(f, rc)
...
}
정리하면 이렇습니다.
- 여전히
mkfs.erofs는 안 돕니다 — tar 파싱과 파일별 생성은 없습니다. - 하지만 전체 스트림 압축 해제 패스가 돌아왔습니다. 27MB를 받아 43MB로 풀어서 디스크에 씁니다.
즉 +zstd는 무압축 네이티브와 tar+gzip 사이 어딘가입니다. 참고로 differ는 +zstd만 받고 다른 접미사는 거부합니다(unsupported erofs layer suffix). +gzip은 아예 고려 대상이 아닌데, PR #13185의 설명에 따르면 zstd에 skippable frame이 있기 때문입니다. 이 대목이 다음 절로 이어집니다.
지연 로딩은 아직 없습니다
여기가 이 글에서 가장 중요한 부분입니다.
#12703은 EROFS 레이어 포맷을 문서화하려는 제안서입니다. 첫 절의 동기 목록 맨 위에 이렇게 적혀 있습니다 — "런타임 랜덤 액세스(지연 로딩): 컨테이너는 이미지 전체가 내려오거나 풀리길 기다리지 않고 즉시 시작할 수 있고, 데이터는 온디맨드로 가져온다."
설계도 구체적입니다. zstd skippable frame(매직 0x184D2A5E) 안에 Chunk Mapping Table을 넣어, 표준 zstd 디코더는 그냥 건너뛰고 아는 리더만 비압축 오프셋을 압축 범위로 매핑해 특정 청크를 집어 오는 구조입니다. 헤더에 매직/버전/전체 크기/청크 크기(예: 4MiB)/해시 알고리즘이 들어가고, 청크마다 blob 내 절대 오프셋과 선택적 체크섬이 붙습니다. 필요한 메타데이터는 OCI 매니페스트 애노테이션으로 전달합니다 — dev.containerd.erofs.zstd.chunk_table_offset, dev.containerd.erofs.zstd.chunk_digest, dev.containerd.erofs.dmverity.root_digest 등.
문제는, 이 제안서가 2025-12-18에 열린 뒤 아직 머지되지 않았다는 것입니다. 본인 설명 첫 줄이 "This is a draft proposal, I'm opening as PR rather than issue to make it easier to comment"입니다.
구현 쪽은 더 결정적입니다. #12764 "Seekable-erofs"가 2026-01-09에 열려 2026-04-13에 마지막으로 갱신됐고, 지금도 머지되지 않았습니다. PR 본문에 구현 목록과 미구현 목록이 나란히 있는데, 미구현 쪽이 이렇습니다.
Does NOT implement:
- lazy-loading: the chunk table is written but not consumed
- dm-verity usage: the dm-verity data is decoded but yet not used in the differ / at mount time
청크 테이블을 쓰기는 하지만 읽지는 않습니다. 즉 2026년 7월 현재 containerd의 EROFS 경로에 지연 로딩은 없습니다. 제안서의 헤드라인 문구와 실제로 배에 실린 것 사이의 거리가 이만큼입니다.
그러니 containerd 2.3의 EROFS 네이티브 레이어를 한 줄로 요약하면 이렇습니다 — "게으르게 받기"가 아니라 "풀지 않기"입니다. 이미지 전체를 미리 받는다는 사실은 그대로고, 사라진 건 언팩입니다. 나쁜 게 아닙니다. 다만 다른 문제를 푸는 것이고, "이미지 풀이 빨라진다"는 기대를 갖고 켜면 어긋납니다.
dm-verity — 조용히 더 흥미로운 쪽
지연 로딩 이야기에 묻히지만, 2.3에서 실제로 완성돼 들어간 건 무결성 쪽입니다.
EROFS snapshotter는 이제 세 가지 무결성 수단을 제공합니다.
set_immutable— 레이어 blob에IMMUTABLE_FL을 걸어 삭제/개명/수정을 막고 dirty 데이터를 즉시 flush합니다. 하드웨어 결함으로 인한 데이터 손상은 못 잡습니다. 대가가 있는데, containerd 문서가 자체 측정치를 적어 뒀습니다 — EXT4에서tensorflow:2.19.0의 언팩 시간이 10.090초에서 21.074초로 108.86퍼센트 늘어남. 프로젝트 자체 측정이고 조건은 그 이미지, 그 파일시스템뿐이며, 런타임 성능에는 영향이 없다고 명시합니다.enable_fsverity— commit 시점에 fs-verity를 켜고 마운트 전에 상태를 검증합니다. 문서가 솔직히 적어 뒀듯, 컨테이너의 모든 이미지 읽기가 머클 해시 트리를 먼저 검증해야 하므로 런타임 오버헤드가 붙습니다.dmverity_mode— #12502로 들어온 신참. 레이어마다 dm-verity 디바이스를 만들어 read-only로 마운트합니다.
dm-verity 구현이 꽤 깔끔합니다. veritysetup CLI를 부르는 대신 containerd/go-dmverity 라이브러리로 머클 트리를 직접 만들고, 트리를 blob 안에 인라인으로 붙인 뒤 루트 해시와 오프셋만 layer.erofs.dmverity JSON에 따로 둡니다. 나머지 파라미터는 blob 안 슈퍼블록에 있어 마운트 때 자동 감지됩니다. 블록 크기는 일반 모드 4096바이트, tar-index 모드 512바이트(dm-verity의 logical_block_size 제약).
모드는 셋입니다. auto(기본, .dmverity 메타데이터가 있으면 쓰고 없으면 일반 EROFS로 마운트), on(전 레이어 강제, 없으면 실패), off(완전 비활성).
여기 함정이 있습니다. 문서가 경고를 굵게 달아 뒀는데, 이미 dm-verity 없이 언팩된 레이어가 있는 상태에서 dmverity_mode = "on"을 켜면 그 레이어들엔 .dmverity 파일이 없어 마운트가 실패합니다. 스냅샷을 정리하고 differ의 enable_dmverity = true와 함께 다시 pull해야 합니다. 기존 노드에 롤아웃할 생각이라면 이건 그냥 함정이 아니라 계획해야 할 마이그레이션입니다.
제안서 #12703은 여기서 한 발 더 나갑니다 — dm-verity 데이터가 있으면 그 루트 해시를 레이어 DiffID로 써야 한다는 것. 커널이 블록 단위로 검증하는 값과 이미지 config가 가리키는 값이 같아진다는 뜻인데, 아직 제안서입니다.
값을 치러야 하는 것들
켜기 전에 알아야 할 것들을 모아 두겠습니다. 대부분 containerd의 EROFS snapshotter 문서에 있습니다.
커널. EROFS 모듈이 필요하고 Linux 5.4 이상입니다(modprobe erofs). 다만 진짜 원하는 건 file-backed mount인데, 그건 Linux 6.12 이상입니다. 그 아래에서는 loop 디바이스를 거치게 됩니다. dm-verity를 쓰려면 CONFIG_DM_VERITY와 device-mapper 모듈이 추가로 필요합니다.
외부 바이너리. 네이티브 레이어가 아닌 평범한 이미지를 EROFS로 변환하려면 mkfs.erofs가 있어야 합니다. 문서 기준 erofs-utils 1.7 이상, 재현 가능 빌드용 -T0 --mkfs-time은 1.8 이상, 불필요한 tar 재정렬을 피하는 --sort=none은 1.8.2 이상. 즉 containerd 바이너리 하나로 끝나지 않고 노드 이미지에 패키지 의존성이 하나 늘어납니다.
명시적 지정. EROFS snapshotter는 기본값이 아닙니다. --snapshotter erofs를 주거나 설정에서 지정해야 하고, differ 우선순위도 손대야 합니다(default = ["erofs","walking"]).
메인테이너 본인의 라벨. containerd의 EROFS 트래킹 이슈 #11340은 네이티브 blob 관련 PR들을 이렇게 묶어 놨습니다 — "Native EROFS blob support in content store and image (experimental)". 실험적이라는 건 제 평가가 아니라 프로젝트의 표기입니다. 같은 목록에서 go-erofs 네이티브 라이브러리 통합은 아직 미완입니다.
생태계. 이게 아마 가장 큰 벽입니다. 지금 레지스트리에 있는 EROFS 네이티브 이미지는 사실상 프로젝트 관계자가 올린 테스트 아티팩트입니다. 표준 빌드 파이프라인이 EROFS 레이어를 뱉지 않고, 주요 레지스트리·스캐너·서명 도구가 이 벤더 media type을 이해한다는 보장도 없습니다. 이미지 빌드 파이프라인 전반은 Docker BuildKit과 OCI 이미지 레이어 편에서 다뤘는데, 그쪽 세계는 아직 tar입니다.
벤치마크는 확인 못 했습니다. containerd 문서에 Top25 이미지와 대형 AI 이미지 언팩 벤치마크가 실려 있지만, 숫자가 PNG 이미지 안에만 있고 텍스트로는 없습니다. 게다가 조건이 문서에 적혀 있듯 containerd 2.2.1, 로컬 레지스트리, 병렬 언팩 없음입니다. 확인할 수 없는 숫자는 인용하지 않겠습니다. 웹 검색 스니펫에서 "WordPress 이미지 14퍼센트 개선" 같은 값이 돌아다니는데, 원문 문서를 받아 grep해 보면 그런 문장은 없습니다.
표준은 어디에 있나
한 발 물러서서 보면 이 이야기의 진짜 주제는 EROFS가 아니라 OCI 표준화의 속도입니다.
containerd가 쓴 media type은 application/vnd.erofs.layer.v1입니다. org.opencontainers.*가 아닙니다. 애노테이션도 dev.containerd.*이고, 제안서 #12703이 각주로 이렇게 적어 뒀습니다 — "Future Goal: 채택이 넓어지면 dev.containerd.* 애노테이션을 표준화된 네임스페이스(예: org.opencontainers.*)로 전환한다."
그럼 OCI 쪽 문은 어떤 상태일까요. image-spec 저장소의 관련 이슈들을 날짜와 함께 늘어놓으면 그림이 선명합니다.
| 이슈 | 열린 날 | 상태 |
|---|---|---|
| #815 eStargz 명세 추가 (지연 풀 지원) | 2020-12-09 | open, 마지막 갱신 2021-10-21, 댓글 2개 |
| #936 압축 tar를 랜덤 액세스 포맷으로 대체 | 2022-08-15 | open |
| #1191 erofs/squashfs 레이어 media type 추가 | 2024-05-29 | open, 댓글 18개 |
지연 풀을 스펙에 넣자는 제안이 2020년 12월에 열려서 2021년 10월 이후 5년 가까이 손이 안 닿았습니다. EROFS 레이어 타입 이슈는 2년 넘게 열려 있습니다. #1191의 문제 정의는 지금 읽어도 그대로 유효한데 — 큰 아티팩트는 (1) 내려받는 데 너무 오래 걸리고 (2) 푸는 데 너무 오래 걸린다, 그리고 이 이슈는 (2)를 다루기 시작한다 — 그 "시작"이 2년째입니다.
그래서 containerd는 기다리지 않고 벤더 타입으로 먼저 쏜 겁니다. 이건 비난이 아니라 관찰입니다. 실제로 굴러가는 구현이 있어야 표준화 논의에 살이 붙는 것도 사실이니까요. 다만 이걸 쓴다는 건 표준이 아닌 것에 의존한다는 뜻이고, 표준화 과정에서 media type이나 애노테이션 이름이 바뀔 수 있다는 뜻이기도 합니다. 제안서 스스로 그 가능성을 적어 놨습니다.
그래서, 지금 켜야 하나
켜 볼 만한 경우
- 노드에서 언팩이 실제 병목임이 측정으로 확인된 경우. 수 GB짜리 AI 이미지에서 파일 수가 많고 CPU/IO가 언팩에 갈려 나가는 상황이라면, 2.1부터 있던 EROFS snapshotter + differ만으로도(네이티브 레이어 없이) 얻을 게 있습니다.
mkfs.erofs로 blob을 찍어 내는 게 파일별 추출보다 유리하고, GC도 파일 수만 개 삭제가 아니라 blob 하나 삭제입니다. - VM 기반 런타임을 쓰는 경우. containerd 문서가 이 용도를 명시적으로 듭니다 — Kata 같은 VM 컨테이너에서 이미지 레이어를 virtiofs나 9p 대신 EROFS로 패스스루하면 성능과 메모리 풋프린트가 낫고, gVisor도 EROFS를 지원합니다.
- 블록 단위 무결성이 요구사항인 경우. dm-verity나 fs-verity를 레이어마다 강제해야 하는 규제 환경이라면, 이건 overlayfs snapshotter로는 잘 안 되는 일입니다. 문서가 이유를 설명하는데,
FS_IMMUTABLE_FL과 fs-verity는 서브트리가 아니라 개별 파일을 보호하는 수단이라 파일이 수만 개인 overlayfs 레이어엔 효율이 안 나옵니다. EROFS는 레이어가 파일 하나라 자연스럽게 맞습니다. - 쓰기 레이어에 디스크 쿼터를 걸어야 하는 경우.
default_size로 고정 크기 블록 디바이스를 overlayfs upper로 줄 수 있습니다.
아직 아닌 경우
- 문제가 언팩이 아니라 다운로드인 경우. EROFS 네이티브 레이어는 여기에 도움이 안 되고, 무압축이면 오히려 해롭습니다. 지연 로딩이 필요하면 eStargz/SOCI/Nydus 쪽을 보거나, 그냥 병렬로 빨리 받는 쪽을 보십시오.
- 네이티브 EROFS 레이어를 배포하고 싶은 경우. 빌드 파이프라인이 없고, media type이 표준이 아니며, 레지스트리 주변 도구들이 이해한다는 보장이 없습니다.
- 노드 커널이 6.12 미만이거나 노드 이미지에 erofs-utils를 넣기 곤란한 경우.
- experimental 딱지가 부담스러운 경우. 프로덕션에서 이 라벨의 의미는 "설정 표면과 온디스크 포맷이 마이너 릴리스에서 바뀔 수 있다"입니다.
한 가지 덧붙이면, EROFS snapshotter를 켜는 것과 EROFS 네이티브 레이어를 받는 것은 별개입니다. 앞의 것은 2.1부터 있었고 오늘 당장 평가해 볼 만합니다. 뒤의 것이 2.3의 새 이야기이고, 아직 실험적입니다. 이 둘을 섞어서 판단하면 안 됩니다. containerd의 이미지 처리 전반이 궁금하다면 containerd 이미지 관리 편도 참고하십시오.
마치며
정리하면 이렇습니다. containerd 2.3(2026-04-30, 첫 연간 LTS)은 application/vnd.erofs.layer.v1과 +zstd media type을 추가해, 레지스트리에서 EROFS 파일시스템 이미지를 그대로 받아 마운트하는 경로를 열었습니다. 압축 없는 네이티브 레이어의 언팩은 코드상 blob 복사 한 번으로 끝나고, os.features로 한 태그가 EROFS 클라이언트와 평범한 클라이언트를 동시에 서빙할 수 있으며, dm-verity로 레이어마다 블록 단위 무결성을 걸 수 있습니다.
대신 지연 로딩은 없습니다 — 청크 테이블 설계는 제안서에 있고, 구현 PR은 열려 있고, 그 PR이 스스로 "테이블은 쓰지만 소비하지 않는다"고 적어 뒀습니다. media type은 벤더 타입이고, OCI 쪽 문은 2년 넘게 열려만 있습니다. 무압축이면 와이어 바이트를 더 쓰고, zstd로 되찾으면 압축 해제 패스가 돌아옵니다. 커널 6.12와 erofs-utils가 필요하고, 메인테이너가 experimental로 표시해 뒀습니다.
이 글에서 하나만 가져간다면 이것이었으면 합니다 — 컨테이너 이미지 최적화 이야기에서 "빠르다"는 말이 나오면, 다운로드가 빠른 건지 언팩이 빠른 건지부터 물으십시오. 지난 5년간 이 바닥의 화제는 대부분 전자였고, containerd 2.3이 실제로 배에 실은 건 후자입니다. 당신 노드에서 어느 쪽이 아픈지는 릴리스 노트가 아니라 프로파일이 답합니다.
참고 자료
- containerd 2.3.0 릴리스 노트 — EROFS 네이티브 이미지, 첫 연간 LTS
- containerd EROFS snapshotter 문서 (v2.3.0)
- PR #12567 — Add EROFS layer media type
- PR #13185 — erofs-differ: support zstd-wrapped EROFS layers
- PR #13091 — Add os.features support for EROFS native container images
- PR #12502 — Add dmverity support to the erofs snapshotter using go-dmverity
- PR #12703 — EROFS 레이어 포맷 제안서 (draft, 미머지)
- PR #12764 — Seekable-erofs (미머지, 지연 로딩 미구현)
- Issue #11340 — EROFS Support and Improvements 트래킹
- OCI image-spec — image-index.md의 os.features 정의
- OCI image-spec Issue #1191 — erofs/squashfs 레이어 media type
- OCI image-spec Issue #815 — eStargz 지연 풀 명세
- containerd 릴리스 정책 — LTS와 지원 기간
- endoflife.date — containerd 릴리스/EOL 표
- EROFS 파일시스템 공식 문서
- 컨테이너 런타임 비교 — containerd, runc, Kata, gVisor (관련 글)
EROFS Native Layers in containerd 2.3 — What You Get in Exchange for Removing Unpack
- Introduction — "Slow Image Pulls" Is Actually Two Problems
- What Actually Merged in containerd 2.3
- What the Differ Code Tells You — Where Unpack Actually Disappears
- Checking It Directly Against the Registry
- os.features — Serving Two Worlds from One Tag
- +zstd — Compression Comes Back, But Only Half the Unpack Avoidance
- Lazy Loading Doesn't Exist Yet
- dm-verity — Quietly the More Interesting Part
- What You Pay For
- Where the Standard Stands
- So, Should You Turn This On Now?
- Closing
- References
Introduction — "Slow Image Pulls" Is Actually Two Problems
When people say container image pulls are slow, two different problems are actually bundled into one word.
One is download time — the network cost of pulling several GB from a registry. The other is unpack time — the cost of streaming tar+gzip decompression while creating tens of thousands of files on the host filesystem one at a time. The latter is a CPU and filesystem-metadata-journal problem, not a network one, so it stays put even if you lay down a 100Gbps link.
For the past several years, most of the buzz in this space has been about the first problem. Lazy-pull approaches like eStargz, SOCI, and Nydus said "don't fetch everything, fetch only the pieces you need," and AWS's SOCI even added a mode that flips the direction entirely: "just fetch it all in parallel, fast." That lineage was covered in the container runtime comparison post.
containerd 2.3, released on April 30, 2026, goes straight at the second problem. The idea is simple — instead of unpacking a tar to build a filesystem, ship the filesystem itself as the layer. This post is a record of how far that idea has actually come, verified against the code and the registry manifests.
What Actually Merged in containerd 2.3
Let's pin down the dates and PRs first. EROFS itself didn't debut in 2.3.
| When | What |
|---|---|
| containerd 2.1.0 (2025-05-07) | First shipped the EROFS snapshotter and differ (#10705, merged 2025-02-05) |
| containerd 2.2.0 (2025-11-06) | Mount-manager-based improvements, tar index mode, parallel unpack |
| containerd 2.3.0 (2026-04-30) | Native EROFS layers — receive EROFS blobs straight from the registry |
The pieces that landed in 2.3:
- #12567 (merged 2026-01-06) — adds the
application/vnd.erofs.layer.v1media type. In the PR description's own words, this lets "containerd pull EROFS-native container images directly." - #13091 (merged 2026-04-01) — has the transfer service select the EROFS snapshotter/differ when
os.featuresincludeserofs. - #12502 (merged 2026-04-01) — adds dm-verity support to the EROFS snapshotter.
- #13185 (merged 2026-04-12) —
application/vnd.erofs.layer.v1+zstd, an EROFS layer wrapped in zstd.
For context, 2.3.0 is also the first release under containerd's changed release policy. In the release notes' own phrasing, the project moved to a roughly 4-month cadence aligned with the Kubernetes schedule, and 2.3 is the first annual LTS under that scheme. endoflife.date independently shows the same figures — 2.3 released 2026-04-30, EOL 2028-04-30, marked LTS. Worth noting from the same table: containerd 2.1 already went EOL on 2026-07-03.
What the Differ Code Tells You — Where Unpack Actually Disappears
The payoff of EROFS is clearest not in a benchmark chart but in a few lines of the differ code. containerd 2.3.0's plugins/diff/erofs/differ.go branches three ways.
1. When it receives a plain OCI tar layer — it spins up mkfs.erofs and converts the tar into EROFS on the fly. That's what ConvertTarErofs in internal/erofsutils/mount.go does.
args := append([]string{"--tar=f", "--aufs", "--quiet", "-Enoinline_data"}, mkfsExtraOpts...)
cmd := exec.CommandContext(ctx, "mkfs.erofs", args...)
cmd.Stdin = r
In other words, the tar stream is piped straight into mkfs.erofs's stdin. Rather than creating files on the host FS one by one, it stamps out a single EROFS blob, which means less metadata-journal traffic than overlayfs snapshotter's per-file extraction, and no need to delete tens of thousands of files during GC. This part has been true since 2.1.
2. When it receives an uncompressed native EROFS layer — this is the new path in 2.3, and the code ends like this.
layerBlobPath := path.Join(layer, "layer.erofs")
// Allow copy file range when there is an uncompressed native EROFS layer
if fastcopy {
f, err := os.Create(layerBlobPath)
...
_, err = io.Copy(f, content.NewReader(ra))
f.Close()
...
return desc, nil
}
This is the crux of it. No mkfs.erofs, no tar parser, no per-file creation. It just copies the blob from the content store to layer.erofs and returns. The unpack step itself hasn't been eliminated — it has been reduced to a single blob copy. The snapshotter then mounts layer.erofs under fs/ and returns an overlayfs mount combined with the parent layers.
3. When it receives a +zstd layer — covered separately below.
Checking It Directly Against the Registry
Reading the docs alone isn't much fun, so I poked at an actual registry. The EROFS maintainer has uploaded relevant tags to docker.io/hsiangkao/ubuntu as a test repository. What follows is what I pulled directly from registry-1.docker.io on 2026-07-16.
Open the OCI index for the 22.04-platforms tag, and you'll find two amd64 manifests.
{
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{ "digest": "sha256:1c4cc37c...",
"platform": { "architecture": "amd64", "os": "linux" } },
{ "digest": "sha256:06ae2788...",
"platform": { "architecture": "amd64", "os": "linux",
"os.features": ["erofs"] } }
]
}
Same architecture, same OS — only os.features differs. Open the layers of each manifest and the media types diverge.
| Format | media type | layer size |
|---|---|---|
| Standard tar+gzip | application/vnd.oci.image.layer.v1.tar+gzip | 29,536,798 B (28.17 MiB) |
| EROFS native (uncompressed) | application/vnd.erofs.layer.v1 | 45,289,472 B (43.19 MiB) |
| EROFS + zstd | application/vnd.erofs.layer.v1+zstd | 27,214,834 B (25.95 MiB) |
The whole tradeoff sits in those three lines. The uncompressed EROFS layer is about 1.53x the wire bytes of tar+gzip — you spend 53 percent more network to remove unpack. Wrap it in zstd and it actually comes in about 8 percent smaller than tar+gzip.
One honest caveat to attach here: this is not a benchmark, just one manifest I opened. It's a single image (an Ubuntu 22.04 base, one layer), and a test artifact uploaded by the EROFS project itself, and I did not verify whether the EROFS blob's contents are byte-for-byte the same rootfs as the tar layer. Compression ratio swings a lot depending on image content. What you should take away here isn't the exact ratio but the sign — uncompressed native gets bigger, wrapping it in zstd makes it smaller again.
os.features — Serving Two Worlds from One Tag
Let's look at why the index above is elegant. A client that can't use EROFS picks the plain tar+gzip manifest; a client that can picks the EROFS manifest. Same tag, same pull command. The PR description shows syntax like ctr i pull --platform="linux(+erofs)" docker.io/hsiangkao/ubuntu:22.04-platforms.
But was that actually what os.features was meant for? Reading the OCI image-spec's image-index doc, it's defined as follows — this OPTIONAL property is an array of strings for required OS features, and when os is windows, implementations SHOULD understand the value win32k. And then comes the decisive line.
When
osis notwindows, values are implementation-defined and SHOULD be submitted to this specification for standardization.
In other words, on Linux, os.features is a door the spec deliberately left open — "use it however you like, but come standardize it" — and containerd walked through that door. It hasn't submitted anything yet.
One easy thing to misread — support for os.features is not an automatic switch. PR #13091's description nails it down itself.
Users still need to explicitly specify the EROFS snapshotter to run EROFS native images, and this is by design. This only improves the user experience around the unpack process.
Instead of the opaque extraction error overlayfs throws when it runs into an EROFS image on the default snapshotter, you get a clear error — that's the entire scope of the improvement.
+zstd — Compression Comes Back, But Only Half the Unpack Avoidance
The 53 percent penalty on the uncompressed native layer stings, which is what +zstd is for. The idea is laid out well in RFC #12506 — leave the filesystem itself uncompressed (so runtime reads stay fast), and wrap it in zstd only for transport (so the pull is fast).
But looking at the code that actually landed in 2.3.0, the +zstd path does not take the fastcopy branch. It passes through the standard zstd processor chain before being copied to layer.erofs.
processor := diff.NewProcessorChain(diffLayerType, content.NewReader(ra))
...
if native {
f, err := os.Create(layerBlobPath)
_, err = io.Copy(f, rc)
...
}
To sum it up:
mkfs.erofsstill doesn't run — no tar parsing, no per-file creation.- But the full stream decompression pass is back. It receives 27MB, unpacks it to 43MB, and writes that to disk.
So +zstd sits somewhere between uncompressed native and tar+gzip. For reference, the differ only accepts +zstd and rejects other suffixes (unsupported erofs layer suffix). +gzip isn't even on the table — according to PR #13185's description, that's because zstd has skippable frames. Which leads into the next section.
Lazy Loading Doesn't Exist Yet
This is the most important part of this post.
#12703 is a proposal to document the EROFS layer format. Top of the motivation list in its first section reads: "Runtime random access (lazy loading): containers can start immediately without waiting for the entire image to be downloaded or unpacked, and data is fetched on-demand."
The design is concrete, too. Inside a zstd skippable frame (magic 0x184D2A5E) sits a Chunk Mapping Table, so a standard zstd decoder just skips over it while an aware reader maps uncompressed offsets to compressed ranges to fetch specific chunks. The header carries magic/version/total size/chunk size (e.g., 4MiB)/hash algorithm, and each chunk carries an absolute offset within the blob plus an optional checksum. The metadata needed for this is passed through OCI manifest annotations — dev.containerd.erofs.zstd.chunk_table_offset, dev.containerd.erofs.zstd.chunk_digest, dev.containerd.erofs.dmverity.root_digest, and so on.
The problem is that this proposal, opened on 2025-12-18, has not been merged yet. Its own first line reads: "This is a draft proposal, I'm opening as PR rather than issue to make it easier to comment."
The implementation side is even more decisive. #12764 "Seekable-erofs" was opened on 2026-01-09, last updated on 2026-04-13, and is still not merged. The PR body lists what's implemented alongside what isn't, and the "isn't" list reads:
Does NOT implement:
- lazy-loading: the chunk table is written but not consumed
- dm-verity usage: the dm-verity data is decoded but yet not used in the differ / at mount time
The chunk table is written but not read. So as of July 2026, there is no lazy loading in containerd's EROFS path. That's the distance between the proposal's headline claim and what has actually shipped.
So if you boil down containerd 2.3's EROFS native layers to one line, it's this — it's not "fetch lazily," it's "don't unpack." The fact that the whole image is fetched up front is unchanged; what disappeared is the unpack. That's not a bad thing — it's just solving a different problem, and turning it on expecting "image pulls get faster" will leave you disappointed.
dm-verity — Quietly the More Interesting Part
Buried under the lazy-loading story is the part that actually shipped complete in 2.3: integrity.
The EROFS snapshotter now offers three integrity mechanisms.
set_immutable— setsIMMUTABLE_FLon the layer blob to block delete/rename/modify and flushes dirty data immediately. It doesn't catch data corruption from hardware faults. There's a cost, and the containerd docs recorded their own measurement of it — on EXT4, unpack time fortensorflow:2.19.0grew 108.86 percent, from 10.090 seconds to 21.074 seconds. This is the project's own measurement, limited to that image and that filesystem, and it's stated to have no effect on runtime performance.enable_fsverity— turns on fs-verity at commit time and verifies state before mount. As the docs candidly note, this adds runtime overhead because every image read in the container has to verify the Merkle hash tree first.dmverity_mode— the newcomer, added by #12502. Creates a dm-verity device per layer and mounts it read-only.
The dm-verity implementation is fairly clean. Instead of shelling out to the veritysetup CLI, it builds the Merkle tree directly with the containerd/go-dmverity library, appends the tree inline inside the blob, and keeps only the root hash and offset separately in a layer.erofs.dmverity JSON file. The remaining parameters live in the superblock inside the blob and are auto-detected at mount time. Block size is 4096 bytes in normal mode and 512 bytes in tar-index mode (a constraint from dm-verity's logical_block_size).
There are three modes: auto (default — uses .dmverity metadata if present, otherwise mounts as plain EROFS), on (forces it on every layer, fails if metadata is missing), and off (fully disabled).
There's a trap here. The docs bold a warning: if you turn on dmverity_mode = "on" while layers already exist that were unpacked without dm-verity, mounting fails for those layers because they have no .dmverity file. You have to clean up the snapshots and re-pull with the differ's enable_dmverity = true. If you're planning to roll this out to existing nodes, this isn't just a trap — it's a migration you need to plan for.
Proposal #12703 goes one step further here — if dm-verity data exists, its root hash should be used as the layer's DiffID. That would mean the value the kernel verifies block by block and the value the image config points to become the same thing — but this is still just a proposal.
What You Pay For
Here's what you need to know before turning this on, most of it from containerd's EROFS snapshotter docs.
Kernel. You need the EROFS module and Linux 5.4 or later (modprobe erofs). But what you actually want is file-backed mount, and that requires Linux 6.12 or later. Below that, it falls back to a loop device. Using dm-verity requires CONFIG_DM_VERITY and the device-mapper module on top of that.
External binaries. Converting an ordinary (non-native-layer) image into EROFS requires mkfs.erofs. Per the docs: erofs-utils 1.7 or later; -T0 --mkfs-time for reproducible builds needs 1.8 or later; --sort=none, which avoids unnecessary tar reordering, needs 1.8.2 or later. So this doesn't end with a single containerd binary — it adds one more package dependency to your node image.
Explicit opt-in. The EROFS snapshotter isn't the default. You have to pass --snapshotter erofs or set it in config, and you also need to touch the differ priority (default = ["erofs","walking"]).
The maintainers' own label. containerd's EROFS tracking issue #11340 groups the native-blob-related PRs under this line — "Native EROFS blob support in content store and image (experimental)." "Experimental" isn't my judgment — it's the project's own labeling. In the same list, go-erofs native library integration is still unfinished.
Ecosystem. This is probably the biggest wall. The EROFS native images sitting in registries right now are effectively test artifacts uploaded by project insiders. Standard build pipelines don't emit EROFS layers, and there's no guarantee that major registries, scanners, or signing tools understand this vendor media type. The broader image build pipeline was covered in Docker BuildKit and OCI Image Layers, and that world is still tar.
I couldn't verify the benchmarks. The containerd docs include unpack benchmarks for Top25 images and large AI images, but the numbers only exist inside PNG images, not as text. On top of that, the conditions stated in the docs are containerd 2.2.1, a local registry, and no parallel unpack. I won't cite a number I can't verify. Figures like "14 percent improvement for WordPress images" circulate in web search snippets, but pulling the original doc and grepping it turns up no such sentence.
Where the Standard Stands
Step back, and the real subject of this story isn't EROFS — it's the pace of OCI standardization.
The media type containerd used is application/vnd.erofs.layer.v1. Not org.opencontainers.*. The annotations are dev.containerd.* too, and proposal #12703 notes in a footnote — "Future Goal: as adoption widens, transition the dev.containerd.* annotations to a standardized namespace (e.g. org.opencontainers.*)."
So what's the state of the door on the OCI side? Lay out the relevant image-spec issues with their dates and the picture gets sharp.
| Issue | Opened | Status |
|---|---|---|
| #815 Add eStargz specification (lazy pull support) | 2020-12-09 | open, last updated 2021-10-21, 2 comments |
| #936 Replace compressed tar with a random-access format | 2022-08-15 | open |
| #1191 Add erofs/squashfs layer media types | 2024-05-29 | open, 18 comments |
The proposal to add lazy pull to the spec opened in December 2020 and went untouched for nearly 5 years after October 2021. The EROFS layer type issue has been open for more than 2 years. #1191's problem statement still holds up today — large artifacts (1) take too long to download and (2) take too long to unpack, and this issue starts to address (2) — and that "start" is now 2 years old.
So containerd didn't wait, and shipped with a vendor type instead. That's an observation, not a criticism — a working implementation genuinely does give the standardization discussion something to chew on. But using it does mean depending on something that isn't a standard, which also means the media type or annotation names could change during standardization. The proposal itself notes that possibility.
So, Should You Turn This On Now?
Worth trying
- If measurement has confirmed that unpack is the actual bottleneck on your node. If a multi-GB AI image with a large file count is grinding CPU/IO away on unpack, there's something to gain from just the EROFS snapshotter + differ that's existed since 2.1 (without native layers). Stamping out a blob with
mkfs.erofsbeats per-file extraction, and GC becomes deleting one blob instead of tens of thousands of files. - If you're running a VM-based runtime. The containerd docs call this use case out explicitly — passing image layers through as EROFS instead of virtiofs or 9p in a VM container like Kata improves performance and memory footprint, and gVisor supports EROFS too.
- If block-level integrity is a requirement. In a regulated environment where you need to enforce dm-verity or fs-verity per layer, this is something the overlayfs snapshotter doesn't do well. The docs explain why —
FS_IMMUTABLE_FLand fs-verity protect individual files, not subtrees, so they're inefficient on an overlayfs layer with tens of thousands of files. EROFS is a natural fit because a layer is a single file. - If you need to enforce a disk quota on the writable layer.
default_sizelets you hand overlayfs a fixed-size block device as the upper layer.
Not yet
- If the problem is download, not unpack. Native EROFS layers don't help here, and if uncompressed, actively hurt. If you need lazy loading, look at eStargz/SOCI/Nydus, or just fetch faster in parallel.
- If you want to distribute native EROFS layers. There's no build pipeline, the media type isn't standard, and there's no guarantee the tooling around the registry understands it.
- If your node kernel is below 6.12, or getting erofs-utils onto your node image is impractical.
- If the experimental label is a dealbreaker for you. In production, that label means "the config surface and on-disk format can change in a minor release."
One thing worth adding — turning on the EROFS snapshotter and pulling native EROFS layers are two separate things. The former has existed since 2.1 and is worth evaluating today. The latter is 2.3's new story, and it's still experimental. Don't judge them as one bundle. If you want the fuller picture of containerd's image handling, see the containerd image management post as well.
Closing
To sum up: containerd 2.3 (2026-04-30, the first annual LTS) added the application/vnd.erofs.layer.v1 and +zstd media types, opening a path to pull an EROFS filesystem image straight from the registry and mount it. In the code, unpacking an uncompressed native layer ends with a single blob copy, os.features lets one tag serve both EROFS-aware and plain clients at once, and dm-verity gives you block-level integrity per layer.
In exchange, there's no lazy loading — the chunk table design lives in a proposal, the implementation PR is open, and that PR itself states it "writes the table but doesn't consume it." The media type is a vendor type, and the door on the OCI side has sat open for more than 2 years. Go uncompressed and you spend more wire bytes; claw that back with zstd and the decompression pass returns. It needs kernel 6.12 and erofs-utils, and the maintainers have marked it experimental.
If there's one thing to take from this post, I hope it's this — the next time someone says "faster" in a container image optimization conversation, ask first whether they mean faster download or faster unpack. Most of the buzz in this space over the past 5 years has been about the former, and what containerd 2.3 actually shipped is the latter. Which one hurts on your node is a question your profiler answers, not the release notes.
References
- containerd 2.3.0 release notes — EROFS native images, first annual LTS
- containerd EROFS snapshotter docs (v2.3.0)
- PR #12567 — Add EROFS layer media type
- PR #13185 — erofs-differ: support zstd-wrapped EROFS layers
- PR #13091 — Add os.features support for EROFS native container images
- PR #12502 — Add dmverity support to the erofs snapshotter using go-dmverity
- PR #12703 — EROFS layer format proposal (draft, not merged)
- PR #12764 — Seekable-erofs (not merged, lazy loading not implemented)
- Issue #11340 — EROFS Support and Improvements tracking
- OCI image-spec — os.features definition in image-index.md
- OCI image-spec Issue #1191 — erofs/squashfs layer media types
- OCI image-spec Issue #815 — eStargz lazy pull specification
- containerd release policy — LTS and support windows
- endoflife.date — containerd release/EOL table
- EROFS filesystem official documentation
- Container runtime comparison — containerd, runc, Kata, gVisor (related post)