docker build を使わずコンテナイメージの中身をいぢる

# ことの発端

`docker image ls` で表示される IMAGE ID ってなんだろう？

```shell
$ docker image ls                        # ↓ これ
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
prom/prometheus      latest              7317640d555e        3 weeks ago         130MB
alpine               latest              965ea09ff2eb        6 weeks ago         5.55MB
```

こちらの記事で「Copy On Write(COW)ファイルシステム」という要素技術を知りました。

[コンテナ技術入門 - 仮想化との違いを知り、要素技術を触って学ぼう - エンジニアHub｜若手Webエンジニアのキャリアを考える！](https://employment.en-japan.com/engineerhub/entry/2019/02/05/103000)

> Dockerコンテナを作成する際に、そのベースファイルシステムとなるDockerイメージはOverlayFSのようにレイヤを重ね合わせた形で提供されています。ベースとなるOSレイヤ、そこにアプリケーションに必要なツールやライブラリをインストールしたレイヤ、さらにアプリケーションをインストールしたレイヤなど、複数のレイヤを重ね合わせてDockerイメージとなります。

これを知ると IMAGE ID について憶測ができます。

- IMAGE ID はレイヤごとのファイル差分の digest 値に関係するのでは？
- しかし単一レイヤの digest 値では Dockerfile の FROM が何であるかにかかわらず同じになってしまう。全レイヤのファイルか digest 値を合算した後さらに digest した値では？
- もしも IMAGE ID を偽った不正コンテナイメージが流通できるとしたら問題がある。Docker には IMAGE ID の検証機能と不正なコンテナイメージを拒絶する仕様があるのでは？

わたし、気になります！

# 調べてみよう・やってみよう

- docker の IMAGE ID 計算方法を調べる
- Linux コマンドで手作業でコンテナイメージをビルドする
- IMAGE ID そのままで中身を改ざんすると docker はどのような挙動をするか調べる

今回はこのあたりまでやってみたいと思います。

# 検証環境

- ハードウェア: Pixelbook (Chromebook)
- OS: Debian GNU/Linux 9 (stretch) Linux on Chromebook
- Docker CE: 19.03.5
- Storage Driver: btrfs

まだちょっとマイナーな Linux on Chromebook です。

# 参考資料

[docker CE](https://github.com/docker/docker-ce) のコードを全部読んで把握できればすべて理解できるでしょうが、まったく取っ掛かりがないので docker build とか docker image id などで検索してみます。すると、とても参考になる Qiita の記事に出会いました。

[Dockerのコードを読む -イメージ生成編-](https://qiita.com/Ladicle/items/5d23bd8cc19e5ac362f7)

> イメージの構造がわからない状態で読み始めると辛くなるので
> 先に[OCI image-spec](https://github.com/opencontainers/image-spec)を読むことをオススメします。
> なぜOCIかというと、Dockerのドキュメントより仕様としてしっかりまとまっているのと、
> Dockerを参考にしているのもあって大枠は一致しているからです。

なるほど！

# 仕様を読む前にとりあえず不正なコンテナを食わせてみる

この手のイメージファイルは tar で固めてあるのが定番なので、

1. `docker save` でコンテナイメージをファイルとして取得してから展開する
2. ファイルシステムをいぢる
3. どこかにあるだろうマニフェストの類はいぢらない
4. tar で固める。これで不正なコンテナイメージの一丁上がり (たぶん)
5. `docker load` で食わせる

という操作をすれば docker が不正なイメージを拒絶するかどうかはわかるでしょう。 (たぶん)

## かんたんな元イメージを build

hogehoge と書かれたファイル hogehoge を alpine の /tmp にコピーしただけのイメージを作ります。

```dockerfile:Dockerfile
FROM alpine:latest
COPY hogehoge /tmp/ # hogehoge は echo 'hogehoge' >| hogehoge で予め作っておきます
```

```shell
$ docker build . -t hogehoge:latest
Sending build context to Docker daemon  3.072kB
Step 1/2 : FROM alpine:latest
 ---> 965ea09ff2eb
Step 2/2 : COPY hogehoge /tmp/
 ---> 4936a59b94ad
Successfully built 4936a59b94ad
Successfully tagged hogehoge:latest

$ docker image ls
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
hogehoge             latest              4936a59b94ad        27 seconds ago      5.55MB
```

IMAGE ID は `4936a59b94ad` ですね。
念の為コンテナを走らせて、/tmp/hogehoge の内容を確認しておきます。

```shell
$ docker run -it hogehoge:latest /bin/sh
/ # cat /tmp/hogehoge
hogehoge
```

## イメージをファイルに save して展開

```shell
$ docker save hogehoge:latest > hogehoge.tgz
ls -lh
合計 5.6M
-rw-r--r-- 1 weakboson weakboson   39 12月  7 21:44 Dockerfile
-rw-r--r-- 1 weakboson weakboson    9 12月  8 18:33 hogehoge
-rw-r--r-- 1 weakboson weakboson 5.6M 12月  8 20:37 hogehoge.tgz

$ mkdir image
$ tar xf hogehoge.tgz -C image # 後でわかりますが tar + gzip ではなく tar でした

$ cd image
$ ls -lh
合計 12K
drwxr-xr-x 1 weakboson weakboson   40 12月  8 20:32 0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0
drwxr-xr-x 1 weakboson weakboson   40 12月  8 20:32 2ce302b70292e3484f04aa768fde2c54a93c208b28e205ffda9e373ffd40a575
-rw-r--r-- 1 weakboson weakboson 1.7K 12月  8 20:32 4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json
-rw-r--r-- 1 weakboson weakboson  281  1月  1  1970 manifest.json
-rw-r--r-- 1 weakboson weakboson   91  1月  1  1970 repositories
```

お。さっそく manifest.json ともう一つ IMAGE ID の由来になってそうな `4936a59b94ad..(中略).json` というファイルがあります。このようなファイルがあれば sha256 digest をとりたくなるのが人の性 (サガ)

```shell
$ sha256sum 4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json
4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9  4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json
```

ファイル名と sha256 digest が一致しますね。

## 2 つの json はなんだろう？


```json:manifest.json
[
  {
    "Config": "4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json",
    "RepoTags": [
      "hogehoge:latest"
    ],
    "Layers": [
      "2ce302b70292e3484f04aa768fde2c54a93c208b28e205ffda9e373ffd40a575/layer.tar",
      "0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0/layer.tar"
    ]
  }
]
```

manifest.json にはもう一方の json ファイル名とサブディレクトリにある tar ファイル、そしてコンテナイメージ名とラベルがあります。もう一方の json ファイルが Config というキーにあるので、仮に Config.json と表記します。

```json:Config.json
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    // 中略
    "Image": "sha256:965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652",
    // 中略
  },
  "container_config": {
    // 中略
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) COPY file:ee9321d69eb59158edcb22888b3c6665be5c9230a5713a2bfeb0f069358e86f8 in /tmp/ "
    ],
    // 中略
    "Image": "sha256:965ea09ff2ebd2b9eeec88cd822ce156f6674c7e99be082c7efac3c62f3ff652",
    // 中略
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:77cae8ab23bf486355d1b3191259705374f4a11d483b24964d2f729dd8c076a0",
      "sha256:dbdb51e45bed7ab135ee0050a34ce75f64dee736c78c936e20bbe0f7a02d6b33"
    ]
  }
}
```

Config.json には自身の sha256 digest が何回かと、 diff_ids という sha256 digest 値が2種登場します。

……直感ですがサブディレクトリにある tar ファイルが怪しいと思います。
サイズと sha256 digest 値を見てみましょう。

```shell
$ ls -lh */layer.tar
-rw-r--r-- 1 weakboson weakboson 2.5K 12月  8 20:32 0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0/layer.tar
-rw-r--r-- 1 weakboson weakboson 5.6M 12月  8 20:32 2ce302b70292e3484f04aa768fde2c54a93c208b28e205ffda9e373ffd40a575/layer.tar

$ sha256sum */layer.tar
dbdb51e45bed7ab135ee0050a34ce75f64dee736c78c936e20bbe0f7a02d6b33  0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0/layer.tar
77cae8ab23bf486355d1b3191259705374f4a11d483b24964d2f729dd8c076a0  2ce302b70292e3484f04aa768fde2c54a93c208b28e205ffda9e373ffd40a575/layer.tar
```

お。2ファイルの digest 値が diff_ids それぞれと一致しました。

ところで `layer.tar` ってもう見るからにレイヤのファイルシステムアーカイブっぽいですよね。サイズが大きな `2ce302b70292(中略)/layer.tar` が FROM である `alpine:latest` だとすると、小さい `0d27994a6e9f(中略)/layer.tar` を展開したら /tmp/hogehoge が出てきそうだと思いませんか？

## layer.tar を展開する

```shell
$ ls -lh
合計 12K
-rw-r--r-- 1 weakboson weakboson    3 12月  8 20:32 VERSION
-rw-r--r-- 1 weakboson weakboson 1.3K 12月  8 20:32 json
-rw-r--r-- 1 weakboson weakboson 2.5K 12月  8 20:32 layer.tar

$ mkdir layer
$ tar xf layer.tar -C layer

$ find layer
layer
layer/tmp
layer/tmp/hogehoge

$ cat layer/tmp/hogehoge
hogehoge
```

あたりです！

ところで VERSION と json も見てみましょう。

```:VERSION
1.0
```

```json:json
{
  "id": "0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0",
  "parent": "2ce302b70292e3484f04aa768fde2c54a93c208b28e205ffda9e373ffd40a575",
  "created": "2019-12-08T11:32:35.014345961Z",
  // 中略
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) COPY file:ee9321d69eb59158edcb22888b3c6665be5c9230a5713a2bfeb0f069358e86f8 in /tmp/ "
    ],
  // 後略
```

id は layer.tar のあるサブディレクトリ名で parent はもう一方、alpine だと踏んでいる方のディレクトリ名ですね。それに Dockerfile に記述したコマンドらしきものも見えます。

## 悪いことしましョ

内容を改ざんして json 類はそのままでパックし直します。

まず展開した layer 中の /tmp/hogehoge の内容を fugafuga に書き換えます。

```shell
$ cd layer
$ echo 'fugafuga' >| tmp/hogehoge
$ cat tmp/hogehoge
fugafuga
```

展開用に自作したサブディレクトリをお掃除しつつ tgz にパックします。 `layer.tar` は名前からして gzip かけてないようですから無圧縮にします。

```shell
$ tar cf ../layer.tar ./*
$ cd ../
$ rm -rf layer

$ cd ../
$ tar czf ../fugafuga.tgz ./*
$ cd ../
$ ls -lh *.tgz

-rw-r--r-- 1 weakboson weakboson 2.6M 12月  8 21:24 fugafuga.tgz
-rw-r--r-- 1 weakboson weakboson 5.6M 12月  8 20:37 hogehoge.tgz
```

あれ？だいぶサイズが違いますね……まあいいか。(イメージは正しくは gzip しない tar でした。)

そしておもむろに docker に食わせたいのですが、各種 json ファイル中の sha256 digest が変わっていないせいでしょうか？すぐに食べさせても何も起こりません。
一度 docker が持っているイメージを削除します。先程確認のために起動したコンテナも先に消します。

```shell
$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
5251c56e7b95        4936a59b94ad        "/bin/sh"           55 minutes ago      Exited (0) 54 minutes ago                       infallible_rubin
$ docker container rm 5251c56e7b95
5251c56e7b95

$ docker image rm hogehoge:latest
Untagged: hogehoge:latest
Deleted: sha256:4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9
```

## 食らえ！(食べないでー)

さあ、改めて召し上がれー♪

```shell
$ docker load -i fugafuga.tgz
dbdb51e45bed: Loading layer [==================================================>]  10.24kB/10.24kB
invalid diffID for layer 1: expected "sha256:dbdb51e45bed7ab135ee0050a34ce75f64dee736c78c936e20bbe0f7a02d6b33", got "sha256:4a64864cd5b3e1ac07d0b47eaf126746ba59e95897094527788502ebca556db2"
```

はい、ペッと吐き出してくれました！偉いぞ docker！

**docker load は内容が改ざんされたコンテナイメージを受け付けない！**

# 人力 docker build 前に OCI の仕様にあたってみよう

[OCI Image Format](https://github.com/opencontainers/image-spec) リポジトリを "image id" や先ほど docker が改ざんイメージをペッと吐き出したエラーに表示された "diffID" で検索すると [OCI Image Configuration](https://github.com/opencontainers/image-spec/blob/e562b04403929d582d449ae5386ff79dd7961a11/config.md) という Markdown で書かれた仕様が見つかります。

この仕様を読むと Config.json と表記していたファイルは OCI では Image JSON とか configuration json と呼ぶようで、たしかにこのファイルの sha256 digest が Image ID になるようです。

> ImageID
>
> Each image's ID is given by the SHA256 hash of its [configuration JSON](#image-json).
> It is represented as a hexadecimal encoding of 256 bits, e.g., `sha256:a9561eb1b190625c9adb5a9513e72c4dedafc1cb2d4c5236c9a6957ec7dfd5a9`.
> Since the [configuration JSON](#image-json) that gets hashed references hashes of each layer in the image, this formulation of the ImageID makes images content-addressable.↲

まとめると以下のようなことが書かれていると理解しました。

- Docker の IMAGE ID は [configuration json (Image JSON)](https://github.com/opencontainers/image-spec/blob/e562b04403929d582d449ae5386ff79dd7961a11/config.md#imageid) の sha256 digest 値
- Configuration json は各レイヤーの Layer DiffID を持つ
- [Layer DiffID](https://github.com/opencontainers/image-spec/blob/e562b04403929d582d449ae5386ff79dd7961a11/config.md#layer-diffid) はレイヤーごとの非圧縮 tar アーカイブの sha256 digest 値

# Let's 人力 docker build

## Digest 値を正しく修正する

1. "/tmp/hogehoge" の内容を修正した後の tar から Layer DiffID を計算
2. configuration json 中の diff_id を計算値に置換
3. configuration json の digest 値を計算
4. configuration json を新しい digest 値でリネーム
5. manifest.json 中の configuration json ファイル名を計算値に置換

4, 5 の操作は OCI の [image-spec.md](https://github.com/opencontainers/image-spec/blob/master/config.md) と [manifest.md](https://github.com/opencontainers/image-spec/blob/master/manifest.md) には記述されていないように読めましたが、docker イメージ中の manifest.json には対応があるので念のため修正しました。 (修正しないでも動作するかは未検証です。)

```shell
# 0. 置換用に前の Layer DiffID を退避
$ OLD_DIGEST=dbdb51e45bed7ab135ee0050a34ce75f64dee736c78c936e20bbe0f7a02d6b33

# 1. "/tmp/hogehoge" の内容を修正した後の tar から Layer DiffID を計算
$ NEW_DIGEST=`sha256sum 0d27994a6e9f841bf6a9973941008b303a7234d70c640570d70148b5b5d2a3d0/layer.tar | awk '{print $1}'`
$ echo $NEW_DIGEST
4a64864cd5b3e1ac07d0b47eaf126746ba59e95897094527788502ebca556db2

# 2. configuration json 中の diff_id を計算値に置換
$ sed -i "s/${OLD_DIGEST}/${NEW_DIGEST}/g" 4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json

$ OLD_IMAGE_ID=4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9

# 3. configuration json の digest 値を計算
$ NEW_IMAGE_ID=`sha256sum 4936a59b94ad4d2c2a4bce855e7891276f4c2b3fb3b25dbe3b2774ae4185d6e9.json| awk '{print $1}'`
$ echo $NEW_IMAGE_ID
53667683a1bc3a9de27dbb96457ba102d4cd9dc6a78cba9a534b3d626fe2ff63

# 4. configuration json を新しい digest 値でリネーム
$ mv ${OLD_IMAGE_ID}.json ${NEW_IMAGE_ID}.json

# 5. manifest.json 中の configuration json ファイル名を計算値に置換
$ sed -i "s/${OLD_IMAGE_ID}/${NEW_IMAGE_ID}/g" manifest.json
```

## Digest 値が正統な Image tgz を作成して docker に食わせる

```shell
$ tar czf ../fugafuga.tgz ./*
$ cd ../
$ docker load -i fugafuga.tgz
4a64864cd5b3: Loading layer [==================================================>]  10.24kB/10.24kB
Loaded image: hogehoge:latest
```

お！食べてくれましたね……

```shell
$ docker image ls
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
hogehoge             latest              53667683a1bc        4 hours ago         5.55MB
```

他のメタ情報を修正していないので CREATED は hogehoge を docker build した時刻になっているようです。中身はきちんと hogehoge -> fugafuga が反映されているでしょうか？起動できるでしょうか？

```shell
$ docker run -it hogehoge:latest /bin/sh
/ # cat /tmp/hogehoge
fugafuga
```

**いけました！内容を変更後も IMAGE ID を正しく計算・設定すると docker で扱えます！**

# まとめ

- docker の IMAGE ID は configuration json (Image JSON) の sha256 digest 値です。configuration json はさらに各レイヤの Layer DiffID という sha256 digest 値を持ちます
- レイヤを改ざんして IMAGE ID 計算時と内容が変わったイメージは docker load できません
- レイヤを変更しても正しく IMAGE ID を計算・設定したイメージは docker load, run できます (docker build 自身がやってることの最低限の操作に相当するのでしょう)

# 続けて調べたいこと

## イメージサイズが大きなサイドカーコンテナを過剰に忌避するものでもないのか検証する

> 同じホストで同じ構成のコンテナを複数作成する場合、各コンテナ同士では多くの共有可能なバイナリやライブラリがあります。コンテナごとにファイルシステムをまるごと用意する、とはディスクスペースを無駄に消費していることと同義なのです。また、大きな環境の場合、ファイルシステムの作成にも時間がかかりコンテナの起動が遅くなってしまう可能性もあります。こうした無駄を改善するのが、COW（Copy On Write）ファイルシステムです。
>
> コンテナ技術入門 - 仮想化との違いを知り、要素技術を触って学ぼう - エンジニアHub｜若手Webエンジニアのキャリアを考える！ から引用

最近 Kubernetes でアプリ運用をするにあたり Envoy や mtail のようなサイドカーコンテナのイメージサイズはできるだけ小さくしたいと考えていました。しかし今回の記事を書くために上記引用記事を再読して COW ファイルシステムの性質を再認識しました。
既存レイヤのファイルを変更しなければ1ワーカーノードにサイドカーコンテナがいくつあろうと1個分のディスクしか消費しないはずで、そうそうイメージのサイズにセンシティブにならなくてよいのかもしれません。意図的にイメージサイズが大きなコンテナを複数起動して検証してみようと思います。

## Docker CE の実装を読んでいぢってみる

今回 OCI の仕様書と試行錯誤で動作できてしまったので Docker のコードをまーーーったく読みませんでした。Docker のコードを読んで改修してみて、例えば Image ID 詐称イメージでも食う docker とかビルドしてみたいものです。今回は docker load でブロックされることを確認しましたが、おそらく docker のほとんどあらゆる機能にコンテナイメージの検証プロセスが含まれているのではと予想しています。

エラーメッセージで検索した感じ docker load をブロックしたコードはこのあたりかな？
https://github.com/docker/docker-ce/blob/d49c4ca453ce4d1161ce5fb2482be7538bf73e07/components/engine/image/tarexport/load.go#L119-L121

明日は @hazanyaan の「Web脆弱性を体感してみよう」です。