2015 年、僕がメインで担当したとある PHP 案件の振り返りを行ってみようと思います。おもに技術面から、設計前に想定したこと、実際に導入してうまくいったこと・いかなかったことを振り返ってみたいと思います。
技術的な環境は、次のとおりです。
- インフラ環境: オンプレミス(だいだい6台くらい、このときのためにほぼハードウェアを新規に調達しました)
- OS: CentOS 7.0
- 言語: PHP 5.6
- フレームワーク: FluelPHP 1.7.x
- データベース: Postgresql 9.3
- ミドルウェア
- ロードバランサー冗長化: Keepalived(新規)
- ウェブサーバ: Apache から Nginx に変更、PHP は FPM
- キャッシュ: Redis(新規)
- 検索: Elasticsearch(新規)
- ログ: Fluentd(新規)
- 監視: Nagios & Cacti & Munin(従来と同じですが、新規にセットアップしています)
案件内容的には、とある既存の Java だったサイトを PHP にてフルリニューアルするというものです。
初回の設計時の構成は、次のとおりです。僕の担当は、全体のアーキテクチャ設計と実装とインフラ構築と切り替え時の運用、そしてすべての引き継ぎです。
- 納期がかなりタイトだったため、データベースは既存の構成のままとしました(テーブル構成・SQL チューニングなどは、フルリニューアル後にお任せすることにしました)
- サイトのデザインは、そのままの形で移行しますが、あまりにも不要な機能名は移行しない(デザインは、PC、スマフォ、フィーチャーフォン版の3種類ありました)
- リニューアル後、すぐに引き継ぐため、なるべく新しいミドルウェアは導入しない(あまり技術的な進化はしないですが、業務なので仕方がない面が多いのも事実です)
サーバ構成を図にすると、次のような感じになります。
まず、インフラ側から、各コンポーネントについて説明します。
OS
- オンプレのため、OS をインストールが必要ですが、オンプレのホスティング先に設定内容をまとめて依頼することにしました(ネットワーク構成や IPMI になりますが、コストは多少かかりますが個人的にはかなり信頼できるホストティング先でこの面はとても安心できました)
- ミドルウェア以上の導入は、すべてこちらの管轄として、責任分解点を明確にしました
ロードバランサー
- オンプレなので、いつもなら Linux のカーネルの変更(LVS ハッシュサイズ変更、TCP WAIT_TIME 変更)+ LVS (DSR) + Keepalived の構成をとりかったですが、引き継ぎ先のエンジニアのスキルセットとそれほどの規模ではなかったので、カーネルの変更は行いませんでした
- Keepalived + Nginx の Active / Standby 構成としました
- Keepalived は、VIP のみをもつ設定として、ローカルの Nginx へリクエストを行い、 Nginx をロードバランサーとして各ウェブサーバへプロキシする構成としました
- L7 Nginx にしましたが、ページの静的ファイルなども、すべてウェブサーバへリクエストを振り分けました(※ロードバランサーにアプリケーションプログラムをデプロイするか、Nginx の静的ファイルキャッシュを使う選択肢も当時は考えましたが、静的ファイルのリクエストはそれほど多くなかったのでキャッシュは不要と判断しました)
- なので、当然 DSR ではなく、NAT になります
ウェブ
- ウェブサイトのページを表示するサーバです
- 一般的な Nginx + PHP FPM の構成としました
- ページを表示するために必要なデータは、すべてウェブサーバごとに Redis へキャッシュする方針としました(かなり当時の SQL が重かったので、別に PHP でクローラーとして実装してキャッシュしました)
- ウェブサーバの Redis は、各ウェブサーバごとにキャッシュとしてデータをもっているため、レプリケーションは行っていません)
- 今後の緊急サーバ増強にあわせて、AWS EC2 のハイブリッド構成も視野に入れていたので、awscli は最初から導入しておきました
- PHP は、remi にある PHP 5.6 をそのまま導入しました
- この他に、php-mecab、php_qr を PHP 5.6 で動くようにパッチをあてて使うことにしました
検索
- 今まで SQL like 的な感じだったので、思い切って Elasticsearch を導入しました
- 定期的にデータベースから Elasticsearch へデータを入れるプログラムを cron で動作させるようにしました
キャッシュ
- ウェブサーバごとの Redis とは別に共通に必要なデータを Redis を、別のキャッシュサーバとして設けました
- キャッシュ1の Redis をマスターとして、各ウェブサーバに別の Redis でスレーブとしました(ページを表示するために共通のキャッシュを取得するためにウェブサーバのローカルのスレーブ Redis から取得する構成としました)
データベース
- 一般的な Postgresql 9.3 のマスター x スレーブ x 1 構成になります
- リプレイス後、スレーブを 1 台追加、Keepalived を使って HA 構成としました
監視まわり
- Sensu & Grafana & Kibana の構成で考えましたが、引き継ぎ先のエンジニアのスキルセットを考慮して見送りました
- Munin は、別の非エンジニアチームの方が使っていたため、そのまま引き継いで導入しました
- Mackrel を無料の範囲で導入しました
- Cacti は、引き継ぎ先のエンジニアのスキルセットを考えて導入しましたが、個人的にはかなり久しぶりだったのでかなり設定に手間取りました
その他のツール
- 構成管理ツールは、Ansible にしました(本当は Itamae にしたかったですが、Ruby だったので厳しかったです・・・)、Nagios の設定ファイルも自動生成したりしていました
- Serverspec も導入したかったのですが、Ruby になってしまうため、こちらも引き継ぎ先のエンジニアのスキルセットを考慮して見送りました
- 案件管理は Backlog、コード管理も Backlog の git、チャットは Slack(※もちろん有料!)、共通系は Qiita Team、を導入していただきました
- コードのデプロイは、PHP 縛りということで Capistrano ではなく、同等の Rocketeer を使いました、このツールいろいろと罠があったので、別の機会にブログにまとめておきたいと思います
と、こんな感じです。
インフラのオンプレサーバは、サーバ選定からすべて僕一人で行いました。(※ラッキングと OS のインストールは、前述のとおりすべてホスティング先にお任せしました。)
サーバの選定にあたっては、台数が増えると運用コストが増えるため、できるかぎり予算の範囲でスペックの高いサーバとしていますが、次のようなスペックにしました。NIC は、安定の Intel 製にしました。
- ロードバランサー: CPU 8 コア、メモリ 16GB、ディスク SAS 300GB RAID 1
- ウェブ: CPU 32 コア、メモリ 64GB、ディスク SAS 300GB x 4、RAID10
- キャッシュ: CPU 32 コア、メモリ 64GB、ディスク SSD 120GB x 4、RAID10
- データベース: ioDrive サーバを導入できる見込みもありましたが、コストの問題、そこまでデータベースネックではない(SQL をちゃんとチューニングすれば問題ないはず)様子だったので、既存のサーバをそのまま転用しました、ただしディスク 4 台 SAS 300GB x 2 RAID1 + Spare 構成だったので RAID10 構成に統一しました
次に PHP の部分です、PHP の実装は、引き継ぎ先のエンジニアにも最初から担当していただきました。
- フレームワークは、CodeIgniter か Laravel かどうか迷いましたが、けっこうシンプルな FuelPHP にしました(どうも現在 FuelPHP は、作者の人が病気?のため、かなり開発が停滞してしまっているようなので、もしかしたら選定に失敗したかもしれません)
- FuelPHP は、基本的に MySQL での用途となっていたので、Model 部分を頑張って Postgresql に対応しました(今回の案件は画像のデータはそのまま Postgresql に格納されていて、その部分の Model 対応が一番大変でした)
- テストも同梱されていることが魅力的で、僕の実装ではほぼ PHPunit によるユニットテストを書きましたが、他のエンジニアの方までテストを書く体制がとれなかったです
- なので、非エンジニアが行うブラックボックステスト QA にみの品質管理体制となっていました(エンジニア側での動作確認は、すべてのブラウザを F5 リロードになっていました)
- Redis へキャッシュするプログラムは、バッチ処理のように記述してデータベースの変更にあわせてほぼリアルタイムに更新できるようしました(そのため、FuelPHP の tasks + Supervisord の組み合わせにしました)
本番リリース前には、wrk を使って、それぞれの UserAgent で簡易ベンチマークを行いました。そのといは結果的にギリギリのパフォーマンスでした。
リリース後、いくつか問題が発生しました。。。
まずロードバランサー側で iptables の ip_conntack 設定ミス問題が発生しました。これ完全に僕の設定ミスだったので、すぐに設定を変更しました。Nagios での監視も漏れてもれていたことが原因でした。やはり、Serverspec を導入しておけよかったと思います。
Ansible 的には、次の設定を追加しました。
- sysctl: name="{{ item.name }}" value="{{ item.value }}" sysctl_file=/etc/sysctl.d/10-lb.conf with_items: - { name: net.nf_conntrack_max, value: 2097152 }
次に、それほどアクセスが多くないページの部分的な、キャッシュを使っていない部分でデータベースのボトルネックが発生しました。すぐにすべてキャッシュに入れる構成に変更して再リリースしました。納期的な問題から、あとで対応しようと考えていたのですが、やはりかなり甘い考えだったようです。
さらに PHP でキャッシュデータを生成していたクローラーデーモンがメモリリークして、OOM Killer を抑制していたため、サーバに SSH できない問題が発生しました・・・。調べてみると、どうも FuelPHP の Model まわりで発生しているようで、かなり根本解決が難しかったため、一定時間おきに PHP クローラーデーモンの内部で実際にキャッシングしている処理を別の PHP で実行する方式に変更しました。
クローラーデーモンは、Supervisor 経由で FuelPHP の task を、次のような設定に変更しました。次の設定では、FuelPHP tasks の crawler::run_fork を実行して、foo_cache という crawler に定義されている関数を別 PHP で 10 秒おきに実行するという意味になります。
[program:crawler]
command= /opt/remi/php56/root/usr/bin/php oil refine crawler:run_fork foo_cache --wait=10
そして、キャッシュの方のパフォーマンス問題がピーク時間帯やピークを少し越えたときに発生してしまいました。原因とすると、キャッシュ構造性の設計が起因していました。具体的には、ページのリクエストおきに ZSET なキーを取得して、そのキーをもとにすべての HASH を取得してキャッシュデータとデータベースから取得したデータを作成していましたが、この構成だとパフォーマンス的な限界があったようです。
この修正はかなり時間がかなりそうだったので、取り急ぎ PHP から Redis へアクセスする部分を Redis_db から phpredis に変更しましたが、大きくパフォーマンスは改善しませんでした。
この改善は、キャッシュデータを構造を大幅に見直して、PHP Serialize したデータのみにアクセスする構成にしましたが、結局時間がかかってしまい、引き継ぎもあってこの変更は受け入れられませんでした。。。
結局時間がかかったこともあって、ロードバランサー側にすべての動的なページを 10 分間キャッシュするように設定変更することになかったようです。パフォーマンス問題があったとはいえ、個人的にはちょっとありえない施策だったと思います。
こうしていくつかの問題があったのですが、引き継ぎもすべて完了して、この案件は僕としては終了しました。個人的な反省は、次のとおりです。
- PHP を常時起動しているクローラーとして実装しない方がよい: やはり Apache の MaxRequestsPerChild や PHP FPM の pm.max_requests にみるように PHP は一定量処理したあとは再起動するのが鉄板だったようです、Supervisor を使っていたので、わざとデーモンプログラムを終了するようにすれば一定時間おきに起動することができました
- キャッシュ構造の設計ミス: 今回二段階のキャッシュの構造でパフォーマンス問題がありました、二段階にした理由はキャッシュデータ量を減らすためそうしたのですが、キャッシュする量が対象増えても一段階に最初からするべきでした
あわせていくつか課題が残りました。
- PHP ユニットテスト: 僕が実装した範囲はほぼテストコードを書いていましたが、その他のところはまったくテストコードがない状態となってしまいました、今回の案件の場合すべてのテストを書く必要はないと思いますが、最低限 Model と Controller の部分はテストコードを書いておけば QA するときの工数がかなり下がるため、総合的にみると工数は減るはずです、ヒアリングしてみるとテストコードを書いた経験がないため難しいと言っておられましたが、誰も最初は初めてなのでテストコードを書いてほしかったと思います
- インフラの運用: オンプレなので、日々の運用で PHP や Nginx やセキュリティパッチをあてていく運用が必要となってきます、必要なものはすべて Qita Team にまとめておきましたが、PHP のバージョンが 3 つくらい古い様子なので、ちゃんと引き継ぎできているのか心配です
ということで、貴重な体験を振り返ってみました。
2016 年も始まっているので、引き続きウェブサービスを開発していきたいと思います。
PlanetMySQL Voting: Vote UP / Vote DOWN