様々な開発マシンでもPhoenixが動くDockerfile/docker-composeの作り方


この記事は[Elixir Advent Calendar 2021](https://qiita.com/advent-calendar/2021/elixir)の6日目です。

https://qiita.com/advent-calendar/2021/elixir

昨日は @h3_potetoで[「ElixirでHot Code Reloadingするときに気をつけること」](https://medium.com/ovice/elixir%E3%81%A7hot-code-reloading%E3%81%99%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AB%E6%B0%97%E3%82%92%E3%81%A4%E3%81%91%E3%82%8B%E3%81%93%E3%81%A8-b5058eec86dd)でした

# ごあいさつ

こんにちは [Koyo](https://twitter.com/KoyoMiyamura) です！普段はコミュニティで Elixir 開発しつつ、仕事ではRuby on RailsでWebアプリケーションをごにょごにょ書いております。

さて会社外でプロジェクトに参加していると、開発マシンがMacの人もいればWSL2やLinuxもいたりとバラバラだったりしますよね。なのでDockerを用いて開発環境をなるべく統一しよう！・・・と思ったのですが、意外にハマりどころがあって苦戦したので書いてみました。

- Mac
- WSL2 / Linux
- Mac / WSL2 / Linux 混在環境

の3パターンに分けて解説していきたいと思います！

なお説明で使うファイルはこちらにまとめております。

https://github.com/koyo-miyamura/elixir_phoenix_docker

# 想定バージョン
```
- elixir 1.13
- phoenix 1.6.x
```

※20220417 最新版にアップデート

# Docker Desktop for Mac で Phoenix が動く Dockerfile / docker-compose
こんな感じで動きます。今回はベースイメージはalpineにしてます！

```dockerfile:Dockerfile
FROM elixir:1.13-alpine

RUN apk update && apk add \
  inotify-tools git build-base npm bash

RUN mix do local.hex --force, local.rebar --force, archive.install --force hex phx_new
```

```yml:docker-compose.yml
version: '3'
services:
  web:
    build:
      context: .
    command: mix phx.server
    environment:
      DB_HOST: db
    links:
      - db
    ports:
      - 4000:4000
    volumes:
      - .:/work
    working_dir: /work
  db:
    image: postgres:14-alpine
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:
```

こんな感じで新しいプロジェクトも作ることができます！

```bash
docker compose run --rm --no-deps web mix phx.new sample
```

## ※Docker経由でPhoenixを動作させるときの注意点

なお、実際に動作させる場合は `Dockerfile` と `docker-compose.yml` を作成したプロジェクトのディレクトリ（今回はsampleディレクトリ）にコピーするのを忘れずに。
さらに `config/dev.exs` の編集が必要です。 
まず hostname を `docker-compose.yml` の環境変数で指定したホスト名にしてください。
また、ここがハマりポイントですが、デフォルトだと `http: [ip: {127, 0, 0, 1}, port: 4000],` になっているのを `http: [ip: {0, 0, 0, 0}, port: 4000]` に修正する必要があります。

```elixir:config/dev.exs
config :sample, Sample.Repo,
  ...
  hostname: System.get_env("DB_HOST") || "localhost",

...

config :sample, SampleWeb.Endpoint,
  # Binding to loopback ipv4 address prevents access from other machines.
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
  http: [ip: {0, 0, 0, 0}, port: 4000],
```

# Docker for Mac 以外（WSL2 / Linux など）でも動くように
上記でも `Docker Desktop for Mac` であれば root でコンテナを動かしてファイル生成しても、ホスト側のUID/GIDと一致するため問題ありません（ドキュメント探してもパッと見つからなかったので、理由分かる人いたら教えてくださいｗ）。

一方で他の環境（例えば `Docker Desktop for Windows` ）で先ほどの `mix phx.new` コマンドを実行してみると以下のようになってしまいます。

```bash
$ ls -l
drwxr-xr-x 9 root root 4096 Dec  5 20:24 sample/
```

コンテナのユーザが root であるため、作成されたファイルももちろん root になってしまいます。
`mix phx.new` 済みのプロジェクトであっても `mix deps.get` などでコンテナ経由でファイル生成することはよくあるため、これだとパーミッション周りのエラーが頻発し、困ってしまいますね。

## コンテナ内にホストと同一UID/GIDのユーザを作る

ということで、これを解決するために「コンテナ内にホストと同一UID/GIDのユーザを作りたい」と思います。
下準備として下記のようなMakefileを書いてみます。

```makefile:Makefile
.PHONY: setup

setup:
	echo "host_user_name=${USER}" > .env
	echo "host_group_name=${USER}" >> .env
	echo "host_uid=`id -u`" >> .env
	echo "host_gid=`id -g`" >> .env
```

実行すると `.env` ファイルが生成されます。

```bash
$ make setup
```

さて、これを踏まえて下記のようにDockerfile/docker-composeを作成します。
環境変数をdocker-compose.ymlに渡し、Dockerfileへはビルド時の引数という形でホストのユーザ情報を渡します。そしてコンテナビルド時にホストと同じUID/GIDのユーザが作られるようにします。

```dockerfile:Dockerfile
FROM elixir:1.13-alpine

RUN apk update && apk add \
  inotify-tools git build-base npm bash

ARG host_user_name
ARG host_group_name
ARG host_uid
ARG host_gid

RUN apk add shadow && groupadd -g $host_gid $host_group_name \
  && useradd -m -s /bin/bash -u $host_uid -g $host_gid $host_user_name

USER $host_user_name

RUN mix do local.hex --force, local.rebar --force, archive.install --force hex phx_new
```

```yml:docker-compose.yml
version: '3'
services:
  web:
    build:
      context: .
      args:
        - host_user_name=${host_user_name}
        - host_group_name=${host_group_name}
        - host_uid=${host_uid}
        - host_gid=${host_gid}
    command: mix phx.server
    environment:
      DB_HOST: db
    links:
      - db
    ports:
      - 4000:4000
    volumes:
      - .:/work
    working_dir: /work
  db:
    image: postgres:14-alpine
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:
```

さて、これで実行してみると今度はrootにならずにできていますね！

```
$ docker compose run --rm web mix phx.new sample
...
$ ls -l
drwxr-xr-x 9 koyo koyo 4096 Dec  5 20:52 sample/
```

# Mac / WSL2 / Linux 混在環境でも動くように
これで解決・・・と思いきや Mac 環境で上記の Dockerfile をビルドしてみると問題が発生してしまいます。
どうも Mac 側の GID と衝突してしまうよう・・・（いい回避策知ってたら教えてくださいｗ）。

```bash
 > [3/4] RUN apk add shadow && groupadd -g 20 koyo   && useradd -m -s /bin/bash -u 504 -g 20 koyo:                                                                          
#6 0.388 (1/2) Installing linux-pam (1.5.1-r1)
#6 1.901 (2/2) Installing shadow (4.8.1-r0)
#6 1.962 Executing busybox-1.33.1-r6.trigger
#6 1.968 OK: 264 MiB in 55 packages
#6 2.008 groupadd: GID '20' already exists
```

プロジェクトがMacのみ！とかMac以外のみ！であれば、上記のどちらかを選択して使えばいいかと思います。
しかし実際には混在していることが多いのではないでしょうか。
筆者が参加したプロジェクトでは Mac / WSL2 / Linux と様々なＯＳで開発している方が集まっていました笑
ということで頑張ってみましょう！

…とその前に、 `docker-compose.yml` を上書きする手法と、マルチステージビルドについて説明します。

## docker-compose の設定を上書きする

https://docs.docker.com/compose/extends/#example-use-case

`docker compose` コマンドは標準で `docker-compose.override.yml` という上書き用のファイルをサポートしています。これが存在する場合 `docker-compose.yml` の設定を `docker-compose.override.yml` で上書きしてくれます。

## マルチステージビルド

Dockerfile には実は「複数のFROM」を書くことができます。例えば1つ目のFROMフェーズでビルドを行い、そこで作成したビルド結果だけを後続のステージに渡すことで、コンテナ容量を削減できます。
今回はこちらをビルド処理の共通化に使ってみたいと思います。

```dockerfile:Dockerfile
FROM ... AS base

FROM base AS ...
```

https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds

## Mac / WSL2 / Linux 混在環境でも動く Dockerfile/docker-compose.yml

まず `Dockerfile` を以下のようにします。 `base` フェーズで共通の処理を行い、その後 `setup_user` フェーズを経由してユーザを作成する `build_as_user` と、経由せずユーザを作成しない `build_as_root`　フェーズに分岐します。

```dockerfile:Dockerfile
FROM elixir:1.13-alpine AS base

RUN apk update && apk add \
  inotify-tools git build-base npm bash

FROM base AS setup_user
ARG host_user_name
ARG host_group_name
ARG host_uid
ARG host_gid

RUN apk add shadow && groupadd -g $host_gid $host_group_name \
  && useradd -m -s /bin/bash -u $host_uid -g $host_gid $host_user_name

USER $host_user_name

FROM setup_user AS build_as_user
RUN mix do local.hex --force, local.rebar --force, archive.install --force hex phx_new

FROM base AS build_as_root
RUN mix do local.hex --force, local.rebar --force, archive.install --force hex phx_new
```

`docker-compose.yml` では `target: build_as_root` とすることでユーザを作成せずビルドが行われるようにしておきます。

```yml:docker-compose.yml
version: '3'
services:
  web:
    build:
      context: .
      target: build_as_root
    command: mix phx.server
    environment:
      DB_HOST: db
    links:
      - db
    ports:
      - 4000:4000
    volumes:
      - .:/work
    working_dir: /work
  db:
    image: postgres:14-alpine
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:
```

このままだと `Docker Desktop for Mac` 環境でしか動かないため、ここからさらに手を加えます。
まず `docker-compose.yml` とは別のディレクトリに以下の `docker-compose.override.yml` を配置します。
`target: build_as_user` とすることでユーザを作成してビルドが行われるようにしつつ、環境変数経由でホストのユーザ情報をDockerfileに渡します。

```yml:docker/docker-compose.override.yml
services:
  web:
    build:
      target: build_as_user
      args:
        - host_user_name=${host_user_name}
        - host_group_name=${host_group_name}
        - host_uid=${host_uid}
        - host_gid=${host_gid}
```

そして Makefile を以下のようにして、 `docker/docker-compose.override.yml` を `docker-compose.yml` があるディレクトリにコピーするようにしておきます。

```makefile:Makefile
.PHONY: setup_for_docker_user

setup_for_docker_user:
	cp docker/docker-compose.override.yml .
	echo "host_user_name=${USER}" > .env
	echo "host_group_name=${USER}" >> .env
	echo "host_uid=`id -u`" >> .env
	echo "host_gid=`id -g`" >> .env
```

こうすることで **「Macユーザはそのまま、そうでない場合は `make setup_for_docker_user` を実行してもらう」** とすることで、異なるOSが混在する環境でも Elixir/Phoenix を動作させることができます！あとは `README.md` にでも書いておくといいですね。
便利！

# まとめ

- Mac
- WSL2 / Linux
- Mac / WSL2 / Linux 混在環境

で動く Dockerfile と docker-compose.yml を作ってみました。特に「Mac / WSL2 / Linux 混在環境」で動くようにするのにだいぶ苦労したので、ぜひ使ってください笑
今回のDockerfileは一例ですので、alpineを使わないようにしてみるなど、プロジェクトの構成に併せて適宜カスタマイズしてみてください！（alpine使わずに動かす Dockerfile 書いたらぜひ記事にして教えてください！）

今回紹介したDockerfile群はこちらのリポジトリで公開しています！
READMEにも動かし方を書いてあるので参考にしてみてください＾ｗ＾

https://github.com/koyo-miyamura/elixir_phoenix_docker


私の記事はここでおしまいです！

明日は、@kn339264の「[Elixir/Phoenix入門者向けコミュニティpiyopiyo.exを立ち上げた話](https://qiita.com/kn339264/items/5567929bfc91de28b181)」です

ぜひぜひ読んでみてください！