knqyf263's blog

自分のためのメモとして残しておくためのブログ。

コンテナイメージのlazy pullingをcurlで試してみる

はじめに

コンテナイメージのlazy pullingが各ツールで利用可能になりつつあるようです。以下は stargz-snapshotter のメンテナである @TokunagaKohei さんによるブログです。

medium.com

lazy pullingが何かを簡単に説明しておくと、コンテナイメージ全体を最初にpullせずにコンテナ実行後に必要なファイルのみを遅延でpullするものです。docker runしようとすると、ローカルにないイメージは各レイヤーをpullしてから実行されますが、pullが終わるのを待たなければならずコンテナ実行までに時間がかかるので何とかしたいというのがモチベーションです。

Docker使ってるけどレイヤーとかが何かそもそも分からない方は以下の本を読みましょう。イラストで仕組みを分かりやすく説明してくれています。上のブログを書いている @TokunagaKohei さん執筆です。自分はeStargz周りも載ってるかも?と思って買ったのですが、それは載っていなかったのでお気をつけください。ですがランタイム周りなども丁寧に整理されていて、既にある程度コンテナの知識がある方にもおすすめの本です。

詳細は上記ブログを参照してほしいのですが、とある調査によるとコンテナを起動するまでの時間の76%をpullが占めているにも関わらず、実際に使われるデータは6.4%程度とのことです。つまり、大きいサイズのイメージを頑張ってpullしたところでほとんどのファイルが読まれないということです。それなら必要なファイルだけpullしておいて、起動後にreadが発生したらそのタイミングで取得するほうがコンテナ実行までの無駄な時間が発生せず効率的ということになります。

もちろん既にローカルにイメージがあればpullは不要ですが、クラスタの新しいノードでpullする際や更新されたイメージを使いたい場合など、pullが必要な場面は多いです。イメージサイズを小さくするというのは一つの対策ではあるのですが限界があります。そういう場合にこのlazy pullingが役立ちます。

lazy pullingを達成するための案が少し前にGoogleからCRFS (Container Registry Filesystem)として公開されました。

github.com

この中でStargzというフォーマットが提案されているのですが、これがlazy pullingするために重要になります。そしてそれをさらに効率化したeStargzというものの実装が進められているというのが上記ブログの内容です。

CRFSが公開された時にちらっと見たのですが、実装されるのはまだ先だろうと思って放置していました。ですが最近そろそろ実用的な段階に来た気がしたので真面目に調べました。あとで説明しますが、lazy pullingと脆弱性スキャナの相性は非常に良いと思っています。なぜなら脆弱性スキャナの必要とするファイルはコンテナイメージ内のごく僅かだからです。

今回のブログでは仕様を学びつつlazy pullingを手元で試してみます。実際にファイルシステムとしてマウントされてreadのタイミングで取りに行くといった辺りには触れません。コンテナレジストリからどうやって必要なファイルのみを取得するのか、というところに焦点を当てています。

普通lazy pullingを試すと言うとcontainerdでの利用方法やKubernetesでの設定方法がメインになると思いますが、このブログではそういったものは一切説明せずにcurlのみでlazy pullingをします。

参考

lazy pulling歴わずか二日目(昨日資料を読み始めた)なのでこれから書くことは嘘の可能性があります。出来ればちゃんと仕様書やメンテナの方々の資料に目を通すことをおすすめします。

以下でeStargzの仕様が定義されています。

github.com

概要については日本語のLT資料があります。

www.slideshare.net

KubeCon Europe 2020での発表資料もあります。

https://static.sched.com/hosted_files/kccnceu20/ba/lazy-image-distribution.pdf

他にも探すと資料がいくつか見つかるので目を通すと良いです。

Stargz

概要

コンテナイメージというのは複数のレイヤーで構成されていて、Docker Hubなどのコンテナレジストリにはレイヤーごとに分けて保存されています。以下は上記KubeConの資料より引用したものですが、各レイヤーにはそのコンテナを構成するファイルがtarで固めて保存されています。そして多くの場合は、そのままでは非効率なので圧縮されています。gzipやzstdが使えたはずですが、とりあえず今回はgzipとして話を進めます。

f:id:knqyf263:20210615063710p:plain
KubeCon Europe 2020 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.5 より引用

つまりlayer.tar.gzの中にそのレイヤーの全てのファイルが含まれているわけですが、前述したようにこれらすべてのファイルが必要とは限りません。たった1KBの /var/lib/foo/data というファイルが一つだけ必要な可能性もあります。tar.gzはseekableではないため、1KBのファイルを取得するためにもlayer.tar.gz全体をpullしてくる必要があります。これが100MBなどあったらかなり無駄になります。なのでtar.gzをseekableにしてあげよう、というのがStargz(Seekable tar.gz)です。

詳細

まず通常のtar.gzとStargzのフォーマットの違いを見てみます。CRFSのリポジトリから引用します。

  • *.tar.gzGzip(TarF(file1) + TarF(file2) + TarF(file3) + TarFooter))
  • Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

これだけ見ても意味が分からないと思うので説明していきます。通常のtar.gzは複数ファイルをtarでアーカイブしたあとにgzipで圧縮しています。しかしこれではseekが出来ません。ではどうするかと言うと、tarの各エントリをgzipで圧縮して単純にくっつけます。単にくっつけるだけだとgzipとして壊れてしまう、と思う方もいるかもしれませんが実はgzipは連結してもgzipとして正しいフォーマットのままになります。

どういうことか試してみます。まず適当にファイルを2つ作ります。

$ echo aaa > a.txt
$ echo bbb > b.txt

これをくっつけてから圧縮してみます。

$ cat a.txt b.txt > ab.txt
$ gzip ab.txt

単に連結されたファイルを圧縮しただけなので、解凍すると内容が連結されています。

$ cat ab.txt.gz | gzip -d
aaa
bbb

では次にそれぞれを圧縮して単純にcatでくっつけた場合にどうなるか試します。

$ gzip a.txt b.txt
$ cat a.txt.gz b.txt.gz > ab-gzip-concat.txt.gz

2つのgzip圧縮ファイルをくっつけただけです。ではこれを解凍してみます。

$ cat ab-gzip-concat.txt.gz | gzip -d
aaa
bbb

何と特にエラーもなく上と同じ結果が得られました。RFC 1952 にも以下のように書いてあり、複数のgzipファイルは連結が可能であることが分かります。

A gzip file consists of a series of "members" (compressed data sets). The format of each member is specified in the following section. The members simply appear one after another in the file, with no additional information before, between, or after them.

サイズを見ても ab-gzip-concat.txt.gz は単純に a.txt.gzb.txt.gz のサイズを合計した60バイトになっています。gzipのヘッダが複数入ってしまいますし、圧縮効率も落ちるのでサイズは多少増えますが依然としてgzipとして正しいフォーマットであることが分かります。Stargzはこの連結可能であるgzipの特性をうまく活用したフォーマットになっています。

$ ls -alh
total 16K
drwxr-xr-x  6 teppei 192  6 14 21:09 ./
drwxrwxrwt 11 teppei 352  6 14 21:05 ../
-rw-r--r--  1 teppei  30  6 14 21:05 a.txt.gz
-rw-r--r--  1 teppei  60  6 14 21:09 ab-gzip-concat.txt.gz
-rw-r--r--  1 teppei  35  6 14 21:06 ab.txt.gz
-rw-r--r--  1 teppei  30  6 14 21:05 b.txt.gz

CRFSを採用するとレイヤーのサイズが数%程度増えることになります。しかしGoogleはこれは許容できる程度であると説明しています。

layer.tar.gzの話に戻すと、tarのアーカイブはシンプルで各ファイルの先頭にヘッダがあってそのあとにファイルの内容が続きます。これが1エントリになります。そしてそれが複数続いて最後にtarのフッタが足されます。つまりこのtarの1エントリ単位でgzip圧縮して全てを連結すればgzipフォーマットを保ったまま各ファイルを独立に圧縮することが出来ます。解凍すれば元のtarファイルに戻ります。

それを理解した上で再度この式を見ると理解できると思います。これ全体でgzipとして正しいフォーマットになります。

 Gzip(TarF(file1)) + Gzip(TarF(file2)) + Gzip(TarF(file3_chunk1)) + Gzip(F(file3_chunk2)) + Gzip(F(index of earlier files in magic file), TarFooter)

しかし欲しいファイルが何番目にあるかわからないと結局tarの先頭から見ていく必要があります。そのため、最後に各ファイルの名前やオフセットを付与します。これはTOCと呼ばれています。つまり流れとしては最初にフッタをpullし、その後にTOCから必要なファイルのオフセットを取得して必要なデータだけpullします。

HTTP Range

どうやってlayer.tar.gzのうち必要なデータだけコンテナレジストリから取得するのか?というとHTTPのRangeヘッダを使います。

developer.mozilla.org

自分も知らなかったのですが、OCI Distribution SpecによってRangeも仕様の一部として定義されていました。

※ 2021/12/06 追記:details.mdは現在削除されており、Rangeに関する記述はない状態になっています。また、元々の記述もMAY supportになっていたので必ず利用可能というわけではなかったようです。ただし、Docker HubやGHCRは対応していますし実態として対応しているレジストリは割とあるかと思います。

https://github.com/opencontainers/distribution-spec/blob/main/detail.md#fetch-blob-partgithub.com

つまり、OCI Distribution Specに従っているコンテナレジストリはRangeを受け付けてくれるということになります。Docker Hubなども対応しています。通常はレイヤーを丸ごとpullするのでRangeは利用されないですが、lazy pullingのためにはRangeがフル活用されます。

互換性

アーカイブや圧縮の方法が異なってくるため、既存のレイヤーのままでは使えません。Stargz形式に変換してコンテナレジストリにpushしてあげる必要があります。しかし上で述べたようにStargzは依然としてtar.gzとして正しいフォーマットであるため、コンテナレジストリ側は追加の対応不要でレイヤーを保存することが出来ます。そして既存のlazy pullingに対応していないツールであっても通常のlayer.tar.gzとして丸ごとpullして解凍すれば本来のレイヤーと同じデータが得られるので(TOCなどの追加ファイルは含まれますが)、Stargz形式のイメージであっても通常通り動作します。

自分の理解をまとめると、Stargz形式でlazy pullingするために必要な対応は以下です

  • コンテナイメージ:Stargz形式に変換する
  • コンテナランタイム:Stargz形式に対応する(ただし対応していなくてもlazy pullingしないだけで通常通り動作する)
  • コンテナレジストリ:対応不要(OCI Distribution Spec準拠の場合)

Stargzまとめ

Stargzは複数のgzip圧縮ファイルを連結してもgzipとして解凍できるという特性とHTTP Rangeを組み合わせてtar.gzをseekableにしたものです。細かい説明は省いているので、詳細はCRFSのリポジトリを確認してください。

図があると分かりやすいと思うので再度引用しておきます。

f:id:knqyf263:20210615064638p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.8 より引用

eStargz

概要

Stargzではファイルへのアクセスが発生したタイミングで必要なファイルのみpullするような仕様になっていました。しかしそれではネットワークアクセスのオーバーヘッドが無視できません。ではどうするかというとコンテナ起動時にアクセスされやすいファイルだけ最初にprefetchしてキャッシュしておきます。そうすることでアクセスされにくいファイルだけ遅延で取得すれば良くなります。

以下もKubeCon EU 20の発表より引用です。StargzとeStargzの比較になっています。

f:id:knqyf263:20210615064815p:plain
KubeCon EU 20 "Startup Containers in Lightning Speed with Lazy Image Distribution" P.10 より引用

この例では /bin/bashentrypoint.sh が起動時に必要と特定して、それらのファイルをレイヤー内の最初に固めて保存しておきます。そしてprefetchされるファイルとされないファイルの2つのグループに分けます。このグループを特定するためにlandmarkファイルが使われます。prefetchする場合は .prefetch.landmark というファイルを仕切りとして入れ、prefetch不要な場合は .no.prefetch.landmark を先頭に入れます。.prefetch.landmark より手前にあるファイルをprefetchします。

これはStargzの仕様として含まれていないため、新たに定義されたのがeStargzです。

最適化

どうやってprefetchするファイルを特定するのか?というと実際にコンテナイメージをsandboxとして動かし実行時にアクセスされたファイルをprefetchすべきと判断するようです。他にもENTRYPOINTやENVなども見ているようですが、今回の趣旨から外れるので一旦詳細は追いません。ただアクセスされるファイルを知りたいニーズは自分にもあるので、今度時間ある時に実装を追いたいと思います。

TOC, TOCEntry

これはStargzの仕様から引き継いでいますが、レイヤー内の各ファイルへのオフセットを保持するためのファイルをTOCと呼んでいます。このTOCはtarエントリの最後に保存されます。TOCJSON形式で、ファイル名は stargz.index.json である必要があります。

このJSON内に entries というフィールドがあり、その中に実際のファイルに関する情報(ファイル名やディレクトリかどうか、など)が含まれています。これはTOCEntryと呼ばれています。どういうフィールドがあるのかはeStargzの仕様を確認してください。

以下はTOCの例です。

{
  "version": 1,
  "entries": [
    {
      "name": "bin/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "bin/busybox",
      "type": "reg",
      "size": 833104,
      "modtime": "2019-06-12T17:52:45Z",
      "mode": 33261,
      "offset": 126,
      "NumLink": 0,
      "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
      "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
    },

上のTOCはファイルの数によって大きさが異なるため、最初に取得するのは少し難しいです。というのは、TOC自体のオフセットが分からないためです。そのため、TOCの後ろにさらに固定長のフッタを付与しています。理由は勝手に自分で想像しただけなので全然違う理由だったらすみません。ですが固定長だとHTTP Rangeで簡単に取得できるのでやはり必要なんじゃないかなと思います。

このフッタは少し面白いですが空のgzipになっています。つまり解凍しても本来のレイヤーに影響を与えません。空のgzipくっつけてどうするの?と思うかもしれませんが、gzipの拡張フィールドに情報が含まれています。特に重要なのはTOCへのオフセットです。

仕様書から転載しますがフッタは以下の構造で必ず51バイトになります。

- 10 bytes  gzip header
- 2  bytes  XLEN (length of Extra field) = 26 (4 bytes header + 16 hex digits + len("STARGZ"))
- 2  bytes  Extra: SI1 = 'S', SI2 = 'G'
- 2  bytes  Extra: LEN = 22 (16 hex digits + len("STARGZ"))
- 22 bytes  Extra: subfield = fmt.Sprintf("%016xSTARGZ", offsetOfTOC)
- 5  bytes  flate header: BFINAL = 1(last block), BTYPE = 0(non-compressed block), LEN = 0
- 8  bytes  gzip footer
(End of eStargz)

上の fmt.Sprintf("%016xSTARGZ", offsetOfTOC) の部分が重要です。ちなみにSI1, SI2, LENはeStargzで足されたフィールドらしく、Stargzには存在しません。つまりStargzではフッタのサイズが47バイトになっています。ただ docker.io/stargz/golang:1.12.9-esgz を触ってみたらesgzというタグにも関わらずフッタが47バイトでした。ちゃんと調べてないですが、eStargzも初期は47バイトだったのかなと推測しました。

eStargzの構造の図を引用しておきます。最後にFooterがあってTOCへのオフセットが保存されている様子が分かるかと思います。

f:id:knqyf263:20210615065359p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

Stargz Snapshotter

containerdでeStargzを利用できるようにするためのプラグインです。

github.com

ここでは特に触れないので詳細は上のドキュメントを見てください。

eStargzまとめ

Stargzをより効率的にしたものがeStargzで、優先的にアクセスされるファイルをprefetchするのが主な特徴となっています。レイアウトなど若干Stargzと異なるところはあるが概ね同じです。

実験

さて導入が長くなりましたが、lazy pullingの仕様が理解できたところでcurlでやってみます。

以下のイメージのlazy pullingを試してみます。

ghcr.io/stargz-containers/alpine:3.10.2-esgz

基本的なdocker pullの流れは以前のブログを参照してください。

knqyf263.hatenablog.com

トークン取得

まずトークンを取得します。

export TOKEN=$(curl "https://ghcr.io/token?scope=repository%3Astargz-containers%2Falpine%3Apull&service=ghcr.io" | jq -r '.token')

インデックス取得

以前はイメージとマニフェストは1対1でしたが、最近は一つのイメージ名でamd64やarm64など複数のプラットフォームに対応することが出来ます。複数プラットフォームに対応している場合は以下のようにmanifestのリストが返ってきます。コンテナランタイム側でプラットフォームに合わせてmanifestを選択します。

curl -s -H "Accept: application/vnd.oci.image.index.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/3.10.2-esgz | jq .
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99",
      "size": 534,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ]
}

今回はlinux/amd64しかありませんが、他のプラットフォームのマニフェストも含めることが出来ます。この時、Acceptヘッダで application/vnd.oci.image.index.v1+json を指定する必要があります。

マニフェスト取得

ではようやくマニフェストを取得します。上で得られたdigestを指定する必要があります。また、この際もAcceptヘッダに気をつけてください。

curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/manifests/sha256:237e28761b771d41d0b841bacfe39f4a49d30b1c8dfecc90aef09b68d86ebc99 | jq .
{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:619584669c61bff457ede5e1880099801e58ef8c3f02fe1ea4fc81bbb77f32e9",
    "size": 1348
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840",
      "size": 2803987,
      "annotations": {
        "containerd.io/snapshot/stargz/toc.digest": "sha256:7c1dc9fef98424e05181ccbfa6784937231221a34545645011f306612b9dcfe5",
        "io.containers.estargz.uncompressed-size": "5935104"
      }
    }
  ]
}

レイヤーIDが取得できました。サイズは2803987になっています。これはフッタの取得時に重要です。ちなみにannotationsにstargz関連の情報もいくつか含まれているようです。

Footer取得

上の処理はeStargz関係ありませんでしたが、ここからようやく固有の操作になります。レイヤーはblobとして保存されているので、blobのAPIを叩きます。

全体のサイズが2803987なので51バイト引いて2803936以降だけをRangeで取得します。レイヤーIDは上の sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840 を使います。

curl -o footer.gz -L -H "Range: bytes=2803936-" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

footer.gzが取得できました。本来であれば2803936バイト取得するところ、51バイトのpullだけで済んでいます。

$ file footer.gz
footer.gz: gzip compressed data, extra field, original size modulo 2^32 0

確かにgzipになっています。中身を見てみます。

$ hexdump -C footer.gz
00000000  1f 8b 08 04 00 00 00 00  00 ff 1a 00 53 47 16 00  |............SG..|
00000010  30 30 30 30 30 30 30 30  30 30 32 61 61 63 33 36  |00000000002aac36|
00000020  53 54 41 52 47 5a 01 00  00 ff ff 00 00 00 00 00  |STARGZ..........|
00000030  00 00 00                                          |...|
00000033

1f 8bgzipのマジックバイトです。STARGZの手前がTOCへのオフセットになっているので、 00000000002aac36 であることが分かります。これは16進数なので10進数に直すと 2796598 です。

ちなみにfooter.gzを解凍すると以下のようにやはり空になっています。

$ gunzip footer.gz
$ du footer
0       footer

TOC取得

TOCのオフセットが分かったのでTOCを取得します。Footerの手前までなので、2796598 - 2803935 がTOCの位置になります。

先程同様にRangeでTOCを取得します。

curl -o toc.tar.gz -L -H "Range: bytes=2796598-2803935" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

中身を確認します。

$ tar tvf toc.tar.gz
----------  0 0      0       89590  1  1  1970 stargz.index.json

確かに stargz.index.json が入っています。では解凍してJSONを確認します。

$ tar xvf toc.tar.gz
x stargz.index.json
$ chmod +r stargz.index.json
$ head -n 21 stargz.index.json
{
        "version": 1,
        "entries": [
                {
                        "name": "bin/",
                        "type": "dir",
                        "modtime": "2019-08-20T10:30:43Z",
                        "mode": 16877,
                        "NumLink": 0
                },
                {
                        "name": "bin/busybox",
                        "type": "reg",
                        "size": 833104,
                        "modtime": "2019-06-12T17:52:45Z",
                        "mode": 33261,
                        "offset": 126,
                        "NumLink": 0,
                        "digest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f",
                        "chunkDigest": "sha256:8b7c559b8cccca0d30d01bc4b5dc944766208a53d18a03aa8afe97252207521f"
                },

確かにTOCEntryが含まれています。

ファイル取得

今回はOSバージョンの特定に必要な /etc/alpine-release を取得してみます。offsetはTOCによれば887659です。実はまだファイル取得時の正しいRangeの指定の仕方が分かってないのですが、今回は次のoffsetの手前まで取得しておきます。つまり今回では次が887788なので887787まで取得します。

{
      "name": "etc/alpine-release",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:35Z",
      "mode": 33188,
      "offset": 887659,
      "NumLink": 0,
      "digest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e",
      "chunkDigest": "sha256:adfd5666b735e8f90dec03769a84d624675243d1c08f7f6b1f7ad107378b868e"
    },
    {
      "name": "etc/apk/",
      "type": "dir",
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 16877,
      "NumLink": 0
    },
    {
      "name": "etc/apk/arch",
      "type": "reg",
      "size": 7,
      "modtime": "2019-08-20T10:30:43Z",
      "mode": 33188,
      "offset": 887788,
      "NumLink": 0,
      "digest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62",
      "chunkDigest": "sha256:aaf631698ae5160ceb04a97681a14887fdcab47cd6e0f163c87485b3b1340b62"
    },

上の説明ではtarのエントリごとにgzipすると言ったのですが、実際には以下のような構造になっています。

f:id:knqyf263:20210615070301p:plain
https://github.com/containerd/stargz-snapshotter/blob/master/docs/stargz-estargz.md より引用

つまりoffsetが指す場所はtarエントリのデータ部分のようです。そのため、gzip解凍したあとのデータはtarのヘッダがないため正しいtarフォーマットになりません。逆に末尾に次のファイルのtarヘッダが含まれています。と思っているのですが、もし自分の理解が間違っていたらすみません。

上の図から分かるように、gzip解凍したあと先頭からsize分取り出せば欲しいファイルのデータになりそうです。最初自分はsizeは次のoffsetまでの長さ、つまりRangeを指定するのに必要な情報かと思ったのですがドキュメントを見ると解凍後のサイズのようです。

  • size uint64

    This OPTIONAL property contains the uncompressed size of the regular file tar entry.

ということでまずはcurlgzipを落とします。

curl -o release.gz -L -H "Range: bytes=887659-887787" -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/stargz-containers/alpine/blobs/sha256:4ce67ba5aa52932aa5fa5f51c49c8fce87c9a10bfb97786a4a61e30cfb9f2840

これを解凍したあとheadを使ってsize分だけ(今回は7バイト)取り出します。

$ cat release.gz | gzip -d | head -c 7
3.10.2

ということで無事に /etc/alpine-release のファイルのみを落としてくることが出来ました。

実験まとめ

curlでlazy pullingをやってみました。実際にstargz-snapshotのソースコードを読むとRangeが複数の範囲を指定できる場合はなるべく1リクエストにまとめる工夫をしたり、毎回リダイレクトされないような工夫をしていたりするので、内部で上のように動くわけではないですが大枠は合っているかなと思っています。そしてもちろん今回のcurlの検証では行いませんでしたが、eStargzの場合は .prefetch.landmark の前までをprefetchする必要があります。上のAlpineの例では lib/ld-musl-x86_64.so.1bin/busybox がprefetch対象に指定されていました。

裏側がどうなっているのか手動で試して理解すると自分でちょっとしたツールとかも作れるようになるのでより良いかと思います。

余談

応用例

lazy pullingはコンテナ起動までの時間を短縮するためのものですが、実はコンテナの脆弱性スキャンでも有用だと考えています。自分はTrivyというOSSのコンテナ脆弱性スキャンを開発しているのですが、もしeStargzに対応できるとコンテナレジストリにあるイメージの脆弱性スキャンが高速に行えると考えています。というのも脆弱性スキャンで必要なのはOSを特定するためのファイルやインストールされているパッケージ情報を含むファイルだけであって、ほとんどのバイナリやランタイムは不要なためです。更にそれらのファイルはサイズも小さいです。大きいイメージ・レイヤーであっても必要な小さいファイルのみpull出来ればスキャンは高速に終わります。eStargzの実用例として面白いかもなと思ったのでKubeConに出そうと思ったらNorth America 2021のCfP終わってました...次のKubeConか何か別のカンファレンスを見つけて話したいと思います。

自作ライブラリ

Trivyに取り込むためにstargz-snapshotterをライブラリとして利用しようと思ったのですが、ソースコードを読んでいるとファイルシステム周りの処理やキャッシュ関連の処理が多かったです。そしてcontainerdへの依存もあるため依存が膨らむ恐れがありました。

github.com

Stargz SnapshotterのようにFUSEファイルシステムを提供し、遅延でアクセスされたファイルを取得するような仕組みは自分の場合は不要で、単にレジストリからファイルを指定して取得できれば良かったので簡易的なライブラリを作ってみました。キャッシュの仕組みもTrivy側で持っているので不要です。入門二日目で作った簡易的なやつなので最適化は全くされていませんしエッジケースも全然対応できていないのですが、stargz-snapshotterのfs/remoteパッケージを流用しているのでとりあえず最低限それっぽく動きます。

github.com

実際にTrivyに取り込む上ではfilepath.Walkのようにlayer内のファイルをiterateしていくメソッドがstargz-snapshotterに欲しいなと思っているので、同意してもらえるか分かりませんがPRを送ろうかと思います。そしてこのライブラリを育てていって品質が上がったらTrivyに取り込みたいと考えています。もしくはstargz-snapshotterがライブラリとして再利用しやすい形になったら不要になるかも...?

ライブラリのままだとテストがしにくかったのでecraneというCLIツールも入れています。これはstargz-registryの内部でgoogle/go-containerregistry を使っており、その付随CLIツールがcraneという名前なのでそこからパクっています。craneもRange対応してほしいのでPR送るかもしれません。

利用方法はイメージ名とファイルパスを指定するだけです。そうするとイメージ全体をpullせずに指定されたファイルだけ取ってきます。

Usage: ecrane IMAGE_NAME FILE_PATH

ちなみにopaqueファイルの処理など一切せずにレイヤーを上から見てファイルパスが一致したらその内容を表示するという雑実装なので間違っても結果を信頼しないでください。あくまで手元の検証用のテストコマンドです。

計測

せっかくなのでdocker runで大きめのイメージ内のファイルの中身を表示するまでの時間を計測します。今回は ghcr.io/stargz-containers/drupal:8.7.6-esgz の /usr/lib/os-release を取得します。

$ /usr/bin/time docker run --rm -it --entrypoint /bin/sh ghcr.io/stargz-containers/drupal:8.7.6-esgz -c "cat /usr/lib/os-release"
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
86.31 real         0.16 user         0.11 sys

86秒でした。イメージサイズが464MBなので仕方ありません。

同様に自作ツールで /usr/lib/os-release を取得します。

$ /usr/bin/time ./ecrane ghcr.io/stargz-containers/drupal:8.7.6-esgz usr/lib/os-release
PRETTY_NAME="Debian GNU/Linux 9 (stretch)"
NAME="Debian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

        5.09 real         0.36 user         0.11 sys

5秒で同じ結果が得られています。何の最適化もしていないのでもっと速く出来ると思いますが、そこそこの結果です。一発の結果なのでベンチマーク取らないと正しい比較はできませんが、雰囲気掴むには十分かと思います。

まとめ

Stargz/eStargzの仕様を見つつ実際に手でlazy pullingを試してみました。イメージをeStargz形式に変換する必要があるので実用化はまだ大変かなと思いましたが、コンテナレジストリ側が気合を入れて一斉に変換したら一気に進むような気もします。もちろん自分の必要なイメージだけ変換してレジストリにpushし直しても良いですが、何かしら自分で変換用のジョブを回す必要はありそうです。

互換性を壊さないように工夫しつつ高速化するためのアイディアが散りばめられていて面白かったです。