【設定自動化】docker composeのコンテナごとの設定をすべて一つのenvから注入する

# 今回の目的
- 開発環境でdockerを利用することで開発環境を統一できるようにはなるものの、laravelなど設定ファイルの記述が多いものや、コンテナ自体が多い場合などにdockerを立ち上げるまでにいろいろ設定ファイルを記述する必要があります。
- これが少し面倒だなと感じ、自動的に注入できるようにはできないかと考えました(単なる**横着**です🤫)。

:::note warn
今回の記事はあくまで**開発環境**での利用を目的としているので、あとで記載しますが本番環境ではセキュリティ的にあまりよくない環境になります。あまりないかもしれませんがこの設定ごとvpsやec2などには挙げないでください（対処方も載せますが保証はできないので💦）。
:::
# 前提知識
- Dockerの`compose.yml`の記述方法
- 使用したいアプリケーションの設定ファイルの記述方法

:::note info
例では、nginx, laravel, react(vite)の設定ファイルの注入を行っていますが、**おそらく**他のアプリケーションでも同様に使用できるはずです。
できないフレームワークなどあればごめんなさい🙏
:::
# サンプルのプロジェクト構成
```
myproject(root) /
 ├─ .vscode /
 │   ├─ setting.json
 │
 ├─ apps /
 │   ├─ api / # laravel用のコンテナ
 │      ├─ scripts /
 │         ├─ entrypoint.sh
 │      ├─ templates /
 │         ├─ .env.template
 │      ├─ myproject-api /
 │         ├─ .env
 │         ├─ その他laravelディレクトリ群
 │      ├─ Dockerfile
 │      ├─ docker.conf
 │      ├─ php.ini
 │   ├─ web / # react(vite)用のコンテナ
 │      ├─ scripts /
 │         ├─ entrypoint.sh
 │      ├─ templates /
 │         ├─ .env.local.template
 │      ├─ myproject-api /
 │         ├─ .env.local
 │         ├─ その他reactディレクトリ群
 │      ├─ Dockerfile
 │
 ├─ postgres /
 │   ├─ Dockerfile
 │
 ├─ nginx-proxy /
 │   ├─ frontend
 │      ├─ sh
 │         ├─ entrypoint-nginx.sh
 │      ├─ templates
 │         ├─ default.conf.template
 │      ├─ log /
 │   ├─ backend
 │      ├─ sh
 │         ├─ entrypoint-nginx.sh
 │      ├─ templates
 │         ├─ default.conf.template
 │      ├─ log /
 │   ├─ sh
 │      ├─ entrypoint-nginx.sh
 │   ├─ templates
 │      ├─ default.conf.template
 │      ├─ domain_list.template
 │   ├─ log /
 │   ├─ .gitignore
 │
 ├─ .env
 ├─ .env.example
 ├─ .dockerignore
 ├─ .gitignore
 ├─ compose.yml
 └─ README.md
```
- 汎用的に使えることを示すためにnginxの構成など少し複雑になってはいますが、単純化(nginxプロキシを一つにするなど)も自由に変えてください。
# 実装の流れ
- イメージしやすいようにまず実装のある程度の流れを説明した後に実際のコード記述に入っていきます。
    - 通常通り、`Dockerfile`や`compose.yml`、その他必要なファイルを構成していく。
    - プロジェクト直下に`.env`ファイルを作成して、プロジェクト全体での設定を記載していく。
    - この`.env`を各々のコンテナに適用させることでこれがコンテナ内の環境変数として渡される。
    - 後はこれを起動時に実行するシェルスクリプトと各アプリケーションの設定ファイルのテンプレートを書いていく。
- わかりにくいところがあると思うので、githubにてサンプル用のコードを貼っておきます。
- [サンプルコード](https://github.com/ablankz/docker-env-injection.git)
# プロジェクトの立ち上げ
### プロジェクトのクローン

```sh
git clone https://github.com/ablankz/docker-env-injection.git
```

### 事前準備とhosts ファイルの追記

- 今回は作成するプロジェクトを`myproject`、api のプロジェクト名は`myproject-api`、web のプロジェクト名は`myproject-web`とします
- また使用するドメインは、web フロントを`myproject.com`、api サーバーを`api.myproject.com`とします
- 今回、localで動作するように設定するのですが、本番でもそのまま使えるコード（あくまでアプリケーション内のコード）にするために、ドメインもwebは`myproject.com`、apiは`api.myproject.com`のように動作するようにしたいのでまずはこれらの名前解決をlocalで行わせるようにします。
- unix系の場合なら`/etc/hosts`を編集。windowsなら[[共通] hostsファイルの編集（WindowsOSのPCの場合）](https://faq.wadax.ne.jp/s/article/1345)など多く記事が上がっているのでこれを確認して以下のような設定を**追記**してください(元の記述は残しておいてください)。

    ```hosts
    127.0.0.1        myproject.com
    127.0.0.1        api.myproject.com
    ```
### api サーバーのプロジェクト作成

- `apps/api`まで移動した後、以下のコマンドでプロジェクトを作成する
- `myproject-api`の部分は先程決めたapiのプロジェクト名が入る
    ```sh
    composer create-project laravel/laravel --prefer-dist myproject-api
    ```

### webサーバーのプロジェクト作成
- `apps/web`まで移動した後、以下のコマンドでプロジェクトを作成する
- projectが聞かれたときに先程決めたwebのプロジェクト名を入力する
- 他の設定は基本的には以下のような設定で良さそう
    ```sh
    npm create vite
    Need to install the following packages:
    create-vite@4.4.1
    Ok to proceed? (y) y
    ✔ Project name: … myproject-web
    ✔ Select a framework: › React
    ✔ Select a variant: › TypeScript + SWC
    
    Scaffolding project in /home/blank/docker-env-injection/apps/web/myproject-web...
    
    Done. Now run:
    
      cd myproject-web
      npm install
      npm run dev
    ```
- ターミナルにも書いてくれてるコマンドを実行する(`npm run dev`は今はいらない)
    ```sh
    cd myproject-web
    npm install
    ```
- `apps/web/myproject-web/vite.config.ts`を以下のように書き換える(docker内でhotreloadを解決するため)
    ```ts
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react-swc'
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [react()],
      server: {
        host: true,
        watch: {
          usePolling: true,
        },
        hmr: {
          path: "_vite/ws-hmr",
        },
      },
    })
    ```

### envファイルの記載

- プロジェクト直下の`.env`にコメント記述通りの設定を記載していく

### コンテナの立ち上げ

```sh
docker compose up -d
```
# 失敗した時（もしくはコンテナが立ち上がらない時）
- 大抵の場合はwebやapiの作成したプロジェクト名と`.env`で設定したプロジェクト名が異なったり、`hosts`で設定したドメインと`.env`で記述したドメインが異なるなどだと思います
- 以下のような感じのエラーが出た場合
    ```sh
    Error response from daemon: error while mounting volume '/var/lib/docker/volumes/docker-env-injection_node_modules/_data': failed to mount local volume: mount /path/to/project/apps/web/myproject-web/node_modules:/var/lib/docker/volumes/docker-env-injection_node_modules/_data, flags: 0x1000: no such file or directory
    ```
- `.env`の`APP_ROOT_PATH`の記述ミスだと思うのですが、このエラーになるとdockerがvolumeを記憶させてしまうらしく、通常は`.env`を修正後、`docker compose down`した後、`docker compose up -d`をすると良い(`docker compose restart`でも良い)のですが、この場合は一度以下のコマンドでvolumeも削除してから再度startを行ってください(もちろん`.env`の修正を行ったあとで)
    ```sh
    docker compose down --volumes --remove-orphans
    ```

# 重要部分のコードリーディング
- 実装の流れを追っていきたいところですがファイル数が多いため、お手数ですが必要な設定ファイルなどだけ追っていくので、残りはgithubのリポジトリの方をご確認ください🙇
### `compose.yml`と`.env`ファイル
- プロジェクト直下の`.env`ファイルは以下のように記述します
    ```.env
    変数名=値

    ####### 以下に例を示す ######

    # プロジェクトのrootパス
    # unix系ならプロジェクト直下でpwdで確認したものを貼る
    APP_ROOT_PATH=/path/to/project
    
    # デフォルトのデータベースの設定
    POSTGRES_DB=myproject # はじめに作るdb(基本的にはプロジェクト名でok)
    POSTGRES_USER=postgres # 基本的には変更なし
    POSTGRES_PASSWORD=postgres # 基本的には変更なし
    POSTGRES_PORT=5432 # 基本的には変更なし
    ...
    ```
- その`.env`ファイルを`compose.yml`の以下の部分などでenvfileに設定しています
    ```compose.yml
      web: # reactWebサーバー
        container_name: ${APP_NAME}-web
        tty: true
        build:
          context: "./apps/web"
        volumes:
          - ./apps/web/${WEB_APP_NAME}:/usr/src/app
          - node_modules:/usr/src/app/node_modules
          - ./apps/web/scripts:/docker-init/scripts
          - ./apps/web/templates:/docker-init/env_templates
        environment:
          - TZ=Asia/Tokyo
          - CHOKIDAR_USEPOLLING=true
        env_file: ./.env # 👈ここ
        command: sh -c "chmod +x /docker-init/scripts/entrypoint.sh && /docker-init/scripts/entrypoint.sh"
    ```
    - これを設定したコンテナでは環境変数としてこの`env`ファイルの変数たちがはじめから設定されているような感じになります。
### `templates`と`sh`(`scripts`)
- 次にこの環境変数をもとにそれぞれの設定ファイルを記述する必要があるのですが、はじめに実行できるコマンドを設定するのは後述しますが、ここでは例として`nginx-proxy/backend/sh`と`nginx-proxy/backend/templates`について説明します。
- おそらく1コンテナさえ理解できればあとは、templateだけが変わって残りは同じです
#### nginx-proxy/backend/templates/default.conf.template
```default.conf.template
server {
    listen ${BACKEND_PROXY_PORT};
    listen [::]:${BACKEND_PROXY_PORT};

    server_name "${PROXY_SERVER_BACKEND}";

    root /var/www/${API_APP_NAME}/public;
    ...
}
```
- このファイルは単に埋め込みたい環境変数の箇所を`${BACKEND_PROXY_PORT}`のように記述しているだけのtemplateファイル。
- これと次に説明する`sh/entrypoint-nginx.sh`は`compose.yml`の以下の部分でvolumeに登録しているのでこのファイルは起動時、コンテナにマウント？される
    ``` compose.yml
    ...
     backend-server:
        container_name: ${APP_NAME}-backend-server
        image: nginx
        volumes:
          - ./apps/api/${API_APP_NAME}:/var/www/${API_APP_NAME}
          - ./nginx-proxy/backend/templates:/etc/nginx/conf.d/templates # 👈ここ
          - ./nginx-proxy/backend/sh:/etc/nginx/conf.d/sh # 👈ここ
    ...
    ```
#### nginx-proxy/backend/sh/entrypoint-nginx.sh
```sh
#!/bin/bash
expand_variables() {
    local content
    content=$(< "$1")  # テンプレートファイルの内容を読み込む
    
    # 環境変数を正規表現で検索し、対応する値で置き換える
    for var in $(compgen -A variable); do
        value="${!var}"  # 環境変数の値を取得
        content="${content//\$\{$var\}/$value}"  # 置換
    done
    echo "$content" > "$2"
}

expand_variables /etc/nginx/conf.d/templates/default.conf.template /etc/nginx/conf.d/default.conf
nginx -g 'daemon off;'
```
- 簡単に言えば、/etc/nginx/conf.d/templates/default.conf.templateファイル(compose.ymlにてvolumesに登録済み)の`${変数名}`となっている部分に環境変数のその変数名を埋め込んで`/etc/nginx/conf.d/default.conf`(nginxの設定ファイルを記述する場所)に貼り付けるというシェルスクリプトです。
- 例えば`${変数名}`の形がいやな場合はtemplateは別の形式の`$\変数名\`などにしたりして、このスクリプトファイルの`expand_variables`関数の中身を変更すればok。
- ここで重要なのは一番最後の行の`nginx -g 'daemon off;'`。これはnginxを立ち上げるためのコマンドです。
    - 実はdockerではコマンドは一つしか登録することができず、nginxのimageでコンテナを立ち上げた場合、これがコマンドに登録されているわけです。
    - しかし、このシェルスクリプトを後述する`compose.yml`にてcommandに登録すると、このshスクリプトがこのコンテナのコマンドとなり、本来実行されるべき`nginx -g 'daemon off;'`が実行されず、最終的に実行するべきプロセスがなくなり、コンテナは落ちてしまいます。
    - nginxではこのコマンドですが、他のコンテナでこの方法を利用するときはこのことに十分注意してください。
- また`expand_variables`関数では上書き`>`で上書きしているのでもとの`/etc/nginx/conf.d/default.conf`の設定は残らないことになります。
    - これの解決策としてはapi(laravel)の方のこのファイルを見ていただければ確認できると思います。
        ```apps/api/scripts/entrypoint.sh
        #!/bin/bash
        expand_variables() {
            # 省略
        }
        
        # envの作成
        app_key=`grep '^[[:space:]]*APP_KEY=' /var/www/${API_APP_NAME}/.env`/.env`
        
        expand_variables /docker-init/env_templates/.env.template /var/www/${API_APP_NAME}/.env #それ以外を追記
        
        echo ${app_key} >> /var/www/${API_APP_NAME}/.env # key保存
        
        # fpmの起動
        sh -c "/usr/local/sbin/php-fpm"
        ```
    - laravelでは`APP_KEY`を保存しとくべきなのですが、何もしなければただ上書きされてこの情報が消えてしまいます。
    - そこで`APP_KEY=`から始まる行を元の`/var/www/${API_APP_NAME}/.env`から探してきて、見つかれば、それをapp_keyに保存。
    - `expand_variables`で上書きした後に今回は`>>`でapp_keyに保存された行を追記しています。
    - 保存はこのように行い、他にも様々な設定ファイルに合わせてこのシェルスクリプトをカスタマイズしていけば、コンテナごとにうまく設定できるはずです
#### コンテナへコマンドの設定
```compose.yml
  backend-server:
    ...
    volumes:
      - ./apps/api/${API_APP_NAME}:/var/www/${API_APP_NAME} # laravelのアプリケーションサーバーにはアクセスせず、同じディレクトリ参照するだけ
      - ./nginx-proxy/backend/templates:/etc/nginx/conf.d/templates
      - ./nginx-proxy/backend/sh:/etc/nginx/conf.d/sh
      - ./nginx-proxy/backend/log:/var/log/nginx
    restart: always
    command: sh -c "chmod +x /etc/nginx/conf.d/sh/entrypoint-nginx.sh && /etc/nginx/conf.d/sh/entrypoint-nginx.sh" # 👈ここ
    ...
```
- 最後はコンテナ立ち上げ時に毎回このシェルスクリプトを実行するようにします
- まずコンテナ内にvolumeで置かれただけではshの実行権限がないので、まずは権限を付与します
:::note info
権限付与はvolumeではなく、Dockerfileにて、初回build時にshをコピー後、runで権限付与することで毎回の実行を不要にすることも可能ですが、ぞれではshに変更があった場合に毎回buildをし直す必要があるのと、そこまで重たい処理ではないので毎回実行するように設定しています
:::
- そしてそのshを実行

# やってみて感じたこと

- これでコンテナを立ち上げる度に各コンテナのtemplateとプロジェクト直下の`.env`のみからコンテナごとの設定ファイルを自動注入できるようになりました
- こういった設定ファイルは通常、GitHubなどに挙げるわけにもいきませんが、templateをあげるのにはなんら問題ないため、プロジェクト開始後は複数人で開発するにしても設定ファイルを一つ書くだけで済むようになるというのが意外と便利だなと思ってます

# 注意点

- コンテナすべてに.envに記載した設定情報が環境変数として組み込まれる(これがはじめに言ったセキュリティの問題)
  - もともと開発環境のために作った構成なので、本番環境では使用しない
  - もしくは`api.env`や`web.env`など、設定ファイルを分けて`envfile`の指定にそれぞれ設定すれば良いが、これだとそれぞれの設定ファイルを記述するのと変わらない気がする😅
- 例えば、laravelのenvファイルを変更した際、そのコンテナが立ち上がっている際はその設定が保たれるが、一度落とすと`.env.template`の設定に合わせて`.env`の内容が消えるので注意
  - laravelの`APP_KEY`など残したい設定がある場合は`apps/api/scripts/entrypoint.sh`にて以下のように記述する
    ```
    ...
    app_key=`grep '^[[:space:]]*APP_KEY=' /var/www/${API_APP_NAME}/.env` # APP_KEYの値をapp_keyに保存
    
    expand_variables /docker-init/env_templates/.env.template /var/www/${API_APP_NAME}/.env # templateを元に環境変数を埋め込み追記
    
    echo ${app_key} >> /var/www/${API_APP_NAME}/.env # keyを後から追記
    ...
    ```
- postgresサーバーなどdocker内のネットワークで閉じているのでコンテナの外だとから`php artisan migrate:fresh`などデータベースアクセスコマンドが失敗する
  - portをコンテナ外部に出した後、laravelの設定テンプレートのdb_hostやdb_portをコンテナ内部から外に出たものに変更する
  - もしくは毎回、コンテナ内に入ってから実行する、僕は以下のようなスクリプトを作成して簡単にartisanコマンドを実行できるようにしています
    ```container-artisan.sh
    #!/bin/bash
    args=("$@")
    docker compose exec api php artisan "${args[@]}"
    sudo chmod -R a+w ./database
    sudo chmod -R a+w ./app
    sudo chmod -R a+w ./config
    sudo chmod -R a+w ./tests
    ```
  - `./container-artisan migrate:fresh`のように使用できる