先週からのPHPの脆弱性、ほどほどに疲れました。
とはいえ僕はコンテナを使っていたので、アップデートは楽勝!
Dockerfileの
FROM php:8.4.1-fpm
を
FROM php:8.4.27-fpm
と書き換えて、
docker compose up -d
でコンテナを作り直せばOK!
ウソです。それはちょっと危険です。
治安の悪いコンテナ
コンテナの中身、起動したあとに、誰かが書き換えていませんか? コンテナを作り直すと、その書き換えは失われます。
書き換える場所は、バインドマウントやボリュームで外に出しておくのが常識です。とはいえ、
docker exec -it コンテナ名 /bin/bash
でコンテナの中に入って、「ちょっとだけファイルを変更した」 こと、ありますよね。そういう奴は半○しですけど、それが実際の運用というものです。
この記事は、そんな治安の悪いコンテナを更新するとき、どうすればいいかというお話です。
結論
結論から言うと、作り直す前に次の3つの工程で更新します。
- docker diffで、消えるファイル(コンテナ起動後に書き換わったファイル)を確認する
- docker commitで、コンテナを丸ごと保存して念のためのバックアップをとっておく
- tarで、救出したいファイルをホストに退避し、作り直したあとに戻す
以下、その具体的な手順です。
マウントしないと消える
PHPのイメージを変更して、docker compose up -dすると、古いコンテナは削除され、新しいコンテナが作られます。このとき、マウントしているものは引き継がれますが、それ以外にコンテナ内に書き込んだファイルは、コンテナと一緒に捨てられます。
▲マウントしたものは引き継がれ、それ以外は消える
「stop」や「start」「イメージの更新」「down」で、それぞれ、消えるかどうかをまとめると、次のようになります。
| 操作 | マウントしていないファイル | マウントしたファイル |
|---|---|---|
| stop → start | 残る | 残る |
| イメージを変えてup(作り直し) | 消える | 残る |
| down | 消える | 残る |
[メモ]作り直すと消える理由
コンテナは、読み取り専用の「イメージ」の上に、「書き込み可能レイヤー」を重ねた構造です。起動後に書き込んだファイルは、この書き込み可能レイヤーに入ります。stop → startは同じコンテナを再開するだけなのでレイヤーは残りますが、コンテナを削除するとレイヤーごと捨てられます。
なお、up -dはいつも作り直すわけではありません。「compose ファイルに書いた状態に合わせる」コマンドなので、イメージや設定が変わったときだけ作り直し、何も変わっていなければ何もしません。
変更されたファイルを待避して書き戻す
ではこうした「消えるファイル」(コンテナ起動後に書き換えたファイル)を、そのままの状態で、コンテナを更新するには、どうすればよいか。
これは「できない」ので、「該当のファイルを待避して、新しいコンテナを起動したあとに書き戻す」ようにします。次の図のように、変更されたファイルを抽出して、それをtarでまとめていったん保存しておきます。そして新しいバージョンのコンテナを起動して、そのまとめておいたファイルで上書きするようにします。
▲ホストにいったん待避して、新しいコンテナに戻す
具体的手順は、次の通りです。
ここでは、compose ファイルのサービス名をphp、コンテナ名をmyapp-php-1とします(コンテナ名はdocker compose psで確認できます)。
【手順】 PHPのコンテナを更新して、退避したファイルを戻す
[1]消えるファイルを確認する
まずは、消えるファイル――すなわち、コンテナの起動後に書き換わったファイル――の一覧を抽出します。これは、docker diffで取得できます。
$ docker diff myapp-php-1
C /usr
C /usr/local
C /usr/local/etc
C /usr/local/etc/php
C /usr/local/etc/php/conf.d
A /usr/local/etc/php/conf.d/zz-custom.ini
C /var
C /var/www
A /var/www/uploads
A /var/www/uploads/2026
A /var/www/uploads/2026/photo01.jpg
C /tmp
A /tmp/sess_8f3a...
先頭の記号は、A(追加)、C(変更)、D(削除)です。この例では、手で書いたzz-custom.iniと、アップロードファイルの置き場所/var/www/uploadsが、マウントし忘れだとわかります。
/usrや/varのような親ディレクトリのCは、中にファイルを作ったために「変更」と表示されているだけです。無視してかまいません。/tmpのように消えても困らないものも無視します。
[2]コンテナを丸ごと保存する
さて、このあと、現在のコンテナを更新のために破棄するのですが、何かあったときに困るので、保険として、コンテナの中身をイメージとして保存しておきます。こうしておけば、このイメージから、現在の状態を取り出せます。ここでは、イメージ名を「backup-php841:before-upgrade」としていますが、イメージ名はお好みで。
$ docker commit myapp-php-1 backup-php841:before-upgrade
[3]救出したいファイルをtarにまとめる
現在のコンテナに対して、[2]から判断して、必要と思われるファイルを、tarでアーカイブするコマンドを実行します。パーミッションや所有者もそのまま保存できます。
$ docker exec myapp-php-1 tar czf - \
/usr/local/etc/php/conf.d/zz-custom.ini \
/var/www/uploads \
> backup.tar.gz
$ tar tzvf backup.tar.gz
[4]Dockerfileを書き換える
コンテナを更新するため、Dockerfileを変更します。たとえば、次のようにです。
【変更前】
FROM php:8.4.1-fpm
【変更後】
FROM php:8.4.27-fpm
[5]新しいイメージを先にビルドする
コンテナを更新しますが、先にビルドしておくのがよいです。ビルドのあいだも現在コンテナは動いたままなので、止まる時間を短くできるからです。つまり、現在のコンテナを動かしたまま、次のコマンドを実行してビルドだけ先に実行します。もしビルドでエラーが出ても、古いコンテナには影響ありません。
$ docker compose build --pull php
[6]コンテナを差し替える
ビルドが終わったら、次のようにして、コンテナを差し替えます。
$ docker compose up -d --no-deps php
これで古いコンテナが削除され、新しいコンテナが起動します。--no-depsは、depends_onで依存しているコンテナ(サービス)には、手を出さないという意味です。よくあるのが、「MySQL」や「PostgreSQL」が、depends_onで設定されている場合、--no-depsを付けないと、それらも再起動してしまうためです。
[7]バージョンを確認する
念のため、更新されたかを確認します。
$ docker compose exec php php -v
今回の例では、「PHP 8.4.27」と表示されればOKです。
[8]backup.tar.gzを戻す
そして待避していたファイルを戻します。
$ docker compose exec -T php tar xzf - -C / < backup.tar.gz
-Tは、ファイルの内容をコンテナに流し込むために必要なオプションです。tarは保存時に先頭の「/」を外しているので、-C /で元の場所に展開します。
[9]コンテナを再起動する
[8]の手順では、何か再起動が必要な場面(たとえばphp.iniなど)があることが多いので、再起動します(再起動しなくていいなら、この手順は必要ありません)。
$ docker compose restart php
restartはコンテナを作り直さないので、戻したファイルは消えません。
[10]動作を確認する
$ docker compose exec php ls -l /var/www/uploads
$ docker compose logs php
戻したファイルがあるか、エラーが出ていないかを確認し、ブラウザなどでアプリの動作を確かめます。
補足
ファイルが多いとき
対象ファイルが多いと、いちいちtarのコマンドとして書くのは大変です。
そんなときは、docker diffの結果から、退避するファイルのリストを作るとよいでしょう。ただし、/tmpのような要らないものも含まれるので、必ず中身を見て、戻したいものだけに絞ってから使ってください。
$ docker diff myapp-php-1 | awk '$1 != "D" {print $2}' > files.txt
(files.txtを編集して絞り込む)
$ docker exec -i myapp-php-1 tar czf - --no-recursion -T - < files.txt > backup.tar.gz
ここで大事なのが--no-recursionです。一覧のリストには/usrや/varといった親ディレクトリも入っています。tarはディレクトリを指定すると中身までまとめるので、--no-recursionを付けないと、/usrがまるごとtarに入ってしまいます。--no-recursionを付ければ、リストに書いたものだけをまとめます。
うまくいかなかったとき
Dockerfileをphp:8.4.1-fpmに戻して、[5]以降をもう一度実行すれば元に戻せます。
退避し忘れたファイルがあったときは、[2]で保存したイメージから取り出します。tarだけを実行すれば、コンテナ内のサービスを起動せずに取り出せます。
$ docker run --rm --entrypoint tar backup-php841:before-upgrade \
czf - /path/to/forgotten-file > forgotten.tar.gz
戻しただけだと、次にまた消える
戻したファイルは、あいかわらずマウントしていない場所にあるので、次に作り直すとまた消えます。救出はあくまで応急処置です。落ち着いたら、アプリが書き込む場所はボリュームやバインドマウントに、設定ファイルはバインドマウントかDockerfileのCOPYに移しておきましょう。
拡張モジュールはDockerfileで入れる
これはPHPに関する補足ですが、docker diffで/usr/local/lib/php/extensions/配下のファイルが出てきたら、これは手作業で入れた拡張モジュールです。
今回のような8.4系どうしの更新なら戻しても動きますが、8.4から8.5のようにマイナーバージョンをまたぐと動きません。
拡張モジュールは、Dockerfileでdocker-php-ext-installやpecl installを使って入れておくと、バージョンを上げたときに自動でビルドし直されます。
まとめ
コンテナを作り直す前に、docker diffで確認、docker commitで保険、tarで退避。これが基本です。そうしないと、治安の悪い、誰かが勝手に中身を変更しているコンテナは、ファイルが失われます。
とはいえ、目指すべきは**「コンテナは、いつ捨ててもいい」状態**です。書き込む場所はすべてマウントし、パッケージや設定はDockerfileに書いておけば、PHPのバージョンアップは、Dockerfileを1行書き換えるだけで済みます。
ちなみに、どこに書き込んでいるかわからないときは、compose ファイルでread_only: trueを指定して試しに起動してみるのも手です。マウントしていない場所に書き込もうとするとエラーになるので、マウントし忘れを洗い出せます。