> Source: https://www.ymotongpoo.com/works/adopting-erlang-ja/production/docker/


[Docker](https://docker.com)は、使いやすさとビルド済みイメージを揃えたレジストリによってLinuxコンテナを普及させた立役者であり、いまでは「Linuxコンテナ」とほぼ同義の言葉として使われるようになりました。

Dockerイメージは複数のレイヤーで構成されており、実行時にそれらを重ね合わせてコンテナのファイルシステムを作ります。
レイヤーは`Dockerfile`に書かれたコマンドを実行することで作られ、コマンド1つにつき1つの新しいレイヤーが生成されます。
レイヤーはイメージ間で共有されるため容量を節約でき、イメージのビルドを高速化するキャッシュとしても使えます。
仮想マシン(VM)などの他の選択肢と比べてさらに容量を節約できるのは、イメージにLinuxカーネルを含めないためです。
イメージのサイズは、デプロイするErlangリリースをパッケージ化したサイズよりわずかに大きい程度です。

ファイルシステムとネットワークを分離した状態でコンテナを動かす利点は、プログラムを分離しない場合によく発生する次のような作業が不要になることです。

- 共有ライブラリの事前インストール
- 設定の更新
- 空いているポートの探索
- ノード名の重複しない名前の決定

> **注**
>
> 本章ではDockerを使うときに`latest`タグを一切使いません。
> このタグはよく誤解され誤用されています。
> `latest`はタグを指定せずに最後に使われたイメージに割り当てられるものであり、最後に作成された最新のイメージを指すわけではありません。
> どのバージョンのイメージが使われても構わない場合を除き、頼るべきではありません。

本章では、[service_discovery](https://adoptingerlang.org/gh/servicediscovery)プロジェクトを実行するイメージと、テストやDialyzerを実行するイメージを効率よくビルドする方法を扱います。
そのうえで、新しいイメージをビルドして公開するように継続的インテグレーションのパイプラインを更新します。

本章で必要となるDockerの最小バージョンは19.03で、[buildx](https://github.com/docker/buildx)がインストールされている必要があります。
`buildx`は次のコマンドでインストールできます。

```shell
$ export DOCKER_BUILDKIT=1
$ docker build --platform=local -o . git://github.com/docker/buildx
$ mv buildx ~/.docker/cli-plugins/docker-buildx
```

## イメージのビルド

新しいOTPリリースが出るたびに、[公式のErlang Dockerイメージ](https://hub.docker.com/_/erlang/)が公開されます。
これらのイメージにはRebar3が含まれており、[Alpine](https://alpinelinux.org/)版と[Debian](https://www.debian.org/)版が用意されています(Rebar3やAlpine、Debianの新しいリリースに合わせて更新されます)。
タグ付きイメージは新しいリリースのたびに更新されるため、イメージの`sha256`ダイジェストを使うことと、たとえ自分のリポジトリもDocker Hub上にあるとしても使用するイメージを自分のリポジトリにミラーしておくことの両方が推奨されます。
コピーを持っておけば開発者の意図しない変更でベースイメージが変わることを防げますし、Docker Hubとは別のレジストリにミラーを持っておけばDocker Hubの可用性に依存せずに済みます。
このベストプラクティスに従い、以降の例や`service_discovery`リポジトリでは`ghcr.io/adoptingerlang/service_discovery/`と`us.gcr.io/adoptingerlang/`のイメージを使います。

### プライベートな依存関係

業務でDockerイメージをビルドするときに多くの人が最初につまずくのが、プライベートな依存関係へのアクセスです。
依存関係にプライベートなgitリポジトリや[Hexのオーガニゼーションパッケージ](https://hex.pm/docs/rebar3_private)がある場合、ビルド中のDockerコンテナ内ではそれらを取得できません。
このため`.dockerignore`に`_build`を含めないままにして`docker build`を実行する前にRebar3で依存関係を取得しておくというやり方に流れがちですが、これはローカルの成果物でビルドを汚し、他の環境で再現できなくなる危険を伴います。
もう一つの方法は、ホストのSSH認証情報やHexのAPIキーをビルドコンテナにコピーすることですが、これはDockerのレイヤーに残ってしまいイメージをpushした先でどこでも漏洩するため推奨されません。
そこで、最近のDocker(18.06以降)にはシークレットやSSHエージェントの接続、鍵を安全な方法でマウントする機能があります。
このデータは最終的なイメージにも、明示的にマウントしていないコマンドにも漏れません。

`service_discovery`にはプライベートな依存関係がないため、`service_discovery`のイメージビルドに取りかかる前に、まずそれらへの対応方法を別途見ていきます。

#### Hexの依存関係

Rebar3はプライベートなHex依存関係のアクセスキーを`~/.config/rebar3/hex.config`というファイルに保持しています。
実験的なDockerfile構文`--mount=type=secret`を使えば、このconfigをコンパイルコマンドのときだけコンテナにマウントできます。
このファイルは専用のtmpfsファイルシステムにマウントされ、ビルドキャッシュの対象からは除外されます。

```dockerfile
# syntax=docker/dockerfile:1.2
RUN --mount=type=secret,id=hex.config,target=/root/.config/rebar3/hex.config rebar3 compile
```

`docker build`を実行するときにホストの`hex.config`をマウントするには、一致する`id`とファイルへの`src`パスを指定してシークレットを渡すだけです。

```shell
$ docker build --secret id=hex.config,src=~/.config/rebar3/hex.config .
```

#### Gitの依存関係

前節のシークレットマウントをSSH鍵のマウントに使うこともできますが、DockerにはSSHの扱いに特化したより良い解決策として専用のマウントタイプが追加されています。
SSHアクセスが必要な`RUN`コマンドは`--mount=type=ssh`を使えます。

```dockerfile
# syntax=docker/dockerfile:1.2
RUN apt install --no-cache openssh-client git && \
    mkdir -p -m 0600 ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    git config --global url."git@github.com:".insteadOf "https://github.com/"
WORKDIR /src
COPY rebar.config rebar.lock .
RUN --mount=type=ssh rebar3 compile
```

まず`RUN`コマンドで必要な依存関係であるSSHとgitをインストールします。
続いて`ssh-keyscan`を使ってGithubの現在の公開鍵を取得し、`known_hosts`に追加します。
公開鍵が`known_hosts`にあることで、SSHはホストの公開鍵を受け入れるかどうかを尋ねるプロンプトを出さなくなります。
次に、gitの設定によって`rebar.config`内のgit URLが`https`を使っていてもSSH経由に置き換わるようにします。
プライベートリポジトリがGithub以外にある場合は、このURL置き換えを該当する場所に合わせて変更する必要があります。

本章でこのあと登場するDockerfileに前述のスニペットを加えるのに合わせて、ビルドコマンドの実行時にも`--ssh default`の追加と`DOCKER_BUILDKIT`の設定が必要です。

```shell
$ export DOCKER_BUILDKIT=1
$ docker build --ssh default .
```

SSHマウントタイプの詳細な情報やオプションは[Mobyのドキュメント](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/experimental.md#run---mounttypessh)にあります(MobyはDockerの中核機能を構成するプロジェクトの名前です)。

### 効率的なキャッシュ活用

#### 命令の基本的な並び順

Dockerfile内のコマンドの並び順は、ビルド時間と生成されるイメージのサイズに大きく影響します。
Dockerfile内の各コマンドはレイヤーを作り、そのレイヤーは以降のビルドで何も変わっていなければそのコマンドをスキップするために再利用されます。
Rebar3ではこれを活かして、プロジェクトのビルド済み依存関係だけを含むレイヤーを作ります。

```dockerfile
COPY rebar.config rebar.lock .
RUN rebar3 compile
```

`COPY`コマンドは、`rebar.config`または`rebar.lock`が以前作成されたレイヤーと異なる場合にのみ、`rebar3 compile`を実行するコマンド(とそれ以降のコマンド)のキャッシュを無効化します。
プロジェクトのコードは一切コピーしておらずRebar3は依存関係だけをビルドするため、結果として`_build/default/lib`以下にビルド済みの依存関係だけを含むレイヤーができます。

依存関係がビルドされキャッシュされたら、プロジェクトの残りをコピーしてコンパイルできます。

```dockerfile
COPY . .
RUN rebar3 compile
```

Dockerfileの操作順序のおかげで、`docker build .`を実行するたびに変更があった場合だけプロジェクトのソースがコンパイルされ、変更がなければここでも既存のレイヤーが使われます。
プロジェクトに変更があっても再実行する必要のないコマンド、たとえばDebianパッケージのインストール(`RUN apt install git`)や作業ディレクトリを設定する`WORKDIR /app/src`は、いずれの`COPY`コマンドよりも前に置く必要があります。

`COPY . .`はキャッシュを無効化しやすくなるため推奨されません。
可能であれば、ビルドに必要なファイルとディレクトリだけをコピーするか、`.dockerignore`ファイルで不要なファイルを除外するほうがよいでしょう。
`.git`ディレクトリはサイズが大きく、その中身の変更がビルド成果物に影響しないため、除外しておくとよい対象の一つです。
ただし`service_discovery`では、リリースのバージョンやリリースを構成するアプリケーションの設定にgitコマンドを利用しています。
Rebar3のこの機能に依存しないプロジェクトでは、`.dockerignore`に`.git`を追加することを推奨します。

#### 実験的なマウント構文

Dockerイメージをビルドするときの効率化は、もはやイメージへのファイルコピーとレイヤーのキャッシュだけが選択肢ではありません。
ビルド済みの依存関係をレイヤーにキャッシュするのはよいのですが、そのレイヤーにはRebar3が`~/.cache/rebar3/hex`以下に作るHexパッケージキャッシュも含まれてしまいます。
`rebar.config`や`rebar.lock`に変更があると、すべてのパッケージが再ビルドされるだけでなくHexから再取得もされることになります。
さらに、プロジェクト全体をコピーしてソース一式を含む追加のレイヤーを作る命令も、本当に必要なのはビルド成果物だけなので無駄です。

これらの問題は、Docker 19.03から`RUN`コマンドのコンテキストにファイルをマウントする実験的な構文によって解決されました。
この実験的な構文を有効にするには、環境変数`DOCKER_BUILDKIT`を設定するか`/etc/docker/daemon.json`に`{"features":{"buildkit": true}}`を設定し、さらにDockerfileの1行目に`# syntax=docker/dockerfile:1.2`を書く必要があります。

```dockerfile
# syntax=docker/dockerfile:1.2

[...]

WORKDIR /app/src

ENV REBAR_BASE_DIR /app/_build

# 依存関係を個別のレイヤーとしてビルドしキャッシュする
COPY rebar.config rebar.lock .
RUN --mount=id=hex-cache,type=cache,sharing=locked,target=/root/.cache/rebar3 \
    rebar3 compile

RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,sharing=locked,target=/root/.cache/rebar3 \
    rebar3 compile
```

この新しい命令群では、ビルドは`WORKDIR`を`/app/src`に設定し、以降のコマンドの作業ディレクトリとします。
また環境変数`REBAR_BASE_DIR`を`/app/_build`に設定します。
このベースディレクトリはRebar3がすべてのビルド成果物を出力する場所で、デフォルトではプロジェクトルート直下の`_build/`ディレクトリになりますが、この環境変数がなければ今回は`/app/src/_build`になっていたはずです。

Rebar3のconfigとlockファイルの`COPY`はそのままですが、続く`RUN`にはタイプ`cache`の`--mount`オプションが加わっています。
これによりDockerは、Dockerのレイヤーとは別にホスト上にローカルで保存されるキャッシュディレクトリを作ります。
このキャッシュは`docker build`の実行をまたいで残るため、以降ローカルで`docker build`を実行するときはconfigやlockファイルが変更されていてもこのキャッシュがマウントされ、新しく必要になったパッケージだけがHexから取得されます。

次に、プロジェクトの残りをビルドしていた以前の命令とは異なり、`COPY . .`という命令は取り除かれています。
代わりに、`target`を`.`としたタイプ`bind`(デフォルト)のマウントを使っています。
`cache`マウントとは違い、bindマウントはDockerがビルドコンテキストからコンテナへマウントすることを意味し、`COPY . .`と同じ結果を得ながらファイルをコピーしたレイヤーを作らずに済むため、ビルドが速く小さくなります。
`COPY`コマンドではホストからのコピーが2回発生します。ビルドコンテキストへのコピーと、ビルドコンテナ内へのコピーです。
`COPY`を使う`build`の実行のたびに、ビルドコンテキストからビルドコンテナへプロジェクト全体を毎回コピーし直す必要があります。

デフォルトではこのマウントはイミュータブルであり、`/app/src`に何かを書き込もうとするとビルドがエラーになります。Rebar3のベースディレクトリを`/app/_build`に設定しているのはこのためです。
`read-write`モードでマウントするオプションもありますが、書き込みは永続化されず、ビルドコンテキストのデータをビルドコンテナ用にコピーせずに済むという最適化も失われます。
マウントオプションの詳細は、Buildkitのドキュメント「[Dockerfile frontendの実験的構文](https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/experimental.md)」で読めます。

最終的には、コンパイル済みの依存関係を含む`/app/_build`のレイヤー(あわせて`/app/src/rebar.*`も含まれますが、レイヤーサイズへの影響はごくわずかです)に続いて、コンパイル済みのプロジェクトを含む`/app/_build`のレイヤーができますが、`/app/src`には何も残りません。
これとは別に、ダウンロードされたすべてのHexパッケージのキャッシュがあります。

#### ローカルキャッシュとリモートキャッシュ

この節ではこれまで2種類のキャッシュを使ってきましたが、どちらもキャッシュにアクセスするにはビルドが同じホスト上で行われている必要があります。
`RUN`の間にマウントされる`hex-cache`は純粋にローカルキャッシュのための機能であり、レジストリへのエクスポートやレジストリからのインポートはできません。
一方、Dockerfileの各命令から作られるレイヤーはレジストリからインポートできます。

リモートキャッシュを使うようにビルドを設定しておくと、継続的インテグレーションや何らかのビルドサーバーで作業するときに特に役立ちます。
ビルドを実行するノードが1つしかない場合を除き、Dockerfileのすべてのステップを毎回ビルドし直すのは時間の無駄になってしまいます。
この問題を解決するために、`docker build`には`--cache-from`でレイヤーを探す場所(リモートレジストリ上のイメージを含む)を指定できます。

`--cache-from`には2つのバージョンがあり、新しいほうは技術的にはまだ[buildx](https://github.com/docker/buildx)と呼ばれる実験的な「テックプレビュー」の一部であるため、本章では両方を扱います。
ただし`buildx`のほうがはるかに効率的で使いやすく、必要とする機能の範囲では安定しているように見えるため、`service_discovery`プロジェクトではこちらをデフォルトとして使います。

古い`--cache-from`は「マルチステージを意識」していません。つまりマルチステージのDockerfileにある各ステージを、ユーザーが手動で個別にビルドしてpushする必要があります。
前のステージを土台にビルドするステージでは、`--cache-from`を通じてそのイメージを参照でき、レジストリからpullされます。

`buildx`では、マルチステージビルドの以前のステージに関する情報を含んだキャッシュマニフェストが作られます。
引数`--cache-to`を使うと、このキャッシュをさまざまな方法でエクスポートできます。
本章ではキャッシュマニフェストをイメージのメタデータに直接書き込む`inline`オプションを使います。
イメージはレジストリにpushしたあと、以降のビルドで`--cache-from`から参照できます。
新しいキャッシュマニフェストの特徴は、キャッシュヒットしたレイヤーだけがダウンロードされる点です。古い形式では`--cache-from`で参照すると前のステージのイメージ全体がダウンロードされていました。

> **危険**
> **キャッシュとセキュリティアップデート**
>
> レイヤーキャッシュを使うときはセキュリティ上の懸念に注意が必要です。
> `RUN`コマンドはコマンドのテキストが変わるか、それより前のレイヤーでキャッシュが無効化された場合にしか再実行されないため、セキュリティ修正がリリースされていてもインストール済みのシステムパッケージは同じバージョンのままになります。
> このため、ときどき`--no-cache`をつけてDockerを実行し、イメージビルド時にレイヤーを再利用しないようにするとよいでしょう。

### マルチステージビルド

Erlangプロジェクトではビルド済みリリースを含むイメージが必要ですが、このイメージにはリリースの実行に不要なものを含めるべきではありません。
Rebar3のようなツール、プロジェクトのビルドに使ったErlang/OTPのバージョン、GitHubから依存関係を取得するのに使ったgitなどは、すべて取り除く必要があります。
ビルド完了後に不要なものを削除するのではなく、マルチステージのDockerfileを使い、Erlangランタイムを同梱した最終的なリリースを、ビルドしたステージからDebianベースでOpenSSLのようなリリースの実行に必要な共有ライブラリだけを持つステージへコピーできます。

`service_discovery`プロジェクトの[Dockerfile](https://github.com/adoptingerlang/service_discovery/blob/docker-chapter/Dockerfile)にあるステージを順に見ていきます。
最初のステージは`builder`という名前です。

```dockerfile
# syntax=docker/dockerfile:1.2
FROM ghcr.io/adoptingerlang/service_discovery/erlang:26.0.2 as builder

WORKDIR /app/src
ENV REBAR_BASE_DIR /app/_build

RUN rm -f /etc/apt/apt.conf.d/docker-clean

# Hex以外の依存関係を取得するためにgitをインストールする
# プロジェクトのコンパイルに必要な他のDebianライブラリがあればここに追加する
RUN --mount=target=/var/lib/apt/lists,id=apt-lists,type=cache,sharing=locked \
    --mount=type=cache,id=apt,target=/var/cache/apt \
    apt update && apt install --no-install-recommends -y git

# 依存関係を個別のレイヤーとしてビルドしキャッシュする
COPY rebar.config rebar.lock .
RUN --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 compile

FROM builder as prod_compiled

RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 as prod compile
```

`builder`ステージはベースイメージ`erlang:26.0.2`から始まります。
`as builder`でこのステージに名前を付けているため、以降のステージで`FROM`のベースイメージとして使えます。

> **情報**
> **古いDockerのキャッシュ**
>
> 前節で説明した古い`--cache-from`を使ったリモートキャッシュでは、含まれるRebar3の依存関係にもとづいてイメージを参照できるような識別子を付けて`builder`ステージをビルドし、タグ付けします。
> これには`rebar.config`と`rebar.lock`に対して`cksum`コマンドを使えます。これはDockerがキャッシュを無効化するかどうかを判断する前に行っている処理とよく似ています。
>
> ```shell
> $ CHKSUM=$(cat rebar.config rebar.lock | cksum | awk '{print $1}')
> $ docker build --target builder -t service_discovery:builder-${CHKSUM} .
> $ docker push service_discovery:builder-${CHKSUM}
> ```
>
> `FROM builder`を使うステージをビルドするときは、以前ビルドした依存関係をpullするために`--cache-from=service_discovery:builder-${CHKSUM}`を指定します。
>
> 開発者が同じプロジェクトの複数のブランチを並行して作業し、依存関係がブランチごとに異なることはよくあります。キャッシュとして使うイメージを定義する際に現在のRebar3のconfigとlockファイルのチェックサムを使えば、プロジェクトの複数の依存関係の組み合わせをキャッシュしておき、ビルド時に正しい組み合わせを使えます。

次の`releaser`という名前のステージは、`prod_compiled`イメージをベースにします。

```dockerfile
FROM prod_compiled as releaser

WORKDIR /app/src

# リリースを展開するディレクトリを作成する
RUN mkdir -p /opt/rel

# リリースのtarballをビルドして展開する
# 次のステージでビルドするイメージにコピーするため
RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 as prod tar && \
    tar -zxvf $REBAR_BASE_DIR/prod/rel/*/*.tar.gz -C /opt/rel
```

このステージでは`prod`プロファイルを使ってリリースのtarballをビルドします。`rebar.config`の該当箇所は次のとおりです。

```erlang
{profiles, [{prod, [{relx, [{dev_mode, false},
                            {include_erts, true},
                            {include_src, false},
                            {debug_info, strip}]}]
            }]}.
```

このプロファイルは`include_erts`が`true`に設定されているため、tarballにErlangランタイムが含まれ、Erlangがインストールされていないターゲットでも実行できます。
最後にtarballは`/opt/rel`に展開されるため、`releaser`ステージからリリースをコピーするステージに`tar`をインストールしておく必要はありません。

> **質問**
> **そもそもなぜリリースをtar化するのか**
>
> リリースのtarballを作成した直後に展開していることに気付いたかもしれません。
> リリースディレクトリの中身をそのままコピーするのではなくこうしているのには2つの理由があります。
> 1つ目は、このバージョンのリリースに明示的に含まれると定義されたものだけが使われることを保証するためです。Dockerイメージ内でビルドする場合、`_build/prod/rel`ディレクトリに以前のリリースビルドが残っていることはないためそれほど重要ではありませんが、それでもやっておく価値はあります。
> 2つ目は、tar化する際にリリースへいくつかの変更が加えられ、それが`release_handler`のようなツールを使うのに必要になるためです。たとえばブートスクリプトは`RelName.boot`から`start.boot`に名前が変わります。
> 詳細は[systools](http://erlang.org/doc/man/systools.html#make_tar-1)のドキュメントを参照してください。

最後に、デプロイ可能なイメージは前段のステージではなく通常のOSイメージ(`debian:bullseye`)をベースにします。
まずリリースの実行に必要な共有ライブラリをインストールし、続いて`releaser`ステージで展開済みのリリースを`/opt/service_discovery`にコピーします。

```dockerfile
FROM ghcr.io/adoptingerlang/service_discovery/debian:bullseye as runner

WORKDIR /opt/service_discovery

ENV COOKIE=service_discovery \
    # 起動時に生成されるファイルを/tmpに書き込む
    RELX_OUT_FILE_PATH=/tmp \
    # service_discovery固有のデフォルト値となる環境変数
    DB_HOST=127.0.0.1 \
    LOGGER_LEVEL=debug \
    SBWT=none

RUN rm -f /etc/apt/apt.conf.d/docker-clean

# cryptoアプリケーションに必要なopenssl
RUN --mount=target=/var/lib/apt/lists,id=apt-lists,type=cache,sharing=locked \
    --mount=type=cache,id=apt,sharing=locked,target=/var/cache/apt \
    apt update && apt install --no-install-recommends -y openssl ncurses-bin

COPY --from=releaser /opt/rel .

ENTRYPOINT ["/opt/service_discovery/bin/service_discovery"]
CMD ["foreground"]
```

`ENV`コマンドでは、リリースを実行する際に使う環境変数にいくつか便利なデフォルト値を設定しています。
`RELX_OUT_FILE_PATH=/tmp`は、リリース起動スクリプトが生成するファイルの出力先ディレクトリとして使われます。
リリースの実行時には`sys.config`と`vm.args`をそれぞれの`.src`ファイルから生成する必要があり、デフォルトでは元の`.src`ファイルと同じディレクトリに配置されます。
コンテナのファイルシステムに書き込みをしないのがベストプラクティスであるため、これらのファイルを`.src`ファイルのあるリリースディレクトリに書き込ませたくありません。
このイメージは`/tmp`に書き込むだけであれば任意のユーザーで実行できますが、`/opt/service_discovery`以下のどこかに書き込む必要がある場合は`root`で実行しなければなりません。
したがって`/tmp`に書き込むようにしておくことで、コンテナを`root`で実行しないというもう一つのベストプラクティスにも従えます。
さらに踏み込んで実行時のファイルシステムを読み取り専用にすることもでき、それは後述の「コンテナの実行」で見ていきます。

`/opt/service_discovery`はrootが所有しており、コンテナをrootで実行しないことが推奨されます。
`RELX_OUT_FILE_PATH`が設定されていれば、代わりにその場所が使われます。
ここでは`ENV`コマンドを使い、コンテナの実行時に環境変数`RELX_OUT_FILE_PATH`が確実に`/tmp`に設定されるようにしています。

```shell
$ docker buildx build -o type=docker --target runner --tag service_discovery:$(git rev-parse HEAD) .
```

あるいは、CircleCIからイメージをビルドしてpushするために`service_discovery`に含まれるスクリプトを使うこともできます。

```shell
ci/build_images.sh -l
```

このスクリプトはイメージに2回タグを付けます。手動のコマンドと同じgitのref(`git rev-parse HEAD`)と、ブランチ名(`git symbolic-ref --short HEAD`)です。
ブランチのタグは、`--cache-from`でビルドマニフェストキャッシュとして参照するために使われます。
スクリプトは`master`ブランチと現在のブランチにタグ付けされたイメージが利用できればキャッシュとして使い、そのためにはビルドコマンドに`--cache-to=type=inline`を含める必要があります。

キャッシュヒットを調べる対象として現在のブランチ名と`master`を使うのは、依存関係をビルドするステージだけを含むイメージに対して`rebar.config`と`rebar.lock`のチェックサムを使う方法ほど厳密ではありません。
チェックサムで明示的にタグ付けしたイメージを別途ビルドしてpushし、それも`--cache-from`のイメージの一つとして使うことは可能です。
しかし少なくともこのプロジェクトでは、Buildkitのキャッシュマニフェストがすべてのステージを追跡してくれるため追加のイメージを管理せずに済む手軽さのほうが、もう一方の方式なら起きない依存関係のキャッシュミスの可能性を上回るメリットになっています。

最後に、後述の「CIでのイメージビルドと公開」で見るCircleCIでのこのスクリプトの使い方と、ここでの使い方との違いとして`-l`オプションに触れておきます。
CIではイメージをリモートレジストリに届けることだけが目的なので、ビルドしたイメージをDockerデーモンに読み込まないことで時間を節約できます。
ローカルでイメージをビルドするときは次節「コンテナの実行」で見るようにそのあと実行したい場合が多く、そのためDockerデーモンへの読み込みが必要です。

## コンテナの実行

イメージができたので、ローカルでの検証やテストのためにリリースを起動するのに`docker run`を使えます。
デフォルトでは、`ENTRYPOINT`で`/opt/service_discovery/bin/service_discovery`として設定されたリリース起動スクリプトに、`CMD`である`foreground`が渡されます。
`docker run`の最後の引数として渡せば`CMD`を上書きできます。
`console`コマンドを使うと、コンテナの実行時に対話的なシェルが得られます。

```shell
$ docker run -ti service_discovery console
[...]
(service_discovery@localhost)1>
```

`-ti`オプションは、対話的なシェルが欲しいことを`docker`に伝えます。
これは、実行中のリリースを調べるためのシェルが欲しいときに、イメージをローカルでテストするのに便利です。
Dockerfileの`runner`ステージで`CMD`として設定されたデフォルトは`foreground`です。
この場合`-ti`は不要なので省くことができ、コマンドは単純に次のようになります。

```shell
$ docker run service_discovery
Exec: /opt/service_discovery/erts-10.5/bin/erlexec -noshell -noinput +Bd -boot /opt/service_discovery/releases/8ec119fc36fa702a8c12a8c4ab0349b392d05515/start -mode embedded -boot_var ERTS_LIB_DIR /opt/service_discovery/lib -config /tmp/sys.config -args_file /tmp/vm.args -- foreground
Root: /opt/service_discovery
/opt/service_discovery
```

誤ってシャットダウンしてしまうのを防ぐため、このコンテナは`Ctrl-c`では停止できません。
コンテナを停止するには`docker kill <container id>`を使います。

`foreground`がデフォルトになっているのは、本番環境ではこのように実行するべきだからですが、実際にはバックグラウンドで動かすことになります。

```shell
$ docker run -d service_discovery
3c45b7043445164d713ab9ecc03e5dbfb18a8d801e1b46e291e1167ab91e67f4
```

`-d`で実行するのは`--detach`の略で、出力はコンテナIDです。
`stdout`に書き込まれたログは`docker log <container id>`で確認でき、Kubernetesでログを好きなログストアに転送する方法は[次章](../kubernetes/)で見ていきます。

実行中のノードには、コンテナID(`docker run -d`の出力や`docker ps`で確認できます)と`remote_console`コマンドを指定して`docker exec`でアタッチすることもできます。
コンテナが`console`と`foreground`のどちらで起動されていたかは関係ありませんが、もちろん`console`で起動していなかったノードのシェルが必要なときに最も役立ちます。
`exec`はイメージで定義された`ENTRYPOINT`を使わないため、実行するコマンドはリリース起動スクリプトである`bin/service_discovery`から始める必要があります。

```shell
$ docker exec -ti 3c45 bin/service_discovery remote_console
[...]
(service_discovery@localhost)1>
```

あるいは`docker exec -ti 3c45 /bin/sh`でLinuxシェルを実行することもでき、この場合`/opt/service_discovery`に入るので、そこから`remote_console`で接続したり実行中のコンテナの他の側面を調べたりできます。

リモートコンソールを終了するときは`q().`を実行してはいけません。これを実行するとErlangノードとDockerコンテナがシャットダウンしてしまいます。
`Ctrl-g`を使って`q`と入力してください。
`Ctrl-g`はシェルをJob Control Modeと呼ばれるモードに入れます。
このシェルモードの使い方の詳細は[Job Control Mode (JCLモード)のドキュメント](http://erlang.org/doc/man/shell.html#jcl-mode)を参照してください。

リリースが起動しない場合など、`ENTRYPOINT`を上書きしてコンテナ内でシェルを取得し、そこからリリースの起動を試みると便利なことがあります。

```shell
$ docker run -ti --entrypoint /bin/sh service_discovery
/opt/service_discovery #
```

最後に、前節で見たとおり、読み取り専用のままにしておくべきリリースディレクトリにファイルが書き込まれないよう`RELX_OUT_FILE_PATH`を`/tmp`に設定していました。
Dockerには、イメージのファイルシステムと実行中のコンテナの現在のファイルシステムとの差分を表示する`diff`コマンドがあります。

```shell
$ docker container diff 3c45
C /tmp
A /tmp/sys.config
A /tmp/vm.args
```

このコマンドは、問題が起きたときやリリースが本来すべきでないことをしていないか確認したいときに、ディスクへの書き込み内容を手軽に調べるのに便利です。
リリースからディスクへの書き込みが多い場合は[ボリューム](https://docs.docker.com/storage/volumes/)をマウントしてすべての書き込みをそこに向けるのが最善ですが、今回のような小さな設定ファイル2つ程度であればその必要はありません。
ただし、テンプレートから設定ファイルを生成する際に機密データを扱っている場合や、コンテナを`--read-only`で実行したい場合は例外です。
そうしたケースでは[tmpfs](https://docs.docker.com/storage/tmpfs/)マウントが推奨されます。
Linuxでは`docker run`コマンドに`--tmpfs /tmp`を追加するだけです。
こうすると`/tmp`はコンテナの書き込み可能レイヤーの一部ではなく、メモリ上にのみ存在しコンテナの停止とともに破棄される別のボリュームになります。

> **危険**
> **ゾンビに注意!**
>
> Erlang/OTP 19.3以降、ErlangノードはDockerやKubernetesがコンテナをシャットダウンする際に使う`TERM`シグナルを受け取ると、`init:stop()`によって正常にシャットダウンします。
>
> しかし、コンテナ内のゾンビプロセスに関する問題は依然として起こりえます。`docker exec`で`remote_console`や`ping`のような他のリリーススクリプトコマンドを実行すると、Dockerが提供するentrypointの前に小さなinitを起動する`--init`引数を付けてコンテナを起動していない限り、ゾンビプロセスが残ってしまいます。
>
> これは通常は問題になりませんが、atomと同じように、使い方を制限しなければ確実に問題になりえます。この理由から避けるべき例の一つが、`--init`やその他の小さなinitをpid 0として実行していない状態で、コンテナランタイムがコンテナの生存期間中に定期的に実行するヘルスチェックとして`ping`を使うことです。
> このようなコンテナを長時間動かし続けると、やがてカーネルのプロセステーブルのスロットが尽き、新しいプロセスを作れなくなります。

## CIでのイメージビルドと公開

リリースのたびにレジストリへ手作業でイメージをビルドして公開するのは面倒なので、イメージのビルドを継続的インテグレーションのプロセスに組み込むのが一般的です。
通常はmasterへのマージや新しいタグの作成時だけに限定されますが、テスト目的でブランチのイメージもビルドしておくと役立つことがあります。
本節ではこのプロセスを自動化するいくつかの方法を扱いますが、すでに使っているCIツールが何であれ同様のことは実現できるはずです。

### CircleCI

[テスト](../../development/testing/)の章(近日公開予定)では、テストの実行に[CircleCI](https://circleci.com)を使う方法を扱いました。
`service_discovery`のDockerイメージをビルドして公開するために、`docker-build-push`という新しいジョブを追加します。
このジョブはexecutorとしてDockerイメージではなくVMを使い、まず最新版のDockerをインストールします。これは、本章の執筆時点でデフォルトで使えるバージョンが`service_discovery`の`Dockerfile`で使っている機能に対応していないためです。

```yaml
jobs:
  docker-build-and-push:
    executor: docker/machine
    steps:
      - run:
          name: Install latest Docker
          command: |
            sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
            sudo apt-get update

            # 最新のDockerにアップグレードする
            sudo apt-get install docker-ce
            docker version

            # buildxをインストールする
            mkdir -p ~/.docker/cli-plugins
            curl https://github.com/docker/buildx/releases/download/v0.3.0/buildx-v0.3.0.linux-amd64  --output ~/.docker/cli-plugins/docker-buildx
            chmod a+x ~/.docker/cli-plugins/docker-buildx
      - checkout
      - gcp-gcr/gcr-auth
      - run:
          name: Build and push images
          command: |
            ci/build_image.sh -p -t runner -r gcr.io/adoptingerlang
```

最新のDockerをインストールしたあと、`service_discovery`リポジトリのコードをチェックアウトし、レジストリとKubernetesにGoogle Cloudを使っているためレジストリで認証します。
最後に、`service_discovery`の`ci/`ディレクトリにあるスクリプトを呼び出してイメージをビルドし公開します。
このスクリプトは、本章の前半で説明した`docker build`コマンドを使って個々のステージをビルドし、各ビルドで`--cache-from`によってステージをキャッシュとして参照します。

このジョブをテストの成功後にだけ実行するには、`rebar3/ct`への`requires`制約を付けてCircleCIのワークフローに追加します。

```yaml
workflows:
  build-test-maybe-publish:
    jobs:

      [...]

    - docker-build-and-push:
        requires:
        - rebar3/ct
```

### Google Cloud Build

2018年、Googleはユーザー空間で動作しデーモンに依存しないイメージビルドツール[Kaniko](https://github.com/GoogleContainerTools/kaniko)を公開しました。
この特徴により、Kubernetesクラスタのような環境でもコンテナイメージのビルドができます。
Kanikoは`gcr.io/kaniko-project/executor`というイメージとして実行することを想定しており、Google Cloud Buildの`step`として使えます。

Kanikoは`RUN`コマンドで作られる各レイヤーに対してリモートキャッシュを提供します。
ビルドはレイヤーをビルドする前に、イメージレジストリ内のレイヤーキャッシュに一致するものがないか確認します。
ただし、`service_discovery`のDockerfileで使ったBuildkitの機能はKanikoでは使えないため、Google Cloud Buildの設定`cloudbuild.yaml`では別途`ci/Dockerfile.cb`というDockerfileを使います。

```yaml
steps:
- name: 'gcr.io/kaniko-project/executor:latest'
  args:
  - --target=runner
  - --dockerfile=./ci/Dockerfile.cb
  - --build-arg=BASE_IMAGE=$_BASE_IMAGE
  - --build-arg=RUNNER_IMAGE=$_RUNNER_IMAGE
  - --destination=gcr.io/$PROJECT_ID/service_discovery:$COMMIT_SHA
  - --cache=true
  - --cache-ttl=8h

substitutions:
  _BASE_IMAGE: gcr.io/$PROJECT_ID/erlang:22
  _RUNNER_IMAGE: gcr.io/$PROJECT_ID/alpine:3.10
```

Kanikoはレジストリのキャッシュディレクトリ内で各命令を確認するように動作するため、Dockerの`--cache-from`のように特定のイメージをキャッシュとして使うよう指示する必要はありません。
Dockerのビルドキャッシュも、キャッシュメタデータをレジストリにエクスポートしすべてのステージのレイヤーをエクスポートするよう`--cache-to=type=registry,mode=max`を設定すればKanikoに近い動きにできますが、本章の執筆時点ではほとんどのレジストリが対応していないため扱いません。

Google Cloud BuildでKanikoを使う詳細については、[Using Kaniko cache](https://cloud.google.com/cloud-build/docs/kaniko-cache)のドキュメントを参照してください。

## 次のステップ

本章では、サービスのイメージをビルドし、リポジトリに変更が加わるたびにそれらのイメージを継続的にビルドして公開するCIパイプラインを作るところまでを行いました。
[次章](../kubernetes/)では、これらのイメージからKubernetesへのデプロイを構築する方法を扱います。
Kubernetes上で動かせるようになったあとの章では、実行中のノードへの接続、構造化されたログ、メトリクスの報告、分散トレースといったオブザーバビリティを扱います。

