LoginSignup
0
0

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

Last updated at Posted at 2024-05-17

※以下、ホスト(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での動作確認済み。

結論

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

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

  1. Makefile を使って、ホストユーザーの情報(ユーザーID、グループID、ユーザー名)を取得する
  2. 1.で取得した情報を compose.yamlenvironment に設定する
  3. docker compose up でコンテナ起動時にユーザー作成用シェルコマンドentrypoint.shを実行し、コンテナ内部にホストユーザーと同じユーザーを作成する
  4. コンテナが立ち上がったら、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 が自動的に実行されるというものです。
書いた

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"]

DockerfileENTRYPOINTentrypoint.shが読み込まれます。

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 で変更する

③の説明は割愛します。(すみません:sweat_smile:


また、こちらの記事は、おそらく前述の記事を読んだ方が書いたと思われますが、さらに踏み込んで docker comose を使って実装する方法が紹介されています。

他にも、こちらの記事のようにdockerコマンドに直接、ユーザーIDとグループIDを渡す方法もあります。

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

問題点

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

なぜ docker compose だと上手くいかないのか?

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

これらのイメージを構成する Dockerfile には元々、ENTRYPOINT に実行ファイルが定義されています。例えば、php:8.3-fpm-bullseyeの公開されているソースコードを見ると、226行目に記述があります。

Dockerfile
ENTRYPOINT ["docker-php-entrypoint"]

docker-php-entrypoint別ファイルにある実行ファイルで、php-fpm を起動します。

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
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等を解決します。

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.yamlenvironment に設定する

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を実行し、コンテナ内部にホストユーザーと同じユーザーを作成する

さらに DockerfileENTRYPOINT を追加して、コンテナ起動時にentrypoint.shが実行されるようにします。工夫した点としては、CMDapache2-foregroundコマンドを指定し、entrypoint.sh の引数として渡しているところです。

ENTRYPOINTCMD の違いについては、こちらの記事が詳しいです。

Dockerfile
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
Makefile
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.yamldepends_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の動きを理解する

0
0
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0