Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
236
Help us understand the problem. What are the problem?

posted at

updated at

Organization

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

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

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

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

なお、ホスト側で設定済みの環境変数が渡せない場合、Docker 本体や OS のアップデート待ちが発生していないか確認してください。セキュリティ・アップデートが含まれていると、適用 & 再起動するまでロックされて渡されません。

TL; DR (今北産業)

  1. docker-compose が使える場合
    オススメは、「外部ファイルに環境変数を用意」しておき docker-compose.yml 内で指定してコンテナ起動時に読み込ませる方法です。ホスト OS 側で設定済みの環境変数も指定可能です。[詳細]

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

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

🐒  【注意点】 環境変数を渡せる先には3箇所あります。「イメージ」「コンテナ」と「設定ファイル(Dockerfile)」の3箇所です。
①「イメージ」に渡す場合は、変数をイメージに焼き付け(埋め込み)ます。そのため、そのイメージから作成されたコンテナ全てに反映されるため、値に注意します。

② イメージに焼き付けたくない値や上書きしたい値は、イメージから「コンテナ」を作成 or 起動する際に渡します。コンテナをイメージ化しない限り、コンテナが消滅すると渡した値も消えます。
③ 最後の「設定ファイル」とは、Docker イメージのビルド時に使う Dockerfile です。Dockerfile 内で定義した ARGDockerfile 内変数)の値を外部から渡せます。Dockerfile 内で、ARG の変数を、さらに ENV に代入すると「イメージ内の環境変数」として埋め込むことができます。詳しくは TS; DR 参照。

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

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

以下はコマンド毎にポイントをまとめたものです。各々の詳細な説明は 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
    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
      # 外部からの受付とデフォルト値(下記2つの FROM ブロックに適用できる)
      ARG name_base=php
      ARG my_app_version=dev
      
      # ----------------------------------------------------        
      # FROM ブロック開始
      # ----------------------------------------------------        
      FROM ${name_base}_1st_stage
      # FROM 内での利用宣言(宣言しておかないと受け取れない)
      ARG my_app_version
      ARG my_arch
      # 変数を環境変数にセット
      ENV VERSION_APP=$my_app_version
      # 変数を実行に利用
      RUN echo $my_arch
      
      # ----------------------------------------------------        
      # 別の FROM ブロック開始
      # ----------------------------------------------------        
      FROM ${name_base}_final_stage
      # 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=????????????
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 イメージをインポートするコマンドです。


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

コマンドの引数で環境変数を渡す

-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 にホストの環境変数を渡す際の注意点

VS Code が認識していないとコンテナにも渡されない。

VS Code + Remote-Containers 拡張機能 + Docker を使っていて「あれ?渡されないな」という場合、大抵の場合 VS Code がローカルの最新の環境変数を読み込めていない可能性があります。

VS Code は、VS Code 自身が起動した時点の環境変数を利用するからです。

Open in Container でコンテナを起動する(コンテナ上でリポジトリを開く)前に、VS Code のターミナル上で(ローカルの)環境変数を env コマンド確認してみてください。新規でセットした環境変数が反映されていない場合は、VSCode を再起動すると反映されます。

所感

取り扱い注意な値

コンテナ内のアプリに、アクセス・トークンをハード・コーディングせずに渡したい

ちょっとした開発で Docker コンテナ内のアプリやスクリプトからセンシティブな値、つまり「触る際に注意が必要な値」を扱う必要がありました。

アクセス・トークン、SSH や OpenSSL に必要な秘密鍵などの、どんな場合においてもハード・コーディングする(プログラム内に書いちゃう)のはご法度な設定値です。

そのため、一般的なアプリの場合は「呼び出す時に引数で渡す」か「外部ファイルとして読み込ませる」のが定石とされます。これは基本的に Docker の場合でも同じです。

🐒  Docker のイメージとコンテナの違い Windows、macOS、Linux など、OS をマシンにインストールする時に CD や USB などからインストールすると思います。関係的に CD や USB などのメディアを「イメージ」、そこから作られた(マシンにインストールされた)ものが「コンテナ」に相当します。VM などの場合は、イメージから作られたインスタンスが「コンテナ」に相当します。 これらと違うのは、Docker の場合は OS(カーネル)が入っていません。Docker のイメージは、どちらかと言うと他で言う「スナップショット」や「スリープ処理」(現在のメモリの状態をファイルとして保存したもの)に近いものです。つまりカーネルさえ動いていれば、その上に保存したメモリを展開(コンテナとして起動)すれば動くのと似た働きをします。そのため「イメージに何を含めるのか」によってメモリの消費量に大きく影響します。Linux のフレーバー(ディストリビューション)を丸ごと使ってしまうと、使いもしないものまで入ってしまうので、そのぶんメモリを消費します。

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

🐒 結論から言うと、変数がどのように渡されるかを理解したうえで、開発用や 1 つのサーバで動かすぶんには、環境変数渡しでも問題ないと思います。つまりマシンが手元にあり、Docker 単独もしくは docker-compose で事足りる範囲での用途です。 問題は Kubernetes など、複数マシン・複数ネットワーク・複数コンテナといったクラウディでプロダクション・レベルでの利用です。その場合は Kubernetes などが持つ暗号化の機能を使う必要がありますが、筆者は開発環境用、アプリのビルド用、CI の実行用にしか利用していないので、割愛します。

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

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

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

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

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

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

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

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

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

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

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

まず、先に言っておくと「コンテナに詰め込まない」と言っても「開発に使う場合に限り、コンテナには必要なものは詰め込んでおいた方が楽なことが多い」という前提があります。

「コンテナに詰め込まない」というのは CI での実行や、機能の単発利用、サービスとして稼働させる場合においての話しです。この場合は、ハイバーバイザー型にはない恩恵が受けられます。

Web 関連で例えると、俺様 Web アプリを作った場合は、Apache などの Web サーバや SQL のサーバだけでなく、目的に必須ではないなら wget すら入れていないコンテナにするのが理想ということです。

そして Apache、SQL、俺様 Web アプリなど、各々を別々のコンテナにします。

利用する際は、同じ Docker の閉鎖的なネットワーク内でそれらを起動して利用するのが理想的な使い方なのです。(開発の場合はこれに限りません)

そのため Docker そのものに慣れてきて、セキュリティがいささか心配になってきたら、次に docker-compose に慣れるのが Docker(もしくはコンテナ技術)のメリットを本格的に感じることができると思います。

すると、「いや、それは聞いたことあるんだけど、数ギガ近くあるイメージを複数立ち上げるとメモリがパンパンになるから、1 つのイメージに入れた方がメモリ節約にもなるし」と思うかもしれません。

しかし、それは「Word や Excel のアプリごとに Windows パソコンを用意する」ような考え方なのです。

なぜなら数ギガ近くあるのは、Ubuntu や Debian など OS が丸ごと入ったイメージの上にアプリやサービスを入れているからです。

それでは「Windows のカーネル(根幹機能)と Word と必要な DLL だけ・・の入った OS」だったらどうでしょう?

ブラウザどころかメモ帳や MS ペイントすら入っていません。かなりサイズは小さくなると感じると思います。ってか、もうワープロ状態です。

そこからさらに Windows のカーネルすらもブッこ抜いてみます。つまり「Word と必要な DLL だけが入ったもの」と「Windows のカーネル」を分けるということです。

そして Windows のカーネルの上に、「Word と必要な DLL だけが入ったもの」や「Excel と必要な DLL だけが入ったもの」などが入れ替えられるとアプリのコンテナは最低限で済みます。

この状態であれば「Excel だけ・・アップグレード」なども簡単にできると感じると思います。もぅ、ファミコンのカセットみたいな発想です。

実は、このカセットのような仕組みは Linux が元々持っていた機能がベースにあります。

Linux には元々 chroot という「最低限の OS 環境の上にユーザを置く」仕組みがあります。これをよりスリムにして、アプリごとにまで落とし込んだのが「コンテナ技術」です。

Linux の仕組みを使えば、Go 言語の場合 60 行以内でコンテナ・アプリを作ることができます。Docker が Go で作られているのも同じ理由です。

つまりコンテナの概念は昔からあり、Docker は、その仕組みを利用したアプリの成功者の 1 つでしかないのです。

以下は英語のライトニングトークの動画ですが、実際にコーディングしながら 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 サーバーなどを、各々にコンテナを立てるという意味です。

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

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

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

もちろん、それでも結構なのですが、Apache のバージョンアップのたびに PHP のコンテナもいじらないといけなくなります。

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

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

🐒  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 などのオーケストレーションに興味を持つも、微妙に実感がわかない方は、ざっと流れを観るだけでもピンとくると思います。 - i built a Raspberry Pi SUPER COMPUTER!! // ft. Kubernetes (k3s cluster w/ Rancher) @ Youtube

逆に言えば、各々の機能が独立したサーバーとして同じ Docker ネットワーク内で動いているため、「コンテナ間のやりとりが必要」な場合は「サーバー間のやりとりと同じ実装が必要」ということになります。

つまり、UNIX ドメインのソケットが使えず、基本的に INET ドメインのソケットを利用することになります。

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

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

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

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

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

「ドメイン」とは、「属する」とか「所属する」とか「下に何かがぶら下がっている組織」的なものを言います。そのため、「INET ドメイン」と呼ぶのは、ネットワークカードに属するプロトコルが複数あるからです。例えば HTTP, SSH, FTP などです。そして、各々のプロトコル用に用意したソケットをポートと呼びます。

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

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

さて、閑話休題。

問題は、各々のコンテナの API を利用する際の認証です。

もちろん、Docker ネットワークと言う閉ざされたネットワーク内での通信なので、認証無しで通信しても構わないことが多くあり、一般的な面もあります。(実装が楽になりますし)

しかし、コンテナのサービスを外部にも公開することを考えてセキュリティを重視した場合、認証を考える必要があります。一般的には OAuth などを実装し、各々のコンテナからアクセストークンを取得させます。

しかし全てのコンテナが OAuth のサーバー・クライアントの実装だけでなく、アクセストークン取得のフローまでも実装が必要となると大変です。

そこで、ホスト OS 側から各々のコンテナに直接アクセストークンを渡すことで、実装が楽になります。また、OAuth 認証させつつ別途トークンを渡すことで2要素・・認証的な役割も持たせることすらできます。

つまり、1つのコンテナにあれこれと機能(アプリやサービス)を詰め込みすぎないのであれば、コンテナ起動時に環境変数で渡しても Docker の仕組み的に問題ないということです。

もちろん、環境変数に限らずファイルのマウントでも同じことが言えるのですが、メリット・デメリットを理解せずに盲目的に使うのは危険です。

特に、中で何をしているかわからないプロプライエタリなブラックボックスタイプのライブラリや、得体の知れないサードパーティのライブラリを使っていたり、メンテナンスされていない(脆弱性の Issue や PR が放っておかれている)ものを一緒に入れている場合は大いに注意が必要ということでもあります。

Docker 内で使うアプリのライブラリすら、GitHub や Gitlab などにフォークしたものをわざわざ使う人がいます。これは「知らぬ間に得体のしれないものが入れられないように」というリスク管理のためで、フォークを本家に追随する手間やアプデ内容を毎回チェックする手間を惜しんでもセキュリティが気になるからです。

セキュリティと言えば、Docker のプラクティスの 1 つに「root 権限を持たないユーザを作成して、Dockerfile の最後に USER で指定しておく」というものがあります。

しかし、この設定はコンテナ内でパッケージがインストールできなくなるため、開発用途に使っている場合は root のままの人も多いかもしれません。コンテナ起動時に毎回 --user root を指定しないといけないため、面倒だからです。

root 以外のユーザにするこの設定はポカヨケの役割をするものです。

特に --privileged オプションを付けてコンテナを実行すると、コンテナ側からホストにアクセスできるようになります。この時、コンテナのユーザが root ユーザだと root でホストにアクセスしていることになります。

「(そんな wwww --privileged なんて、わざわざ付けたりしないよ)」と思うかもしれません。

しかし、そもそもホストのデバイスへのアクセスが必要な IoT 関連の記事には --privileged オプションを付けるものがあります。

特に、古い 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 です。

つまり、VirtualBOX や VMWare などのように OS やハードウェアを丸ごとシミュレーション(仮想化)しなくても、Linux のカーネルをシミュレーションできれば、その上にプロセスと、プロセスが必要とするフレイバー(Linux 系 OS の各々の特徴)のプロセスを乗っければ動くと言う理屈です。これが VirtualBOX などのハイパーバイザ型と比べて軽量であると言われる理由です。

この、必要なプロセスを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 モードで構築すると、この前提が崩れてしまうので諸刃の刃といったところでしょうか。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
236
Help us understand the problem. What are the problem?