【WSL + Mac対応】Laravel開発環境をDocker Composeで構築し、コンテナに非rootユーザーを作成&実行する方法

※以下、ホスト（WSLまたはMac OS）側の実行ユーザーのことをホストユーザー、コンテナを実行するユーザーをコンテナユーザーと表記します。

# 対象となる読者
- Docker, Docker Composeについての基本的な知識があり、過去にDockerを使って環境構築をしたことがある方
- Dockerのベストプラクティスを考慮した環境構築に興味のある方

# 概要
Docker Composeを使って、Laravel, apache, MySQL, PHPの実行環境(LAMP)を構築します。
`docker compose up` でコンテナを起動する際に、root権限でコンテナが実行されてしまうと色々と不具合が発生するため、コンテナの実行ユーザーとホストユーザーが同一になるように調整します。

Laravelが最低限動作して、シンプルで軽量かつベストプラクティスなコンテナ設計、ディレクトリ構成を意識しました。※ Windows 11 (WSL) とM1 Macでの動作確認済み。

# 結論
:::note info
経緯と説明のパートが長くなりそうなので、先に結論から始めます。
:::

以下の手順を踏むことで、表題の件を達成することが出来ました。

1. `Makefile` を使って、ホストユーザーの情報（ユーザーID、グループID、ユーザー名）を取得する
2. 1.で取得した情報を `compose.yaml` の `environment` に設定する
3. `docker compose up` でコンテナ起動時にユーザー作成用シェルコマンド`entrypoint.sh`を実行し、コンテナ内部にホストユーザーと同じユーザーを作成する
4. コンテナが立ち上がったら、Makefileに定義されているコマンド `make app` を実行し、3.で作成したユーザーとして、コンテナ内部に入る

# レポジトリ
https://github.com/june1983/docker-lamp

上記レポジトリにあるコードは、[@ucan-lab](https://qiita.com/ucan-lab)さんが、[こちらの記事](https://qiita.com/ucan-lab/items/5fc1281cd8076c8ac9f4)で紹介されているソースコードを下敷きにしています。
※元のソースコードでは、webサーバーに `nginx` を採用していますが、私のリポジトリでは `apache` を採用しています。

# 経緯
Dockerで環境構築を行い、コンテナの内部で `npm run build` のようなファイルを生成するコマンドを打つと、root権限で実行されてしまうため、ファイルの所有者がホストユーザーではなく、`root` になってしまいます。

:::note info
WSL特有の現象です。Macではファイルの所有者はホストユーザーに設定されます
:::

```bash
root@246b3c0d6829:/var/www/html/public/assets/js# ls -l
total 5208
-rw-r--r-- 1 root root 2159432 May 15 03:02 portal.js
-rw-r--r-- 1 root root 2534194 May 15 03:02 portal.js.map
-rw-r--r-- 1 root root  275321 May 15 03:02 stamp.js
-rw-r--r-- 1 root root  353392 May 15 03:02 stamp.js.map
```

**上記のようにファイルの所有者が一致しないとアクセス権の問題が生じます。
またコンテナユーザーがルート権限を持っている場合、ホスト側に重大なセキュリティリスクをもたらす可能性があります。**

Dockerの[公式ドキュメント](https://docs.docker.jp/index.html)でも非rootユーザーでのコンテナ実行を推奨しています。

>## USER
>サービスが特権ユーザでなくても実行できる場合は、 USER を用いて非 root ユーザに変更します。ユーザとグループを生成するところから始めてください。Dockerfile 内で、例えば次のように入力します。
>
>`RUN groupadd -r postgres && useradd -r -g postgres postgres`
>
>(引用: https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html#user)

なので、まずはDockerコンテナを非rootユーザで実行する方法から調べることにしました。

# Docker コンテナに非rootなユーザーを作成し、実行する方法
調査の結果、`docker compose up` または、`docker run` でコンテナを起動する際に、非rootなホストユーザーを指定して実行する方法をいくつか見つけることができました。

例えば、@yohmさんが書いた[こちらの記事](https://qiita.com/yohm/items/047b2e68d008ebb0f001)では、３通りの方法が紹介されています。

### ①ENTRYPOINTでuseraddでユーザーを作る
私が最終的に採用した手法に近いのですが、`entrypoint.sh` というユーザー作成用のシェルコマンドを用意しておき、Dockerfileがビルドされるタイミングで `entrypoint.sh` が自動的に実行されるというものです。
書いた
```Dockerfile:Dockerfile
FROM ubuntu:latest

RUN apt-get update && apt-get -y install gosu
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
```

`Dockerfile`の`ENTRYPOINT`で`entrypoint.sh`が読み込まれます。

```bash:entrypoint.sh
#!/bin/bash
USER_ID=${LOCAL_UID:-9001} // ユーザーIDを取得
GROUP_ID=${LOCAL_GID:-9001} // グループIDを取得

echo "Starting with UID : $USER_ID, GID: $GROUP_ID"
useradd -u $USER_ID -o -m user // ユーザーを作成
groupmod -g $GROUP_ID user // グループを作成
export HOME=/home/user

exec /usr/sbin/gosu user "$@" // 作成したユーザーとして実行
```
:::note warn
`entrypoint.sh`で受け取る環境変数はホスト側で予めエクスポートしておく必要があります。
:::

### ②`/etc/passwd` と `/etc/group` をコンテナにマウントする
こちらの方法ではホスト側でユーザー情報を管理しているファイル（`etc/passwd` と `/etc/group`）をマウントでコンテナ内部に同期することによって、ホストユーザーをコンテナ内部に再現しています。



### ③イメージに作成された一般ユーザーの `UID/GUI` を、ホストユーザーの `UID/GID` で変更する
③の説明は割愛します。（すみません:sweat_smile:）

---

また、[こちらの記事](https://qiita.com/yitakura731/items/36a2ba117ccbc8792aa7)は、おそらく前述の記事を読んだ方が書いたと思われますが、さらに踏み込んで `docker comose` を使って実装する方法が紹介されています。

他にも、[こちらの記事](https://qiita.com/twu_go/items/a449e3006bd74fc7d10d)のようにdockerコマンドに直接、ユーザーIDとグループIDを渡す方法もあります。

```bash
UID_GID="$(id -u):$(id -g)" docker-compose up
```

### 問題点
以上、ここまで紹介してきた方法では、単一のコンテナを起動する分には問題ないです。
**しかしながら、`docker compose` で複数のコンテナを連携しようとすると上手くいきません。:sob:**
実際の案件では、アプリケーション用のコンテナとデータベース用のコンテナを別で立てて、相互に通信したりすることが多いので、単一のコンテナしか扱えないというのは少し不便な気がします。

# なぜ docker compose だと上手くいかないのか？
案件などで開発環境を構築する場合、Dockerのオフィシャルなイメージを採用するケースが多いかと思います。

これらのイメージを構成する `Dockerfile` には元々、`ENTRYPOINT` に実行ファイルが定義されています。例えば、[php:8.3-fpm-bullseye](https://hub.docker.com/layers/library/php/8.3.0-fpm-bullseye/images/sha256-f4cbb76861749f8da2f6e7070e1dd34018881ee1a567569bca9ea8125ec230d4)の公開されているソースコードを見ると、[226行目](https://github.com/docker-library/php/blob/d4474c4041a17923f13a26a2595c786a98de9308/8.3/bullseye/fpm/Dockerfile#L226)に記述があります。

```Dockerfile:Dockerfile
ENTRYPOINT ["docker-php-entrypoint"]
```

`docker-php-entrypoint` は[別ファイル](https://github.com/docker-library/php/blob/d4474c4041a17923f13a26a2595c786a98de9308/8.3/bullseye/fpm/docker-php-entrypoint)にある実行ファイルで、`php-fpm` を起動します。

```sh:docker-php-entrypoint
#!/bin/sh
set -e

# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
	set -- php-fpm "$@"
fi

exec "$@"
```

こんな風にイメージがビルドされて、コンテナが起動するタイミングで `ENTRYPOINT` が実行されるわけですが、紹介した①の方法だと、`Dockerfile` に定義した新しい `ENTRYPOINT` で上書きされてしまいます。

```Dockerfile:Dockerfile
FROM ubuntu:latest

RUN apt-get update && apt-get -y install gosu
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# ここで上書きされてしまう。
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
```

**ENTRYPOINTが上書きされてしまうと、コンテナの起動に必要なサービスが実行されないため、`docker compose up` してもコンテナが途中で終了（Exit）してしまうのです。**

# 最終的に辿り着いた解決策
他にも以下の課題を解決したいと考えていました。
- Windows (WSL) だけでなく、Mac (Apple Chip) でも問題なく動作するようにしたい
- Dockerの扱いに慣れていないメンバーでも、使えるようにしたい

これらの要件を満たしつつ、なんとか複数のコンテナがサーバー内で起動している状態で表題の件を達成できないものかと調べていたところ、以下の記事を見つけました。

https://zenn.dev/hinoshiba/articles/workstation-on-docker

記事では、[Makefile](https://www.gnu.org/software/make/)を使ってホストユーザー情報の取得や環境構築に必要な設定などの複雑な処理を隠蔽し、シンプルなコマンドを打つだけでrootlessなコンテナ実行環境を構築する方法が紹介されています。また、WindoowsやMacなど異なるOSであっても動作し、且つDockerのベストプラクティスを考慮した作りになっているため、大変参考になりました。

ただし、この記事も `docker run` で単一コンテナを起動する想定で書かれているので、`docker compose` でも問題なく動くようにアレンジが必要です。

**ここからは、解決策について順を追って解説していきます。**

### 手順①：`Makefile` を使って、ホストユーザーの情報（ユーザーID、グループID、ユーザー名）を取得します
呼び出し元[Makefile](https://github.com/june1983/docker-lamp/blob/master/Makefile#L3-L13)にて、makeファイルを呼び出した `UID` , `GID`, `Username`等を解決します。

```Makefile:Makefile
## args
UID = $(shell id -u ${USER})
GID = $(shell id -g ${USER})
WHOAMI = $(shell whoami)

ifeq ($(shell uname), Darwin)
	# Default group id is '20' on macOS. This group id is already exsit on Linux Container. So set a same value as uid.
	GID = $(UID)
endif

export UID GID WHOAMI
```

ここでは、ホストのOSがMacの場合の分岐処理も行っています。

### 手順②：①で取得した情報を `compose.yaml` の `environment` に設定する
[compose.yaml の environment:](https://github.com/june1983/docker-lamp/blob/master/compose.yaml#L24-L26)に①で設定した環境変数を定義します。これによって、`docker compose` コマンドの実行時にホストユーザーの情報を扱える状態になります。

```diff_yaml
services:
  app:
    ports:
      - "80:80"
    depends_on:
      db:
        condition: service_healthy
    build:
      context: .
      dockerfile: ./infra/docker/php-apache/Dockerfile
      target: ${APP_BUILD_TARGET:-development}
    volumes:
      - type: bind
        source: ./src
        target: /var/www/html
    environment:
+     - UID=${UID:-1000}
+     - GID=${GID:-1000}
+     - USERNAME=${WHOAMI:-myuser}
```

### 手順③：`docker compose up` でコンテナ起動時にユーザー作成用シェルコマンド`entrypoint.sh`を実行し、コンテナ内部にホストユーザーと同じユーザーを作成する

さらに [Dockerfile](https://github.com/ravenfoolish/docker-lamp/blob/master/infra/docker/php-apache/Dockerfile#L55-L56) に `ENTRYPOINT` を追加して、コンテナ起動時に[entrypoint.sh](https://github.com/june1983/docker-lamp/blob/master/infra/docker/php-apache/entrypoint.sh)が実行されるようにします。工夫した点としては、`CMD` に`apache2-foreground`コマンドを指定し、`entrypoint.sh` の引数として渡しているところです。

`ENTRYPOINT` と `CMD` の違いについては、[こちらの記事](https://pocketstudio.net/2020/01/31/cmd-and-entrypoint/)が詳しいです。

```dockerfile:Dockerfile
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ]
CMD [ "apache2-foreground" ]
```

### 手順④：コンテナが立ち上がったら、Makefileに定義されているコマンド `make app` を実行し、3.で作成したユーザーとして、コンテナ内部に入る

①～③まで問題なく設定できていれば、コンテナの起動時にはホストユーザーと同じユーザーがコンテナにも作成されているはずです。`make app`コマンドまたは、`docker compose exec` コマンドの引数にユーザー名を指定した状態で `bash` を実行します。

```bash
$ make app
# or...
docker compose exec app gosu $(WHOAMI) bash
```

```Makefile:Makefile
app:
	docker compose exec app gosu $(WHOAMI) bash
```


# おまけ：コンテナのヘルスチェック
今回作成したツールを社内で共有して試しに使ってみたところ、例えば Windows 10 など環境によっては下記のようなコネクションエラーが発生してしまう不具合が見つかりました。

```bash
Illuminate\Database\QueryException

  SQLSTATE[HY000] [2002] Connection refused (Connection: mysql, SQL: select table_name as `name`, (data_length + index_length) as `size`, table_comment as `comment`, engine as `engine`, table_collation as `collation` from information_schema.tables where table_schema = 'laravel' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED') order by table_name)

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:813
    809▕                     $this->getName(), $query, $this->prepareBindings($bindings), $e
    810▕                 );
    811▕             }
    812▕
  ➜ 813▕             throw new QueryException(
    814▕                 $this->getName(), $query, $this->prepareBindings($bindings), $e
    815▕             );
    816▕         }
    817▕     }

      +36 vendor frames

  37  artisan:13
      Illuminate\Foundation\Application::handleCommand(Object(Symfony\Component\Console\Input\ArgvInput))

make[1]: *** [Makefile:66: fresh] Error 1
make[1]: Leaving directory '/home/user/myproject'
make: *** [Makefile:28: create-project] Error 2
```

原因を調査したところ、OSのバージョンではなく、マシンの性能（メモリの容量など）に由来するものでした。
どうやらdbコンテナが立ち上がる前にappコンテナが通信を始めてしまっていたようです。WSLを使う場合は、マシンのリソースを多く消費するため、スペックが低いとコンテナの起動などに時間がかかってしまうケースがあります。

この問題を解決するためにヘルスチェックを導入しました。

```diff_yaml

services:
  app:
    ports:
      - "80:80"
+   depends_on:
+     db:
+       condition: service_healthy
# 中略
  db:
    build:
      context: .
      dockerfile: ./infra/docker/mysql/Dockerfile
    ports:
      - target: 3306
        published: ${DB_PUBLISHED_PORT:-3306}
        protocol: tcp
        mode: host
+   healthcheck:   # add healthcheck
+     test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_PASSWORD"]
+     interval: 10s
+     timeout: 5s
+     retries: 3
+     start_period: 10s
# 以下、省略
```

このコードでは `mysqladmin ping` コマンドを使用して10秒ごとにMySQLサーバーの稼働状況を確認しています。ヘルスチェックは5秒でタイムアウトし、失敗時は最大3回リトライされます。コンテナ起動から10秒後にヘルスチェックが開始されます。さらに `compose.yaml` の `depends_on` 以下に `condition` を定義することで、依存される側のコンテナのヘルスチェック結果を元に依存するコンテナの開始タイミングを制御できます。

以上により、dbコンテナが無事起動を完了した安全な状態で、コンテナ間の通信を開始することができるようになりました。

# まとめ
Dockerのベストプラクティスに沿った環境の構築は難易度が高い印象ですが、本記事で解説した仕組みが理解できるようになると、無理なく実装できるのではないかと思います。他にも色々と応用できそうなので、今後も色々と試していきたいと思いました。


# 参考記事
[Docker 公式ドキュメント](https://docs.docker.jp/index.html)
[最強のLaravel開発環境をDockerを使って構築する](https://qiita.com/ucan-lab/items/5fc1281cd8076c8ac9f4)
[dockerでvolumeをマウントしたときのファイルのowner問題](https://qiita.com/yohm/items/047b2e68d008ebb0f001)
[Dockerコンテナの実行ユーザーと権限の関係](https://qiita.com/yitakura731/items/36a2ba117ccbc8792aa7)
[WSL2でDockerを使用する際の権限問題を解決するシンプルな方法（docker-compose.yml使用）](https://qiita.com/twu_go/items/a449e3006bd74fc7d10d)
[作業環境をDockerfileにまとめて、macOSでもLinuxでもWSL2でも快適に過ごせるようになった話](https://zenn.dev/hinoshiba/articles/workstation-on-docker)
[GNU Operating System](https://www.gnu.org/software/make/)
[GNU make　Makefileについて【初心者向け】](https://qiita.com/keitean/items/2f95dfb2944895f001e7)
[ENTRYPOINTは「必ず実行」、CMDは「（デフォルトの）引数」](https://pocketstudio.net/2020/01/31/cmd-and-entrypoint/)
[DockerのHEALTHCHECKの動きを理解する](https://qiita.com/knjname/items/9c0a89af2d9e49749017)