LoginSignup
387
324

Docker で環境変数をホストからコンテナに渡す方法(ホスト OS 側からゲスト OS に渡す方法各種)

Last updated at Posted at 2019-04-29

Docker のコンテナ(ゲスト OS)内のアプリやスクリプトなどに、ホスト OS 側からシステム環境変数をゲスト OS に渡したい

つまり macOS や Linux で言うと export したシェル変数や env コマンドで表示される値を外部から渡したい。LC_ALL とか LANG とかタイムゾーンとかアクセストークンとか SSH の鍵とか色々。

しかしdocker compose 環境変数 ホストOS ゲストOS 渡す 方法 各種」で Qiita 記事に絞ってググっても、まとまったものがなかったので、自分のググラビリティとして。

Docker を恐れずに一言で説明すると「Linux 環境の閉鎖空間で Linux のプログラムを実行するもの」です
つまり、Mac や Windows でも Docker は使えるものの、実際には仮想マシン(VM)上で Linux 環境を起動して Docker を実行しているだけなのです。そのため「docker は軽量と聞いたのに、そうでもない」という体感があるのは、Win や Mac では VM 上で Linux アプリを実行したのと速度やパフォーマンスの違いがないからです。(詳しくは Docker の基本参照)

TL; DR (今北産業)

  1. docker compose を使う場合(旧 docker-compose
    オススメは、「外部ファイルに環境変数を用意」しておき docker-compose.yml 内で指定してコンテナ起動時に読み込ませる方法です。ホスト OS 側で設定済みの環境変数も指定可能です。
    Dockerfile のビルド時に外部から環境変数を渡したい場合は、args 経由で宣言された ARG の変数に値を渡せます。[詳細]

  2. docker compose を使わない場合(旧 docker-compose
    オススメは、「外部ファイルに環境変数を用意」しておき docker run コマンドの --env-file オプションでコンテナ起動時に「絶対パス」で読み込ませる方法です。こちらも、ホスト OS 側で設定済みの環境変数も指定可能です。[詳細]

  3. この記事では docker run, docker build, docker compose run, Dockerfile, docker-compose.yml での「環境変数の渡し方」を説明しています。詳しくは TS; DR を参照ください。なお、オーケストレーション・ツールについては docker-compose のみで、docker swarmKubernetes などについては言及していません。

ホスト側で設定済みの環境変数が、何をしても渡せない場合
Docker 本体や OS のアップデート待ちが発生していないか確認してください。セキュリティ・アップデートが含まれていると、適用 & 再起動するまでロックされて渡されません。またビルドやコンテナ起動といった操作をする前に、env コマンドなどで環境変数の設定が反映されているか確認してください。特に VSCode のターミナルから操作する場合です。VSCode は、VSCode 起動時の環境変数しか保持していません。

docker-composedocker compose の違い
docker-composedocker 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 プラグインのディレクトリに設置する必要があります。

🐒  【注意点

環境変数を渡せる先には3箇所あります。「イメージ」「コンテナ」と「設定ファイル(Dockerfile)」の3箇所です。

  1. 「イメージ」に渡す場合は、変数はイメージに焼き付け(埋め込み)ます。
    そのため、そのイメージから作成されたコンテナ全てに反映されるため、値に注意します。イメージに焼き付けたくない値や上書きしたい値はコンテナ起動時に渡します。
  2. 「コンテナ」に渡す場合は、イメージから「コンテナ」を作成 or 起動する際に渡します。
    コンテナをイメージ化しない限り、コンテナが消滅すると渡した値も消えます。
  3. 「設定ファイル」とは、Docker イメージのビルド時に使う Dockerfile です。
    Dockerfile 内で定義した ARGDockerfile 内変数)の値を外部から渡せます。Dockerfile 内で、ARG の変数を、さらに ENV に代入すると「イメージ内の環境変数」として埋め込むことができます。しかし、RUN 内で ARG の値を書き換えても、他のステップには反映されないので注意します。同様に、docker-compose.yml から環境変数を Dockerfile のビルド時に渡したい場合も、args 経由で ARG 変数に渡し Dockerfile 内で利用したり、ENV に代入してイメージに焼き付けることができます。詳しくは TS; DR 参照。

しかし、どのような方法であっても環境変数で渡すということは、同じコンテナ内にあるサードパーティ製のライブラリからも参照できてしまうということに留意します。

例えサードパーティに悪意がなくても、デバッグ情報をダンプ出力する際に、環境変数も出力することが多くあるためです。つまり、CI などでエラーログに出力されてしまう、などに注意する必要があります。GitHub の GitHub Actions などでは、環境変数として渡したい場合は Environment secrets に登録することで渡せ、標準出力・標準エラー出力に同一のものがあった場合は、マスクしてくれます。

環境変数の渡し方の概要と基本構文

以下はコマンド毎にポイントをまとめたものです。各々の詳細な説明は 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 参照)
  • 環境変数を外部ファイルで渡す

    • 直接的な外部のファイル渡しは無い
    • 別途コンテナ内のスクリプトでファイルを読み込む工夫が必要

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_v18.09.0
$ 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_v1.23.2
$ 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 では通常「ホストOS」「ゲストOS」という呼び方はしません。ここではわかりやすくするため、Docker のエンジン本体を走らせている OS を「ホストOS」、Docker のベース・イメージ(コンテナ内の OS)を「ゲストOS」と呼んでいます。

Alpine OS のイメージの場合、デフォルトの環境変数は以下のようになります。この環境変数に任意の環境変数をコンテナ起動時に指定したいのです。

デフォルトの「環境変数」の確認(AlpineOSコンテナ内の場合)
$ # 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 の簡易説明
runコマンドの基本構文
docker run [ オプション ] <イメージ名> [ 実行コマンド ]
docker run --rm alpine env
  • docker run コマンド
    • イメージ名からコンテナを作成&起動するコマンドです。
  • --rm オプション
    • 実行終了後にコンテナを削除(remove)する指定です。わかりづらいですが、ロングオプション(-- で始まるオプション)です。
  • alpine イメージ
    • 作成するコンテナのイメージ名を指定しています。ここでは Alpine Linux のシンプルな Docker イメージ alpine を指定しています。docker build -t <イメージ名> . で作成した俺様イメージ名も指定できます。
  • env コマンド
    • コンテナ内で実行するコマンドを指定しています。ここでは Linux/Unix の、現在の環境変数を一覧で確認する env コマンドを指定しています。

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-argARG 受け取り。そして 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 の外部ファイル読み込みとしての INCLUDEIMPORT 命令の実装要望は長いこと(2013 年から)ディスカッションされていましたが却下されました。

これは、Dockerfile は特定のイメージを作成することに特化したものであり、INCLUDEIMPORT 命令をサポートすると依存が増えることになるため、シンプルさに欠けるからのようです。

そのため、docker-composevagrant1 といったオーケストレーション系のソフト経由で設定することをオススメします。

なお、間違えやすいコマンドに docker import がありますが、これは docker pull のローカル版です。つまり DockerHub 上にないイメージを利用したい場合に、docker save で保存したアーカイブされた Docker イメージをインポートするコマンドです。


docker-compose run 時に変数を渡す方法

docker-compose rundocker compose run のハイフン(-)違いですが、基本的に同じものです。
現在の docker CLI には docker-composev2 と同等のコマンドが標準で実装されています。つまり docker composedocker-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実行時に引数で渡す例
$ # 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.ymlenv_file:変数をファイル渡し

docker-compose run のオプションには外部ファイルの指定がありませんが、docker-compose.ymlenv_file: 変数にファイルのパスを指定すると環境変数を外部ファイルで設定することができます。これにより、docker-compose up などでも外部ファイルで環境変数を指定することができます。(次項参照)

docker-compose.yml 経由で変数を渡す方法

docker-compose.yml に記載した環境変数を渡す

environment: 変数で環境変数を指定渡し
ホスト OS の環境変数名で指定も可能(下記の場合 MY_SECRET

docker-compose.ymlの設定例(配列形式で渡す。コロンに注意)
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

docker-compose.ymlの設定例(辞書形式で渡す。イコールの有無に注意)
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 をマシンにインストールする時に CD や USB などからインストールすると思います。関係的に CD や USB などのメディアを「イメージ」、そこから作られた(マシンにインストールされた)ものが「コンテナ」に相当します。VM などの場合は、イメージから作られたインスタンスが「コンテナ」に相当します。
しかし、これは動きが似ているというだけで、仕組みは異なります。これらと違うのは、Docker の場合は OS(カーネル)が入っていません。Docker のイメージは、どちらかと言うと他で言う「スナップショット」や「スリープ処理」(現在のメモリの状態をファイルとして保存したもの)に近いものです。つまりカーネルさえ動いていれば、その上に保存したメモリを展開(コンテナとして起動)すれば動くのと似た働きをします。そのため「イメージに何を含めるのか」によってメモリの消費量に大きく影響します。Linux のフレーバー(ディストリビューション)を丸ごと使ってしまうと、使いもしないものまで入ってしまうので、そのぶんメモリを消費します。詳しくは後述。

環境変数でデータを渡すことの考察

🐒 結論から言うと「変数がどのように渡され、他者からも見ることができるか」を理解したうえで、開発用やローカルのマシンなどで非公開で動かすぶんには、環境変数渡しでも問題ないと思います。つまり、マシンが手元にあり、Docker 単独もしくは docker-compose で事足りる範囲での開発用途なら、ということです。
問題は、管理が広範囲に及ぶ場合です。例えば、1 つのコンテナに色々詰め込み過ぎて、実は意図しないサービス(プロセス)が立ち上がっているのに、プロセス一覧に埋もれて気づかない場合です。他にも Swarm や Kubernetes など、複数マシン・複数ネットワーク・複数コンテナといったクラウディでプロダクション・レベルでの利用です。その場合は Swarm や Kubernetes などが持つ暗号化の機能を使う必要がありますが、筆者は開発環境用、アプリのビルド用、CI の実行用にしか利用していないので、割愛します。

一般的なアプリの場合、セキュリティの観点から「センシティブなデータ」の環境変数経由での受け取りは推奨されません。他のアプリからも見れてしまうためです。

しかし Docker の場合は、環境変数を通してコンテナ内にデータを渡すことを多くみかけます。他にも GitHub ActionsTravis CICircle CIJenkins といった CI/CD などはデプロイ(成果物をリリースする際の公開先へのアップロード)に必要なアクセストークンなどを環境変数で渡しています。

コンテナの元となるイメージには焼き付けない、つまり「センシティブなデータ入りでイメージをビルドしない」というのはハードコーディングに通ずるのでわかります。しかし「環境変数経由でなく、なぜファイルをマウントして読み込ませないのかな」と思いました。

結論から言うと、ファイルをマウントさせる方法でも、環境変数経由でも構いません。なぜならリスク自体は変わらないからです。

ファイルをマウントする場合は、マウント・ポイント(マウント先)の仕様決め、フォーマット、ファイルのアクセス権、etc. を考えると、環境変数で渡した方が実装がらくでしょう。逆に、公開鍵などは、コンテナ起動時に -v などでマウントした方が楽でしょう。

どちらの方法であっても、同じ環境下にあるなら他のアプリやサービスからもアクセスできてしまうということを念頭に置く必要があります。これが共通するリスクです。

「いや、そもそもアプリやサービスごとにユーザとアクセス権を設定して、ファイルにもアクセス権を付けて制限させればいいじゃん」と思うかもしれません。同じサーバ内で色々と動かす場合の必須プラクティスですね。

しかし、Linux 管理者は長年の経験から、引き継ぎの煩雑さ、ドキュメント嫁に叱られるメンテナの多さなどの経験からポカよけを考えてきた結果、ある答えにたどり着きました。

「他のアプリを入れなければいい」です。

そして、その先にあるのがコンテナ技術です。「1 つのコンテナは 1 つのシンプルな目的だけに徹する」ことでリスク分散するだけでなく、各々が特化したサービスを提供しているため、安定性やメンテナンス性も向上します。

「1 つのコンテナに詰め込まない」って、どういうこと?

🐒 「1 コンテナ、1 アプリ」のルーツは、Linux に慣れ親しんだ人向けに一言で説明すると「ENTRYPOINT で指定した自分のアプリのプロセスが、コンテナ内の PID 1 になるから」です。
つまり、通常 PID 1 で動いている initsystemd のようなプロセスを管理してくれるサービス(デーモン)がコンテナ内に存在せず、変わりに自分のアプリが 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 が必要なのです。

先に、「アプリごとに 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 種です。機械学習の世界でも「直接はわからないが、関数を通して現れた、データの根幹となるデータ(特徴)」をカーネルと呼んだりするのも同じ語源です。

カーネルのうち、プロセスを管理するプログラムが 1 番目のプロセスで動いている initsystemdlaunchd などです。通常 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 側から届いた SIGTERMSIGKILL といった終了シグナルを、管理している子プロセスに伝達させることです。

これにより、各々の子プロセスが「後処理」を行い、全員が無事終了したことを確認してから、OS に終了準備ができたことを通知することができます。これを Graceful Shutdown と呼びます。

このため、PID 1 は「すべてのプロセスの親プロセス」と言っていいでしょう。

そして chroot は、そことユーザーのプロセスの間に入り、他のユーザーから隔離する(PID 1 と子プロセスの間に入り、子プロセス間の通信を管理する)機能です。

この機能を利用すると、ユーザー・ディレクトリ(/usr/myname)を、あたかも /(ルート・ディレクトリ)のように見せることができます。これが change root 略して chroot の語源です。

そのため、chdirls などを実行して(プロセスを走らせて)も、隔離されているため /usr/myname より上のディレクトリは参照できないし、他のユーザーのプロセスも見ることはできない、といったことができます。

「Python でいう pyenv の OS 版のようなもの」と言えばピンとくるでしょうか。

これは、他のユーザーが何をしているのか覗こうとコマンドを打った(プロセス化した)としても、カーネルがブロックしてくれるメリットが生まれます。

しかし、chroot には問題もありました。閉鎖された空間なので、最低限必要なファイルやライブラリのコピーを、ユーザーごとの空間(この場合、ユーザーのディレクトリ)に設置しておかないといけないことです。つまり「汎用的にカプセル化する」用途には向いていないものだったのです。

その後、cgroups という「必要なものをグループ化」する仕組みが Linux に組み込まれ、「状態 A の環境」「状態 B の環境」といったものが作れるようになりました。そして、それらの環境を作成しコンテナ化する LXC(Linux Containers)というソフトが生まれました。

LXC は、体感的には Linux 版の仮想マシンに近いものです。しかし、厳密には「仮想 Linux 環境をカプセル化する」ためのものです。つまり「ハードウェアを仮想化したもの」よりは、「Linux でのみ動く Linux 環境を仮想化したもの」という感覚が近いかもしれません。

このような概念を活用したものが「コンテナ技術」です。

「カプセル」と言わずに「コンテナ」と呼ばれるのは、一度作成した環境を別の Linux に持ち運べたり、積み上げたり(コンテナに、機能を追加し新たなコンテナを作成したり)できることを目的としているからです。先のファミコンや Switch のカセットやカードの例のように、Linux カーネル上に「カプセル化された環境」を載せて、プロセスを隔離しつつ動かせるということです。

そして、Docker は、そこから派生したプログラムの 1 つです。

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」を作っています。コードが出来上がっていく流れをみると「あー、隔離ってそういうことなのか」と気づきがあると思います。

Docker を改めて考える

前述のように、Docker は基本的に(ポカや意図して設定しない限り)ホスト OS 側の設定値やファイルにはコンテナからはアクセスできません。

また、他のコンテナの設定値やファイルにも直接アクセスできません。サンドボックスに近いものだからです。その為に Docker を使っているとも言えます。

そして Docker の場合「1コンテナ1プロセスが推奨されている」と言われます。これはコンテナごとにシンプルに1つのサービスのみを実行させる、と同義です。例えば Apache だけ、MySQL だけ、といったことです。

これは同じ Docker ネットワーク内に機能ごとにサーバーを設置するのと同じことになり、機能の入れ替え、拡張やメンテナンスが楽になるメリットを産みます。

🐒   ここで言う「同じ Docker ネットワーク内」というのは、Docker の仮想的な LAN のことです。
Docker には、コンテナが起動したときに接続できるネットワーク先(仮想 HUB or 仮想ルーターのようなもの)がデフォルトで 3 つあります。bridgehostnone です。
これは、コンテナ起動時に「どの HUB の LAN くちに挿してネットワークに参加するかが選択できる」と想像すると把握しやすいと思います。(接続可能なネットワーク先は docker network ls コマンドで確認できます)
bridge が、その Docker の仮想ネットワークで、デフォルトの接続先です。特に指定がない場合は bridge に接続され、ほかのコンテナと同じ LAN に参加します。コンテナ起動時に --network host オプションを付けると、ホストと同じネットワークに参加します。
1. bridge に挿した場合は、Docker コンテナ専用の LAN に参加。(デフォルト動作)
- docker run --rm -it --network bridge alpine ifconfig(Docker ネットワークの IP が振られる)
2. host に挿した場合は、ホストと同じ LAN に参加。
- docker run --rm -it --network host alpine ifconfig(ホストマシンと同じネットワークの IP が振られる)
3. none の場合は、誰もいない LAN に参加。
- docker run --rm -it --network none alpine ifconfig(お一人様ネットワークの 127.0.0.1 だけが振られる)
また、docker-compose でコンテナを起動した場合も、docker-compose.yml ファイル内に記載されているコンテナは、同じ仮想 LAN 内で起動します。もちろん network の設定も行えます。
複数コンテナの設定を一括で管理できるので「同じホスト OS」上に「複数コンテナを起動する」場合は docker-compose が使えると便利です。最新の Docker CLI(Win や macOS の最新の Docker Desktop)であれば、標準で同梱されています。
このような、複数コンテナを一元的に管理するツールを「オーケストレーション・ツール」と呼びます。
このオーケストレーション・ツールには、docker-compose のような同じホスト OS だけでなく、「複数のホスト 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 ProxyHAProxy など色々あります

コンテナをサービスごとに個別に立てられれば、開けないといけないポートも該当サービス用のポートだけでいいためシンプルになり、「当てたパッチが他のサービスに影響しないか」といった心配も減ります。

慣れないうちは、どうしても同じコンテナに複数サービスを入れたくなります。

「だって PHP や Go のビルトイン・サーバは大量のアクセスには向かないので、Apache と同じコンテナに入れたい」と思うかもしれません。

もちろん、それでも結構です。しかし、結局のところ、大量アクセスのハンドリング交通管理をしているのが Apache というだけで、PHP のプロセスは次々と作成されていきます(特に CGI 版の場合)。

また、そのコンテナが落ちた場合、Apache の下にいるプログラムが全て一緒に落ちます。さらに、そのコンテナにさまざまな機能(サービス)が凝縮されていた場合、負荷分散や増設がしづらくなります。俗に言う「SPOF」の 1 つになりかねないのです。

そこで Docker は、アクセス数が増えたら比例して複数の PHP や Go のコンテナを立てるという考え方をします。そして、Nginx のコンテナなどで負荷分散するという考え方をします。

つまり、接続が空いているコンテナに回したり、各々のコンテナに持ち回りさせることでロードバランスするということです。コンテナ同士が共通の DB さえ参照していれば、プロキシにより外部からは 1 つのサービスとして見えます。

これをサポートするのが docker-compose、Docker Swarm や Kubernetes といったオーケストレーション・ツールです。Kubernetes などは自動的に起動するコンテナを増減してくれます。

🐒  docker-compose は、同じマシン内で Docker ネットワークを組むもの。Docker Swarm は同じ LAN 内の別々のマシンで Docker ネットワークを組むもの。Kubernetes は別々のネットワークの、別々のマシンで Docker ネットワークを組むもの、といった認識から入ると良いと思います。
どの方法であっても、外部から見ると 1 つの Docker ネットワークに見せることができます。つまり、外部からは 1 つのポート(例えば Web など)にアクセスしていても、実は裏側(Docker ネットワーク内)では余力のあるコンテナにつながっているという動作になります。そのため、Docker 関連では Apache より Nginx が好まれるのは、プロクシ機能(外部からの接続を内部のマシンに接続・転送する機能)に特化しているからです。
このような 1 つのサービスを複数マシンで提供する仕組みをクラスタと言います。もちろん複数サービス(複数のポート)も同様の仕組みで提供できます。
以下の動画は、英語で、いささかクセがあるのですが、Kubernetes の軽量版である K3S を使って、複数台のラズパイ 4Raspberry Pi 4でクラスタを組み、Minecraft サーバをゼロから建てる方法を具体的に紹介しています。Kubernetes などのオーケストレーションに興味を持つも、微妙に実感がわかない方は、ざっと流れを観るだけでもピンとくると思います。

このように(docker-compose などで)複数コンテナを同じ Docker ネットワークで起動させるというのは、各々の機能が独立したサーバーとして、同じ LAN 内で動いているのと同義です。

そのため、「コンテナ間のやりとりが必要」な場合は「サーバー間のやりとりと同じ実装が必要」ということになります。つまり、UNIX ドメインのソケットが使えず、基本的に INET ドメインのソケットを利用することになります。

「INET ドメインのソケット」と言うのは「ネットワークカードにつながった通信くち」という意味です。

UNIX/Linux では、データをやりとりする窓口を「ソケット」と呼び、その通信先がネットワークカードの場合を「INET ドメイン」と呼びます。

また、UNIX/Linux ではソケットは実態のないファイルとして作成され、そのファイルを読み書きすることで、ソケットの接続先にデータを送受信します

そのため Linux の場合、Bluetooth、シリアル・ポートや USB など、各種デバイスの通信も、このソケット(のファイル)を読み書きすることでアクセスできます。そして、読み書きする際のバイナリ・データの書式が、俗に言う「通信プロトコル」です。

そして、ネットワーク・カード・デバイスに接続されたソケットが「INET ドメインのソケット」になります。

「ドメイン」とは、「属する」とか「所属する」とか「下に何かがぶら下がっている組織」的なものを言います。そのため、「INET ドメイン」と呼ぶのは、ネットワークカードに属するプロトコルが複数あるからです。

例えば HTTP, SSH, FTP などです。そして、各々のプロトコル用に用意したソケットをポートと呼びます

つまり「INET ドメインのソケットを利用する」というのは、「任意のポートでインターネットで使われるプロトコルを通して通信する」ということです。

具体的には 8080 ポートで待機する WebAPI を作り、専用のコンテナを立て、他のコンテナから 8080ポートに HTTP リクエストを送り、レスポンス結果を利用する、といったイメージです。

Linux において、デバイスのソケットは(シンボリックリンクなどのように)実態がないとは言えファイルです。そのため複数プロセスが同時に同じファイルにアクセスできません。そこで、代表してファイルを開き、ファイルの読み書きを一手に請け負うプログラムも存在します。例えば MIDI やオーディオ・デバイスの場合は JACK などです。

さて、閑話休題。

問題は、各々のコンテナの 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種またはそのたぐいであることはご存知だと思います。そして VirtualBOXVMWare といった仮想マシン系アプリを見聞きしたこともあると思います。

VirtualBOX や VMWare はハイパーバイザ型と呼ばれ、ハードウェアを仮想化して、その上に任意の OS を乗せる技術です。

Docker と何が違うのかと言うと、恐れずに言うなら Docker は「プロセス」を仮想化した技術であるということです。

どういうことか。

例えば、以下の Bash スクリプトを実行したとします。

sample.sh
#!/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-nelkern です。

そして、待機しているプロセスは、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 なども、この概念を取り入れたりしています。

この概念をカーネルベースにしたものが KVMKernel-based Virtual Machine)と呼ばれるものです。

Docker は、単純に、この概念を利用した成功者に過ぎないのです。おそらく、その成功の理由としては、Golang(Go 言語)で開発されたことにより、マルチ・プラットフォーム対応やインストールが容易だった(扱いやすい)こともあったのだと思います。

コンテナ技術の歴史に興味のある方は「KVM 歴史」でググるなどしてみたり、以下の記事なども参考にすると面白いと思います。

コンテナの種類

さて、コンテナの話しに戻りますが、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コンテナ、複数プロセスにしてみましょう。

  • メインの無限ループ・スクリプト
sample.sh
#!/bin/sh

while true
do
  sleep 60
done

  • 複数プロセスを起動する ENTRYPOINT のスクリプト
entrypoint.sh
#!/bin/sh

# このスクリプトは3つのプロセスを実行します。

# バックグラウント実行
/root/sample.sh &
/root/sample.sh &

# フォアグラウンド実行
# すべてをバックグランドで実行すると、この entrypoint.sh 自体が正常終了
# してしまい、コンテナも終了してしまう。そのため、最低 1 つはフォアグラウンドで
# 動かしておく必要がある。
/root/sample.sh

  • コンテナのイメージ用 Dockerfile
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 が丸ごと入っているので、「(なんか無駄だよなぁ)」と感じることでしょう。

ここで、先の「コンテナをわけることはサーバーをわけるのと同じ」ということが活きて来ます。

  1. アプリが必要な OS のライブラリやモジュールなど
  2. アプリ本体
    3.(非コンパイル型言語の場合は「プログラム言語のランタイム」)

だけをコンテナに 1 つにしたものを Linux カーネル上で動かして 1 サーバとして機能させるのです。

そして「コンテナ間通信=サーバー間通信」ということになります。

逆に言えば、別のコンテナの内部で起きていることは他のコンテナからはわからないということでもあります。むしろ知る必要もなく、リクエストに対してレスポンスが返ってくるだけでいい、ということです。

そのため、主なプロセスごとにコンテナをわけることは、各コンテナは自分のコンテナが提供しているサービスに注力できるので、不要なポートは安心して閉じれるということです。また、余計なサービスやアプリも入れていないので、意図しない脆弱性に悩まされる率も減ります。

これがセキュリティの面でのメリットです。

また、たとえコンテナの中で root 権限を持ったとしても、他のコンテナ(サーバー)にも root で入れるというわけではないことも大事です。

これは Web サーバーを外部に公開して、ハッキングで侵入されたとしても、他のコンテナにアクセスしにくいだけでなくホスト OS のローカルにも入れないということにつながります。

しかし、プロセス間通信のために privileged モードで構築すると、この前提が崩れてしまうので諸刃の刃といったところでしょうか。

  1. Vagrant(ソフトウェア) @ Wikipedia

387
324
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
387
324