👋

CloudRunを意識したPHP/ApacheのDockerイメージ作成と関連知識

2023/06/11に公開

前提

【背景】

  • LaravelアプリケーションをCloudRunで走らせたく、必要なDockefileについて調べたことをまとめた内容。
  • Docker関連の派生知識についても調べた部分があるので、脱線しすぎない範囲で詰め込んだ。

【想定読者】

  • 現時点の自分のような、ローカル開発環境構築に使っていてDockerの使い方が軽く分かるレベルを想定

【環境】

  • ホストマシンはM1チップのMacBookで、OSはVentura
  • ベースイメージにはphp-apacheを使用(debian系)
  • phpのバージョンはこの記事を書き始めたときの最新8.1を使用

ディレクトリ構成

├ docker-compose.yml
├ Dockerfile
├ Dockerfile.dev
├ entrypoint.sh
├ apache2
├ php.ini
└ src

ビルド時にカレントディレクトリ以下を全てデーモンに送信してしまうので本当は開発環境と本番環境を別々に用意した方が良いかもしれないが、ワンリポジトリで管理することをイメージして上記のディレクトリ構成に。

本番用をDockerfile、開発環境用をDockerfile.devという名前にし、開発用の環境については一々引数にオプションを渡さなくても楽に起動できるようにdocker-composeを使用。

アプリケーション本体はsrcディレクトリ内に置いている。

Dockerfile

※12/26追記:useradd時に-mコマンドを追記。/home/userディレクトリが作成されず、npmコマンド実行時にエラーが出るため。
※12/26追記:Node.jsインストール時のコマンドを修正。

FROM php:8.1-apache

ARG USERNAME=user
ARG GROUPNAME=user
ARG UID=1000
ARG GID=1000

RUN groupadd -g $GID $GROUPNAME && \
useradd -m -u $UID -g $GID $USERNAME && \
usermod -aG www-data $USERNAME

RUN apt-get update && apt-get install --assume-yes --no-install-recommends --quiet \
  unzip \
  vim \
  && docker-php-ext-install pdo_mysql \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

# Composerをセットアップ
COPY --from=composer /usr/bin/composer /usr/bin/composer

# Node.jsをセットアップ
RUN curl -SL https://deb.nodesource.com/setup_lts.x | bash
RUN apt-get install -y nodejs && \
  npm install -g npm@latest

WORKDIR /var/www/html
COPY --chown=$USERNAME:$GROUPNAME ./src .
COPY ./php.ini /usr/local/etc/php/php.ini
# headersモジュールとhtacessを編集可能に
COPY ./apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-default.conf
RUN a2enmod headers rewrite

EXPOSE 80

COPY ./entrypoint.sh /usr/local/bin

RUN ["chmod", "+x", "/usr/local/bin/entrypoint.sh"]

ENTRYPOINT [ "sh", "/usr/local/bin/entrypoint.sh" ]

USER $USERNAME

Dockerfile.dev

FROM php:8.1-apache

ARG USERNAME=user
ARG GROUPNAME=user
ARG UID=1000
ARG GID=1000

RUN groupadd -g $GID $GROUPNAME && \
useradd -m -u $UID -g $GID $USERNAME && \
usermod -aG www-data $USERNAME

RUN apt-get update && apt-get install --assume-yes --no-install-recommends --quiet \
  unzip \
  vim \
  && docker-php-ext-install pdo_mysql \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

# Composerをセットアップ
COPY --from=composer /usr/bin/composer /usr/bin/composer

# Node.jsをセットアップ
RUN curl -SL https://deb.nodesource.com/setup_lts.x | bash
RUN apt-get install -y nodejs && \
  npm install -g npm@latest

WORKDIR /var/www/html
COPY ./php.ini /usr/local/etc/php/php.ini
COPY ./apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-default.conf
RUN a2enmod headers rewrite

USER $USERNAME

EXPOSE 80

docker-compose.yml

version: "3"

services:
  php:
    build:
      dockerfile: ./Dockerfile.dev
      context: .
    platform: linux/x86_64
    container_name: php
    ports:
      - "80:80"
    environment:
      APP_DEBUG: true
      APP_ENV: local
    volumes:
      - ./src:/var/www/html

ローカル実行時にはファイルの変更を何度も行うので、バインドマウントでsrcディレクトリとコンテナ内のドキュメントルートを同期させている。

また、実際には本番環境で使うものに合わせたデータベースの設定が入ってくるだろう。

entrypoint.sh

#!/bin/sh

composer install

php artisan optimize:clear

apache2-foreground

apacheを立ち上げてコンテナを立ち上げたままにするために記載。

Dockerfileではデフォルトで立ち上げるプロセスを1つ設定することができ、今回はentrypoint.shという実行ファイルに切り出したものを指定している。

プロセスが終了するとコンテナが終了してしまうので、コンテナを起動した状態にするには、最後に常駐プロセスを立ち上げて(今回apacheをフォアグラウンドで実行)おく必要がある。

tail -f /dev/nullを指定する方法もあるとのことで試してみたが、想定した動作にならずコンテナが終了してしまったので、キャッシュクリアコマンドを実行した後に素直にapacheを実行する記述にした。

Dockerfileの置き場所

docker build -fのように-fオプションを使えば任意の場所に置いてあるDockefileを指定することはできる。

しかし、Dockefile内に親ディレクトリを参照を参照してCOPYするような記述はできないのと、COPY対象のファイルはどちらにしろコンテクストに含める必要があるとのことでプロジェクトルートに置くことにした。

DockerfileとDockerfile.devの使い分け

Dockerfile => 本番環境用
Dockerfile.dev => ローカル開発環境用

CloudRun上ではDockerfileから単一コンテナをビルドするために上記にようにして、ローカルではdocker-composeを使った方が簡単にセットアップできるのではないかと考えた。

アプリケーションディレクトリとDockerfileの置き場所を同一にしない

COPY命令で不必要なファイル(アプリケーションと直接関係ないような設定ファイルやセキュリティに関するもの)を含めないようにsrcディレクトリにアプリケーションを設置する。

https://sysdig.com/blog/dockerfile-best-practices/

使用イメージの選定

PHPのDocker公式イメージにおいては主にcliapachefpmalpineの種類が存在する。

Webサーバーがnginx => php-fpm + nginx
WebサーバーがApache => php-apache
軽量イメージを使いたい => alpine

CloudRunでアプリケーションを動かそうとしたら単一のコンテナで動作できるというのが要件になってくる。
また、Dockerの公式ドキュメントでは1コンテナ1プロセスが推奨されているので、nginxと合わせて動作させるという選択肢は無くなる。

結果、今回はphp-apacheのイメージをベースにしてDockerfileを作成することにした。

【関連知識】命令文のざっくりした概要

関連知識として記載。

命令文 内容
FROM ベースイメージを指定し、新しいビルドステージを構築する。1つのDockerfile内に複数記載した場合は、それまでのビルドステージとは独立した新しいビルドステージを構築する(マルチステージビルド)。
ARG ビルド時に用いられる変数を指定。具体的な値が何も渡されなかった場合のデフォルト値も設定することが可能。ARGで指定した変数を使いたい場合は、使いたいステージのFROM命令以降に書く必要がある。
USER ユーザー名orUID、もしくは<ユーザー名(UID):<グループ名(GID)>を指定する。後続するRUNなどの各命令においてこの情報が利用される。
WORK_DIR ワーキングディレクトリの指定。その後に続くRUN、CMD、ENTRYPOINT、COPY、ADDの各命令において利用することができる。
RUN 現在処理しているイメージレイヤー上でコマンドを実行する。RUN命令実行後はその処理結果を反映して次の処理に移行する。
COPY ローカルファイルをイメージ内にコピーする。
ADD COPYに加えて、ローカルだけでなくリモートURLを指定してファイルをコピーすることが可能で、指定したファイルがローカルの圧縮ファイル(tarアーカイブ)であれば展開まで行う。この差分が不要であればCOPYを使ったので問題ない。
EXPOSE ~~コンテナがリッスンするポートを指定するが、そのままでは外部に公開されない。docker run -Pを使うとEXPOSE命令で指定したポートが公開される。~~あくまで公開ポートを分かりやすくするためのものであり、この命令分自体がポートを公開する機能を持っているわけではない模様。
CMD コンテナ実行時にデフォルトで行いたい処理を記載する。1つのDockerfile内でCMD命令を実行できるのは1回。あくまでデフォルト処理なのでコンテナ立ち上げ時に引数があれば上書きされる。
ENTRYPOINT コンテナ実行時に必ず指定したいコマンド・引数を指定。コンテナ立ち上げ時に渡されたコマンドによって上書きされない。
ENV 環境変数を設定する。
SHELL コマンド実行に使われるデフォルトのシェルを指定。

ビルド時にはDockerfileの上から実行されるが、前回ビルド時からの差分箇所にあたるまではキャッシュを利用する。差分が出たところからはキャッシュが使われなくなるため、更新される可能性がある箇所は後の方に書く。

https://matsuand.github.io/docs.docker.jp.onthefly/engine/reference/builder/

WORKDIR

実行ユーザーのホームディレクトリか、慣例的にドキュメントルートに使われる/var/www/htmlのどちらかを指定することが多いと思われる。今回は後者を指定。

後からベースイメージを見たが、そちらでも/var/www/htmlが設定されていた。

COPY

srcディレクトリにあるアプリケーションのソースコードをコピーするために記載。先にWORKDIR命令文を実行して作業ディレクトリを決めているため、コピー先は/var/www/htmlからの相対パスで指定することが可能。

RUN命令でcomposerなどのパッケージをインストールする場合、composer.jsonのCOPYをを実行してからインストールを走らせることによって、ソースコード変更時にはキャッシュを利用することができる。

今回はsrcフォルダを指定しているが、srcフォルダ自体ではなく中身のコピーが実行される。

--chownオプションは実行ユーザーとファイルのOwnerを揃えるために使用。これをやっておかないと、分かりやすいところだとログファイル生成時にパーミッションエラーが発生する。

RUN

RUN命令を一番使うのはパッケージのインストールと思われる。
Dibian系なのでapt-updateでパッケージ一覧を更新し、apt-getで指定したパッケージをインストールする。

依存パッケージを含めるとそれなりの数を指定することになるので、バックスラッシュで改行して繋いだ方が見やすい。

PHPの場合はdocker-php-ext-installで拡張モジュールをインストールすることが多い。

最後の文はapt-getのキャッシュがイメージレイヤに含まれるのを防ぐために、キャッシュを削除する記述。

 RUN apt-get update && apt-get install -y \
 ... \
 && docker-php-ext-install \
 ... \
 && apt-get clean \
  && rm -rf /var/lib/apt/lists/* 

EXPOSE

コンテナの公開ポートを指定する。

ただし…

EXPOSE 命令だけは、実際にはポートを 公開publish しません。これは、どのポートを公開する意図なのかという、イメージの作者とコンテナ実行者の両者に対し、ある種のドキュメントとして機能します。コンテナの実行時に実際にポートを公開するには、 docker run で -p フラグを使い、公開用のポートと割り当てる( マップmap する)ポートを指定します。

https://docs.docker.jp/engine/reference/builder.html#expose

EXPOSE命令文でポート番号を指定してもそのポートが公開されるのではなく、コンテナ実行者が明示的にポートフォワーディングを行うためのDocとしての役割しか果たさない。

実際のポートフォワーディングはdocker run実行時に-pオプションで指定する。

CloudRunのコンソール上でもポートは指定できるので、ここはアプリケーションに合わせた任意のポートで問題ないと思われる。

USER

特権ユーザー(root)以外でサービスを実行したい場合は、一般ユーザーを作成してUSER命令でそのユーザーを指定する。

基本的にDockerのコンテナを特権ユーザーで実行しない方が良いとされているので、今回は一般ユーザーとしてuserを追加。

debian系のOSではwww-dataがapacheのデフォルト実行ユーザーになっている(/etc/apache2/envvarsを参照)ので、userをwww-dataのグループに属させている。

Dockerfile内でUSER命令文(一般ユーザへの切り替え)が早過ぎると、apt-getの実行などroot権限を必要とする場合に失敗してしまうのでDockerfile内の最後の方で実行する。

ENTRYPOINT

ENTRYPOINT命令に直接コマンドを書くと見づらくなる可能性があるので、entrypoint.shファイルを読み込む。

Linuxにおいては/usr/local/binが自作シェルスクリプトの置き場所ということになっており、そちらにコピーした上で実行権限を付与しておく必要がある。

shellとexec

  • shell
    ・渡したコマンドを/bin/sh -cで新しいシェルプロセスとして実行する。
    ・そのため変数展開などのシェル機能を使用することができる。
    ・ただしイメージに/bin/shを含めておく必要がある。
    ・また、PID1の実行プロセスが/bin/sh -cとなる。

  • exec
    ・新しいシェルプロセスではなく渡したコマンドを直接実行する。
    ・JSON形式でコマンドや引数はダブルクオテーションで囲って記述する。
    ・変数展開などのシェル機能を利用することができず、変数を渡すのであれば結局/bin/sh -cを間接的に立ち上げることになる。

https://www.creationline.com/lab/39662

こちらの記事を読んだ上で自分の手元でも同じことを試してみたが、exec形式で/bin/shを実行しても/bin/sh -cがPID=1で実行されるのは変わらない模様。

公式ではexec形式が推奨されており、シェル変数展開や複数のコマンドを連結させる必要がない場合はexecを使用する。

【メモ】 ENTRYPOINTとCMDの違い

どちらもコンテナを立ち上げた際に実行したいコマンドを指定する。

違いとして、ENTRYPOINTは固定で実行したいコマンドやスクリプトを指定し、CMDではデフォルトで実行したいコマンドや引数を指定する。docker runの引数にはコンテナ内で実行したいコマンドや引数を渡すことができるが、このときENTRYPOINTに記載されているものは上書きされずにCMDに書いたものが上書きされる。

また、複数指定した場合はENTRYPOINTとCMDのそれぞれで最後に書かれたものが有効になり、ENTRYPOINTとCMDの両方を指定した場合は、CMDに記載されている内容がENTRYPOINTに記載したコマンドに引数として追加される。

下記はphp-apacheのベースイメージの中身からENTRYPOINTとCMDの行を抜粋したものである。

ENTRYPOINT ["docker-php-entrypoint"]

CMD ["apache2-foreground"]

https://hub.docker.com/layers/library/php/8.1-apache/images/sha256-7c9c33922966dac22f4c18d0de922673954829ed5e16b423fa33f391e76a74f3?context=explore

同一イメージのものが見つけられなかったが、docker-php-entrypointの中身は下記のような感じだと思われる。
https://github.com/docker-library/php/blob/master/docker-php-entrypoint

このようにapache2-foregroundというapacheをフォアグラウンドで起動するコマンドがdocker-php-entrypointに渡され、シェルスクリプト内の条件分岐でfalseとなり、apache2-foregroundがそのまま実行されている。

ちなみに最初の方に書いた開発用のDockerfile.devにはapacheを起動するような記載をしていなかったが、ベースイメージにapacheを実行するコマンドが記載されているからであり、ENTRYPOINTを上書きする場合はapacheを起動する記述を自分で行う必要が出てくる。

今回のDockerfileではスクリプトファイルを呼び出して実行するだけなので、ENTRYPOINTとCMDのどちらを使っても変わらないはず。

最低限インストールしたパッケージ

PHPアプリケーションを動かす上で最低限必要そうなものを記載。

unzip

ダウンロードしたパッケージを解答するのに必要なモジュール。

composerでLaravelの新規プロジェクトを作成するときにunzipかgitがないとエラーが発生する。

Composer

PHP用のパッケージ管理ツールでLaravelを運用するのにマスト。

composer公式では下記のようにインストール手順が示されているが、4行目のハッシュ値はバージョンが更新されるたびに変わるものであるため面倒が生じる。

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === '55ce33d7678c5a611085589f1f3ddf8b3c52d662cd01d4ba75c0ee0459970c2200a51f492d557530c71c15d8dba01eae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup. php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

そのため下記のようにDockerのマルチステージビルドの仕組みを用い、公式イメージからcomposer.pharのみを取り込んで実行するのがベストプラクティスとされている。

COPY --from=composer /usr/bin/composer /usr/bin/composer

pdo_mysql or pdo_pgsql

SQLに接続するためのドライバ。

データベースにmysqlを使う際はpdo_mysql、postgresの場合はpdo_pgsqlを使うなど、実行環境に合わせた何らかのドライバは必要になるだろう。

node.js

Laravelでフロントエンドまで完結させようとした際にはCSSやJSをVite(Ver.8以前はLaravelMix)でビルドするケースが多いと思われる。Vite(or LaravelMix)はnode環境が必要なのでグローバルにインストールしておく。

ビルドとオプション

▼ コマンドとオプション

$ docker build [オプション] パス | URL | -

▼ 実行例

$ docker build -t apache:dev .  
  • オプション
オプション 内容
-t ビルドしたイメージに<image>:<tag>の形式でイメージ名とタグを付与する。
-f ビルド元のDockerfileを指定する。対象のファイル名がDockerfileの場合は省略可能。
--build-arg ARG命令で定義した変数に値を渡す。--build-arg UID="`id -u`" --build-arg GID="`id -g`"でホストマシンと同じUID、GIDを渡すことができる。

実行とオプション

▼ コマンドとオプション

$ docker run [オプション] イメージ[:タグ|@ダイジェスト値] [コマンド] [引数...]

ここで注意なのがイメージ名の前にオプションを並べる必要があり、イメージ名の後にdocker run実行のオプションを記載するとコマンドや引数として解釈されてしまう点。

▼ 実行例(開発用)

docker run -it -d --name apache -p 80:80 --mount type=bind,source="$(pwd)"/src,target=/var/www/html apache:dev

上記コマンドでは

  • apache:devというイメージを、
  • apacheという名前のコンテナとして走らせ、
  • ホストマシンとコンテナの80番ポート同士を繋ぎ、
  • srcディレクトリと/var/www/htmlを同期させるバインドマウントをオプションとして指定し、
  • -dオプションでバックグランドでコンテナを起動
    という命令になっている。

▼よく使われるオプション群

オプション 内容
--name コンテナに名前をつける。IDを指定しなくてもdocker exec -it <任意のコンテナ名> bashでコンテナ内のシェルに入ることができる。
-d デタッチドモード=true。バックグラウンドでコンテナを起動する。
-it -intractiveと-ttyの2つのオプションが組み合わさったもの。
-p ポートフォワーディングの設定。<ホストマシンのポート>:<コンテナのポート>の形式で指定する。EXPOSEで指定したポートを公開する。なおLaravelにて、php artisan serveで立ち上がるビルトインサーバーを使う際にはlocalhost(127.0.0.1)からのアクセスしか受け付けないため、php artisan serve --host=0.0.0.0としないとホストマシンのブラウザからアクセスできない。
-v volumeを指定する。ホストマシンからコンテナ内にCOPYしたファイルはコンテナを破棄すると消えてしまうが、volumeを設定しておくことで永続化することができる。<volume名>:<コンテナ内の絶対パス>の形式で指定。
-mount バインドマウントを実行する。ホストマシン上の任意のディレクトリとコンテナ内の任意のディレクトリを同期させることができる。

ビルドして立ち上げてみる

本番用に作ったイメージをビルドしてみます。

▼ プロジェクトルートにてビルドコマンドを実行

docker build -t apache:prod .

▼ コンテナの立ち上げ

docker run --name apache -it -d -v apache-volume:/volume_dir -p 80:80  apache:prod

▼ ブラウザからアクセス

http://localhost

デフォルトのホーム画面を表示させるところまで成功。

【メモ】 dockerでよく使うコマンド群と説明

docker run と docker exec の違い

runとexecという単語からはどちらも「実行する」という意味を想起させるが、下記のような違いがある。

docker runは指定したイメージからコンテナを作成して起動までを行う。

docker execは既に起動中のコマンド内で指定した命令を実行する。ルートプロセスとは別に新たなプロセスが作られて実行されるので、execで立ち上げたプロセスを修了してもコンテナは終了しない。

-itオプションの挙動

docker run docker exec で指定可能なオプションとして --interactive--tty が存在する。-itとはそれらの省略形である -i-t のオプションを一括指定できるもの。

--interactiveオプションは公式ではさらっと「アタッチしていなくても標準入力を開き続ける」と説明されている。つまり「コンテナは標準入力を待ち受け、ローカルマシンからの入力にも反応することができる状態」となる。

ここでアタッチという言葉が出てきたが、これはdocker attachコマンドで実現できる状態のことで、ローカルの標準入力、標準出力、標準エラー出力を対象コンテナのルートプロセス(PID=1)に接続すること。

「標準出力って何?何が標準なのか?逆に標準でない出力って何?」といった疑問については下記の記事で説明してくださってる内容が分かりやすいと思う。

https://qiita.com/angel_p_57/items/03582181e9f7a69f8168

--ttyは「疑似TTY(pseudo-TTY)を割り当てる」とのこと。つまり標準入出力先として擬似的な端末を割り当て、その端末を通じてローカルマシンとコンテナで入出力のやり取りが可能という解釈。

「--interactiveは標準入力をつなぎ、--ttyは標準出力をつなぐ」という説明もちらほら見かけたが、正確には「--ttyで標準入出力の両方とも有効になるが、ローカルマシンからの入力を受けつけるには--interactiveが必要」と言った方が正しいのではないかと思った。(自信ないので間違っていたらご指摘頂けると嬉しいです)

実際の挙動についてはexecの方が、いわゆるコンテナ内に入るためのまじないとして使われるので馴染みがあると思われる。

docker exec -it <containerId> bashを実行すると、コンテナ内で新たにbashプロセスを立ち上げ、-itオプションによってローカルから入力可能なターミナルが開かれた状態になる。

docker runでの挙動については-iと-tを片方ずつつけてみるのが分かりやすい。

▼ -iオプションのみつけた場合(-tオプションがない場合)

  • Composerのインストール箇所で本来緑色の文字になる箇所が白黒で出力されている。
  • ホストマシンからCtrl + Cを受け付けず、CLI上でコンテナを停止できない。

▼ -tオプションのみつけた場合(-iオプションがない場合)

  • Composerのインストール箇所がいつも通りの色で出力されている。
  • ホストマシンからCtrl + Cを受け付けないのは変わらない。

コンテナ・イメージを削除したい場合

Dockerイメージを作る過程では何度もビルドし直すことになると思うので古いDockerイメージを削除する方法をメモ。

$ docker images

上記コマンドを実行するとこれまでにビルドしたDockerイメージの一覧が表示される。
そのうち<Repository>:<tag>が<none>:<none>になっているものは、同一イメージを複数回ビルドした際に、同じ名前のものが重複して存在できないため、古い方の名前が置き換えられたものである。

下記コマンドはタグがついていないイメージを全て削除する。

docker rmi $(docker images -f 'dangling=true' -q)

イメージはそのイメージを使用しているコンテナがあると削除できない。起動中のコンテナは停止し、停止中のコンテナを下記で一括削除する。

$ docker container prune

次にコンテナから参照されていないvolumeを削除

$ docker volume prune

停止しているコンテナや参照のないimage、volumeを削除するには下記を実行。

$ docker system prune

以上。

Discussion