本番環境のPHPバージョンを7.3から8.0に上げてみた

MoriKensukePosted by

はじめに

この記事はGRIPHONE Advent Calendar 2021 13日目の記事です。

こんにちは、バックエンドエンジニアの森です。今回、Studio MGCMから提供されているマジカミのサーバーで動いているPHPのバージョンを7.3.11から8.0.13にアップデートしたので、そこで得た知見を書いていこうと思います。

なぜ上げようと思ったのか?

言語のバージョンアップは影響範囲が大きく不具合が出る可能性もあるので、気軽にはできません。今回は以下のような点を考慮してバージョンアップをすることになりました。

1. JITによる高速化

PHP8で導入されたJITはさまざまなベンチマークによるとPHP7と比べてかなりの高速化がされています。重いAPIの高速化が見込まれたのが一番の理由です。

2. 言語バージョンアップの経験を積みたい

言語バージョンアップの機会はあまりなく、会社として経験を積めたら大きいというのもあります。個人的にも言語バージョンアップにチャレンジしてみたかったですし、結果的に大きな経験になったと思っています。

3. 過去にRC版PHP8を検証済みだった

私が着手する時点で過去に開発環境でRC版PHP8を検証済みだったので、その知見を活用すればコスト低くできそうというのも大きかったです。こちらについては本ブログで記事になっていますので詳細は以下をご覧ください。

アップデートの流れ

以下のような流れでPHP8にバージョンアップを行いました。

  1. PHP7.3とPHP8.0の変更差分調査
  2. composerのライブラリのバージョンアップ
  3. PHP8の環境+設定を作る
  4. 静的解析をする
  5. ユニットテストを通す
  6. デバッグ
  7. 本番にデプロイ

上記で述べた既にPHP8を動かしているブランチがあったので、そこをベースにPHP8対応ブランチを作っていきます。

1. PHP7.3とPHP8.0の変更差分調査

まず、何が変わったのかを調べました。公式ドキュメントにバージョンごとに下位互換性のない変更点がまとめられているので、これを見ます。以下の変更が危なさそうに思ったので、この辺りが問題ないかコードを重点的に調べました。

  • intとstringの厳密でない比較の挙動が変わった
  • LSP違反の継承が常に致命的エラーになった
  • 連結演算子の優先順位が変わった
  • 多くの警告がエラーになった
  • 静的でないメソッドを静的に呼び出せなくなった
  • implodeの引数の順番を逆にするのは許されなくなった

PHP 7.3.x から PHP 7.4.x への移行 – 下位互換性のない変更点

PHP 7.4.x から PHP 8.0.x への移行 – 下位互換性のない変更点

2. composerのライブラリのバージョンアップ

ライブラリはそのままだとPHP8で動かなくなるものがあります。まずcomposer自身のバージョンを最新にし、そのあとライブラリも全部最新まで上げます。最新まで上げて挙動を確認したところ、メソッドのシグネチャが変わるなどいくつか挙動が変わるライブラリがあったのでその対応をしました。

3. PHP8の環境+設定を作る

環境構築はDockerで行っているので、あまり手間もなくDockerfileを少しいじるだけでいけました。PHPの設定で変えたのはJIT周りの設定くらいです。

zend_extension=opcache
opcache.enable=1
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=128M

opcache.jit_buffer_sizeは最適値がよくわからなかったので、ググって多かった128Mを採用しました。

また、JITはxdebugと併存できないので、ローカル環境のxdebugをデフォルトでオフにし、必要な時だけ有効にするようにしました。

※ M1 MacではJITが有効にならないので注意が必要です。自分は開発にM1 Macを使っているのですが、これに気づかずなぜJITが有効にならないのかしばらくハマってしまいました。8.1では動くようになるようです。

4. 静的解析をする

IntelliJのInspect Code機能と、PHPStanによるLevel0の解析を行いました。あまり本質的なバグは見つかりませんでしたが(ほとんどが実質的に使われていないコードのものだった)、タイプヒンティングやdocの不足などがいくつか発見できました。

5. ユニットテストを通す

PHP7の時に全て通すようにしたユニットテストだったので、問題なく通るかなと思ったら結構落ちました。ただこれはPHP8由来というよりは、composerのPHPUnitのバージョンアップデートによるものがほとんどでした。

6. デバッグ

PHP Conference Japan 2021の発表で「他の施策に早めにPHP8対応を混ぜて、一緒にデバッグしてもらうのがとてもよかった」というのを聞いていたので、それにならってPHP8をリリースする予定の便に早めにマージして各施策に取り込んでもらいました。これによって見つかったバグが結構あったので、やってよかったです。PHPのアップデートを考えている方はぜひ以下の発表を見てください。

サービス運用エンジニアによるPHP8バージョンアップ奮闘記

レガシーシステムにおけるPHP8バージョンアップのアプリ対応記録

また、デバッガーさんに時間をとってもらって数日かけて全部の機能の正常系を一通り触るデバッグをしてもらいました。

7. 本番にデプロイ

待ちに待ったリリースです。PHP8対応で一部キャッシュの構造が変わったので、そのキャッシュを飛ばして後は緊張しながら見守っていました。

PHP8にしたことにより発生したバグ

ライブラリの挙動変更

これがかなり影響大きかったです。Redisのライブラリのメソッドのシグネチャや挙動変更が多く、色々な機能が動かなくなりました。いまだに未解決で暫定対応でなんとかしているものもあります。

usort(): Returning bool from comparison function is deprecated, return an integer less than, equal to, or greater than zero

usortに使う比較関数の戻り値がboolだとエラーになるようになりました。intにします。

before

usort(
    $array,
    function ($a, $b) {
        return $a > $b;
    }
);

after

usort(
    $array,
    function ($a, $b) {
        return $a <=> $b;
    }
);

serialize、unserializeの挙動がおかしくなることがある

ErrorException: unserialize(): Error at offset 0 of 257 bytesのようなエラーが出ます。全く原因がわからず、いったんその処理をスキップする暫定対応を入れています。あるAPIでしか発生していないので、何か他の処理と組み合わさった時に発生するようです。

ループの回数が大きくなると__callの引数が減る

ループの中でマジックメソッドの__callを呼ぶと、ループ回数が大きい時に引数を4つ渡しているのに引数の$argumentsに2つしか入ってこないことがありました。こちらはJITを無効にしたり、JITの設定をtracingからfunctionにしたら再現しなくなったのでtracing JITが原因そうです。また、このバグが発生したのはPHP8.0.12だったのですが、PHP8.0.13にあげたらtracingでも再現しなくなったので、以下のバグ修正が関係ありそうに思っています。

Bug #81512 Unexpected behavior with arrays and JIT

segmentation faultが発生する

こちらも原因全く分かっていませんが、本番でsegmentation faultを観測しています。どうも一度でも起きたサーバーはその後segmentation faultが発生しやすくなるような挙動をしています。

全てのリクエストではなく、特定のメモリを多く使うリクエストに集中して発生していること、またサーバーを起動してからある程度時間が経つと発生するので、メモリ周りが怪しいのかなと思っています。ただ、メモリ使用量に関係していそうなopcache.jit_buffer_size=32Mにしても解決せず。こちらはsegmentation faultが閾値以上発生したサーバーを再起動することで暫定対応しています。JITをやめたらsegmentation faultがなくなったという情報があるので、JITが怪しいと思っています。

php://stdoutにログを出力するとプロセスが落ちる

Monologライブラリを使ってphp://stdoutにログを出力するとプロセスが異常終了するようになりました。

$streamHandler = new StreamHandler('php://stdout', Logger::INFO);
$streamHandler->setFormatter(new LineFormatter(null, null, false, true));
self::$batch->pushHandler($streamHandler);

JITをOFFにしたり、Monologのバージョンを変えてみても解決しません。原因は不明なのですが、出力先をphp://stdoutからphp://stderrにしたら問題が起きなくなったのでいったんこれで様子を見ています。

パフォーマンスについて

PHP8のJITはとてもよい評判を聞いていたので結果に期待していたのですが、レスポンス全体の平均はあまり変わりませんでした。以下は改善を期待していたバトル周りのAPIの平均レスポンスタイムです。PvEはモンスターとのクエストバトルで、PvPは他の人が作った編成と戦います。

APIPHP7PHP8
PvEバトルの処理0.3秒0.27秒
PvPバトルの処理0.39秒0.36秒

しかし、1秒以上かかる重い処理に絞って調べてみると、割合が減っていることがわかりました。以下は1秒以上のレスポンスの割合 / 1秒以上のレスポンスタイムの平均秒数です。

APIPHP7PHP8
PvEバトルの処理0.335% / 1.35秒0.113% / 1.36秒
PvPバトルの処理2.690% / 1.53秒2.131% / 1.46秒

レスポンスタイムの平均もサバトバトルの方は少し早くなっていることがわかります。バトルのAPIは呼ばれる回数が多いので、1秒以上のレスポンスの割合が減らせたのはPHP8を入れた甲斐があったのかなと思います。

おわりに

PHP8はパフォーマンス以外にも新機能が多くあり、名前付き引数やmatch式、Nullsafe演算子などが使えるようになるのでプログラムもしやすくなりました。

JITを入れて、ある程度パフォーマンス改善はできましたが期待していたよりは改善できませんでした。PHPの複雑な処理があるバトルは大きく改善できたので、PHPの処理が多いAPIについては改善が見込めると思います。

JITはまだ挙動に不安定なところがある印象です。上記の暫定対応を入れているところも、JITを無効化したら解決するかもしれないと考えています。PHP8.1でも多くのJIT周りの修正があるので、JITを使うのであれば現状は可能な限りバージョンをあげておくのがよさそうです。