技術約9分で読めます

Remiの修正版を待たずに、PHP 8.3.36をソースビルドしてFPMへ当てた

いけさん目次

TL;DR

  • php2shell修正版(PHP 8.3.36)の公式ソースから php、php-fpm、opcache.so の3ファイルのみをビルド
  • 稼働中のDockerコンテナへ差し替え、FPMマスターへUSR2シグナルを直接送信して無停止リロードを実施
  • docker kill ではPID 1(Apache)へシグナルが飛ぶため、FPMのPIDを直接指定して送信
  • RPMの管理外となるため dnf.conf でPHPパッケージを除外し、コンテナ再作成用のイメージも別途作成

前回の記事(php2shellの公式パッチ)で、PHPの脆弱性(php2shell)に対応したPHP 8.3.36が10月21日23時に公開されたこと、Remiのリポジトリは当日中に更新されないことを書いた。
手元で管理しているサーバーの一例が、ちょうどそのRemiのPHPで動いていた。
修正版のRPMパッケージを待っていると翌日以降になるため、公式のソースコードからビルドして当てることにした。

リリース直後に利用可能だった配布元

公開前の時点では、公式の修正版はまだ出ていなかった。
Remiの最新は8.3.35で、これは今回の2件の修正を含まない。

23時に8.3.36が公開され、PHP公式の案内にはRemiは当日は更新されないと書かれていた。
Remiのリポジトリを確認しても8.3.35までしかなく、AlmaLinuxのAppStreamにも今回の2件の脆弱性(CVE)を直した案内は見当たらなかった。
RC(リリース候補版)は使わないことにして、公式の安定版をソースからビルドする方針に切り替えた。

検証・本番のコンテナ構成

AlmaLinux 9のDockerコンテナで、PHPはRemiのリポジトリから入れた8.3系。
コンテナの中ではApacheがPID 1(コンテナのメインプロセス)として稼働し、PHPはPHP-FPMで処理している。
PHP-FPMはマスタープロセスが1つあり、その下のワーカープロセスがリクエストを処理する構成。

flowchart TD
    A["docker kill --signal"] --> B["PID 1: Apache"]
    B -->|リクエストを転送| C["PHP-FPM master"]
    C --> D["worker"]
    C --> E["worker"]

検証環境と本番環境があり、どちらも同じ構成になっている。
検証環境で動作を確認してから、本番へ同じファイルを配置した。

専用コンテナでのビルド

ビルドは、検証環境のサーバーに公開ポートもDB接続もない専用コンテナを立ち上げて、その中で行ってみた。
ベースは稼働中のコンテナと同じAlmaLinux 9のイメージを使用している。
稼働中のPHP環境には、ビルドしたバイナリの動作を確認できるまで手を付けなかった。

差し替え対象は、CLIの php、php-fpm、opcache.so の3ファイルのみとした。
RemiのPHPは、本体に静的に組み込まれたモジュールと、/usr/lib64/php/modules に置かれた共有拡張(.so ファイルとして別に読み込む拡張)に分かれている。
共有拡張は既存のものをそのまま使い、新しい本体側でAPIバージョン(8.3は20230831)や非スレッドセーフ(NTS)の仕様を既存環境に合わせた。
OPcacheはPHP本体と同じソースツリーにあるため、8.3.36から一緒にビルドした。

configure のオプションは、既存バイナリの php -n -m(php.ini を読まずにモジュールを一覧表示する)に出る静的モジュールに合わせた。
--disable-all で一度すべて外し、既存と同じものだけを追加している。
設定ファイルの場所、拡張の置き場所、FPMを動かすユーザーとグループも既存に合わせた。

実際に使ったスクリプトは以下のとおりで、専用のビルド用コンテナの中で実行した。

#!/bin/bash
set -euo pipefail
exec > /build/build-output.txt 2>&1
printf 'BUILD_STARTED '; date -u
php -n -m > /build/static-modules-before.txt
php -m > /build/modules-before.txt
dnf -y --setopt=install_weak_deps=False --setopt=timeout=20 --setopt=retries=1 install gcc gcc-c++ make autoconf bison re2c pkgconf-pkg-config libxml2-devel openssl-devel readline-devel zlib-devel libacl-devel systemd-devel xz tar
cd /build
curl --fail --location --retry 2 --connect-timeout 15 --max-time 120 --output php-8.3.36.tar.xz https://www.php.net/distributions/php-8.3.36.tar.xz
printf '%s  %s\n' dd54693aa334c5c4d72e9aee96b082c2473750a18a6c1d105789c93a3b1692fd php-8.3.36.tar.xz | sha256sum -c -
tar -xf php-8.3.36.tar.xz
cd php-8.3.36
export EXTENSION_DIR=/usr/lib64/php/modules
./configure --prefix=/usr --bindir=/usr/bin --sbindir=/usr/sbin --libdir=/usr/lib64 --sysconfdir=/etc --localstatedir=/var --with-libdir=lib64 --with-config-file-path=/etc --with-config-file-scan-dir=/etc/php.d --disable-all --enable-cli --enable-fpm --disable-cgi --disable-phpdbg --enable-filter --with-libxml --with-openssl --enable-pcntl --with-readline --enable-session --enable-sockets --with-zlib --enable-opcache=shared --with-fpm-user=apache --with-fpm-group=apache --with-fpm-acl --with-fpm-systemd
make -j4
mkdir -p /build/runtime
cp -p sapi/cli/php /build/runtime/php
cp -p sapi/fpm/php-fpm /build/runtime/php-fpm
cp -p modules/opcache.so /build/runtime/opcache.so
/build/runtime/php -n -v
/build/runtime/php -n -r 'if(PHP_VERSION!=="8.3.36"||ini_get("extension_dir")!=="/usr/lib64/php/modules"){exit(1);}'
/build/runtime/php-fpm -t
sha256sum /build/runtime/php /build/runtime/php-fpm /build/runtime/opcache.so > /build/runtime/SHA256SUMS
printf 'BUILD_COMPLETE '; date -u

ソースのtar.xzは、php.netのリリース情報に載っているSHA-256と照合してから展開している。
ビルドは3分ちょっとで終わった。
できた3ファイルのSHA-256も記録しておき、検証環境と本番で同じファイルを使ったことをこの値で確かめた。

ビルドしたら、新しい php で既存の共有拡張をすべて読み込み、ビルド前に取っておいた php -m の一覧と比べた。
欠けた拡張はなく、読み込み時のエラーも出なかった。

コンテナ内バイナリの差し替え

コンテナの中の元の3ファイルは、退避してから差し替えた。

ビルドしたファイルコンテナ内の置き場所
php/usr/bin/php
php-fpm/usr/sbin/php-fpm
opcache.so/usr/lib64/php/modules/opcache.so

ファイルを差し替えただけでは、稼働中のFPMプロセスはメモリ上の古いPHPのまま動き続ける。

USR2シグナルによる無停止切り替え

PHP-FPMのマスタープロセスにUSR2シグナルを送ると、ワーカーを順に入れ替えながら、設定とバイナリを読み直す。
PHP-FPMのマニュアル にも「graceful reload of all workers + reload of fpm conf/binary」とある。
コンテナを再起動しなくても、差し替えた php-fpm で再稼働する。

ここで docker kill --signal USR2 を使うと、シグナルはApacheへ送信される。Dockerの仕様上、docker kill はコンテナのメインプロセス(PID 1)にシグナルを送るため、PID 1であるApacheに届いてしまいFPMには届かない。
FPMのマスターのPIDは、コンテナの中の /run/php-fpm/php-fpm.pid から取得して、そのPIDに直接送信した。

送る前に、php-fpm -t と httpd -t で設定の構文チェックが通ることを確かめた。

docker exec <コンテナ> php-fpm -t
docker exec <コンテナ> httpd -t
docker exec <コンテナ> sh -c 'kill -USR2 "$(cat /run/php-fpm/php-fpm.pid)"'

切り替えたあとは、実際のアプリのAPIを叩いて確かめた。
php -v はCLIの版を出すだけなので、FPMが入れ替わったかはこれでは確認できない。
APIがHTTP 200でJSONを返し、レスポンスの X-Powered-By ヘッダーが PHP/8.3.36 になっていることを確認した。

このヘッダーは外からPHPの版が分かってしまうため、確認できたら消しておく。
php.ini で expose_php=Off にして、もう一度FPMのマスターへUSR2を送って読み直させた。
expose_php はアプリの中の ini_set() では変えられない設定なので、反映には読み直しがいる。
ヘッダーが消えたことと、APIが変わらず正常に応答することを確認した。

検証環境での確認

検証環境では、まず差し替え前の版のままUSR2とコンテナ再起動を試して、切り替え中にAPIが停止しないかを確認した。
USR2の間に21回アクセスして失敗は0件だった。
一方、コンテナの再起動中は1回だけ通信が切れ、その後に復旧した。

8.3.36に差し替えてUSR2を送ったあとは、APIへ12回続けてアクセスしてすべてHTTP 200、JSON、X-Powered-By: PHP/8.3.36 だった。
そのあとコンテナも再起動してみると、約1.6秒で戻り、FPMとApacheが立ち上がって同じAPIが応答した。

本番環境への反映

本番では、検証環境と同じ3ファイルを、SHA-256で同一のものと確かめてから差し替えた。
本番のコンテナは再起動しないで、USR2シグナルだけで切り替えている。

作業中にはいくつか細かい引っかかりもあった。
リモートからSSH経由でコマンドへ引数を渡す構文で失敗してUSR2送信前に直したり、マウント一覧の表示順序が変更前と違って見えてDocker Compose定義と突き合わせて確認したり、検証環境のcomposeファイルが所有者権限で直接書き込めず使い捨てコンテナから編集したりといった作業を挟んでいる。

切り替え前後の通信接続性は、アクセスを継続しながら確認して36回中エラー0件だった。
APIがHTTP 200でJSONを返し、X-Powered-By ヘッダーが PHP/8.3.36 に変わったあと、expose_php=Off を反映してヘッダーを消した。
古いFPMワーカープロセスが残っていないこと、コンテナIDやイメージ、起動時刻、環境変数が変更前と同じ状態を保っていることを確認している。
公式の公開から20分ほどで、本番環境での新しいFPMの応答を確認できた。

RPMの表示とdnf更新対策

ソースからビルドしたファイルで上書きしたため、rpm -q の表示は旧版のままになる。
修正済みかどうかはパッケージの版では分からないため、実際に動いている版と3ファイルのSHA-256をコンテナ内の記録に残した。

このまま dnf upgrade を実行すると、未修正のRPMが新しいバイナリを上書きしてしまう。
そこで、既存の dnf.conf を退避したうえで、PHPのパッケージだけを exclude(更新の対象から外す設定)にした。

コンテナ再作成用のイメージ準備

いま動いているコンテナはファイルを差し替えただけなので、コンテナを作り直すと元のイメージに戻ってしまう。
差し替え前のコンテナイメージをベースに、3ファイルと dnf.conf の変更を入れたイメージを作成した。
Docker Composeでは、PHPのサービスだけをこのイメージに指定し、書き換えたあとに docker compose config で定義の検証を通した。

動いているコンテナは今回は作り直していない。
代わりに、公開ポートのない一時的なコンテナをこのイメージから起動して、ゼロから立ち上がることを確かめてから削除した。
公式RPMの更新が出たら、この暫定バイナリからRemiのパッケージ管理へ戻すことになる。深夜の緊急対応としては、リポジトリの更新を待つより数十分で安定版を当てて無停止で切り替えられたので、手元の選択肢としては十分実用的だった。