ENECHANGE Developer Blog

ENECHANGE開発者ブログ

DockerfileでのENTRYPOINTとCMDの関係を整理する 〜 2つの記述形式に環境変数展開とシェル引数展開を添えて 〜

ENECHANGE所属のエンジニア id:tetsushi_fukabori こと深堀です。
この記事を執筆している日にMLBのアーロン・ジャッジ選手がシーズン62本目のホームランを打ってアメリカンリーグのシーズンホームラン記録を塗り替えたことが伝えられました。
メジャーリーグの長い歴史に新たな1ページが加わった日に同席したと思うと、なんとなく気分が盛り上がりますね。


今回は「既存アプリケーション基盤のコンテナ化プロジェクト」でアプリケーションのコンテナ化、具体的にはDockerfileの作成で四苦八苦した内容を振り返ります。
このテーマ自体はDockerfileを書いている人なら何度も見た内容だと思いますが、実際に困ったので整理しておいて損はないと思い書いています。
同じ苦労をする人が少しでも減れば嬉しいです。

この記事を届けたい人

Dockerfileを書いた人で「buildはできるけどrunすると何故かプロセスが(正常)終了し、原因がわからない」人です。
私自身がそうなりました。
私の場合は

  • コンテナをローカルで起動するとRailsアプリがデーモンとして立ち上がってくれるはずのコンテナが終了コード0で正常終了する
  • ログ(標準出力・標準エラー出力)には何も怪しい表示が出ない
  • (当然ながら)AWS ECS上ではコンテナが即終了してタスク起動に失敗する

という状態になりました。

トラブル事例

私の事例です。
(あとから見直すとこの失敗状態を公開するのはやや恥ずかしいですね。)

以下のようなDockerfileとentrypoint.shを作成しました。

# Dockerfile

FROM docker.io/ruby:2.7.1-slim-buster

# 諸々のビルド

ENTRYPOINT ["/bin/bash", "-c", "${APP_ROOT}/.container/web/entrypoint.sh"]

CMD ["/bin/bash", "-c", "bundle exec pumactl start -F ${APP_ROOT}/.container/web/pumaconf.rb"]
# entrypoint.sh
#!/usr/bin/env bash
set -e

bundle exec rails db:migrate

exec "$@"

この状態でコンテナを起動するとコンテナは即時正常終了します。特にエラーのログは出ません。
今見直すと「あーそれはそうね」となりますね。
当時は大ハマリしていました。

この事象の原因はENTRYPOINTCMDの振る舞いです。
加えて以下のようなポイントが影響を及ぼしました。

  • Dockerfile内で環境変数の展開を含むコマンドを実行したかった
  • シェルスクリプト内での$@の振る舞いを正しく理解しないまま他のプロダクトから動いているentrypoint.shをコピペしてしまった

DockerfileにおけるENTRYPOINTCMD

ではDockerfileに於いてENTRYPOINTCMDとは何でしょうか。
これらはDockerfileに記述できる命令(instruction)です。
日本語と英語それぞれのdockerのドキュメントから引用すると、それぞれの命令は以下のことを行ってくれます。

  • ENTRYPOINT
    • ENTRYPOINTは、コンテナを 実行ファイルとして処理するように設定できます。(参照
    • An ENTRYPOINT allows you to configure a container that will run as an executable. (参照
  • CMD
    • CMDの主な目的は、コンテナ実行時のデフォルト(初期設定)を指定するためです 。(参照
    • The main purpose of a CMD is to provide defaults for an executing container. (参照

日英ともに同じことを示しています。
内容を読むといずれも「実行時」「実行」をキーワードとしていて、「コンテナ実行時に何を行うか」を指定していると読み解けます。

では複数の命令が同じ「コンテナ実行時に何を行うか」を指定する、というのはどういうことでしょうか?
Dockerfileに両方を指定する、または両方を指定しない場合はどうなるのでしょうか?

(以降の議論ではdocker runの引数としてコマンドを与えてCMDを上書きしたり、--entrypoint=オプションでENTRYPOINTを上書きする場合については簡単のため省略しています。あくまでDockerfile内で指定されたENTRYPOINTCMDがどう振る舞うかについての整理ということをご了承下さい。)

ENTRYPOINTCMDの相互作用

ENTRYPOINTCMDはどちらも実行時に何を行うかの指定に使える命令ですので、相互作用があります。

相互作用の内容はこちらも公式ドキュメント(日本語英語)にまとまっているのですが、日英で記載内容に差分があります。
実験したところ実際のふるまいと一致しているのは英語版の方だったので日本語ドキュメントの修正PRを送りました。マージされたら嬉しいです。

github.com

相互作用は以下のとおりです。
それぞれの命令で記載された内容はコンテナ内で実行されるコマンドとして考えるとわかりやすいので、実行されるコマンドであるという意味で$記号始まりの形式で書いています。
(ただし後述するように記述形式によっては実行されるコマンドだと理解すると誤るポイントもあるので、あくまでわかりやすさのためのイメージだと理解して下さい。)
また、固定で入る/bin/sh -cは灰色、ENTRYPOINT由来のコマンド・引数は赤色、CMD由来のコマンド・引数は緑色にそれぞれ色付けしています。

ENTRYPOINTとCMDの相互作用

この表を読むためにはexec形式とshell形式の説明が必要ですので、次で説明します。

ENTRYPOINTCMDのexec形式とshell形式

exec形式とshell形式はどちらもコマンドの実行を記述する形式ですが、大きく「コマンドとしてのシェルを実行するか」どうかが異なります。
最も大きな影響があるとすると環境変数の展開かと思います。

exec形式では環境変数は展開されませんが

# Dockerfile 
FROM docker.io/ruby:2.7.1-slim-buster

ENTRYPOINT ["echo", "$HOME"]
$ docker container run sample:1
$HOME

shell形式ではシェルが実行されシェルによって環境変数が展開されます。

# Dockerfile 
FROM docker.io/ruby:2.7.1-slim-buster

ENTRYPOINT echo $HOME
$ docker container run sample:1
/root

exec形式で環境変数を展開したければシェル自体を実行してその引数としてコマンドを与えるよう記述することが可能です。

# Dockerfile 
FROM docker.io/ruby:2.7.1-slim-buster

ENTRYPOINT ["/bin/sh", "-c", "echo $HOME"]
$ docker container run sample:1
/root

上記の振る舞いはENTRYPOINTCMDどちらも共通です。
なお、DockerのドキュメントではENTRYPOINTCMDどちらもexec形式が推奨(ENTRYPOINTCMD)されています。

このように複数の形式があることを理解した上で表を見直すと、一部気になるところが出てくると思います。

ENTRYPOINTとCMDの相互作用

まずexec形式では実行時には

$ /bin/sh -c [exec形式で与えた文字列]

の形式で実行され、shell形式では

$ [shell形式で与えた文字列]

のような形式で実行されます。
ENTRYPOINTのみ、CMDのみの行・列を参照していただければそうなっているとわかると思います。

次にENTRYPOINTCMDどちらも存在する場合、CMDで与えた文字列はENTRYPOINTで与えた文字列の引数になっています。
ただしENTRYPOINTがexec形式の場合に限って、です。ENTRYPOINTがshell形式の場合はCMDで与えた文字列は無視されます。

このようにENTRYPOINTCMDはどちらも存在する場合に非対称性があり、非対称性は記述形式に左右される部分が存在するというのが大きなポイントです。

トラブル事例は何が悪かったのか?

トラブル事例は以下のような思考の順序で作られました。

  1. ENTRYPOINTCMDが併存するDockerfileを既存プロジェクトからコピペして利用しよう
  2. ENTRYPOINTCMDで実行したい命令に環境変数を含めて展開したいので、exec形式でシェルにわたすかshell形式で記述しよう
  3. Dockerの推奨はexec形式なので["/bin/sh", "-c", "[環境変数を含む命令]"]の形式で記述しよう
  4. ENTRYPOINTの命令の引数としてCMDで与えた文字列が渡されるのでENTRYPOINTで実行するentrypoint.shの中で引数を参照($@)してexecコマンドで実行しよう

しかしこの結果作られた以下のDockerfileは実行時に以下のように実行されます。

# Dockerfile

FROM docker.io/ruby:2.7.1-slim-buster

# 諸々のビルド

ENTRYPOINT ["/bin/bash", "-c", "${APP_ROOT}/.container/web/entrypoint.sh"]

CMD ["/bin/bash", "-c", "bundle exec pumactl start -F ${APP_ROOT}/.container/web/pumaconf.rb"]
# docker runの結果は以下とコマンド実行と同じ
$ /bin/bash -c "${APP_ROOT}/.container/web/entrypoint.sh" "/bin/bash" "-c" "bundle exec pumactl start -F ${APP_ROOT}/.container/web/pumaconf.rb"

この場合において/bin/bash -cで実行されるentrypoint.shからは以降の引数は参照できません
簡易なスクリプトを作ってentrypoint.sh内から$@ $* $0 $1 $2などを参照するとわかるのですが、ここで参照できるのは$0がentrypoint.shであることだけです。

# Dockerfile

FROM docker.io/ruby:2.7.1-slim-buster

COPY entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/bin/sh", "-c", "/entrypoint.sh"]

CMD ["/bin/sh", "-c", "echo $HOME"]
#!/bin/bash

echo "\$@ : $@"
echo "\$* : $*"
echo "\$0 : $0"
echo "\$1 : $1"
echo "\$2 : $2"

exec "$@"
$ docker container run sample:1
$@ :
$* :
$0 : /entrypoint.sh
$1 :
$2 :

つまりトラブル事例ではCMDで与えた命令はentrypoint.shには引き渡されず、最後のコマンドの実行は以下のように動きます。

$ exec "$@" #=> exec ""と同じ #=> なにも実行せず正常終了

これにより「なんのログも出さず正常終了するコンテナ」が生まれてしまっていたのでした。

どう対処したか

ENTRYPOINT及びCMDで実行したい命令には環境変数が必要である、という前提を守ると自然と取れる手段が限定されます。

  • ENTRYPOINTをexec形式で/bin/bash -cを呼び出す形にするとCMDの命令がentrypoint.shから参照できません(トラブル事例そのもの)
  • ENTRYPOINTをshell形式で実行するとCMDの命令は無視されます(Dockerfileの仕様)

この時点でENTRYPOINTCMDの併存はできなくなります。 どちらか1つに絞る必要が出てきますが、今回のコンテナはdocker runの引数としてコマンドや--entrypoint=を受け取る想定がないので、より通常の使用方法では上書きされにくいENTRYPOINTに片寄せし、以下のような実装としました。

# Dockerfile

FROM docker.io/ruby:2.7.1-slim-buster

# 諸々のビルド

ENTRYPOINT ["/bin/bash", "-c", "${APP_ROOT}/.container/web/entrypoint.sh"]
# entrypoint.sh
#!/usr/bin/env bash
set -e

bundle exec rails db:migrate

exec bundle exec pumactl start -F ${APP_ROOT}/.container/web/pumaconf.rb

なお、大事な余談ですが、たとえトラブル事例の$@がうまくCMDを引き渡せていたとしても良い結果にはならなかったと思われます。
その場合最後のコマンドはexec /bin/bash -c bundle exec pumactl start -F ${APP_ROOT}/.container/web/pumaconf.rbとなったかと想像されますが、この場合コンテナのメインプロセスは(おそらく)bashになります。
コンテナのメインプロセスがbashの場合、bashの実装依存ではあるようですがシグナルを伝播できません。
つまりコンテナのメインプロセスがSIGTERMで正常終了できずSIGKILLによる強制終了のみになっていたはずです。
これはコンテナアプリケーションとしては避けるべき状態で、今回のコンテナが稼働する予定のAWS ECSでもSIGTERMで終了できるよう求められています

aws.amazon.com

まとめ

コンテナ環境で稼働するアプリケーションを作る上で欠かせないDockerfileの仕様をまとめ、仕様を整理できていなかったがゆえに発生していたトラブル事象を解決しました。
今回の事象には以下の要素が影響しあって発生していました。

  • DockerfileのENTRYPOINTCMDのexec形式とshell形式の違い
  • DockerfileのENTRYPOINTCMDの相互作用
  • シェルの環境変数展開
  • シェル引数が受け取れる内容

コンテナ環境で正しく動くアプリケーションを作るには様々な知識が求められます。
この記事もあるトラブルの一例だけしか解決していないので、他にも様々な苦労があるかと思いますが、この記事を読んだ人がこの事象だけは短時間で解消できることを期待しています。