はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

クラウドやコンテナの採用が当たり前となった現代では、Pets vs. Cattle (※) に例えられるように、インスタンスは使い捨ての Immutable かつ Volatile なものとして扱うことが多いと思います。

※ 「Pets vs. Cattle」を表す図として最も有名なのはこのスライドだと思いますが、この比喩の起源は Microsoft の Bill Baker氏 だそうです。

一方、そんな時代であっても、弊社では諸々の事情により 1台のベアメタル・サーバをペットのごとく大事に使い続ける案件は多く、そういった使い捨てできない環境下でも PHP のバージョン管理をやりやすくするために phpenv を活用しています。

本記事では、phpenv の弊社での標準的なセットアップ方法と無停止での PHPバージョンアップ手順をご紹介します。

背景

コンテナではない伝統的なサーバで PHP をインストールしたい場合、リポジトリで配布されているパッケージを使うことが多いと思います。

候補となるリポジトリはいくつかあって、RPM系ですと、

などの選択肢があります。

弊社では特殊な制約を負った案件を取り扱うことも多く、1台のサーバに複数の、しかも、それぞれ PHPバージョンが異なるアプリケーションを相乗りさせることがあります。
(もちろん、運用する側としては、アプリごとにコンテナとして分離したりサーバ自体を分けたりしたいところなのですが、そう簡単にはいかない事情をお客様は抱えられているのです。)

そのような事情もあり、複数のバージョンを同時にインストールしないといけないのですが、ディストロ標準リポジトリ (RHEL 8 から導入された Application Streams を含む。) と Amazon Linux Extras は複数バージョンの共存をサポートしていないため、採用できません。

一方、Software Collections と Remi は Minorバージョンのレベルでパッケージが分かれている (= php72-*, php73-* というようなプレフィックスで分かれている) ため、その粒度であれば複数バージョンを同時にインストールできます。
なのですが、それゆえ Patchバージョンのレベルでの共存はできず、この点が我々にとっては致命的な制約になります。
(Patchバージョンの共存とはつまり、例えば 7.4.11 と 7.4.12 を同時にインストールできないということです。)

具体的にどう困るのかというと、バグ・フィックスやセキュリティの観点から継続して細かくバージョンアップしていきたいものの、アプリケーションごとに開発、検証のサイクルや、場合によってはオーナーとなるお客様の部署自体が異なるため、「あるタイミングで一斉に最新の Patchバージョンにアップデートしてしまう」という運用ができません。
各アプリケーションの歩調に合わせて、各々から要求される Patchバージョンを並行して維持していく必要があるのです。

そのような理由で、弊社では Minorバージョンの単位でしかコントロールできない Software Collections と Remi の利用は難しく、代わりに細かくバージョン管理ができる phpenv を主に用いることにしています。

phpenv で気をつけること

phpenv をサーバ上で利用するにあたって、いくつか気をつけるべき点があります。

サーバ上でビルドが走る

パッケージ・インストールとは異なり、サーバ上でいちいちソースからビルドするため、以下の 2点に気をつける必要があります。

  1. ビルド処理の負荷
  2. devel系パッケージが必要

1番はどうしようもないので、1台ずつ順番にオフラインにしてビルドするか、または、負荷が低い時間帯を狙ってえいやでビルドするしかないです。

2番は、サーバにインストールするパッケージは最小限に抑えるべしという原則に起因します。
とはいえ、PECL拡張をビルドすることもあるでしょうから、どのみち devel系パッケージが必要なケースは多く、ゆえに、大した問題ではないと思っています。:)

phpenv のロード

環境変数をセットするなどの 適切な初期化手順 を踏まないとインストールした PHP が認識されません。

sudo するときやデーモンとして動かすときなど、ログイン・シェルを経由せずに実行する場合は特に注意が必要です。

セットアップ

CentOS 7 または 8 でのセットアップ手順をご紹介します。
(どちらのバージョンもだいたい同じ手順です。)

phpenv の準備

まずは phpenvコマンドを叩ける状態までセットアップします。

CentOS 8 の事前準備

ビルドに必要なパッケージが PowerToolsリポジトリに格納されているため、これを有効化しておきます。

$ sudo dnf config-manager --set-enabled PowerTools

パッケージのインストール

ビルドに必要なパッケージをインストールします。

CentOS や PHP のバージョンによって若干の違いはありますが、おおむね以下のパッケージをインストールしておけば OK です。

$ sudo yum install epel-release

$ sudo yum groupinstall 'Development Tools'

$ sudo yum install \
    re2c \
    bzip2-devel \
    freetype-devel \
    krb5-devel \
    libcurl-devel \
    libicu-devel \
    libjpeg-turbo-devel \
    libpng-devel \
    libtidy-devel \
    libxml2-devel \
    libxslt-devel \
    libzip-devel \
    oniguruma-devel \
    openssl-devel \
    readline-devel \
    sqlite-devel \
    systemd-devel \
    zlib-devel

Apacheモジュールで動かす

Apacheモジュール (libphp*.so) として PHP を動かしたい場合は httpd-develパッケージをインストールしておきます。

$ sudo yum install httpd-devel

phpenv のインストール

phpenv を配備&セットアップします。

この記事では /opt/phpenv/ に配置します。

$ sudo git clone https://github.com/phpenv/phpenv.git /opt/phpenv

$ cat <<'EOS' | sudo tee /etc/profile.d/phpenv.sh
export PHPENV_ROOT='/opt/phpenv'

PATH="${PHPENV_ROOT}/bin:${PATH}"

eval "$( phpenv init - )"
EOS

$ sudo git clone https://github.com/php-build/php-build.git /opt/phpenv/plugins/php-build

PHP のビルド

続いて、PHP をビルドします。

基本形

基本は以下のコマンドでビルドします。

オプションは都合に合わせて取捨してください。

$ RUNTIME='{{ PHPデーモン・プロセスに応じた値 }}'
$ OPTS=(
    {{ ビルド・オプション 1 }}
    {{ ビルド・オプション 2 }}
    ...
    ${RUNTIME}
  )
$ sudo CONFIGURE_OPTS="$( echo ${OPTS[@]} )" -i phpenv install -v {{ バージョン }}

$ sudo -i phpenv rehash

変数 RUNTIME には PHP を実行するデーモン・プロセスに応じて以下の値を指定します。

デーモン・プロセス
PHP-FPM --with-fpm-systemd
Apacheモジュール --with-apxs2

Apacheモジュール

残念なことに、Apache のモジュールはサーバのグローバルな領域 (/usr/lib64/httpd/modules/libphp*.so) に作成されてしまいます。

これでは別バージョンをビルドしたらそのたびに上書かれてしまうので、手間ですが都度 phpenv の管理下に移動しておきます。

$ sudo mv /usr/lib64/httpd/modules/libphp*.so ${PHPENV_ROOT}/versions/{{ バージョン }}/lib/

デフォルトのビルド・オプション

デフォルトの PHPビルド・オプションは以下のファイルで定義されています。

  • ${PHPENV_ROOT}/plugins/php-build/share/php-build/default_configure_options
  • ${PHPENV_ROOT}/plugins/php-build/share/php-build/definitions/{{ バージョン }}

これらのオプションと環境変数CONFIGURE_OPTS が連結されて configureスクリプトに渡されます。

CentOS 7 の注意点

libzip周りでエラーが発生した場合、--without-libzipオプションで回避できるかもしれません。

$ OPTS=(
    --without-libzip
    ...
  )

最近の PHP は libzip の v0.11以上を要求するのですが、CentOS 7 の標準の libzip-devel が古い (v0.10) ため、そのままではビルドできないのです。

PHP 7.3 までは --without-libzipオプションを指定すれば PHP にバンドルされた libzip を使ってビルドできます。
(推奨されていないとのことですが…。)

https://www.php.net/manual/en/zip.installation.php

As of PHP 7.3.0, building against the bundled libzip is discouraged, but still possible by adding –without-libzip to the configuration.

ただし PHP 7.4 では libzip がバンドルされなくなったようなので、7.4 をビルドしたい場合は頑張って libzip v0.11以上をインストールする必要があります。

As of PHP 7.4.0, the bundled libzip is removed.

なお、CentOS 8 は標準で 1.5系の libzip が配布されているので、ここで述べたような考慮は不要です。:)

最新のバージョン

インストールできるバージョンのリストは php-build が保持しています。

PHP のリリースに追随して リストは更新される ので、新しいバージョンをインストールしたい場合は php-build をアップデートしましょう。

$ ( cd /opt/phpenv/plugins/php-build && sudo git pull )

利用するバージョンのセット

PHP をビルドしたら、そのバージョンを使うようにセットします。

$ sudo -i phpenv global {{ バージョン }}

(実運用においてはアプリケーションごとに .php-versionファイルでバージョン指定するため global でセットする意味はないのですが、後続のセットアップ作業で便利なので、一応 global でセットしておきます。)

Composer のインストール

Composer を使わないケースはほとんどないので、インストールしておきます。

$ curl -s https://getcomposer.org/installer | sudo -i php -- --install-dir="$( phpenv prefix )/bin/" --filename=composer
All settings correct for using Composer
Downloading...

Composer (version 2.0.2) successfully installed to: /opt/phpenv/versions/7.4.11/bin/composer
Use it: php /opt/phpenv/versions/7.4.11/bin/composer

$ sudo -i phpenv rehash

phpenv-composerプラグイン という便利なものもあるのですが、我々の構成だといろいろ相性が悪い (後述) ので普通にインストールすることにしました。
なので、別バージョンをビルドするたびに Composer もいちいち手動でインストールする運用としています。

phpenv-composerプラグインとの相性が悪い

我々の構成というか、rootアカウントでの利用が想定されていないみたいです。

  1. Bash profileなどで phpenv のロード処理 ($ eval "$( phpenv init - )") が実行されると、内部で $ phpenv rehash が走る。

  2. $ phpenv rehash が走ると、このプラグインの効果でさらに $ composer config ... が走る。

  3. ところで composerコマンドを rootアカウントで実行すると以下のダイアログが出る。

    Do not run Composer as root/super user! See https://getcomposer.org/root for details Continue as root/super user [yes]?

    これは $ composer config ... を叩いた場合も例外ではない。

  4. 以上の合わせ技で、$ sudo su - などとすると、裏で ↑ のダイアログが出るものの画面にはエコーされないので、一見フリーズしたようになってしまう。
    (キー入力は通るので、Enter を何度か叩けば rootアカウントにスイッチできる。)

知っていればどうってことはない不都合さではあるのですが、知らない人が見たら普通に混乱するので、残念ながら phpenv-composerプラグインの利用は諦めました。

実行プロセスのセットアップ

PHP の Webアプリケーションを動かす場合は、PHP-FPM または Apache のどちらかを使うのが一般的かと思います。

それぞれのセットアップ方法は以下のとおりです。

PHP-FPM

Systemd unitファイルを配備します。

$ sudo cp $( phpenv prefix )/etc/systemd/system/php-fpm.service /etc/systemd/system/php-fpm-$( phpenv version-name ).service

$ sudo systemctl daemon-reload
$ sudo systemctl enable php-fpm-$( phpenv version-name ).service

補足

Unitファイルは、シンボリック・リンクではなく実ファイルをコピーしないといけません。

というのも、Systemd はシンボリック・リンクの参照先ファイルの名前で Unit を区別します。
シンボリック・リンクで配置すると参照先ファイルはすべて同名 (= php-fpm.service) であるため、複数のバージョンの Unit を並べたつもりでも、Systemd はそれらが異なる Unit だとは認識できないのです。

以上で systemctlコマンドを使って PHP-FPM を操作できるようになります。

あとは普通にチューニングなどを済ませればセットアップ完了です。
デフォルトでは pidファイルやアクセス・ログを出さないようになっているので、そのあたりの運用面の設定値も忘れずに調整しましょう。

/opt/phpenv/versions/{{ バージョン }}/etc/php-fpm.conf
pid = run/php-fpm.pid
/opt/phpenv/versions/{{ バージョン }}/etc/php-fpm.d/{{ プール }}.conf
access.log = var/log/$pool.access.log

logrotate については、インストールされている全バージョンに対して仕掛けるのがよいでしょう。

/etc/logrotate.d/php-fpm
/opt/phpenv/versions/*/var/log/*.log {
  missingok
  notifempty
  nocreate
  sharedscripts
  postrotate
    find /opt/phpenv/versions/*/var/run/php-fpm.pid -type f | xargs -I @ bash -c 'kill -USR1 $( < @ ) 2> /dev/null || :'
  endscript
}

Apache

Apacheモジュールをビルドすると、同時に /etc/httpd/conf/httpd.conf が書き換えられて LoadModule の 1行が勝手に挿入されます。
(元のファイルは httpd.conf.bak として退避されます。)

--- httpd.conf.bak      2020-10-22 06:13:31.598571632 +0000
+++ httpd.conf  2020-09-15 15:39:59.000000000 +0000
@@ -55,6 +55,7 @@
 #
 # Example:
 # LoadModule foo_module modules/mod_foo.so
+LoadModule php7_module        /usr/lib64/httpd/modules/libphp7.so
 #
 Include conf.modules.d/*.conf

PHP関連の設定は別ファイルに切り出してまとめたほうが扱いやすいので、この変更は Revert して、普通に /etc/httpd/conf.d/php.conf などで定義し直したほうがよいでしょう。

Apacheモジュールは phpenv の管理下に移動してある ので、ロードするパスもそちらに合わせます。

/etc/httpd/conf.d/php.conf
LoadModule php7_module "/opt/phpenv/versions/{{ バージョン }}/lib/libphp7.so"

<FilesMatch "\.php$">
  SetHandler "application/x-httpd-php"
</FilesMatch>

バージョンアップ

なるべく停止時間を短く抑えつつ PHP をバージョンアップしたい場合は、以下のような手順を踏むとよいでしょう。

例として v7.4.11 から v7.4.12 にバージョンアップする際の手順を示します。

PHP-FPM

Webサーバは Nginx とします。

基本方針

新旧両方のバージョンの PHP-FPMプロセスをあらかじめ起動しておき、タイミングを見て Nginx からのプロキシ先を切り替えます。
次いで、バッチや CLI など PHP-FPM以外で実行される PHPプロセスのバージョンを切り替えるために、$ phpenv local$ phpenv global を実行します。
(切り替え計画次第ではありますが、どちらを先にやってもよいでしょう。)

なお、新旧の PHP-FPMプロセスが Listen するポート (またはソケット・ファイルのパス) は被らないようにしておく必要があります。

手順

1. 新バージョンの PHP-FPM の起動

まず、新バージョン (v7.4.12) の PHP-FPMプロセスを起動しておきます。

$ sudo systemctl start php-fpm-7.4.12.service
2. Nginx の fastcgi_pass の切り替え

新旧プロセスの Listen するポートが以下だとした場合、

  • v7.4.11: 9001番
  • v7.4.12: 9002番

新プロセスのポートである 9002番へ向けるようにします。

- fastcgi_pass "127.0.0.1:9001";
+ fastcgi_pass "127.0.0.1:9002";
3. Nginx の再起動

頃合いを見計って Nginx を再起動します。

$ sudo systemctl restart nginx.service

このタイミングで新バージョンに切り替わります。

4. アプリケーションの指定PHPバージョンの切り替え

バッチなどを叩く際にも新バージョンで動くように、アプリケーションの指定PHPバージョンを切り替えます。

アプリケーションのルート・ディレクトリに .php-version が存在する場合は $ phpenv local を。

$ phpenv local 7.4.12

当該ファイルが存在しない場合は、サーバ・グローバルの PHPバージョンを切り替えます。

$ sudo -i phpenv global 7.4.12
5. 旧バージョンの PHP-FPM の停止

新バージョンの動作に問題がないことを確認できたら、旧バージョンの PHP-FPM を停止します。

$ sudo systemctl stop php-fpm-7.4.11.service
6. PHP-FPM の自動起動の切り替え

旧バージョンの自動起動を停止して、代わりに新バージョンが自動起動するようにします。

$ sudo systemctl disable php-fpm-7.4.11.service
Removed /etc/systemd/system/multi-user.target.wants/php-fpm-7.4.11.service.

$ sudo systemctl enable php-fpm-7.4.12.service
Created symlink /etc/systemd/system/multi-user.target.wants/php-fpm-7.4.12.service → /etc/systemd/system/php-fpm-7.4.12.service.

Apache

基本方針

こちらはシンプルで、LoadModule でロードする PHPモジュールを入れ替えて再起動するだけです。

手順

1. モジュールの切り替え

ロードする PHPモジュールを新バージョン (v7.4.12) のものに切り替えます。

- LoadModule php7_module "/opt/phpenv/versions/7.4.11/lib/libphp7.so"
+ LoadModule php7_module "/opt/phpenv/versions/7.4.12/lib/libphp7.so"
2. Apache の再起動

Apache を再起動します。

$ sudo systemctl restart httpd.service

Nginx と同じく、このタイミングで新バージョンに切り替わります。

3. アプリケーションの指定PHPバージョンの切り替え

(Nginx と同様なので、そちらを参照してください。)

まとめ

以上、phpenv を本番環境で運用するうえでの扱い方をご紹介しました。

本来ならば使い捨て可能なコンテナや、そうでなくても Remi などの信頼あるリポジトリを使ってお手軽に PHP を運用したいところなのですが、気軽に入れ替えできないサーバを長く運用する都合上、やはり弊社では phpenv が最適なのです。

ビルド負荷や sudo時の挙動など、サーバとして動かす際に気をつける点や、また、PHP-FPM/Apache のセットアップ上のちょっとした癖はあるものの、今のところ大きな困難はなく運用できています。

phpenv を本番環境で運用したいというケースはそう多くないと思いますが、この記事が 1つの事例としてご参考になれば幸いです。

今後ともよろしくお願い申し上げます。