Docker のコンテナ(ゲスト OS)内のアプリやスクリプトなど、ホスト OS 側からシステム環境変数をゲスト OS に渡したい。
つまり macOS や Linux で言うと export
したシェル変数や env
コマンドで表示される値を外部から渡したい。LC_ALL
とか LANG
とかタイムゾーンとかアクセストークンとか SSH の鍵とか色々。
しかし「docker
compose
環境変数
ホストOS
ゲストOS
渡す
方法
各種
」で Qiita 記事に絞ってググっても、まとまったものがなかったので、自分のググラビリティとして。
... ど ... Docker?
恐れずに Docker を一言で説明すると「Linux 環境の閉鎖空間で、Linux のプログラムを実行するもの」です。
... ん?
つまり、Mac や Windows でも Docker は使えるものの、実際には仮想マシン(VM)上で Linux 環境を起動して Docker を実行しているだけなのです。これが「どの環境でも動く」(なぜなら仮想 Linux 環境上で動かしているから)という理由なのです。
そのため、「docker
は軽量と聞いたのに、そうでもない」という体感があるのは、Win や macOS では VM 上で Linux マシンを起動し、そこでアプリを実行したのと同じだからです。
常に Linux OS(カーネル)ぶんのメモリは食ってるし、VM を介した速度やパフォーマンスとの違いがありません。たとえ WSL2 上で動かしたとしても、目ん玉が飛び出るような速度が出るわけではありません。
実は Windows の Docker 限定ですが「Windows 環境の閉鎖空間で、Windows のプログラムを実行する」、Docker Windows Container というモードも、あるにはあります。
しかし、制約や MS 社の囲い込みも多く、世間一般でいう Docker は Linux カーネル上で動かす方の Docker がデファクト・スタンダードとなっています。Docker 社も MS 社も、囲い込みに躍起になっているため、一長一短あります。
しかも、「どの環境でも簡単に実行できると聞いたのに、そうでもない」という体感があるのは、トラブル時の切り分けに意外と高い基礎知識を必要とするからです。Linux・OS(カーネル)、サーバーなどの基礎知識や、コンパイル(アーキテクチャごとにバイナリが異なる理由)を理解していないといけないなど、意外に敷居が高かったりします。
これが、「ゆうて、さほど便利じゃない」と感じる原因だったりします。それでも Docker(もしくは Podman)を使うメリットは大きいです。特に、上記のような基礎知識の底上げをするための勉強にはもってこいです。
この記事では、Linux の方の Docker を主体に説明します(詳しくは Docker の基本参照)。
TL; DR (今北産業)
-
docker compose
を使う場合(旧docker-compose
)
オススメは、「外部ファイルに環境変数を用意」しておきdocker-compose.yml
内で指定してコンテナ起動時に読み込ませる方法です。ホスト OS 側で設定済みの環境変数も指定可能です。Dockerfile
のビルド時に外部から環境変数を渡したい場合は、args
経由で宣言されたARG
の変数に値を渡せます。[詳細] -
docker compose
を使わない場合(旧docker-compose
)
オススメは、「外部ファイルに環境変数を用意」しておきdocker run
コマンドの--env-file
オプションでコンテナ起動時に「絶対パス」で読み込ませる方法です。こちらも、ホスト OS 側で設定済みの環境変数も指定可能です。[詳細] -
この記事では
docker run
,docker build
,docker compose run
, Dockerfile, docker-compose.yml での「環境変数の渡し方」を説明しています。詳しくは TS; DR を参照ください。なお、オーケストレーション・ツールについては docker-compose のみで、docker swarm
や Kubernetes などについては言及していません。
ホスト側で設定済みの環境変数が、何をしても渡せない場合
Docker 本体や OS のアップデート待ちが発生していないか確認してください(特に Win や macOS 環境の場合)。セキュリティ・アップデートが含まれていると、適用 & 再起動するまでロックされて渡されません。またビルドやコンテナ起動といった操作をする前に、env
コマンドなどで環境変数の設定が反映されているか確認してください。特に VSCode のターミナルから操作する場合です。VSCode は、VSCode 起動時の環境変数しか保持していません。
docker-compose
と docker compose
の違い
docker-compose
と docker compose
は基本的に同じものです。docker-compose
は Python で実装されたもので、docker compose
は Go 言語で実装された docker
のサブコマンド(Docker プラグイン)です。compose
サブコマンドは、docker-compose
の v2 と同等の機能を提供しますが、docker
と同じ Go 言語で実装されたものであるため数倍速く、別途 Python も必要としません。今後は docker compose
に統一されていきますが、この記事では docker-compose
と表記しています。なお、Win、macOS の場合は Docker Desktop に内包されており、Linux 環境の場合は compose のバイナリを Docker プラグインのディレクトリに設置する必要があります。お使いのパッケージ・マネージャーで docker-cli-compose
や docker-compose-plugin
といったパッケージ名で検索してみてください。
🐒 【注意点】
環境変数を渡せる先には3箇所あります。「イメージ」「コンテナ」と「設定ファイル(Dockerfile
)」の3箇所です。
- 「イメージ」に渡す場合は、変数はイメージに焼き付け(埋め込み)ます。
そのため、そのイメージから作成されたコンテナ全てに反映されるため、値に注意します。イメージに焼き付けたくない値や上書きしたい値はコンテナ起動時に渡します。 - 「コンテナ」に渡す場合は、イメージから「コンテナ」を作成 or 起動する際に渡します。
コンテナをイメージ化しない限り、コンテナが消滅すると渡した値も消えます。 - 「設定ファイル」とは、Docker イメージのビルド時に使う
Dockerfile
です。Dockerfile
内で定義したARG
(Dockerfile
内変数)に値を外部から渡せます。Dockerfile 内で、ARG
の変数を、さらにENV
に代入すると「イメージ内の環境変数」として埋め込むことができます。しかし、RUN
内でARG
の値を書き換えても、他のステップには反映されないので注意します。同様に、docker-compose.yml
から環境変数をDockerfile
のビルド時に渡したい場合も、args
経由でARG
変数に渡しDockerfile
内で利用したり、ENV
に代入してイメージに焼き付けることができます。詳しくは TS; DR 参照。
しかし、どのような方法であっても環境変数で渡すということは、同じコンテナ内にあるサードパーティ製のライブラリからも参照できてしまうということに留意します。
例えサードパーティに悪意がなくても、デバッグ情報をダンプ出力する際に、環境変数も出力することが多くあるためです。つまり、CI などでエラーログに出力されてしまう、などに注意する必要があります。GitHub の GitHub Actions などでは、環境変数として渡したい場合は Environment secrets
に登録することで渡せ、標準出力・標準エラー出力に同一のものがあった場合は、マスクしてくれます。
- あわせて読みたい: Docker Composeの環境変数ではなくsecretsで秘密情報を扱う @ Qiita
環境変数の渡し方の概要と基本構文
以下はコマンド毎にポイントをまとめたものです。各々の詳細な説明は TS; DR をご覧ください。
docker run
コマンド
-
コマンド引数で環境変数を「コンテナに」渡す
(-e
--env
)変数名と値を指定して渡すdocker run --env [ 変数名=値 ] [ その他のオプション ] <イメージ名> [ コマンド ] docker run -e [ 変数名=値 ] [ その他のオプション ] <イメージ名> [ コマンド ] 例) docker run -e hoge=$fuga -e piyo="hogera" alpine env
ホストの環境変数のまま渡すdocker run --env [ ホスト側変数名 ] [ その他のオプション ] <イメージ名> [ コマンド ] docker run -e [ ホスト側変数名 ] [ その他のオプション ] <イメージ名> [ コマンド ] 例) docker run -e hoge -e piyo alpine env
VSCodeのRemote-Containersなどでホストの環境変数のまま渡す(.devcontainer/devcontainers.json設定例){ "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--env=[ ホスト側変数名 ]" ], }
-
コマンド引数から外部ファイルで環境変数を「コンテナに」渡す
(--env-file
)環境変数をファイルで渡す(絶対パスであることに注意)docker run --env-file [ ファイルパス ] [ その他のオプション ] <イメージ名> [ コマンド ] 例) docker run --env-file "$(pwd)/myEnvFile" alpine env
VSCodeのRemote-Containersなどでホストの環境変数をファイルで渡す(.devcontainer/devcontainers.json設定例){ "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--env-file=./.devcontainer/devcontainer.env" ], }
./.devcontainer/devcontainer.envの中身の例# ホスト側で定義済みの環境変数 LANG LC_ALL # .env 内で定義する環境変数 HOGE="fuga"
docker build
コマンド
docker build
時に外部ファイルで環境変数は渡せません。--build-arg
オプションで間接的に渡す必要があります。
-
ビルド時にコマンド引数で「Dockerfile の ARG に」環境変数を渡す
(--build-arg
)docker build --build-arg [ 変数名=値 ] .
-
上記で指定した変数は
Dockerfile
内のARG
で定義した同名の変数の値と置き換わります。記載すると「Dockerfile 内で」変数として使える。RUN
時の動的な値、例えばcurl
でダウンロードするアーキテクチャ名やバージョン情報などを渡すのに便利。変数my_app_version(値は$hoge)をDockerfileに渡す$ hoge='v1.0.0' $ osarch=$(uname -m) $ docker build \ --build-arg name_base=php:alpine \ --build-arg my_app_version=$hoge \ --build-arg my_arch=$osarch \ --build-arg my_sample='sample' \ .
Dockerfile(simple)# 外部からの受付とデフォルト値(FROM ブロック外の宣言は事前に行う) ARG name_base=php # ---------------------------------------------------- # FROM ブロック開始 # ---------------------------------------------------- FROM ${name_base} # FROM 内での利用宣言(宣言しておかないと受け取れない) ARG my_app_version ARG my_arch ARG my_sample # 変数を環境変数にセット ENV VERSION_APP=${my_app_version} ENV ARCH_TYPE=${my_arch} # 変数を実行に利用 RUN echo "$my_sample" ...
Dockerfile(マルチステージ・ビルドの例)# 外部からの受付とデフォルト値(下記2つの FROM ブロックに適用できる) ARG name_base=php ARG variant=alpine ARG my_app_version=dev # ---------------------------------------------------- # FROM ブロック開始 # ---------------------------------------------------- FROM ${name_base}:${variant} AS stage1 # FROM 内での利用宣言(宣言しておかないと受け取れない) ARG my_app_version ARG my_arch # 変数を環境変数にセット ENV VERSION_APP=$my_app_version # 変数を実行に利用 RUN echo $my_arch # ---------------------------------------------------- # 別の FROM ブロック開始 # ---------------------------------------------------- FROM ${name_base}:${variant} AS finalstage # FROM 内での利用宣言(宣言しておかないと受け取れない) ARG my_sample # 変数を環境変数にセット ENV MY_SAMPLE=$my_sample # 変数を実行に利用 RUN echo $my_sample ...
-
注意:
ARG
は厳密には環境変数ではなくグローバル変数なのですが、「外部から与えられた変数」という意味でここでは環境変数と同じような扱いをしています。ARG
で宣言/定義した変数をENV
の環境変数に渡すと、ビルドされたイメージに環境変数として埋め込めます。ビルド時のアーキテクチャ名、git
のコミット ID やバージョン情報といったセンシティブな値でないものに利用します。アクセストークンやパスワードなどには使わないことをおすすめします。「別にイメージは公開しないから」ということもあるかもしれませんが、意識がdocker
いっちゃってる時にgit push
のつもりがdocker push
と間違って打ってしまいインターネッツに公開されちゃうなどもありえるからです。
-
Dockerfile
ファイル
-
Dockerfile に環境変数を記載(設定)して「イメージに」渡す
-
ENV
命令/宣言文を使う- 2通りの記述方法があります
ENV [変数名1]=[値1] [変数名2]=[値2] ... [変数名n]=[値n]
ENV [変数名] [値]
- イメージに焼かれるので注意
- ホスト OS 側の環境変数を直接は利用はできないが
docker build
時にARG
経由で受け取ることができる。(上記docker build
or tl;dr 参照)
- 2通りの記述方法があります
-
-
環境変数を外部ファイルで渡す
- 直接的な外部のファイル渡しは無い
- 別途コンテナ内のスクリプトでファイルを読み込む工夫が必要
docker-compose run
コマンド
-
環境変数を引数で「コンテナに」渡す
(-e
)docker-compose run -e [ 変数名 ]=[ 値 ] [ その他のオプション ] <サービス名> [ コマンド ] 例) docker-compose run -e hoge=fuga -e piyo=mogera alpine env
- ロング・オプション(
--env
)は無い
- ロング・オプション(
-
環境変数を外部ファイルで渡す
- オプションによる外部のファイル渡しは無い
-
docker-compose.yml
経由では可能
docker-compose.yml
ファイル
-
docker-compose.yml
に環境変数を記載(設定)して「コンテナに」渡す-
environment:
変数に設定する - ホスト OS 側の環境変数も利用可能
-
-
環境変数を外部ファイルで「コンテナに」渡す
-
env_file:
変数にパスを設定する - ホスト OS 側の環境変数も利用可能
-
動作確認済み環境
詳細
- macOS Mojave(OSX 10.14.4)
$ docker version
Client: Docker Engine - Community
Version: 18.09.0
API version: 1.39
Go version: go1.10.4
Git commit: 4d60db4
Built: Wed Nov 7 00:47:43 2018
OS/Arch: darwin/amd64
Experimental: false
Server: Docker Engine - Community
Engine:
Version: 18.09.0
API version: 1.39 (minimum version 1.12)
Go version: go1.10.4
Git commit: 4d60db4
Built: Wed Nov 7 00:55:00 2018
OS/Arch: linux/amd64
Experimental: false
$ docker-compose version
docker-compose version 1.23.2, build 1110ad01
docker-py version: 3.6.0
CPython version: 3.6.6
OpenSSL version: OpenSSL 1.1.0h 27 Mar 2018
TS; DR(詳細)
- Docker の基本(お暇な人のゴロ寝読み向け)
- あわせて読みたい:
- 忙しい人のための Docker と Docker compose @ Qiita
- Docker で MySQL サーバーを構築するときの環境変数の設定 @ Qiita
- proxyの罠 | 社内でGrowiを立ち上げてみた @ Qiita
🐒 Docker では通常「ホストOS」「ゲストOS」という呼び方はしません。ここではわかりやすくするため、Docker のエンジン本体を走らせている OS を「ホストOS」、Docker のベース・イメージ(コンテナ内の OS)を「ゲストOS」と呼んでいます。
Docker のベース・イメージが Alpine Linux の場合、デフォルトの環境変数は以下のようになります。この環境変数に任意の環境変数をコンテナ起動時に指定したいのです。
$ # Default env(現在の環境変数を出力して終了)
$ docker run --rm alpine env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=b08ff260c4b4
HOME=/root
docker run --rm alpine env
の簡易説明
docker run [ オプション ] <イメージ名> [ 実行コマンド ]
docker run --rm alpine env
-
docker run
コマンド- イメージ名からコンテナを作成&起動するコマンドです。
-
--rm
オプション- 実行終了後にコンテナを削除(
remove
)する指定です。わかりづらいですが、ロングオプション(--
で始まるオプション)です。
- 実行終了後にコンテナを削除(
-
alpine
イメージ- 作成するコンテナのイメージ名を指定しています。ここでは Alpine Linux のシンプルな Docker イメージ
alpine
を指定しています。docker build -t <イメージ名> .
で作成した俺様イメージ名も指定できます。
- 作成するコンテナのイメージ名を指定しています。ここでは Alpine Linux のシンプルな Docker イメージ
-
env
コマンド- コンテナ内で実行するコマンドを指定しています。ここでは Linux/Unix の、現在の環境変数を一覧で確認する
env
コマンドを指定しています。
- コンテナ内で実行するコマンドを指定しています。ここでは Linux/Unix の、現在の環境変数を一覧で確認する
docker run
時に環境変数を渡す方法
コマンドの引数で環境変数を渡す
-e
--env
オプションで変数を引数渡し
ホスト OS の環境変数名で指定も可能(下記の場合MY_SECRET
)
$ # ホスト OS 側で環境変数をセット
$ export MY_SECRET='MySecretIsHimitsu'
$ # 任意の環境変数を --env ロング・オプションで渡す(-e オプションでも可)
$ docker run --rm \
> --env HOGE="fuga" \
> --env foo="bar" \
> --env MY_SECRET \
> alpine \
> env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=b08ff260c4b4
HOGE=fuga
foo=bar
MY_SECRET=MySecretIsHimitsu
HOME=/root
コマンドで外部ファイルを指定して環境変数を渡す
--env-file
オプションで変数をファイル渡し
$ # ホスト OS 側で定義済みの環境変数(例)
$ echo $MY_ENV_VARIABLE
piyo
$ # 変数を記載したファイルの用意
$ cat ./my_env_file.txt
HOGE='FUGA'
foo=bar
MY_ENV_VARIABLE
$ # 引数ファイルを指定して環境変数を確認
$ docker run --rm \
> --env-file ./my_env_file.txt \
> alpine \
> env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=b7b5301cba78
HOGE='FUGA'
foo=bar
HOME=/root
MY_ENV_VARIABLE=piyo
Dockerfile 経由で環境変数を渡す方法
Dockerfile に記載した環境変数を「イメージ」に渡す(焼き付ける)
ENV
命令(宣言文)で変数の指定渡し
$ ls
Dockerfile
$ # Dockerfile の内容確認(ENV の書き方の違いに注目)
$ cat Dockerfile
FROM alpine
ENV HOGE='FUGA' PIYO='PIYO PIYO'
ENV hoo bar
$ # イメージのビルド
$ docker build --tag my_alpine .
...(省略)
$ # ビルドしたイメージのコンテナ内の環境変数を確認
$ docker run --rm my_alpine env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=c1f67f6ff882
HOGE=FUGA
PIYO=PIYO PIYO
hoo=bar
HOME=/root
-
注意①:Dockerfile に記載すると環境変数をイメージに焼き付けることになります。アクセストークンやパスワードなど流出すると、たいへんな事態になる変数の場合は注意します。
この環境変数は-e
--env
オプションで上書きできます。そのため、Dockerfile に記載するENV
の変数はデフォルト値として設定し、利用時に上書きするのがベターな方法です。 -
注意②:Dockerfile 内でホスト側の環境変数は直接呼び出せません。
ENV my_path=$PATH
と指定した場合は、ビルド時のゲスト OS 側の環境変数が使われます。ホスト側の環境変数を使いたい場合は、次項を参照ください。
Dockerfile 「に」環境変数を渡す(docker build
時)
--build-arg
でARG
受け取り。そしてENV
に渡す。
前述の ENV
宣言方式で、Dockerfile に記載した変数の値をコンテナに渡すことができました。しかし、動的に値を渡したい場合があると思います。
例えばバージョン番号などです。特に git
でタグ付けした git describe --tags
で取得できるバージョン情報を渡したいことも多いのではないでしょうか。
このように、ホスト OS 側からコンテナ(ゲスト OS)に環境変数を渡すには後述する docker-compose
コマンドを利用するのが楽です。
しかし、docker-compose
コマンドを使いたくない場合は、docker build
時に --build-arg
オプションを指定すると、Dockerfile
内で ARG
で受け取ることができます。
$ cat Dockerfile
FROM alpine
ARG my_app_version
ENV VERSION_APP=$my_app_version
$ hoge='fuga'
$ docker build --tag my_alpine --build-arg my_app_version=$hoge .
...
$ docker run --rm my_alpine env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=3706b1c92dc5
VERSION_APP=fuga
HOME=/root
Dockerfile で外部ファイルの環境変数を渡す
Dockerfile 自体は外部ファイルの読み込みをサポートしていません
どうしても Dockerfile で外部ファイルから環境変数を設定したい場合
ネットには環境変数を設定するスクリプトを COPY
もしくは ADD
命令でコピーし ENTRYPOINT
の起動時に実行させるなどの方法もありますが、オススメしません。
やはりイメージに書き込まれることになるため、docker run <イメージ名> cat <コピーしたファイルのパス>
などで設定値が見えてしまうからです。
どうしても Dockerfile で完結させたい場合は、-v
や --volume
オプションで環境変数の設定ファイルをマウントさせて、スクリプトで実行させる必要があります。
また、Dockerfile と同じ階層に置かないといけないなどの Docker の制限などもあり、メンテナンス性がすこぶる悪くなります。
しかし、逆に言えば、Dockerfile のディレクトリ内で完結できるということでもあるので、以下で何をしているのかがわかり、スクリプト群も外部に一般公開しないのであれば、シンプルと言えばシンプルです。情報をイメージに渡すボキャブラリーとしては有用です。
$ # 最低限必要なファイル
$ ls
Dockerfile run_something.sh
$ # Dockerfile の中身確認(run_something.sh をイメージに COPY していないことに注目)
$ cat Dockerfile
FROM alpine
ENTRYPOINT /my_volume/run_something.sh
$ # 環境変数を設定するスクリプトの中身(コンテナ起動時に実行される)
$ cat run_something.sh
#!/usr/bin/env sh
export HOGE=FUGA
export foo=bar
export MY_SECRET='MySecretIsHimitsu'
env
$ # イメージのビルド
$ docker build -t my_alpine .
...(省略)
Successfully tagged my_alpine:latest
$ # マウントさせずにコンテナを起動した場合の環境変数を確認(enrtypoint を上書きして env コマンド実行)
$ docker run --rm --entrypoint 'env' my_alpine
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=efba0e3f7a89
HOME=/root
$ # カレントディレクトリを /my_volume にマウントさせてコンテナ起動(Dockerfile の entrypoint が働く)
$ docker run --rm -v "$(pwd)":/my_volume my_alpine
HOGE=FUGA
HOSTNAME=715cc5bd1fa4
SHLVL=2
HOME=/root
foo=bar
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PWD=/
MY_SECRET=MySecretIsHimitsu
実は Dockerfile の外部ファイル読み込みとしての INCLUDE
や IMPORT
命令の実装要望は長いこと(2013 年から)ディスカッションされていましたが却下されました。
- Issue #735 "Proposal: Dockerfile add INCLUDE" | moby @ GitHub
これは、Dockerfile は特定のイメージを作成することに特化したものであり、INCLUDE
や IMPORT
命令をサポートすると依存が増えることになるため、シンプルさに欠けるからのようです。
そのため、docker-compose
や vagrant
1 といったオーケストレーション系のソフト経由で設定することをオススメします。
なお、間違えやすいコマンドに docker import
がありますが、これは docker pull
のローカル版です。つまり DockerHub 上にないイメージを利用したい場合に、docker save
で保存したアーカイブされた Docker イメージをインポートするコマンドです。
docker-compose run
時に変数を渡す方法
docker-compose run
と docker compose run
のハイフン(-
)違いですが、基本的に同じものです。
現在の docker
CLI には docker-compose
の v2 と同等のコマンドが標準で実装されています。つまり docker compose
は docker-compose
のエイリアスになります。この記事では検索と下位互換のため docker-compose
のまま記載していますが、docker compose
とサブコマンドに置き換えてお読みください。
.env
ファイルで環境変数を渡す
docker-compose
v1.28 以降であれば、docker-compose.yml
ファイルと同じ階層に、環境変数を記載した .env
ファイルを設置すると、デフォルトで読み込みます。
$ cat .env
FOO=bar
$ cat docker-compose.yml
version: "3.9"
services:
sample:
image: alpine
environment:
FOO: hoge
entrypoint: ["echo", "$FOO"]
$ # .env が environment より優先されることに注目
$ docker compose run --rm sample
bar
注意点として、.env
のドット・ファイルであるため隠しファイルになります。ユーザーによっては混乱を招く(どこで設定された値かわからず色々弄られる)ため、後述する --env-file
オプションや env_file
指示子で明示する方が無難でしょう。
コマンドの引数で環境変数を渡す
-e
オプションで変数を引数渡し
-
docker-compose
コマンドには--env
のロング・オプションはありません。
$ # docker-compose.yml の内容確認
$ # 環境変数を何も指定していない点に注目
$ cat docker-compose.yml
version: "3"
services:
my_app:
image: alpine
$ # デフォルトのコンテナ内の環境変数確認
$ docker-compose run --rm my_app env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=d7df4af36a24
TERM=xterm
HOME=/root
$ # 環境変数をオプションで引数渡し
$ docker-compose run \
> --rm \
> -e HOGE=FUGA \
> -e foo=bar \
> my_app \
> env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=33dd92872107
TERM=xterm
HOGE=FUGA
foo=bar
HOME=/root
コマンドで外部ファイルを指定して環境変数を渡す
docker-compose.yml
のenv_file:
で変数をファイル渡し
docker-compose run
のオプションには外部ファイルの指定がありませんが、docker-compose.yml
の env_file:
変数にファイルのパスを指定すると環境変数を外部ファイルで設定することができます。これにより、docker-compose up
などでも外部ファイルで環境変数を指定することができます。(次項参照)
docker-compose.yml
経由で変数を渡す方法
docker-compose.yml
に記載した環境変数を渡す
environment:
変数で環境変数を指定渡し
ホスト OS の環境変数名で指定も可能(下記の場合MY_SECRET
)
version: "3"
services:
app:
image: alpine
environment:
HOGE: FUGA
foo: bar
MY_SECRET:
実行例
$ # docker-compose.ymlの用意
$ ls
docker-compose.yml
$ cat docker-compose.yml
version: "3"
services:
my_app:
image: alpine:latest
environment:
HOGE: FUGA
foo: bar
MY_SECRET:
$ # ホスト OS 側で環境変数をセット
$ export MY_SECRET='MySecretIsHimitsu'
$ # オプション引数を渡さずにコンテナ内の環境変数を確認
$ docker-compose run --rm my_app env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=8cbcd1863cd2
TERM=xterm
HOGE=FUGA
foo=bar
MY_SECRET=MySecretIsHimitsu
HOME=/root
version: "3"
services:
app:
image: alpine
environment:
- HOGE=FUGA
- foo=bar
- MY_SECRET
実行例
$ # docker-compose.yml を用意
$ ls
docker-compose.yml
$ cat docker-compose.yml
version: "3"
services:
my_app:
image: alpine:latest
environment:
- HOGE=FUGA
- foo=bar
- MY_SECRET
$ # ホスト OS 側で環境変数をセット
$ export MY_SECRET='MySecretIsHimitsu'
$ # オプション引数を渡さずにコンテナ内の環境変数を確認
$ docker-compose run --rm my_app env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=961a6c9b0ca6
TERM=xterm
HOGE=FUGA
foo=bar
MY_SECRET=MySecretIsHimitsu
HOME=/root
-
注意:
docker-compose.yml
のインデントやスペースの入れ方を間違えると、must be a mapping not an array
エラーが出やすいので注意。ERROR: In file './docker-compose.yml', service 'environment' must be a mapping not an array.
-
environment | Version3 | Compose file reference | Docker Compose @ docs.docker.com
docker-compose.yml
に記載した外部ファイルの環境変数を渡す
env_file:
変数で環境変数を指定渡し
ホスト OS の環境変数名で指定も可能(下記の場合MY_SECRET
)
$ # 最低限のファイル一覧
$ ls
docker-compose.yml my_env_file.env
$ # docker-compose.yml の内容確認
$ cat docker-compose.yml
version: "3.7"
services:
my_app:
image: alpine
# 環境変数をファイルで指定
env_file: my_env_file.env
$ # ホスト OS 側で環境変数をセット
$ export MY_SECRET='MySecretIsHimitsu'
$ # 変数を記載したファイルの用意(ポイントは MY_SECRET で、値がないのでホスト OS の環境変数を使う)
$ cat my_env_file.env
HOGE=FUGA
foo=bar
MY_SECRET
$ # 外部ファイル読み込み後の環境変数確認
$ docker-compose run --rm my_app env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=d324b0014cfb
TERM=xterm
HOGE=FUGA
foo=bar
MY_SECRET='MySecretIsHimitsu'
HOME=/root
VSCode の Remote-Containers にホストの環境変数を渡す際の注意点
VSCode が認識していないとコンテナにも渡されない。
VSCode には、Remote-Containers
という拡張機能を入れると、Docker で開発環境を構築することができます。つまり、Docker 内にワークスペースをマウントし、VSCode からリモートでアクセスできます。これにより、ローカルに色々とインストールする必要もなくなり、使い捨ての環境が作れます。
さて、この VSCode + Remote-Containers 拡張機能 + Docker を使っていて「あれ?ローカルの環境変数が渡されないな」という場合、大抵の場合 VS Code がローカルの最新の環境変数を読み込めていない可能性があります。
VSCode は、VSCode 自身が起動した時点の環境変数を利用するからです。
コンテナ上でリポジトリを開く(Open in Container
でコンテナを起動する)前に、VS Code のターミナル上で(ローカルの)環境変数を env
コマンド確認してみてください。新規でセットした環境変数が反映されていない場合は、VSCode を再起動すると反映されます。
特に macOS でシェルを bash
で利用している場合などは注意が必要です。OS のデフォルトのシェルが zsh
に変更されているので、VSCode も zsh
の設定を読み込むためです。その場合は、VSCode のターミナル設定でデフォルトのシェルを bash
に変更します。
⌘ + Shift + P
からTerminal: Select Default Profile
を開き、bash
を選択。
所感
取り扱い注意な値
コンテナ内のアプリに、アクセス・トークンをハード・コーディングせずに渡したい
ちょっとした開発で Docker コンテナ内のアプリやスクリプトからセンシティブな値、つまり「触る際に注意が必要な値」を扱う必要がありました。
アクセス・トークン、SSH や OpenSSL に必要な秘密鍵などの、どんな場合においてもハード・コーディングする(プログラム内に書いちゃう)のはご法度な設定値です。
そのため、一般的なアプリの場合は「呼び出す時に引数で渡す」か「外部ファイルとして読み込ませる」のが定石とされます。これは基本的に Docker の場合でも同じです。
🐒 Docker のイメージとコンテナの違い
Windows、macOS、Linux など、OS をマシンにインストールする時に USB や CD などからインストールすると思います。
関係的に USB や CD などのメディアを「イメージ」、そこから作られた(マシンにインストールされた)ものが「コンテナ」に相当します。VM などの場合は、イメージから作られたインスタンスが「コンテナ」に相当します。
しかし、これは動きが似ているというだけで、仕組みは異なります。これらと違うのは、Docker の場合は OS(カーネル)が入っていません。
Docker のイメージは、どちらかと言うと他で言う「スナップショット」や「スリープ処理」(現在のメモリの状態をファイルとして保存したもの)に近いものです。つまりカーネルさえ動いていれば、その上に保存したメモリを展開(コンテナとして起動)すれば動くのと似た働きをします。
そのため「イメージに何を含めるのか」によってメモリの消費量に大きく影響します。Linux のフレーバー(ディストリビューション)を丸ごと使ってしまうと、使いもしないものまで入ってしまうので、そのぶんメモリを消費します。詳しくは後述。
環境変数でデータを渡すことの考察
🐒 結論から言うと「変数がどのように渡され、他者からも見ることができるか」を理解したうえで、開発用やローカルのマシンなどで非公開で動かすぶんには、環境変数渡しでも問題ないと思います。つまり、マシンが手元にあり、Docker 単独もしくは docker-compose で事足りる範囲での開発用途なら、ということです。
問題は、管理が広範囲に及ぶ場合です。例えば、1 つのコンテナに色々詰め込み過ぎて、実は意図しないサービス(プロセス)が立ち上がっているのに、プロセス一覧に埋もれて気づかない場合です。他にも Swarm や Kubernetes など、複数マシン・複数ネットワーク・複数コンテナといったクラウディでプロダクション・レベルでの利用です。その場合は Swarm や Kubernetes などが持つ暗号化の機能を使う必要がありますが、筆者は開発環境用、アプリのビルド用、CI の実行用にしか利用していないので、割愛します。
一般的なアプリの場合、セキュリティの観点から「センシティブなデータ」の環境変数経由での受け取りは推奨されません。他のアプリからも見れてしまうためです。
しかし Docker の場合は、環境変数を通してコンテナ内にデータを渡すことを多くみかけます。他にも GitHub Actions、Travis CI、Circle CI や Jenkins といった CI/CD などはデプロイ(成果物をリリースする際の公開先へのアップロード)に必要なアクセストークンなどを環境変数で渡しています。
コンテナの元となるイメージには焼き付けない、つまり「センシティブなデータ入りでイメージをビルドしない」というのはハードコーディングに通ずるのでわかります。しかし「環境変数経由でなく、なぜファイルをマウントして読み込ませないのかな」と思いました。
結論から言うと、ファイルをマウントさせる方法でも、環境変数経由でも構いません。なぜならリスク自体は変わらないからです。
ファイルをマウントする場合は、マウント・ポイント(マウント先)の仕様決め、フォーマット、ファイルのアクセス権、etc. を考えると、環境変数で渡した方が実装が楽でしょう。逆に、公開鍵などは、コンテナ起動時に -v
などでマウントした方が楽でしょう。
どちらの方法であっても、同じ環境下にあるなら他のアプリやサービスからもアクセスできてしまうということを念頭に置く必要があります。これが共通するリスクです。
「いや、そもそもアプリやサービスごとにユーザとアクセス権を設定して、ファイルにもアクセス権を付けて制限させればいいじゃん」と思うかもしれません。同じサーバ内で色々と動かす場合の必須プラクティスですね。
しかし、Linux 管理者は長年の経験から、引き継ぎの煩雑さ、ドキュメント嫁に叱られるメンテナの多さなどの経験からポカよけを考えてきた結果、ある答えにたどり着きました。
「他のアプリを入れなければいい」です。
そして、その先にあるのがコンテナ技術です。UNIX 哲学にも似た、「1 つのコンテナは 1 つのシンプルな目的だけに徹する」ことでリスク分散するだけでなく、各々が特化したサービスを提供しているため、安定性やメンテナンス性も向上します。
「1 つのコンテナに詰め込まない」って、どういうこと?
🐒 「1 コンテナ、1 アプリ」のルーツは、Linux に慣れ親しんだ人向けに一言で説明すると「ENTRYPOINT
で指定した自分のアプリのプロセスが、コンテナ内の PID 1
になるから」です。
つまり、通常 PID 1
で動いている init
や systemd
のようなプロセスを管理してくれるサービス(デーモン)がコンテナ内に存在せず、変わりに自分のアプリが PID 1
になるということです。
そのため、SIGTERM
などのシグナルをコンテナ内の他のプロセスにも送る責任を負うことになり、全てのプロセスが終了したのを確認してから自身を終了する必要があります。色々と詰め込んでしまうと、下手をするとゾンビプロセスが生まれる原因にもなりかねません。
さらには、コンテナ内の複数プロセスを適切に処理したいがために Tini などの軽量版 INIT を入れたりと、コンテナ内の環境構築の沼にハマると「VM を使った方が早い」となりかねません。しかも、せっかくの Docker のメリットが活かせません。
現在は --init
オプションや init
指示子で tini
を手軽に差し込めるようになっているものの、「1 コンテナ、1 アプリ」は変わりません。まずは 1 つの機能だけをしっかり提供できるシンプルなコンテナを作成することに注力した方がいいでしょう。
まず、先に言っておくと「コンテナに詰め込まない」と言っても「開発に使う場合に限り、コンテナには必要なものは詰め込んでおいた方が楽なことが多い」という前提があります(ここで言う「開発に使う」とは「コンテナ内に開発環境を作ってリモートでコンテナ内で作業する」と言う意味ではなく、「開発中にトライ&エラーをする場所」としての利用を指します)。
特にテストなどで再現性が重視される場合に、「使っては捨てる」ことで威力を発揮します。逆に安定した開発環境が欲しい場合は、VM の方が開発に向いているケースもあります。
「コンテナに詰め込まない」というのは、1 つの機能(サービス)として提供できる状態のコンテナを指します。
例えば、CI での実行や、機能の単発利用、サービスとして稼働させる場合においての話しです。
つまり「製品のみ」の入ったコンテナにするということです。この場合、この後説明するようにハイバーバイザー型にはない恩恵が受けられます。
これだけを読むと「いや、コンテナうんぬんでなくても開発ツールは一緒にデプロイ(出荷)しないよwwww」と思うかもしれません。
しかし、そこがポイントではありません。
Web 関連で例えると、俺様 Web アプリを作った場合は、PHP や Python ならランタイムとスクリプトだけ。Go や Rust ならビルドしたバイナリだけ、といった具合です。
つまり、Apache などの Web サーバや SQL のサーバだけでなく、目的に必須ではないなら bash すら入れていない「製品のみが動くコンテナ」にするのが理想ということです。
そして Apache、SQL、俺様 Web アプリなどを、各々を別々のコンテナにします。
利用する際は、同じ Docker の閉鎖的なネットワーク内でそれらを起動して利用するのが理想的な使い方なのです(繰り返しますが開発の場合はこれに限りません)。
そのため Docker そのものに慣れてきて、セキュリティがいささか心配になってきたら、次に docker-compose に慣れるのが Docker(もしくはコンテナ技術)のメリットを本格的に感じることができると思います。
すると、「いやいや。それは聞いたことあるんだけど、数ギガ近くあるイメージを複数立ち上げるとメモリがパンパンになるから、1 つのイメージに入れた方がメモリ節約にもなるし」と思うかもしれません。
しかし Docker において、実は、それは「Word や Excel のアプリごとに Windows パソコンを用意する」ような考え方と同等なのです。
なぜなら、イメージが数ギガ近くあるのは、Ubuntu や Debian など OS が丸ごと入ったイメージの上にアプリやサービスを入れているからです。
また、逆にイメージは小さいのに Windows や macOS ではメモリがパンパンになるのは、VM 上で Linux を動かし、その上で Docker を起動しているからです。つまり、直接 Docker を起動しているのではなく、仮想 Linux のためにメモリを確保(仮押さえ)しているからなのです。
一口メモ: Docker は仮想環境アプリではありません。それに近しい動きはするものの、「Linux アプリ」を Linux 上の閉鎖空間で実行するアプリです。そのため、Win や macOS では VM が別途必要なのです。
Python ユーザなどは venv
が「閉鎖空間での実行==仮想環境」であるため、なおさら違いがわかりづらいと思います。しかし、Python を「仮想環境プログラム言語」と言われたら違和感があると思います。そんな程度の違いです。
先に、「アプリごとに Windows パソコンを用意するようなもの」と言いました。それでは「Windows のカーネル(根幹機能)と Word と必要な DLL だけの入った OS」だったらどうでしょう?
ブラウザどころかメモ帳や MS ペイントすら入っていません。かなりサイズは小さくなると感じると思います。ってか、もうワープロ状態です。
そこから、さらに Windows のカーネルすらもブッこ抜いてみます。つまり「Word と必要な DLL だけが入ったもの」と「Windows のカーネル」を分けるということです。
そして Windows のカーネルの上に、「Word と必要な DLL だけが入ったもの」や「Excel と必要な DLL だけが入ったもの」などが入れ替えられるとアプリのコンテナは最低限で済みます。
この状態であれば「Excel だけアップグレード」なども簡単にできると感じると思います。もぅ、ファミコンや Nintendo Switch のカセットやカードみたいな発想です。
そして最後に「Word と Excel で共通の DLL がある」場合は、それらはメモリ上で共有することで、実メモリの使用量を抑えることができます。
実は、このカセットのような仕組みは UNIX が元々持っていた機能がベースにあります。
UNIX には元々 chroot という「最低限の OS 環境の上にユーザを置く」仕組みがあります。これが Linux/UNIX 系の「コンテナ技術」のルーツです。これを、よりスリムにしてアプリ単位でまで落とし込んだコンテナ技術が Docker です。
Docker の原点は chroot を活用した Web ページ
Windows ベースの一般ユーザには chroot
は馴染みがないかもしれません。しかし、普段から目にしていると思います。
https://www9.plala.or.jp/hogehoge/index.html
例えば、インターネット・プロバイダが提供しているホームページのサービスで、上記のような URL を見たことはないでしょうか。この場合は「ぷらら」のユーザーが、ホームページを公開しているアレです。
この URL の本質的な意味は、plala.or.jp
ドメインの www9
サーバーに登録されている hogehoge
ユーザーの、ホームディレクトリの www
フォルダにある index.html
を、https
のポート(Apache などのサービス)にリクエストしていることになります。
昔は、ホームページのデータは FTP などで上げていました。この時「他のユーザーのディレクトリが見えるも、自分以外のユーザーのディレクトリには入れない」といった経験をしたことがある人も多いのではないでしょうか。
また、高負荷の処理は他のユーザーに迷惑をかけるから云々といった話しも聞いたことがあると思います。「1 つのサーバーに複数人でシェアしているから」という話しです。
同じマシンで動いているのに、他のユーザーが何をしているかは基本的に知ることができず、自分の PHP や Ruby スクリプトを実行しても、他の一般ユーザーは止めることすらできないことを不思議に思ったことはないでしょうか。
これは Unix をベースとした OS は、1 つのマシンを複数人で「同時に」利用することが前提であるためです。(もちろん Windows Server にも似た機能があります)
そして、ディレクトリ名は見えても中身を参照できないことから、「ああ。ユーザーのアクセス権で制御しているのね」と思うも、ある時からログインすると自分のホーム・ディレクトリがルート・ディレクトリになっており、以前のように他の上位のディレクトリを参照することすらできくなったということはないでしょうか。
自分のホームディレクトリをカプセル化されて、そこから外にはアクセスできなくなったような状態です。
サーバー系の OS には、ユーザーに「ユーザー専用の空間」を提供して、その中で行っている内容は他のユーザーには見えない仕組みがあるのです。Unix 系の OS において、このような仕組みの 1 つが chroot
です。
ゆうて、同じマシンにいるので、CPU やメモリといったリソースは共有です。そのため、誰かが高負荷をかけていたら(何をしているかはわからないが)影響を受けるという理屈なのです。
ここで Docker を理解するためにアホみたいな話しですが、実は重要な事実を再認識する必要があります。
それは「どのようなコマンドやプログラムも実行しないと動かない」ということです。
カーネルさん出番だす
「どのようなコマンドやプログラムも実行しないと動かない」というのは、コマンドやプログラムを実行するには「プロセス化しないといけない」ということでもあります。
そして、これらプロセスを管理するのが OS のカーネルです。
カーネルとは「何かに覆われており、直接は触れないが、何かの根幹となるもの」です。日本語で「核」「(牡蠣などの)身」「種」といった意味を持ちます。
OS においては、OS の基本を構成するプログラム群のことで、「シェル(殻)を通してでしか触れないプログラム」のことです。Windows のエクスプローラーや macOS の Finder などもディレクトリにアクセスするためのシェルの 1 種です。
他にも、機械学習の世界では「直接はわからないが、関数を通して現れた、データの根幹となるデータ(特徴)」をカーネルと呼んだりするのも同じ語源です。トウモロコシの「コーン」("Corn")も "Kernel" の "Kern" から来ており、皮をむくと出てくる実を Corn Kernel
と呼ぶのも理解できるのではないでしょうか。
カーネルのうち、プロセスを管理するプログラムが 1 番目のプロセスで動いている init
、systemd
、launchd
などです。通常 PID 1
と呼ばれますが、Windows の場合はプロセス番号は 4 の倍数で振られるため、プロセス番号 4 番で動いている System
がそれに該当します。
Linux や macOS(UNIX系)であれば ps -p 1
で確認できます。
Windows の場合は tasklist /FI "PID eq 4"
(ターミナル)もしくは get-process -Id 4
(PowerShell)で確認できます(Windows のタスクマネージャーは、これらコマンドの GUI 版のシェルとも言えます)。
PID 1
プロセスの主な役割は、OS 側から届いた SIGTERM
や SIGKILL
といった終了シグナルを、管理している子プロセスに伝達させることです。
これにより、各々の子プロセスが「後処理」を行い、全員が無事終了したことを確認してから、OS に終了準備ができたことを通知することができます。これを Graceful Shutdown
と呼びます。
このため、PID 1
は「すべてのプロセスの親プロセス」と言っていいでしょう。
そして chroot
は、そことユーザーのプロセスの間に入り、他のユーザーから隔離する(PID 1
と子プロセスの間に入り、子プロセス間の通信を管理する)機能です。
この機能を利用すると、ユーザー・ディレクトリ(/usr/myname
)を、あたかも /
(ルート・ディレクトリ)のように見せることができます。これが change root
略して chroot
の語源です。
そのため、chdir
や ls
などを実行して(プロセスを走らせて)も、隔離されているため /usr/myname
より上のディレクトリは参照できないし、他のユーザーのプロセスも見ることはできない、といったことができます。
「Python でいう pyenv
の OS 版のようなもの」と言えばピンとくるでしょうか。
これは、他のユーザーが何をしているのか覗こうとコマンドを打った(プロセス化した)としても、カーネルがブロックしてくれるメリットが生まれます。
しかし、chroot
には問題もありました。閉鎖された空間なので、最低限必要なファイルやライブラリのコピーを、ユーザーごとの空間(この場合、ユーザーのディレクトリ)に設置しておかないといけないことです。ユーザー毎にパーティションを切って、OS を入れる感覚に近いかもしれません。つまり chroot
は「汎用的にカプセル化する」用途には向いていないものだったのです。
その後、cgroups という「必要なものをグループ化」する仕組みが Linux に組み込まれ、「状態 A の環境」「状態 B の環境」といったものが作れるようになりました。そして、それらの環境を作成しコンテナ化する LXC(Linux Containers)というソフトが生まれました。
LXC
は、体感的には Linux 版の仮想マシンに近いものです。しかし、ハードウェアを仮想化したものと言うよりは、厳密には「Linux 環境をカプセル化して仮想化する」ためのものです。そのため、LXC
のカーネルはホストのものを利用(同じカーネルを共有)します。
先のファミコンや Switch のカセットやカードの例のように、Linux カーネル上に「カプセル化された環境」を載せて、プロセスを隔離しつつ動かせるということです。
そして、「じゃぁ、カーネルも入れられるようにしちゃおうぜwwww」と、ハードウェアへのアクセスだけを仮想環境につなげることで、VirtualBox のようなハードウェアを仮想化する技術が KVM です。
結局のところ、どの技術も「親となるプログラムがあって、子となプログラムを制御している」と言う大きな仕組みは一緒です。
このような概念を活用したものが「コンテナ技術」です。
「カプセル」と言わずに「コンテナ」と呼ばれるのは、一度作成した環境を別の Linux に持ち運べたり、積み上げたり(コンテナに、機能を追加し新たなコンテナを作成したり)できることを目的としているからです。
そして、Docker は、そこから派生したプログラムの 1 つです。
KVM
は「カーネル(OS)」をコンテナ化するもの、LXC
は「Linux のシステム」(環境)をコンテナ化するもの、Docker は「Linux アプリケーション」をコンテナ化するものと棲み分けるといいでしょう。
元々 LXC
は「セキュリティー目的」というより「利便性」から生まれたものなので、初期の頃はセキュリティ的な問題を持っていました。Docker も初期の頃(Docker v1 以前)は LXC
をベースにしていましたが、プロセスをより強固に監視する独自の実装をすることにより、現在では LXC
とは別のものになっています。
これが Docker のセキュリティに繋がっているのですが、逆に、この「プロセス管理」という仕組みを理解していないと痛い思いもします(特に、Docker の場合、コンテナ内では PID 1
で走るプロセスは、ENTRYPOINT
で指定したプロセス(プログラム)だからです)。
ここで「カーネルはプロセスを実行しているユーザーを見て隔離している」という点に注目します。
Docker には「コンテナ内の実行ユーザーを root
にしない」というベスト・プラクティスがあります。これは、ホスト OS(Docker が動いているマシンの OS)が Linux などの場合に特に重要です。
Docker のコマンドを実行する際、sudo docker ...
と毎回 sudo
を(root
権限がない限り)付けないといけないことにお気づきでしょうか。
つまり、Docker のプログラム自体は root
権限で動いているのです。
そのため、コンテナ内のプロセスのユーザーが root
だと Docker のコンテナを privileged
モードで動かした場合、カーネルもそのプロセスを root
ユーザーとしてみなして実行してしまうため、危険とされるのです。
つまり、せっかくの隔離機能が無駄になってしまうのです。
Docker のドロップインになる(入れ替え互換を持つ)Podman が最近好まれるのは、
デフォルトで rootless
で実行する(root
権限で実行しない)からです。そのぶん権限トラブルのポカヨケになります。
わかれば単純なのですが。
ホスト OS が macOS や Windows の場合は、root
というユーザーはデフォルトで存在しないし、管理者権限もないため割と安易に root
ユーザーのままのコンテナを実行しがちです。
しかし、それらは仮想 Linux 上で Docker を動かしていることや、コンテナの実行 OS が Linux になった場合のことを考えると、やはり root
ユーザーでコンテナを実行するのはリスクを孕んでいるため、よろしくありません。
以降は「コンテナ内のユーザーは root
ではないこと」を前提に考えてお読みください。
なんちゃって Docker は 60 行のコードで作れる
さて、コンテナの概念は UNIX や Linux の世界では昔からあり、Docker は、その仕組みを利用したアプリの成功者の 1 つでしかないことはお伝えしました。
実は、この chroot
の仕組みというか、概念を踏まえると、Go 言語の場合 60 行以内でコンテナ・アプリを作ることができます。
つまり、呼び出し元のアプリが呼び出したアプリのプロセスを監視・管理することで「なんちゃって chroot
」を実装できるのです。Docker や Kubernetes などが Go で作られているのも同じ理由です。
以下は英語のライトニングトークの動画ですが、実際にコーディングしながら 19 分で「なんちゃって Docker」を作っています。コードが出来上がっていく流れをみると「あー、隔離ってそういうことなのか」と気づきがあると思います。
- Building a container from scratch in Go - Liz Rice (Microscaling Systems) | Container Camp @ Youtube
- ソースコード: https://gist.github.com/julz/c0017fa7a40de0543001 @ Gist
Docker を改めて考える
前述のように、Docker は基本的に(ポカや意図して設定しない限り)ホスト OS 側の設定値やファイルにはコンテナからはアクセスできません。
また、他のコンテナの設定値やファイルにも直接アクセスできません。サンドボックスに近いものだからです。その為に Docker を使っているとも言えます。
そして Docker の場合「1コンテナ1プロセスが推奨されている」と言われます。これはコンテナごとにシンプルに1つのサービスのみを実行させる、と同義です。例えば Apache だけ、MySQL だけ、といったことです。
これは同じ Docker ネットワーク内に機能ごとにサーバーを設置するのと同じことになり、機能の入れ替え、拡張やメンテナンスが楽になるメリットを産みます。
■ Docker ネットワークとは
🐒 ここで言う「同じ Docker ネットワーク内」というのは、Docker の仮想的な LAN のことです。
Docker には、コンテナが起動したときに接続できるネットワーク先(仮想 HUB or 仮想ルーターのようなもの)がデフォルトで 3 つあります。
bridge
、host
、none
です。
これは、コンテナ起動時に「どの HUB の LAN くちに挿してネットワークに参加するかが選択できる」と想像すると把握しやすいと思います。
bridge
が、その Docker の仮想ネットワークで、デフォルトの接続先です。特に指定がない場合は bridge
に接続され、ほかのコンテナと同じ LAN に参加します。コンテナ起動時に --network host
オプションを付けると、ホストと同じネットワークに参加します。
-
bridge
に挿した場合は、Docker コンテナ専用の LAN に参加(デフォルト動作)-
docker run --rm -it --network bridge alpine ifconfig
(Docker ネットワークの IP が振られる。デフォルトで172.17.0.0/16
の範囲で自動割り当て)
-
- host に挿した場合は、ホストと同じ LAN に参加
-
docker run --rm -it --network host alpine ifconfig
(ホストマシンと同じネットワークの IP が振られる)
-
-
none
の場合は、誰もいない LAN に参加-
docker run --rm -it --network none alpine ifconfig
(お一人様ネットワークの127.0.0.1
だけが振られる)
-
接続可能なネットワーク先は docker network ls
コマンドで確認することができ、専用の LAN 網(ネットワーク)も作ることもできます。具体的には docker network create mynetwork
コマンドで作ったネットワークは、docker run --network mynetwork mycontainer
でコンテナをそのネットワークに参加させることができます。
--network none
ですが、「ネットワークにつながらなくていいアプリをコンテナ内で処理させたい」場合(例えば、画像の変換など)に利用します。つまり、知らないうちにアプリがネットワーク接続してデータを外部へ送信するような懸念を排除してくれます(そのかわり、外部からもポートを介してのアクセスはできなくなります)。
また、docker-compose
でコンテナを起動した場合は、専用の仮想 LAN が自動作成され docker-compose.yml
ファイル内に記載されているコンテナは、同じネットワーク内で起動します。
もちろん、YAML ファイル内でネットワークを複数定義し、各々のコンテナを割り振るといったこともできます。
例えば、デフォルトの 172.17.0.0/16
でなく 172.16.238.0/24
のセグメント(サブネット)の仮想 LAN 環境でコンテナを起動したい場合は、docker-compose.yml
は以下のようになります(別途ネットワークを定義し、コンテナを割り当てていることに注目)。
services:
demo:
image: alpine
networks:
- mynet
networks:
mynet:
ipam:
driver: default
config:
- subnet: "172.16.238.0/24"
$ docker compose run --rm demo ip addr show eth0
24: eth0@if25: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
link/ether 02:42:ac:10:ae:02 brd ff:ff:ff:ff:ff:ff
inet 172.16.238.2/24 brd 172.16.238.255 scope global eth0
valid_lft forever preferred_lft forever
このように複数コンテナの設定を一括で管理できるので「同じホスト OS」上に「複数コンテナを起動する」場合は docker-compose
が使えると便利です。最新の Docker CLI(Win や macOS の最新の Docker Desktop)であれば、標準で同梱されています。
このような、複数コンテナを一元的に管理するツールを「オーケストレーション・ツール」と呼びます。
このオーケストレーション・ツールには、同じホスト OS 内で Docker ネットワークを作れる docker-compose
(現 docker compose
) だけでなく、エンタープライズ版(事業向け)のオーケストレーション・ツールに、「同じネットワーク」内にある「複数のホスト OS」で仮想ネットワークを作る docker swarm
や、「異なるネットワーク」間の「複数のホスト OS」上で仮想ネットワーク作る Kubernetes などがあります。
重要なことなので復習ですが、「コンテナごとにシンプルに1つの機能のみを実装させて使う」と言うのは、例えば Nginx などのプロクシ・サーバー/Apache などの Web サーバー/SQL サーバー/俺様 Web API サーバーなどを、各々にコンテナを立てるという意味です。
Nginx(エンジンX)は、正確にはプロクシ・サーバーではなくリバース・プロクシのサーバーです。本来プロクシは「LAN 内から外部にアクセス」があった場合の操作をするものです。キャッシュされていれば外部アクセスせずにキャッシュを返す、などです。逆に、リバース・プロクシは「外部のネットワークから LAN 内にアクセス」があった場合の操作をするものです。「ポート 5963 に /foo/bar
のアクセスがあった場合は、LAN 内の 192.168.1.123:8080/foo/bar
にアクセスした結果を返す」といった外部からの窓口的な操作をするためのものです。 つまり、Nginx のコンテナを起動させておいて OS は全てのアクセスを Nginx に向ければ、Docker ネットワーク内の転送をよしなにしてくれるだけでなく、他のコンテナは 1 つのサービスしか提供していないので、ポートのバッティング(重複)なども気にする必要がないといったメリットが生まれます。
この記事では「内外のネットワークの窓口を管理するもの」という意味で、便宜的に Nginx もプロクシと呼んでいます。なお、Docker でよく使われるリバース・プロクシには Nginx 以外に、Traefik Proxy、HAProxy など色々あります。
コンテナをサービスごとに個別に立てられれば、コンテナが開けないといけないポートも該当サービス用のポートだけでいいためシンプルになります。これは「当てたパッチが他のサービスに影響しないか」といった心配も減ります。
慣れないうちは、どうしても同じコンテナに複数サービスを入れたくなります。
「だって PHP や Go のビルトイン・サーバは大量のアクセスには向かないので、Apache と同じコンテナに入れたい」と思うかもしれません。
もちろん、それでも結構です。しかし、結局のところ、大量アクセスのハンドリングをしているのが Apache というだけで、PHP のプロセスは次々と作成されていきます(特に CGI 版の場合)。
また、そのコンテナが落ちた場合、Apache の下にいるプログラムが全て一緒に落ちます。さらに、そのコンテナにさまざまな機能(サービス)が凝縮されていた場合、負荷分散や増設がしづらくなります。俗に言う「SPOF」の 1 つになりかねないのです。
そこで Docker は、アクセス数が増えたら比例して複数の PHP や Go のコンテナを立てるという考え方をします。そして、Nginx のコンテナなどで負荷分散するという考え方をします。
つまり、接続が空いているコンテナに回したり、各々のコンテナに持ち回りさせることでロードバランスするということです。コンテナ同士が共通の DB さえ参照していれば、プロキシにより外部からは 1 つのサービスとして見えます。
これをサポートするのが docker-compose、Docker Swarm や Kubernetes といったオーケストレーション・ツールです。Kubernetes などは自動的に起動するコンテナを増減してくれます。
例えば、docker-compose の場合、--scale
オプションで特定のサービス(コンテナ)を複数個起動させることができます。
docker compose up --scale myapp=3 --detach
- 具体的なサンプル | gist @ GitHub
🐒 つまりはクラスター
docker-compose は、同じマシン内で Docker ネットワークを組むもの。Docker Swarm は同じ LAN 内の別々のマシンで Docker ネットワークを組むもの。Kubernetes は別々のネットワークの、別々のマシンで Docker ネットワークを組むもの、といった認識から入ると良いと思います。
どの方法であっても、外部から見ると 1 つの Docker ネットワークに見せることができます。つまり、外部からは 1 つのポート(例えば Web など)にアクセスしていても、実は裏側(Docker ネットワーク内)では余力のあるコンテナにつながっているという動作になります。そのため、Docker 関連では Apache より Nginx が好まれるのは、プロクシ機能(外部からの接続を内部のマシンに接続・転送する機能)が充実しているからです。どちらも Web サーバーなのですが。もっと高度なプロクシ機能を求める場合は Traefik などを検討するといいでしょう。
このような 1 つのサービスを複数マシンで提供する仕組みをクラスタと言います。もちろん複数サービス(複数のポート)も同様の仕組みで提供できます。
以下の動画は、英語で、いささかクセがあるのですが、Kubernetes の軽量版である K3S を使って、複数台のラズパイ 4でクラスタを組み、Minecraft サーバをゼロから建てる方法を具体的に紹介しています。
Kubernetes などのオーケストレーションに興味を持つも、微妙に実感がわかない方は、ざっと流れを観るだけでもピンとくると思います。注意として、ラズパイでクラスターを組んだとしても、処理速度が爆速になることはありません。同時アクセスに耐えられるだけで、処理速度そのものはラズパイの速度のままです。
このように(docker-compose
などで)複数コンテナを同じ Docker ネットワークで起動させるというのは、各々の機能が独立したサーバーとして、同じ LAN 内で動いているのと同義です。
そのため、「コンテナ間のやりとりが必要」な場合は「サーバー間のやりとりと同じ実装が必要」ということになります。つまり、UNIX ドメインのソケットが使えず、基本的に INET ドメインのソケットを利用することになります。
- 調べなきゃ寝れない!と調べたら余計に寝れなくなったソケットの話 @ Qiita
「INET ドメインのソケット」と言うのは「ネットワークカードにつながった通信くち」という意味です。
UNIX/Linux では、データをやりとりする窓口を「ソケット」と呼び、その通信先がネットワークカードの場合を「INET ドメイン」と呼びます。
また、UNIX/Linux ではソケットは実体のないファイルとして作成され、そのファイルを読み書きすることで、ソケットの接続先にデータを送受信します。
そのため Linux の場合、Bluetooth、シリアル・ポートや USB など、各種デバイスの通信も、このソケット(のファイル)を読み書きすることでアクセスできます。そして、読み書きする際のバイナリ・データの書式が、俗に言う「通信プロトコル」です。
そして、ネットワーク・カード・デバイスに接続されたソケットが「INET ドメインのソケット」になります。
「ドメイン」とは、「属する」とか「所属する」とか「下に何かがぶら下がっている組織」的なものを言います。そのため、「INET ドメイン」と呼ぶのは、ネットワークカードに属するプロトコルが複数あるからです。
例えば HTTP
, SSH
, FTP
などです。そして、各々のプロトコル用に用意したソケットをポートと呼びます。
いずれのポートも、読み込むとバイナリ・データが流れてきたり、書き込めるだけなのですが、利用するプロトコル(通信ルール)がわかっているので、通信できる(サービスと疎通ができる)という塩梅です。
まとめると、「INET ドメインのソケットを利用する」というのは「任意のポートで、インターネットで使われるプロトコルで通信する」ということです。
具体的には 8080 ポートで待機する WebAPI を作り、専用のコンテナを立て、他のコンテナから 8080ポートに HTTP リクエストを送り、レスポンス結果を利用する、といったイメージです。
さて、閑話休題。
問題は、各々のコンテナの API を利用する際の認証です。
もちろん、Docker ネットワークと言う閉ざされたネットワーク内での通信なので、認証無しで通信しても構わないことが多くあり、一般的な面もあります。(実装が楽になりますし)
しかし、コンテナのサービスを外部にも公開することを考えてセキュリティを重視した場合、コンテナ間通信の認証も考える必要があります。
一般的には「OAuth などを実装し、各々のコンテナにアクセストークンを取得させる」「SSL/TSL 通信で行う」などです。つまり、同じ Docker ネットワーク内に、意図しないプロセスがダミー・リクエストをしたり、通信を傍受しているコンテナがある可能性を考えて通信を制限したり、暗号化するケースです。
とは言え、全てのコンテナが OAuth のサーバー・クライアントの実装だけでなく、アクセストークン取得や共通暗号鍵の取得フローまでも実装が必要となると大変です。
そこで、ホスト OS 側から各々のコンテナに直接アクセストークンや暗号鍵を渡すことで、実装が楽になります。
つまり、1つのコンテナにあれこれと機能(アプリやサービス)を詰め込みすぎないのであれば、コンテナ起動時に環境変数で渡しても Docker の仕組み的に問題ないということです。
もちろん、環境変数に限らずファイルのマウントでも同じことが言えるのですが、メリット・デメリットを理解せずに盲目的に使うのは危険です。
くどいのですが、中で何をしているかわからないプロプライエタリなブラックボックスタイプのライブラリや、得体の知れないサードパーティのライブラリを使っていたり、メンテナンスされていない(脆弱性の Issue や PR が放っておかれている)ものを一緒に入れているコンテナの場合は、大いに注意が必要ということでもあります。
他にもアプリの脆弱性をつかれて、コンテナにマイニングのアプリをインストールされたりと、考えるとキリがありません。
しかし、これは Docker やコンテナに限らず「サーバーの注意事項」と、何ら変わらないことにお気づきでしょうか。
Docker 内で使うアプリのライブラリすら、GitHub や Gitlab などにフォークしたものをわざわざ使う人がいます。これは「知らぬ間に得体のしれないものが入れられないように」というリスク管理のためで、フォークを本家に追随する手間やアプデ内容を毎回チェックする手間を惜しんでもセキュリティが気になるからです。
セキュリティと言えば、Docker のプラクティスの 1 つに「root
権限を持たないユーザを作成して、Dockerfile の最後に USER
で指定しておく」というものがあります。
しかし、この設定はコンテナ内でパッケージがインストールできなくなるため、開発用途に使っている場合は root
のままの人も多いかもしれません。コンテナ起動時に毎回 --user root
を指定しないといけないため、面倒だからです。
root
以外のユーザにするこの設定はポカヨケの役割をするものです。
特に --privileged
オプションを付けてコンテナを実行すると、コンテナ側からホストにアクセスできるようになります。この時、コンテナのユーザが root
ユーザだと root
でホストにアクセスしていることになります。
「(そんな wwww --privileged
なんて、わざわざ付けたりしないよ)」と思うかもしれません。
しかし、そもそもホストのデバイスへのアクセスが必要な IoT 関連の記事には --privileged
オプションを付けるものがあります。また、機械学習系のコンテナといった GPU へのアクセスが必要なものは、必須になります。
特に、古い Windows や Mac の記事や掲示板では、データの永続化のためにハードディスクに触れないと吸った揉んだする系で、やれ --link
オプションを付けろだの、--privileged
を付けてみろだのといった案内があったりします。
よくわからず試行錯誤の末、動いたものの、他の人もなぜ動いているのかわからないままだったり、さらりと docker-compose.yaml
に追加されちゃっていたり。
使っては捨てろ(写経よりは乱取り)
さて、ここまでセキュリティのことを、いささかうるさく言って来ました。
しかし、そんな状態のものを業務の本番に使うようなことはないと思います。しかも、このセキュリティの話しは Docker に限らず、VM(仮想マシン)やパソコンであっても同じことが言える内容だと思います。
セキュリティは忘れてはならない事項ですが、Docker 初心者なら、まずは Docker のメリットを活かして、触って覚える方が良いと思います。
VirtualBOX や VMWare などの VM は、仮想的なパソコンと同じです。そのため、ハードと同じようにイメージからインスタンスを作成して、そのインスタンス上に環境構築したら、それを使い続けることがメインです。
しかし、Docker の場合は、使い捨てがメインと考えた方がいいです。
つまり、シンプルな用途ごとに Dockerfile を作ることから始めるのです。
【WIP 書きかけ】
Docker のコンテナを小分けにする理由の細かい事
この記事を読まれているということは、Docker は仮想マシンの1種またはそのたぐいであることはご存知だと思います。そして VirtualBOX や VMWare といった仮想マシン系アプリを見聞きしたこともあると思います。
VirtualBOX や VMWare はハイパーバイザ型と呼ばれ、ハードウェアを仮想化して、その上に任意の OS を乗せる技術です。
Docker
と何が違うのかと言うと、恐れずに言うなら Docker
は「プロセス」を仮想化した技術であるということです。
どういうことか。
例えば、以下の Bash スクリプトを実行したとします。
#!/bin/bash
while true
do
sleep 60
done
上記は sleep
を 60 秒おきに繰り返す無限ループです。当然メモリに常駐して永遠と処理を繰り返すことになります。つまり「このスクリプトのプロセスが走っている」ということです。試しに確認してみましょう。
$ # スクリプトの確認
$ cat ./sample.sh
#!/bin/bash
while true
do
sleep 60
done
$ # スクリプトに実行権限を与える
$ chmod +x ./sample.sh
$ # スクリプトを実行し、"&" で裏で動かす
$ # 下記 4144 は、PID(プロセスID)4144 番で動いているという意味です
$ ./sample.sh &
[1] 4144
$ # 現在動いているプロセスから、PID 4144 があるか確認する
$ ps -p 4144
PID TTY TIME CMD
4144 ttys001 0:00.01 /bin/bash ./sample.sh
$ # 無限ループなのでプロセス ID を指定して kill で強制終了する
$ kill 4144
[1]+ Terminated: 15 ./sample.sh
どのプログラムも実行するとコンパイルなどを経て「カーネルに優しいバイナリの状態」になり、メモリに呼び出されプロセスとして指示があるまで待機します。
🐒 【カーネルとは】 OS におけるカーネルとは「OS の基幹となる小さなプログラムの集合体」です。
特に OS とハードウェアの接点、つまりソフトウェアとハードウェアの仲介を担います。この上に、ドライバであったり、プログラムが載っかって、よりマクロな機能を OS として提供して行きます。
英語では Kernel
と書き、「何かの中核もしくは本質となるもので、何かに覆われており直接触ることができず、見つけるのが大変なもの」と言う意味を持ちます。日本語で言えば「種」「核」「(牡蠣などの)身」と言った意味を持ちます。
このことから、「ユーザーは直接触ることができず、シェル(殻)を通さないと触れない OS の中核となるプログラム群」を(OS の)カーネルと呼んでいます。他にも、機械学習では、そのデータのままではわからない(見えない)ものから、「データの核」となりうる新しいデータの列(特徴)を出力する「カーネル関数」がありますが、同じ語源です
ちなみに、トウモロコシの実である corn
の語源が Kern-nel
の kern
です。
そして、待機しているプロセスは、PID(プロセス ID)順に呼び出されます。カーネルは、そのたびにバイナリデータを CPU の種類に合わせて実行(処理を命令)します。
通常この PID 順に実行する処理を担うのは PID 1
で起動して走っているプログラムです。OS によって異なりますが、initd
systemd
launchd
などです。
このことから、「OS が異なっても、同じカーネルと同じ CPU であれば実行できる状態になったもの」が「プロセス」と言えます。
「じゃぁ、このメモリ上にあるコンパイル済みのプロセスをイメージに落として、別途メモリに読み込ませればいいじゃん」というのが Docker
です。そして Docker
は、コンテナのプロセスが通信するのをコントロールすることで、カプセル化しているのです。
つまり、VirtualBOX や VMWare などのように OS やハードウェアを丸ごとシミュレーション(仮想化)しなくても、Linux のカーネルの上に、プロセスと、プロセスが必要とするフレイバー(Linux 系 OS の各々の特徴)のプロセスをカプセル化したものを乗っければ動くと言う理屈です。
これが VirtualBOX などのハイパーバイザ型と比べて軽量であると言われる理由です。
しかし、macOS や Windows の場合、カーネルがそもそも異なります。そのため、macOS と Windows は Linux の仮想マシン(VM)を動かし、その上で Linux のコンテナを動かします。macOS と Windows ユーザーが VirtualBOX や VMWare と違いがわからなくなる原因がここにあります。
Docker のポテンシャルをフルに活かしたいのであれば、Linux 系のマシンで動かすのがベストでしょう。
まとめると、この必要なプロセスを1つにまとめて保存したものが Docker イメージで、その設定内容が Dockerfile、そして、それをメモリに読み込んだものが Docker コンテナと考えるとわかりやすいと思います。
厳密には、このアイデア自体は Docker の専売特許でもなく、10年以上前から Linux では存在した概念です。
元々 Linux では、chroot
と言う概念があり、ディレクトリごとに異なる環境を構築して切り替えられる機能を持っていました。プログラム言語で言えば、Python の場合は pyenv
、PHP では phpenv
、ruby なら rbenv
のように、各々必要な物を1つのディレクトリにまとめ、切り替えると言う仕組みに似たものです。
この chroot
の仕組みは、不特定多数のユーザーに1つもしくは複数のサーバーで複数の環境を提供するのに適しているため Linux がサーバーとして好まれる理由の1つにもなりました。当然のように Windows Server なども、この概念を取り入れたりしています。
この概念をカーネルベースにしたものが KVM(Kernel-based Virtual Machine
)と呼ばれるものです。
Docker は、単純に、この概念を利用した成功者に過ぎないのです。おそらく、その成功の理由としては、Golang(Go 言語)で開発されたことにより、マルチ・プラットフォーム対応やインストールが容易だった(扱いやすい)こともあったのだと思います。
コンテナ技術の歴史に興味のある方は「KVM 歴史」でググるなどしてみたり、以下の記事なども参考にすると面白いと思います。
- コンテナってなんだろう― 「コンテナ」の概要を知る @ ThinkIT
コンテナの種類
さて、コンテナの話しに戻りますが、Windows ユーザーの場合、Docker を初めて起動すると「Windows コンテナ」と「Linux コンテナ」の選択肢があるのを不思議に思った人もいると思います。
これは、「対応するカーネルが違う」と言う意味です。
Windows コンテナを選択した場合は Windows カーネル、Linux コンテナを選んだ場合は Linux カーネル上でコンテナを動かすということです。そのため、Windows コンテナは Linux カーネル・モードでは動きません。カーネルが異なるためです。もちろん、その逆もしかりです。
しかし、ホスト OS が Windows の場合、カーネルは Linux ではありません。そのため、Linux コンテナを選択した場合は、仮想 Linux 環境上でコンテナを動かします。これは特定の Linux OS でなく、Linux カーネルを仮想化しているので、ハイパーバイザーより軽量となります。イメージとして、Linux の Wine のようなイメージです。
対して、Windows より先に Docker 環境が整った macOS ですが、「macOS コンテナ」とは余り聞きません。
macOS は UNIX で Linux ではありません。macOS のカーネルも Darwin
と呼ばれる BSD 系のものです。しかし、UNIX であるため Linux とは比較的相性が良いこともあり、仮想 Linux 環境が作りやすく、相性も Windows と比べて良いことから、独自のコンテナ仕様(macOS コンテナ)を作るよりは最初から仮想マシン上で動かすことを優先したことが大きいと思われます。
そして、Windows 同様、macOS の場合も仮想的に Linux 環境を作り、その上で Docker コンテナを動かしています。
このように、Windows や macOS などの異なる OS でも Docker を動かせるのは Linux がオープンソースであることの強みです。逆に Linux 上で動く公式の仮想 Windows カーネルや Darwin カーネルがないのは、Microsoft 社も Apple 社も、その仕様を一般公開していないからです。
このことは、OS が最初から Linux であれば Linux カーネルをシミュレーションする必要がない、と言うことでもあります。そのため、Docker のパフォーマンスが一番良いのは Linux 上で Docker を動かすことです。
Windows は現在は VM
(仮想マシン)上で動かしていますが、WSL2 により Linux で言う Wine
のようにネイティブ対応してきているので、今後 macOS を凌ぐ使い勝手に期待したいところです。
いずれにしても Docker は Linux カーネルで動くプロセスをイメージに落としたものと考えるのが一般的です。
docker pull hoge/hoge
と打ってダウンロードされるイメージがそれに該当します。そして、繰り返しますが、そのイメージを起動してメモリにマウントしたものがコンテナになります。
イメージは CPU 依存
Docker イメージを理解するのに大事なのが「コンパイル済み(CPU に最適化済みの状態)」ということです。逆に言えば、別の CPU の種類の環境で作成(ビルド)された Docker イメージは使えないということです。つまり、同じ Linux カーネルであっても CPU が異なる場合は動かせないのです。
例えば x86_64 互換(Intel/AMD)の CPU でビルドされたイメージは、他の種類の CPU では動きません。そのため docker pull
でダウンロードしたイメージが、RaspberryPi などの ARM 系の CPU のマシンでは動かないことがよくあります。
その場合は、Dockerfile や必要なスクリプトをダウンロードして、docker build
で自分の CPU にあったイメージを作成しないといけません。
次に大事なのが「プロセスである」ということです。CPU は PID 順にメモリ上のプロセスを次々に呼び出して実行します。この時、1つのプロセスが時間(処理時間)を食うと他のプロセスに支障を来たすのは想像できると思います。
よく Docker のベストプラクティスで、「Dockerfile は1プロセスに抑える」と言われます。
これは「Dockerfile 内で複数プロセスを実行する処理はよくない」ということなのですが、どういうことかと言うと、先の sample.sh
を思い出してください。このスクリプトを実行すると1プロセスが実行されます。
./sample.sh &
そして、Dockerfile でコンテナ起動時に実行するコマンドは ENTRYPOINT
ディレクティブ(指示子)で指定するのですが、このスクリプトを実行させてみます。
FROM alpine
...
ENTRYPOINT [ "./sample.sh &" ]
上記の場合は、コンテナは1プロセスになります。それでは、単1コンテナ、複数プロセスにしてみましょう。
- メインの無限ループ・スクリプト
#!/bin/sh
while true
do
sleep 60
done
- 複数プロセスを起動する
ENTRYPOINT
のスクリプト
#!/bin/sh
# このスクリプトは3つのプロセスを実行します。
# バックグラウント実行
/root/sample.sh &
/root/sample.sh &
# フォアグラウンド実行
# すべてをバックグランドで実行すると、この entrypoint.sh 自体が正常終了
# してしまい、コンテナも終了してしまう。そのため、最低 1 つはフォアグラウンドで
# 動かしておく必要がある。
/root/sample.sh
- コンテナのイメージ用 Dockerfile
FROM alpine
COPY sample.sh /root/sample.sh
COPY entrypoint.sh /root/entrypoint.sh
ENTRYPOINT ["/bin/sh", "/root/entrypoint.sh"]
- 実行の仕方
$ # ディレクトリ構成
$ ls
Dockerfile entrypoint.sh sample.sh
$ # イメージのビルド
$ docker build --no-cache -t test:local .
...
$ # コンテナの起動(detach でバックグラウンド実行)
$ docker run --rm --detach test:local
a7922249f82e5b2823d2e7662c49ddd670ccf10397f489561e14378c2219d9ab
$ # コンテナ内で実行されているプロセスを確認する
$ # コンテナの起動 ID を確認
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a7922249f82e test:local "/bin/sh /root/entry…" 2 minutes ago Up 2 minutes busy_stonebraker
$ # 起動中のコンテナに接続し、ps コマンドを実行
$ docker container exec a7922249f82e ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh /root/entrypoint.sh
8 root 0:00 {sample.sh} /bin/sh /root/sample.sh
9 root 0:00 {sample.sh} /bin/sh /root/sample.sh
10 root 0:00 {sample.sh} /bin/sh /root/sample.sh
20 root 0:00 sleep 60
21 root 0:00 sleep 60
22 root 0:00 sleep 60
23 root 0:00 ps
上記で1つのコンテナに3つの sample.sh
のプロセスが起動していることがわかると思います。そして、各々が呼び出している sleep
コマンド(プログラム)もプロセスとして実行されているのも確認できます。もちろんプロセスを確認するために ps
コマンドも実行したので、ps
もプロセス一覧にいます。
これは極端な例ですが、SQL サーバーや Web サーバーなど、メインスクリプトとは別に起動させないといけないプロセスは多いと思います。
しかし忘れがちなのが、ホスト OS(ここでは Docker を動かしているマシン)から見れば、各コンテナは Docker エンジンのプロセス内で管理されていることです。
つまり Docker が落ちると、すべてのコンテナが落ちます。
そのため、1つのコンテナに色々サービスや機能を詰め込み過ぎて、コンテナが落ちた時にサービス全体が一気に落ちてしまうだけでなく、どのサービスが問題を起こしているかの特定にも時間がかかります。
実は、Docker は docker-compose
と組み合わせるとコンテナのプロセスが落ちると自動で再起動する仕組みを持っています。
具体的には docker-compose
の "restart" 指示子を "no" 以外に設定します。
この設定で docker-compose up
されると、コンテナが落ちても自動で再起動されます。それだけでなく、docker-compose down
しない限り、OS が(アップデートなどで)再起動しても Docker が起動すると docker-compose
が呼び出され自動で各のコンテナも起動します。
そのため、Web サーバー用/SQL サーバー用/メインスクリプト用と複数の Dockerfile、つまり複数コンテナにわけて構築するのが良い(ベストプラクティス)と言われる理由の1つです。
コンテナをわけることのデメリット
プロセス(サービス)ごとにコンテナをわけるデメリットもあります。(長い目で見るとメリットであったりもするのですが)
先にも少し述べましたが、コンテナ間の通信が面倒であることです。ローカル内のアプリ間通信と言うより、サーバー間通信になるからです。
というのも、コンテナとコンテナの通信は、サーバーとサーバーの通信と同じ方法でないといけません。たとえば HTTP、SSH や FTP といったネットワーク通信が必要です。
また、Python の実行結果を PHP にパイプ渡しする、といったこともコンテナにわけると手軽にできなくなります。(1つのコンテナに Python と PHP をインストールすれば話しは別です)
他にも特定のディレクトリにファイルを吐き出して、他のプログラムで読み込むといったローカルならではの手法も、ネットワーク通信、つまり Web API を実装しないといけないというデメリットがあります。(共通のボリュームをマウントするという力技もありますが、やはり長い目で見るとリスクになります)
同一サーバー内でのプロセス同士の通信、いわるゆプロセス間通信は Docker でも可能です。しかし、とても高いリスクを伴います。
実はコンテナ起動時に --link
オプションをつけるとコンテナ間でプロセス間通信はできるのですが、セキュリティの観点から近い将来のバージョンで排除されることが決まっています。つまり --link
を前提とした環境を構築しても使えなくなることが決まっているということです。
他にも privileged
と呼ばれる特権モードでコンテナを起動すると、root
などのユーザーであれば Docker エンジンを介して他のコンテナにアクセスすることができます。問題は、コンテナ内からホスト OS 側にもアクセスできてしまうということです。
--link
オプションと privileged
モードの違いは、前者はプロセスをつなげることを前提としており、後者はサウンド・カードや Web カムといった、ホスト OS 側のデバイスにつなげることを前提としていることです。いずれもホスト OS を触れるため、セキュリティホールを作ってしまう可能性は大いにあります。
これが「コンテナ起動時のデフォルトユーザーを root
にしない」と言うプラクティスも、これに起因しています。
Docker を使うメリットの1つにセキュリティがあります。しかし、これは「ホスト OS にコンテナから触らせない」ということが前提です。
サーバの KISS
1つのパソコンやサーバーに複数サービスを入れ過ぎてしまうと、どのサービスがどのポートを使っているのかわからなくなるなど、監視対象が多くなり煩雑になる経験をした方は多いと思います。
また、再起動したくても、他の稼働中のサービスやプログラムがあるためできないといったヤキモキなどもあると思います。
こういった場合、従来は別のハードを用意して専用機にしたり、VM の場合は別の仮想マシンを立ち上げて専用にしたりします。しかし、どちらも OS が丸ごと入っているので、「(なんか無駄だよなぁ)」と感じることでしょう。
ここで、先の「コンテナをわけることはサーバーをわけるのと同じ」ということが活きて来ます。
- アプリが必要な OS のライブラリやモジュールなど
- アプリ本体
3.(非コンパイル型言語の場合は「プログラム言語のランタイム」)
だけをコンテナに 1 つにしたものを Linux カーネル上で動かして 1 サーバとして機能させるのです。
そして「コンテナ間通信=サーバー間通信」ということになります。
逆に言えば、別のコンテナの内部で起きていることは他のコンテナからはわからないということでもあります。むしろ知る必要もなく、リクエストに対してレスポンスが返ってくるだけでいい、ということです。
そのため、主なプロセスごとにコンテナをわけることは、各コンテナは自分のコンテナが提供しているサービスに注力できるので、不要なポートは安心して閉じれるということです。また、余計なサービスやアプリも入れていないので、意図しない脆弱性に悩まされる率も減ります。
これがセキュリティの面でのメリットです。
また、たとえコンテナの中で root
権限を持ったとしても、他のコンテナ(サーバー)にも root
で入れるというわけではないことも大事です。
これは Web サーバーを外部に公開して、ハッキングで侵入されたとしても、他のコンテナにアクセスしにくいだけでなくホスト OS のローカルにも入れないということにつながります。
しかし、プロセス間通信のために privileged
モードで構築すると、この前提が崩れてしまうので諸刃の刃といったところでしょうか。
-
Vagrant(ソフトウェア) @ Wikipedia ↩