tar-splitとレイヤーのDiffIDとdocker saveの話

お久しぶりです。etogenです。今回はtar-splitとそれに関わることについて書きたいと思います。

tar-splitって何

github.com

tarアーカイブメタデータを保存しといて、tarアーカイブを展開しちゃった後でも元のtarアーカイブに戻すことができるGolangのパッケージ。また、CLIツールとしても使用できます。

ここで言う「元のtarアーカイブ」とは、アーカイブに含まれているファイルやディレクトリだけでなく、メタデータまでもが一致するもののことです。tar-splitでアセンブルされてできあがったtarアーカイブは、元のtarアーカイブチェックサムが一致します。

インストール

$ go get github.com/vbatts/tar-split/cmd/tar-split

基本的な使い方

tar-splitのREADMEにデモ動画があるのでそれを見ればわかりやすいんですが、ここでも一応書いておきます。

例としてまずtest_folderアーカイブを作ります。ハッシュ値も確認しておきましょう。

$ tar c test_folder/ | sha512sum      
ef50d5ec6c3c314b9bd14c11b5edea8dfa7109a558b8604bfd022a60a00c59148ac9d7518068bac05067d8aa2a3774404cc276fe7ddfd8f83d38df86d026a671  -
$ tar cf test_folder.tar test_folder/
$ sha512sum test_folder.tar 
ef50d5ec6c3c314b9bd14c11b5edea8dfa7109a558b8604bfd022a60a00c59148ac9d7518068bac05067d8aa2a3774404cc276fe7ddfd8f83d38df86d026a671  test_folder.tar
$ 

tar-split disasmはtarアーカイブをディスアセンブルしてメタデータを含んだファイルを作成します。

  • --outputで出力先のファイルを指定
$ tar-split disasm --output test_folder.json.gz test_folder.tar | sha512sum
INFO[0000] created test_folder.json.gz from test_folder.tar (read 40960 bytes) 
ef50d5ec6c3c314b9bd14c11b5edea8dfa7109a558b8604bfd022a60a00c59148ac9d7518068bac05067d8aa2a3774404cc276fe7ddfd8f83d38df86d026a671  -
$

zcatで中をちょっと覗いてみるとこんな感じになってる(ようわからん)

$ zcat test_folder.json.gz | head -n 3
{"type":2,"payload":"dGVzdF9mb2xkZXIvAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA3NzUAMDAwMTc1MAAwMDAxNzUwADAwMDAwMDAwMDAwADEzNDMwNDUzMjI0ADAxMjQxMAAgNQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhciAgAHNob3RhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAc2hvdGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","position":0}
{"type":1,"name":"test_folder/","payload":null,"position":1}
{"type":2,"payload":"dGVzdF9mb2xkZXIvcnVuYy1zdGF0ZS44Lm1kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA2NjQAMDAwMTc1MAAwMDAxNzUwADAwMDAwMDAwNDE3ADEzNDMwNDUzMjI0ADAxNTE2NwAgMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhciAgAHNob3RhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAc2hvdGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","position":2}
$

tar-split asmメタデータのファイルと、tarを展開してできあがったディレクトリ・ファイル群を使って、元のtarアーカイブを作成します。

$ tar-split asm --input test_folder.json.gz  --path ./ --output test_folder_split.tar
INFO[0000] created test_folder_split.tar from ./ and test_folder.json.gz (wrote 40960 bytes) 
$ sha512sum test_folder_split.tar
ef50d5ec6c3c314b9bd14c11b5edea8dfa7109a558b8604bfd022a60a00c59148ac9d7518068bac05067d8aa2a3774404cc276fe7ddfd8f83d38df86d026a671  test_folder_split.tar
$

作成されたハッシュ値を調べると元のtarアーカイブと一致していることがわかります。

tar-splitとレイヤーのDiffIDの話

ここからはDockerの仕様とかを勉強してるような変態さん向けのことを書きます。

/var/lib/dockerがどうなっているかを調べたり、mobyプロジェクトのソースコードを読んだことある方ならご存知だと思いますが、tar-splitは現在のlatestバージョンであるDocker v17.03.2-ceで使用されています。

そして、tar-splitの役割についてはまだ調査中ですが、わたしはレイヤーを再構成してtarアーカイブを作った時に、registry serverからダウンロードして解凍したtarアーカイブとDiffIDが一致しなかったから困るから使ってるんだと思ってます。

何が困るの?

具体的な例をあげましょう。たとえばDockerを使ってXXXというIDのイメージをpullしてきたとします。XXXに含まれるレイヤーは1つで、そのDiffIDはABCであるとします。Dockerはdocker pullの過程で.tar.gzの圧縮されたレイヤーのアーカイブのダウンロードを行なっています。Dockerはこれをまずtempディレクトリに保存したあと、/var/lib/docker/overlay2//diff/`の下に展開し、tempに保存したアーカイブを削除します。

ここで、docker loadで読み込めるようなイメージのtarアーカイブを手動で作成している人物Eちゃんがいるとします。Eちゃんはそれを作るためにネットワークを通じてconfig.jsonやレイヤーのアーカイブをダウンロードするのは邪道だと考えています。そんなわけでDockerのストレージに存在するファイルや情報だけを使ってイメージのtarアーカイブを作ることにしました。

Eちゃんは/var/lib/docker/overlay2/<cache id>/diff/の下にあるファイル群からレイヤーのtarアーカイブを作ったり、/var/lib/docker/ image/overlay2/imagedb/content/sha256/<image ID>からconfig.jsonをコピーしたりして、頑張ってイメージのtarアーカイブを完成させました。しかしながら、これを使ってDockerでイメージを作成することはできませんでした。なぜならconfig.jsondiff_idsフィールドにはABCというハッシュ値が書いてありますが、実際に作成してできあがったtarアーカイブハッシュ値DEFであったからです。

Eちゃんは次に「config.jsondiff_idsにあるABCDEFに変更すればええやん!」と考えconfig.jsonを編集しました。すると今度はイメージの作成が成功したのですが、そのイメージのIDはYYYで、期待していたイメージのIDXXXとは違っていました。それもそのはず、イメージのIDはconfig.jsonハッシュ値なので、このファイルを編集した時点でハッシュ、つまりはイメージのIDは変わってしまったのです。

Eちゃんは次にどうにかしてハッシュ値ABCのtarアーカイブを作ろうとしますが。。。DEFハッシュ値のtarアーカイブしか作れません。

-- BAD END --

DiffID変わっちゃう問題

「ダウンロードしてきたtarアーカイブをローカルに展開されちゃうと、元のハッシュ値アーカイブ作れなくなるんだよね」っていうのをまず見せましょう。

まず適当なイメージに含まれるレイヤーをダウンロードします。

$ curl -L  -H "Authorization: Bearer $(curl -sSL "https://auth.docker.io/token?service=registry.docker.io&scope=repository:google/cadvisor:pull" | jq --raw-output .token)" "https://index.docker.io/v2/google/cadvisor/blobs/sha256:ef94a70c87ede188530b904d75339cec23ed226a7e5cc193b843c53ad119dd55" > layer.tar.gz

layer.tar.gzを解凍してアーカイブハッシュ値を確認しましょう。これが本来のDiffID。

$ sha256sum layer.tar 
f6367c2e07c76227c564fab480483be7b4e5b6fe4bc18302c99480fa6bee78f9  layer.tar
$

で、展開した後、tarアーカイブを再作成します。

$ tar xf layer.tar 
$ ls
layer.tar  usr
$ tar cf layer2.tar usr/
$ sha256sum layer2.tar 
ac7b9a7982263eb38b1c8d1caa3ce74ce75d50c7959c9cd561306018091fbc5d  layer2.tar
$ 

はいDiffID変わりました。

DiffIDが変わる原因は、再作成をする際tarアーカイブにローカル環境のunameなどの情報などが含まれてしまうからです。要するにメタデータが変化したせいでtarアーカイブのバイナリも変わり、ハッシュ値も変わったわけです。

hexdump -Cで元のtarアーカイブと再作成したtarアーカイブのバイナリデータを見比べてみると「ふ〜ん」ってなると思います。

tar-splitを使った解決

本題中の本題。tar-splitを使って再作成をすれば本来のハッシュ値と等しいtarアーカイブを作ることができ、完全に復元することができます。

まずtar-split disasmメタデータのファイルを作成。

$ tar-split disasm --output layer-split.json.gz layer.tar | sha256sum
INFO[0000] created layer-split.json.gz from layer.tar (read 23442944 bytes) 
f6367c2e07c76227c564fab480483be7b4e5b6fe4bc18302c99480fa6bee78f9  -
$

そしてtar展開後のファイル群が存在するディレクトリを指定してtar-split asmを実行します。

$ tar-split asm --input layer-split.json.gz  --path ./ --output layer-split.tar
INFO[0000] created layer-split.tar from ./ and layer-split.json.gz (wrote 23442944 bytes)
$ sha256sum layer-split.tar 
f6367c2e07c76227c564fab480483be7b4e5b6fe4bc18302c99480fa6bee78f9  layer-split.tar
$

すると見事に元のtarアーカイブを復元することができました。

docker saveとtar-splitの話

docker saveはDockerイメージをtarアーカイブとして出力できるコマンドです。実はこのコマンドの裏ではtar-splitによるアセンブルが行われています。mobyソースコードを見てみましょう。

func (ls *layerStore) assembleTarTo(graphID string, metadata io.ReadCloser, size *int64, w io.Writer) error {
    diffDriver, ok := ls.driver.(graphdriver.DiffGetterDriver)
    if !ok {
        diffDriver = &naiveDiffPathDriver{ls.driver}
    }

    defer metadata.Close()

    // get our relative path to the container
    fileGetCloser, err := diffDriver.DiffGetter(graphID)
    if err != nil {
        return err
    }
    defer fileGetCloser.Close()

    metaUnpacker := storage.NewJSONUnpacker(metadata)
    upackerCounter := &unpackSizeCounter{metaUnpacker, size}
    logrus.Debugf("Assembling tar data for %s", graphID)
    return asm.WriteOutputTarStream(fileGetCloser, upackerCounter, w)
}

https://github.com/moby/moby/blob/d147fe0582f44c0fc22ec8bdffff44939dd54d52/layer/layer_store.go#L702-L720

このassembleTarToというメソッドは雑に説明すると、tar-split.jsonを読み込み、tar-splitパッケージのasm.WriteOutputTarStreamメソッドでアセンブルをし、tarアーカイブを復元するというものです。

コードを遡っていくと、このメソッドはイメージをtarアーカイブとして出力するimage/tarexport/save.gosaveメソッドの内部で呼ばれています。tar-splitで復元したファイルはlayer.tarという名前で保存されます。docker saveで作ったアーカイブの中身を見ると、layer.tarがイメージのレイヤーの数だけあるのが確認できるでしょう。これがassembleTarToで作られたものです。

まとめ

ちょっと長くなりましたが、要するに「docker saveでエクスポートしたアーカイブdocker loadで読み込んでもイメージのIDが変わらないカラクリの裏にはtar-splitがいた!」ってこと。

参考