※以下、ホスト(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での動作確認済み。
結論
経緯と説明のパートが長くなりそうなので、先に結論から始めます。
以下の手順を踏むことで、表題の件を達成することが出来ました。
-
Makefile
を使って、ホストユーザーの情報(ユーザーID、グループID、ユーザー名)を取得する - 1.で取得した情報を
compose.yaml
のenvironment
に設定する -
docker compose up
でコンテナ起動時にユーザー作成用シェルコマンドentrypoint.sh
を実行し、コンテナ内部にホストユーザーと同じユーザーを作成する - コンテナが立ち上がったら、Makefileに定義されているコマンド
make app
を実行し、3.で作成したユーザーとして、コンテナ内部に入る
レポジトリ
上記レポジトリにあるコードは、@ucan-labさんが、こちらの記事で紹介されているソースコードを下敷きにしています。
※元のソースコードでは、webサーバーに nginx
を採用していますが、私のリポジトリでは apache
を採用しています。
経緯
Dockerで環境構築を行い、コンテナの内部で npm run build
のようなファイルを生成するコマンドを打つと、root権限で実行されてしまうため、ファイルの所有者がホストユーザーではなく、root
になってしまいます。
WSL特有の現象です。Macではファイルの所有者はホストユーザーに設定されます
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の公式ドキュメントでも非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さんが書いたこちらの記事では、3通りの方法が紹介されています。
①ENTRYPOINTでuseraddでユーザーを作る
私が最終的に採用した手法に近いのですが、entrypoint.sh
というユーザー作成用のシェルコマンドを用意しておき、Dockerfileがビルドされるタイミングで entrypoint.sh
が自動的に実行されるというものです。
書いた
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
が読み込まれます。
#!/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 "$@" // 作成したユーザーとして実行
entrypoint.sh
で受け取る環境変数はホスト側で予めエクスポートしておく必要があります。
②/etc/passwd
と /etc/group
をコンテナにマウントする
こちらの方法ではホスト側でユーザー情報を管理しているファイル(etc/passwd
と /etc/group
)をマウントでコンテナ内部に同期することによって、ホストユーザーをコンテナ内部に再現しています。
③イメージに作成された一般ユーザーの UID/GUI
を、ホストユーザーの UID/GID
で変更する
③の説明は割愛します。(すみません)
また、こちらの記事は、おそらく前述の記事を読んだ方が書いたと思われますが、さらに踏み込んで docker comose
を使って実装する方法が紹介されています。
他にも、こちらの記事のようにdockerコマンドに直接、ユーザーIDとグループIDを渡す方法もあります。
UID_GID="$(id -u):$(id -g)" docker-compose up
問題点
以上、ここまで紹介してきた方法では、単一のコンテナを起動する分には問題ないです。
しかしながら、docker compose
で複数のコンテナを連携しようとすると上手くいきません。
実際の案件では、アプリケーション用のコンテナとデータベース用のコンテナを別で立てて、相互に通信したりすることが多いので、単一のコンテナしか扱えないというのは少し不便な気がします。
なぜ docker compose だと上手くいかないのか?
案件などで開発環境を構築する場合、Dockerのオフィシャルなイメージを採用するケースが多いかと思います。
これらのイメージを構成する Dockerfile
には元々、ENTRYPOINT
に実行ファイルが定義されています。例えば、php:8.3-fpm-bullseyeの公開されているソースコードを見ると、226行目に記述があります。
ENTRYPOINT ["docker-php-entrypoint"]
docker-php-entrypoint
は別ファイルにある実行ファイルで、php-fpm
を起動します。
#!/bin/sh
set -e
# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
set -- php-fpm "$@"
fi
exec "$@"
こんな風にイメージがビルドされて、コンテナが起動するタイミングで ENTRYPOINT
が実行されるわけですが、紹介した①の方法だと、Dockerfile
に定義した新しい ENTRYPOINT
で上書きされてしまいます。
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の扱いに慣れていないメンバーでも、使えるようにしたい
これらの要件を満たしつつ、なんとか複数のコンテナがサーバー内で起動している状態で表題の件を達成できないものかと調べていたところ、以下の記事を見つけました。
記事では、Makefileを使ってホストユーザー情報の取得や環境構築に必要な設定などの複雑な処理を隠蔽し、シンプルなコマンドを打つだけでrootlessなコンテナ実行環境を構築する方法が紹介されています。また、WindoowsやMacなど異なるOSであっても動作し、且つDockerのベストプラクティスを考慮した作りになっているため、大変参考になりました。
ただし、この記事も docker run
で単一コンテナを起動する想定で書かれているので、docker compose
でも問題なく動くようにアレンジが必要です。
ここからは、解決策について順を追って解説していきます。
手順①:Makefile
を使って、ホストユーザーの情報(ユーザーID、グループID、ユーザー名)を取得します
呼び出し元Makefileにて、makeファイルを呼び出した UID
, GID
, Username
等を解決します。
## 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:に①で設定した環境変数を定義します。これによって、docker compose
コマンドの実行時にホストユーザーの情報を扱える状態になります。
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 に ENTRYPOINT
を追加して、コンテナ起動時にentrypoint.shが実行されるようにします。工夫した点としては、CMD
にapache2-foreground
コマンドを指定し、entrypoint.sh
の引数として渡しているところです。
ENTRYPOINT
と CMD
の違いについては、こちらの記事が詳しいです。
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ]
CMD [ "apache2-foreground" ]
手順④:コンテナが立ち上がったら、Makefileに定義されているコマンド make app
を実行し、3.で作成したユーザーとして、コンテナ内部に入る
①~③まで問題なく設定できていれば、コンテナの起動時にはホストユーザーと同じユーザーがコンテナにも作成されているはずです。make app
コマンドまたは、docker compose exec
コマンドの引数にユーザー名を指定した状態で bash
を実行します。
$ make app
# or...
docker compose exec app gosu $(WHOAMI) bash
app:
docker compose exec app gosu $(WHOAMI) bash
おまけ:コンテナのヘルスチェック
今回作成したツールを社内で共有して試しに使ってみたところ、例えば Windows 10 など環境によっては下記のようなコネクションエラーが発生してしまう不具合が見つかりました。
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を使う場合は、マシンのリソースを多く消費するため、スペックが低いとコンテナの起動などに時間がかかってしまうケースがあります。
この問題を解決するためにヘルスチェックを導入しました。
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 公式ドキュメント
最強のLaravel開発環境をDockerを使って構築する
dockerでvolumeをマウントしたときのファイルのowner問題
Dockerコンテナの実行ユーザーと権限の関係
WSL2でDockerを使用する際の権限問題を解決するシンプルな方法(docker-compose.yml使用)
作業環境をDockerfileにまとめて、macOSでもLinuxでもWSL2でも快適に過ごせるようになった話
GNU Operating System
GNU make Makefileについて【初心者向け】
ENTRYPOINTは「必ず実行」、CMDは「(デフォルトの)引数」
DockerのHEALTHCHECKの動きを理解する