Lobsters

Why .tar.gz files can't be combined with cat

왜 .tar.gz 파일은 cat으로 합칠 수 없는가 — tar와 gzip의 내부 구조 들여다보기

여러 개의 .tar.gz 아카이브를 cat으로 이어 붙이려다 실패한 저자가 tar와 gzip의 파일 형식을 파헤치며 원인을 분석합니다. tar의 EOF 마커가 결합을 막는 근본 원인이며, Python의 tarfile 모듈로 멤버를 새 아카이브에 복사하는 것이 올바른 해법입니다.

본문

여러 개의 .tar.gz 아카이브를 생성하는 프로젝트에서 이를 하나의 파일로 합쳐야 했던 저자는 바이트를 단순히 cat으로 이어 붙이면 될 것이라 생각했지만 실패했습니다. 이 단순해 보이는 작업이 tar와 gzip에 대한 잘못된 멘탈 모델을 드러냈고, 코드를 고치기에 앞서 이해를 바로잡는 과정이 자기 테이프, 특허법, EOF 마커로 이어지는 탐구가 되었습니다.

tar는 tape archive입니다

tar는 여러 파일과 메타데이터(파일명, 타임스탬프, 디렉터리 구조)를 하나의 파일로 묶는 아카이버로, 자기 테이프라는 물리 매체의 제약이 파일 구조에 그대로 반영되어 있습니다. 순차적 읽기, 추가 전용 쓰기, 고정 데이터 크기라는 세 가지 특성입니다. 내부적으로 tar 아카이브는 고정 크기 블록들의 나열이며, 각 파일은 메타데이터가 담긴 헤더 블록과 내용이 담긴 데이터 블록으로 구성됩니다. 파일들 뒤에는 전부 0으로 채워진 두 개 이상의 블록이 오는데, 이것이 EOF 마커로서 이후 내용을 무시하라는 신호입니다. 이 구조를 알면 그동안 혼란스러웠던 동작들이 설명됩니다. 첫째, 파일 크기를 데이터 블록보다 먼저 헤더에 기록해야 하므로 Python의 TarFile.addfile에서 tarinfo.size를 빠뜨리면 헤더에 0이 기록되어 빈 아카이브가 만들어집니다. 둘째, 테이프의 기존 블록을 수정할 수 없으므로 같은 파일명의 새 버전을 뒤에 추가하는 방식으로 갱신하며, 중복 파일명이 가능하고 풀 때는 나중 파일이 앞 파일을 덮어씁니다. 셋째, EOF 마커 이후는 전부 무시되므로 cat으로 tar끼리 이어 붙이면 리더가 첫 번째 EOF에서 읽기를 멈춥니다. GNU tar에는 --ignore-zeros 플래그가 있지만 저자는 기본 설정으로 읽히는 아카이브를 원했고, Python의 tarfile 모듈로 각 아카이브를 풀어 멤버를 단일 EOF 마커를 가진 새 아카이브에 복사하는 방식을 택했습니다.

gzip은 단일 스트림을 압축합니다

gzip은 하나의 파일이나 데이터 스트림을 작게 만드는 스트림 압축기로, 물리 하드웨어가 아니라 특허법에 대한 대응으로 태어났습니다. RFC 1952를 보면 세 가지 설계 제약이 시대를 반영합니다. 특허에 묶인 LZW 알고리즘을 쓰는 compress를 대체하는 자유 소프트웨어여야 했고, 메모리가 귀하던 1990년대 초반에 작고 한정된 메모리만으로 스트리밍 처리가 가능해야 했으며, CPU·OS·파일시스템에 독립적이어야 했습니다. 내부적으로 gzip 파일은 하나 이상의 member 나열이며, 각 member는 헤더(원본 파일명, 수정 시각), 압축 데이터, 트레일러(CRC32 체크섬과 원본 크기)로 이루어지고 마지막 트레일러 뒤에 파일이 끝납니다. EOF 마커가 없다는 점이 tar와 결정적으로 다릅니다. member를 파일의 유사물로 생각하기 쉽지만 도구들은 여러 member를 하나의 데이터 스트림으로 취급하며 개별 추출이 불가능합니다. 바로 이 특성 덕분에 gzip 파일끼리는 cat으로 바이트를 이어 붙이기만 해도 정상적으로 합쳐집니다.

그래서 .tar.gz를 합치려면

tar는 디렉터리 트리를 하나의 스트림으로 묶고 gzip은 그 스트림을 줄여주며, 둘 다 순차 읽기를 지원하므로 .tar.gz는 네트워크 스트리밍에 널리 쓰입니다. 저자의 실수는 .tar.gz를 cat으로 합치려 한 것인데, gzip은 압축 member들을 하나의 스트림으로 잘 이어 붙이지만 압축이 풀린 스트림을 tar가 읽다가 첫 번째 아카이브의 EOF 마커를 만나 읽기를 멈추기 때문입니다. 안전한 해법은 각 아카이브의 멤버를 꺼내 새 파일에 쓰는 것으로, 앞의 Python 함수를 r/w 모드에서 r:gz/w:gz 모드로 바꾸기만 하면 됩니다. 혼란스러운 버그로 시작했지만 형식의 동작 원리를 이해하게 되면서 원래 코드가 왜 실패했는지, 어떻게 고쳐야 하는지 명확해졌다는 것이 저자의 결론입니다.

Lobsters 반응

  • @MarkMLl — 제 생각에 문제는 저자가 tar와 gzip을 몰라서가 아니라 cat을 몰라서 생긴 겁니다. cat은 단순한 텍스트 파일용이며, 예를 들어 첫 파일이 ^Z로 끝나고 이후 소프트웨어가 그것을 EOF로 해석하면 제대로 동작하지 않습니다.
  • @donio — 그건 사실이 아닙니다. cat은 바이너리 안전하며 텍스트 파일에 제한되지 않습니다. "^Z"(0x1a)도 다른 바이트와 똑같이 복사합니다. Unix에는 EOF 문자라는 게 없고, ^Z는 DOS가 베낀 CP/M의 유산입니다.
  • @gspr — 이런 얘기가 나올 때마다 드는 생각인데, 압축하기 전에 tar로 묶는 방식이 얼마나 어리석은가 싶습니다. 파일 묶기와 압축을 분리한다는 발상 자체는 괜찮지만, tar는 현대적인 용도에, 적어도 비스트리밍 용도에는 정말 안 맞지 않나요? NPZ 파일을 쓸 때마다 차라리 foo.zip.lz 같은 걸 써야 하나 고민합니다. 물론 안쪽 zip은 무압축으로요.
  • @dseum — TAR가 이렇게 동작하는지 몰랐고, 그 덕에 NAR까지 파보는 래빗홀에 빠졌습니다. 아카이빙은 충분히 단순해서 TAR는 계속 쓰일 겁니다. 크기를 줄이는 건 압축 쪽이 더 나은 접근이고요. NAR는 병렬성을 위해 압축 블록 실험을 하는 것 같습니다. 그런데 비스트리밍 용도가 뭔가요? 이런 아카이브 형식은 보통 전송용으로 만들어지지 않나요?
  • @JulianSildenLanglo — 비스트리밍 용도의 예로는, 아카이브에서 파일 하나만 꺼내고 싶은 경우가 있습니다.

원문: alexwlchan.net / 번역·요약: Trawling