읽기 전용 루트 이미지나 컨테이너 베이스 레이어를 만들 때는 관성적으로 mksquashfs를 쓰게 되는데, 안드로이드와 컨테이너 런타임 쪽에서는 이미 EROFS로 넘어간 지 오래다. 그런데 막상 같은 디렉터리로 두 이미지를 만들어보면 EROFS 쪽이 1.5배쯤 크게 나와서 “왜 요즘 EROFS를 쓴다는 거지?”라는 의문이 먼저 든다. 두 파일시스템은 압축 단위를 잡는 방향 자체가 반대라서, 같은 데이터로도 이미지 크기와 랜덤 읽기 지연이 서로 반대로 움직인다. 이 글에서는 설계 차이를 정리한 뒤 같은 데이터셋으로 이미지 크기·생성 시간·콜드 캐시 읽기 성능·실제 디스크 I/O 증폭을 측정해서, 어떤 상황에 무엇을 골라야 하는지 정리한다.
압축 단위를 잡는 방향이 반대다
SquashFS는 고정 크기 입력을 압축한다. 원본 128KB를 통째로 압축해 가변 길이로 저장하므로 파일 한가운데의 4KB를 읽어도 그 블록 전체를 풀어야 한다. EROFS는 반대로 고정 크기 출력(fixed-sized output compression)을 쓴다. 압축 결과가 블록 크기(기본 4KB)에 맞도록 가변 길이 입력을 밀어 넣기 때문에 압축 데이터가 항상 디스크 블록에 정렬되고, 필요한 페이지만 풀 수 있다.
| 항목 | SquashFS | EROFS |
|---|---|---|
| 압축 단위 | 고정 크기 입력 → 가변 길이 출력 | 가변 길이 입력 → 고정 크기 출력(pcluster) |
| 기본 단위 크기 | 블록 128KB (4KB~1MB) | pcluster 4KB (블록 크기 = 페이지 크기) |
| 디스크 정렬 | 압축 블록이 가변 길이라 블록 경계와 어긋남 | 항상 블록 정렬 |
| 소파일 처리 | fragment 블록에 tail-end packing (기본 동작) | -Efragments / -Eztailpacking으로 opt-in |
| 중복 제거 | 동일 데이터 블록 dedup (기본 동작) | 하드링크 외에는 없음, -Ededupe로 opt-in |
| 압축 알고리즘 | gzip, lz4, lzo, xz, zstd | lz4, lz4hc, lzma, deflate, zstd(erofs-utils 1.8+) |
| 기본 알고리즘 | gzip | 없음 — -z를 안 주면 무압축 |
측정 환경과 데이터셋
Ubuntu 24.04.4 LTS(커널 7.0.0-28-generic), Ryzen 5 7600 6코어, RAM 8GB 가상 머신에서 측정했다. 도구는 배포판 기본 패키지인 erofs-utils 1.7.1, squashfs-tools 4.6.1이다.
# 텍스트(헤더) + 바이너리(실행파일) + 문서를 섞은 데이터셋
cp -a /usr/include "$SRC/include"
find /usr/bin -maxdepth 1 -type f -size -8M -exec cp {} "$SRC/bin/" \;
cp -a /usr/share/doc "$SRC/doc"원본(apparent): 426M 디스크: 472M
파일 16330개 / 디렉터리 2521개
1MB 이상 파일 수: 72 / 4KB 미만 파일 수: 7986
파일 절반이 4KB 미만인, 실제 루트 이미지와 성격이 비슷한 데이터셋이다. 이 구성이 뒤에서 EROFS의 압축률을 크게 끌어내리는 원인이 된다.
기본 옵션으로 만들면 EROFS가 1.5배 크다
| 이미지 | 크기(bytes) | 원본 대비 | 생성 시간 |
|---|---|---|---|
mksquashfs 기본(gzip, 128K) | 151,257,088 | 0.339 | 8.67s |
mksquashfs -comp zstd | 138,797,056 | 0.311 | 8.71s |
mkfs.erofs 기본(옵션 없음) | 449,044,480 | 1.006 | 1.05s |
mkfs.erofs -zlz4hc,12 | 242,618,368 | 0.543 | 19.81s |
mkfs.erofs -zdeflate | 221,376,512 | 0.496 | 9.46s |
mkfs.erofs -zlzma | 181,927,936 | 0.407 | 77.57s |
mkfs.erofs는 -z를 주지 않으면 압축을 아예 하지 않는다(비율 1.006). SquashFS를 쓰던 감각으로 옵션 없이 실행하면 원본보다 큰 이미지가 나오므로, EROFS에서는 압축 알고리즘 지정이 사실상 필수다.
격차의 정체는 알고리즘이 아니라 소파일 처리다
같은 deflate(zlib) 계열끼리 붙여도 0.496 대 0.339로 차이가 크다. SquashFS가 기본으로 켜두는 tail-end packing과 중복 제거를 EROFS에서 하나씩 켜보면 격차가 어디서 오는지 드러난다.
| 이미지 | 크기(bytes) | 원본 대비 | 생성 시간 |
|---|---|---|---|
| squashfs gzip 128K (dedup+fragment 기본 포함) | 151,257,088 | 0.339 | 8.34s |
| erofs deflate C4K (옵션 없음) | 221,376,512 | 0.496 | 8.07s |
erofs deflate C4K -Eztailpacking | 202,752,000 | 0.454 | 8.53s |
erofs deflate C4K -Efragments | 198,377,472 | 0.444 | 8.93s |
erofs deflate C4K -Ededupe | 209,276,928 | 0.469 | 61.61s |
| erofs deflate C4K 세 옵션 전부 | 186,179,584 | 0.417 | 64.82s |
| erofs deflate C128K 세 옵션 전부 | 180,862,976 | 0.405 | 19.76s |
| erofs lzma C128K 세 옵션 전부 | 125,169,664 | 0.280 | 128.50s |
세 옵션을 다 켜도 deflate로는 squashfs gzip을 못 따라잡지만(0.405 대 0.339), lzma까지 쓰면 0.280으로 역전된다. -Ededupe는 생성 시간을 8초에서 62초로 늘리면서 크기는 5%밖에 못 줄이니, 중복이 많은 이미지가 아니라면 굳이 켤 이유가 없다.
확장 옵션이 실제로 적용됐는지는 슈퍼블록에서 확인할 수 있다.
$ dump.erofs -s er4.img
Filesystem magic number: 0xE0F5E1E2
Filesystem blocks: 45454
Filesystem compr_algs: deflate
Filesystem inode count: 19718
Filesystem features: sb_csum mtime 0padding compr_cfgs big_pcluster fragments dedupe
블록 크기를 맞추면 SquashFS 쪽이 무너진다
압축률만 보면 SquashFS가 유리해 보이지만, 그건 128KB 블록을 쓰기 때문이다. 두 파일시스템의 단위 크기를 서로 맞춰 다시 재보면 읽기 쪽 그림이 완전히 달라진다.
| 이미지 | 원본 대비 | 순차 읽기 | 메타데이터 stat |
|---|---|---|---|
| squashfs gzip 128K | 0.339 | 63.2 MB/s | 0.67s |
| squashfs gzip 4K | 0.397 | 19.2 MB/s | 0.77s |
| erofs deflate 4K | 0.496 | 62.3 MB/s | 1.37s |
| erofs deflate 128K | 0.480 | 66.0 MB/s | 1.34s |
SquashFS는 블록을 4KB로 줄이는 순간 순차 처리량이 1/3로 떨어지는데, EROFS는 4KB pcluster가 기본값인데도 그대로 유지된다. 즉 “SquashFS의 좋은 압축률”은 큰 블록을 전제로 한 것이고, 작은 단위로 내려가면 압축률과 성능을 동시에 잃는다.
랜덤 읽기는 지연이 아니라 I/O 증폭으로 봐야 한다
큰 파일 내부의 랜덤 접근에서 압축 단위 차이가 가장 크게 드러난다. 큰 바이너리·라이브러리 위주로 다시 구성한 1.6GB 데이터셋(512KB 이상 파일 380개)에서 4KB를 2000번 랜덤 오프셋으로 읽으면서, 루프 디바이스의 /sys/block/loopN/stat으로 실제 디스크에서 읽힌 바이트를 함께 셌다.
ops = [(p, rnd.randrange(0, sz - 4096) & ~4095)
for p, sz in (rnd.choice(big) for _ in range(2000))]
t0 = time.perf_counter()
for p, off in ops:
fd = os.open(p, os.O_RDONLY)
os.pread(fd, 4096, off) # 요청은 4KB뿐
os.close(fd)
t1 = time.perf_counter()| 이미지 | 원본 대비 | 지연 | 실제 디스크 읽기 | 증폭 |
|---|---|---|---|---|
| ext4 원본 (무압축, 참고용) | 1.000 | 17.93 ms | 22.0 MB | 2.8x |
| squashfs gzip 128K | 0.338 | 10.71 ms | 77.8 MB | 10.0x |
| squashfs gzip 16K | 0.362 | 12.66 ms | 28.3 MB | 3.6x |
| erofs deflate C4K (fragments+tailpacking) | 0.429 | 14.21 ms | 16.8 MB | 2.2x |
| erofs deflate C128K (fragments+tailpacking) | 0.405 | 15.68 ms | 227.6 MB | 29.1x |
요청한 데이터는 4개 구성 모두 7.8MiB로 같은데 실제로 디스크에서 읽힌 양은 16.8MB부터 227.6MB까지 13배 넘게 벌어진다. squashfs 128K 블록은 압축 후 대략 43KB가 되므로 4KB 요청 하나에 43KB를 읽어 10배, EROFS의 -C131072는 출력 pcluster가 128KB라서 통째로 128KB를 읽어 29배가 된다. EROFS 기본값인 4KB pcluster만 블록 하나로 끝난다.
다만 이 호스트의 가상 디스크는 rotational=1로 잡혀 있어 지연이 seek에 지배당한다. 압축을 전혀 쓰지 않는 ext4 원본이 17.93ms로 오히려 가장 느린 것도 1.6GB가 디스크 전역에 흩어져 있기 때문이다. 즉 지연 열로는 순위를 매길 수 없고, 저장장치가 바뀌어도 그대로 따라오는 값은 증폭 열이다. IOPS가 넉넉한 플래시나 대역폭이 비싼 네트워크 스토리지에서는 이 13배가 그대로 체감 차이가 된다.
어느 쪽을 고를까
| 상황 | 선택 | 이유 |
|---|---|---|
| 이미지 크기가 최우선 (배포 대역폭·저장 공간) | squashfs zstd 128K, 또는 erofs lzma + -C131072 | 큰 단위로 몰아 압축하는 쪽이 유리 (대신 랜덤 I/O 증폭을 포기) |
| 랜덤 접근이 잦다 (앱 실행, 컨테이너 콜드 스타트) | erofs, pcluster는 기본 4K 유지 | 4KB 요청에 4KB만 읽음 — 증폭 2.2x |
| 이미지를 자주 다시 만든다 (CI 빌드) | squashfs | mksquashfs는 멀티스레드, mkfs.erofs는 단일 스레드 |
| 구형 커널·부트로더 호환성이 필요 | squashfs | EROFS는 커널 5.4 이상, 확장 옵션은 6.1 이상 필요 |
| 컨테이너 이미지 on-demand 로딩 | erofs | fscache/erofs-over-fscache 연동 지원 |
주의사항
mkfs.erofs에-z를 빼면 압축이 전혀 되지 않는다. SquashFS와 달리 기본 압축 알고리즘이 없다.- Ubuntu 24.04의 erofs-utils 1.7.1은 zstd를 지원하지 않는다.
-zzstd를 주면Cannot find a valid compressor zstd로 실패하므로, zstd가 필요하면 1.8 이상을 직접 빌드해야 한다. -Ededupe는 생성 시간을 8배 가까이 늘리면서 크기 이득은 5% 정도이고, 데이터셋이 커지면 비용이 비선형으로 뛴다(426MB에서 62초였던 게 1.6GB에서는 CPU 4분을 넘겨도 안 끝나 측정을 포기했다). 동일 파일이 많은 이미지가 아니면-Efragments,ztailpacking만 켜는 편이 낫다.-C로 pcluster를 키우면 압축률은 조금 좋아지지만(0.429 → 0.405) 랜덤 읽기 I/O 증폭이 2.2배에서 29.1배로 폭증한다. 순차 읽기만 하는 이미지가 아니라면 기본 4KB를 유지할 것.- 확장 옵션은 커널 버전을 탄다.
fragments/dedupe는 Linux 6.1 이상에서만 마운트되므로 구형 커널 타깃에는 쓸 수 없다. - 웜 캐시 재읽기는 두 파일시스템 모두 260~320MB/s로 수렴한다. 페이지 캐시에 올라가는 건 압축 해제된 페이지라서, 한 번 읽힌 뒤에는 압축 방식 차이가 성능에 나타나지 않는다.
- 순차 읽기 처리량은 같은 이미지를 다시 재도 ±20%까지 흔들린다(동일 설정 반복 측정에서 62.3 / 49.7 MB/s). 표의 수치는 절대값보다 항목 간 배수 차이로 읽어야 한다.
마무리
“EROFS가 SquashFS보다 좋다/나쁘다”로 정리되는 관계가 아니다. 기본값끼리 비교하면 EROFS 이미지가 1.5배 크지만, 그건 SquashFS가 128KB 블록과 소파일 패킹을 기본으로 켜두고 EROFS는 4KB 정렬과 랜덤 읽기 지연을 기본값으로 택했기 때문이다. 같은 4KB 단위로 맞춰보면 SquashFS 쪽이 순차 처리량의 2/3를 잃는다. 고를 때 봐야 할 건 압축률 표가 아니라 워크로드다. 랜덤 접근이 잦으면 EROFS를 기본 4KB pcluster로 쓰고 크기는 -Efragments,ztailpacking과 lzma로 만회하는 게 낫고, 순차로 한 번 읽고 마는 이미지에 빌드 시간까지 중요하다면 SquashFS가 여전히 합리적인 선택이다.