Docker

この記事は英語の原文を日本語に翻訳したものです。原文: https://adoptingerlang.org/docs/production/docker/

翻訳元: adoptingerlang/adoptingerlang 2025-12-18(コミット 899008f

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

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

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

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

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

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

本章で必要となるDockerの最小バージョンは19.03で、buildxがインストールされている必要があります。 buildxは次のコマンドでインストールできます。

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

イメージのビルド

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

プライベートな依存関係

業務でDockerイメージをビルドするときに多くの人が最初につまずくのが、プライベートな依存関係へのアクセスです。 依存関係にプライベートなgitリポジトリやHexのオーガニゼーションパッケージがある場合、ビルド中の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ファイルシステムにマウントされ、ビルドキャッシュの対象からは除外されます。

# 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パスを指定してシークレットを渡すだけです。

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

Gitの依存関係

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

# 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の設定が必要です。

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

SSHマウントタイプの詳細な情報やオプションはMobyのドキュメントにあります(MobyはDockerの中核機能を構成するプロジェクトの名前です)。

効率的なキャッシュ活用

命令の基本的な並び順

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

COPY rebar.config rebar.lock .
RUN rebar3 compile

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

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

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.configrebar.lockに変更があると、すべてのパッケージが再ビルドされるだけでなくHexから再取得もされることになります。 さらに、プロジェクト全体をコピーしてソース一式を含む追加のレイヤーを作る命令も、本当に必要なのはビルド成果物だけなので無駄です。

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

# 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の実験的構文」で読めます。

最終的には、コンパイル済みの依存関係を含む/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と呼ばれる実験的な「テックプレビュー」の一部であるため、本章では両方を扱います。 ただし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にあるステージを順に見ていきます。 最初のステージはbuilderという名前です。

# 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.configrebar.lockに対してcksumコマンドを使えます。これはDockerがキャッシュを無効化するかどうかを判断する前に行っている処理とよく似ています。

$ 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イメージをベースにします。

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の該当箇所は次のとおりです。

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

このプロファイルはinclude_ertstrueに設定されているため、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のドキュメントを参照してください。

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

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.configvm.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に設定されるようにしています。

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

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

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.configrebar.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コマンドを使うと、コンテナの実行時に対話的なシェルが得られます。

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

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

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

$ docker run -d service_discovery
3c45b7043445164d713ab9ecc03e5dbfb18a8d801e1b46e291e1167ab91e67f4

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

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

$ 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モード)のドキュメントを参照してください。

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

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

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

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

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

危険 ゾンビに注意!

Erlang/OTP 19.3以降、ErlangノードはDockerやKubernetesがコンテナをシャットダウンする際に使うTERMシグナルを受け取ると、init:stop()によって正常にシャットダウンします。

しかし、コンテナ内のゾンビプロセスに関する問題は依然として起こりえます。docker execremote_consolepingのような他のリリーススクリプトコマンドを実行すると、Dockerが提供するentrypointの前に小さなinitを起動する--init引数を付けてコンテナを起動していない限り、ゾンビプロセスが残ってしまいます。

これは通常は問題になりませんが、atomと同じように、使い方を制限しなければ確実に問題になりえます。この理由から避けるべき例の一つが、--initやその他の小さなinitをpid 0として実行していない状態で、コンテナランタイムがコンテナの生存期間中に定期的に実行するヘルスチェックとしてpingを使うことです。 このようなコンテナを長時間動かし続けると、やがてカーネルのプロセステーブルのスロットが尽き、新しいプロセスを作れなくなります。

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

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

CircleCI

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

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_discoveryci/ディレクトリにあるスクリプトを呼び出してイメージをビルドし公開します。 このスクリプトは、本章の前半で説明したdocker buildコマンドを使って個々のステージをビルドし、各ビルドで--cache-fromによってステージをキャッシュとして参照します。

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

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

      [...]

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

Google Cloud Build

2018年、Googleはユーザー空間で動作しデーモンに依存しないイメージビルドツール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を使います。

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のドキュメントを参照してください。

次のステップ

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