AWSで構築した「コスト最優先WordPress」改善の歴史を振り返って

# はじめに
本記事は「[Japan APN Ambassador Advent Calendar 2021](https://qiita.com/advent-calendar/2021/japan-apn-ambassador)」の16日目の記事です。

本記事では、私が以前とあるWebサイトの全面改修に関わった時 **「コストを最小化しつつ業務も自動化したい」** という、今思えば中々に難易度の高いオーダーを受けて開発した **「コスト最優先WordPress」** の改善の歴史を振り返ります。

この開発では、とあるVPSで運用されていたWebサイトをWordPress化して、AWSに移行しました。

但し、この移行のテーマは **「コスト最優先」** であり、とにかくAWS利用料が安くなるように考えなければならないものでした。
**その要望は、本当にコストを削るためなら何でもありと言わんばかりのものでした。**
移行から約8年が経過し、運用上のトラブルはありましたが、改善を繰り返し、現役で動き続けています。

本記事は、その **「コスト最優先WordPress」** がどんな形でリリースされ、どうやって改善されてきたのかを振り返ったものです。

なお、執筆にあたり関係者と会話した結果、業務に影響が出ないように、会社名・Webサイト名・取り扱う商品等の内容については触れないように配慮していますので、ご了承ください。

# 移行前の状況
8年前(移行前)、このWebサイトの運営チームは、次のことを1日1回、2時間弱かけて実施していました。

* **ある商品のデータをWeb上から手動で取得し、ランキング形式にソート**
    * **ブラウザでWeb API (REST API) のURLに接続し、画面に表示されたJSON (100件のデータ) を手動で加工**
* **ソートした結果をもとに、テキストエディタでHTMLを手動で作成**
* **作成したHTMLを、CMSの投稿用エディタに貼り、プレビューして問題なければ投稿**

この状況に対して、運営チームから相談されたざっくりした要件は次の3件でした。

* **上記の業務を全て自動化すること**
    * **自動化にあたり、Web API (REST API) でデータを10分間隔で取得するようにして、10分おきに記事を投稿(更新)すること**
* **運用コストをなるべく安くすること**
* **何かが起きても運営チームが手動で介入して柔軟な対応が取りやすい環境にすること**

この中で最重視され、どうしても譲られなかったのが **「運用コストをなるべく安くすること」** であり、これが「コスト最優先WordPress」の構築へと繋がっていきます。

# 第1段階: とにかく安価を目指してAWSに移行する
できる限り安価かつ柔軟な対応が取りやすいだろうということで、当時はあまり深く考えずに移行先にAWSを選択し、今で言うところのクラウドリフトを実施しました。
一番最初のアーキテクチャが、以下の図となります。

## アーキテクチャ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/3f274241-b1d4-d34f-ffa2-83d81d10f445.png)

繰り返しになりますが、この移行では**コストが最優先**されており、その結果、

* **インスタンスは1台　(データベースも含め、あらゆる機能は可能な限り同居！)**
* **スポットインスタンスで運用** (オンデマンドの約5分の1〜6分の1)

**いわゆるAWSのベストプラクティスとは、真逆に向かっていることが分かるかと思います。**

詳細な解説は割愛しますが、スポットインスタンスは入札価格によって起動可・不可が決まるため予期せぬ終了(=インスタンスが破棄される)リスクがあります。
そのため、スポットインスタンスでデータベースを起動することは、ウルトラバッドプラクティスと言え、普通は選択肢に入りません。
しかし、**とにかくコストを削減するんだ**という強い要望があり、考え抜いた結果、こうなりました。
**いわゆるAPとDBの分離や、RDSの採用は、コストが高騰するからという理由で却下されました。**

構成図中の各要素について、少し触れておきます。
## Auto Scaling Group
**私もこの開発で思い知ったことですが「コスト最優先」の思想においては、Auto Scalingは「スポットインスタンスを "最大起動数=最小起動数=1" で起動して維持するための機能」** です。
**「1より小さくなることはないため、最小コストで運用する=1台で運用する」** だと、当時の運営チームに教えられました。
これはその通り(正論)で、AWSの様々な資料で「柔軟性のあるクラウドキャパシティ」として、ピークに合わせて利用料が増減するようなグラフ(例えば[こちら](https://www.slideshare.net/horiyasu/20131206-reduce-costbyawsver2)のP11)が紹介されますが、確かに良く良く考えれば、**増減などせずに一番下の方で張り付いていればそれに越したことはありません。**
運営チームは、私に対して、上記のような状態を目指せと指示してきたわけです。

この「1台でなんとかなるようにして」を叶えるために「[KUSANAGI](https://kusanagi.tokyo/)」を採用したり、余計なプロセスを止めたりするなど、1台で多くのリクエストを捌けるように様々な工夫を行いました。

一方で、あらゆるリスクを承知の上で、**スポットインスタンスをできる限り長時間維持するために、入札価格をオンデマンド並に設定**しておきました。
運営チームがスポットインスタンスの価格グラフの履歴を見た上で「オンデマンドより高くなることは稀だろう」「それで良い」と判断したことによるものでした。(場合によってはオンデマンドより高くなるリスクを説明した上で許されました)

また、性能面でCPU利用率の閾値を設けてAuto Scalingするように設定しました。

## WordPress
WordPress自体の説明は割愛しますが、今回の場合、XMLRPCで記事投稿ができるため、自動化の目的達成のために一番メジャーなCMSを採用したという側面が大きいです。

## Backup
スポットインスタンスを利用しているため、EC2を自動復旧するためにAuto Scalingを採用しました。
ただし、スポットインスタンスなので、AMIが古いままだと最新のデータが全て失われることになります。

そこで、**EC2のAMIを10分に1回取得し、常に最新のAMIから起動するようにAuto Scalingの設定を上書きする**スクリプトを**EC2で同居して**動かすことにしました。
この設定により、何かあっても10分前の状態には戻せるだろうという考え方です。
ちなみに、mysqldumpは取っていません。

AMIに紐づくEBSスナップショットは、増分バックアップです。
ユースケースにもよりますが、今回の場合は10分に1回取っても、大きなコスト増にはなりませんでした。
実際の課金ですが、10分に1回 = 1時間に6回 = **1日に144回**もAMIを取得しても、月額の利用料は以下の通り、お安く運用できています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/a1112ced-be16-fb83-9387-4305d3cb7f0f.png)

**ちなみに、この移行を実施した当時、AWS Lambdaは存在しませんし、AWS Backupも存在していません。**

## Post (記事投稿)
Web APIを実行して外部サービスからデータを取得し、取得したデータを抽出してHTMLを作成し、記事を投稿するまでのプログラムです。
後々問題になりますが、これらは**全て直列かつ同期処理で**リリースされました。
ちなみにWordPressでXMLRPCを使って記事をPostする部分は、下記のコードで実現しています。

```Java:post.java
XmlRpcClient client = new XmlRpcClient(xmlRpcUrl, true);

HashMap hmContent = new HashMap();
hmContent.put("post_title", title);
hmContent.put("post_author", "hoge");
hmContent.put("post_excerpt", excerpt);
hmContent.put("post_content", contents);
hmContent.put("post_name", "hoge");
hmContent.put("post_status", "publish");

HashMap hmTerms = new HashMap();
hmTerms.put("category", new String[] { category });
hmContent.put("terms_names", hmTerms);

Object response = client.invoke("wp.newPost", new Object[] { new Integer(1), username, password, hmContent, true });
String post_id_str = response.toString();
int post_id = Integer.parseInt(post_id_str);

return post_id;
```

# 第2段階: 待望のLambda登場、EC2からスクリプト等が分離される
上記のようにハラハラドキドキする設計・構成でリリースされた環境ですが、**約1年以上の間、想定の範囲内で運用を続けます。**
何度かEC2が停止し、Auto Scalingで新たなEC2に置換されましたが、特に問題は起きませんでした。
Webサイトは月間約100万PVに成長しましたが、物ともせず捌いてくれました。

しかし、徐々にEC2のCPUやメモリの利用率が上がり始め、Auto Scalingの頻度が上がり始めたため、負荷対策として、EC2で同居しているスクリプト等を分離しようということになりました。
但し、**もちろん「コストが最優先」であり、EC2をもう1台増やすという提案は受け入れられません。**

この提案をしていた時期に、本当にタイミング良くですが、AWS Lambdaがリリースされました。
そこで、**Lambdaを利用してEC2の外部に処理をオフロードしてしまおうということになりました。**
本件でAWSを採用したことによって、柔軟な対応が実現できた最初の出来事でした。

この第2段階のアーキテクチャが、以下のものです。

## アーキテクチャ
図のように、EC2内に存在していたPostとBackupを、Lambdaに分離しました。
これにより、EC2の負荷が下がることが期待され、分離直後は期待通りに動いてくれました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/d0f35dce-35d8-a10b-094e-fdd23bcbe40d.png)

# 第3段階: 「Lambdaの実行失敗=データの欠損」を解消する
第2段階のリリース後、Lambdaのチューニングを繰り返しつつ、運用を続けていました。
そんな中**「Lambdaの実行が失敗すると、その実行で取得するはずだったデータが欠損する」**という事実に直面します。
この原因は、前述のPostプログラムの説明の通りで、Web APIでデータを取得してからWordPressにポストするまでの処理が、全て直列かつ同期処理になっているためでした。

そこで、Postに同居していた処理を分離し、さらにリトライできるようにするためのアーキテクチャ改善を行いました。
この時、Backup側も同様のリスクがあると見て、同じく処理を分離しています。

## アーキテクチャ
当時リリースされて間もなかった、Step Functionsを利用しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/4646c9a7-dcfb-46d3-f180-1b30129e5bc5.png)
ちなみに、この仕組みにおける**Lambdaの月額コストは無償枠内**、**Step Functionsの月額コストはおよそ$2.00**以内で収まっており **「コスト最優先」から逸脱することなく改善を実現** できました。

# 第4段階: 「リトライしてもデータが失われてしまう」事象を改善する
第3段階の改善によって解消されるかと思われたデータ欠損問題ですが、**実は全く解消できていませんでした。**

原因はこのアーキテクチャ全体にあり、Web APIの実行をリトライしてデータの取得に成功しても、**WordPressへの投稿が何らかの原因で失敗すれば、そのデータがどこにも残らない仕様**となっていたためです。
データ欠損の原因はWordPressへの投稿が失敗することにあり、その原因は、接続すると502 Errorが返る現象が多発してXMLRPCもエラーとなってしまったことによるものでした。

そこで、Web APIでデータの取得に成功した場合にその**生データをまず保管(退避)し、何か起きても必ず取得したデータからリトライできる**ようにアーキテクチャを改善しました。

ちなみに、**S3の利用料は微々たるものであり「コスト最優先」を逸脱することなく改善を実現できました。**

## アーキテクチャ
図のように、Get用Lambdaで、Web APIで取得したデータ(JSON形式)を、まずS3バケットに保管するようにしました。
Get用Lambdaの処理はここまでで、Post用LambdaはGet用LambdaからJSONを受け取るのではなく、S3から取得するようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/d746c73d-760b-c7d8-1e15-6472f21fbf5b.png)

また、S3に置いた生データを後々のことを考えて破棄することなく、命名規則を持って保管することとしました。
当時のこの判断は、いつでも任意の時点のデータを参照してリトライできるようにするためであり、後にデータ分析などの2次利用の検討に活きてくるとは当時は考えもしませんでした。

# 第5段階: 502 Error多発をCloudWatchによる監視で自動対処
第4段階でデータが失われるリスクは激減したのですが、**深夜に502 Errorが長時間継続した場合に、S3からのデータの追いつき(WordPressに投稿されていない欠損部分の穴埋め)をする作業が毎回手作業での実行**になっており、これをなんとかできないかという話が上がりました。
結論、502 Errorが継続する事象自体はphp-fpmを再起動して改善するのですが、深夜帯に発生すると再起動が翌朝になり、欠損範囲が増えることで追いつき作業に時間がかかってしまうため、そもそもこの**php-fpmの再起動を自動化してデータの追いつきも自動化できないか**という相談を受けました。

これを実現するには、そもそも502 Errorを監視して気づかないといけませんが、**実はこの第5段階に至るまでの間、「コスト最優先」を理由に、監視機能は一切実装されていませんでした。**

そこで、第5段階にして初めてCloudWatch Logsが採用され、アクセスログから502 Errorの発生を判断し、自動的にphp-fpmを再起動する仕組みが実現しました。

ちなみに、以下で紹介する**CloudWatch Logsの月額利用料は$1.00前後、SQSの月額利用料は無償枠内であり「コスト最優先」を逸脱することなく改善を実現できました。**


## アーキテクチャ
WordPressのログをEC2からCloudwatch Logsに送信し、キーワードで「502」をフィルタして、SQSにアラートさせます。
EC2には、SQSを1分間隔でポーリングするスクリプトを実装し、キューがあればphp-fpmを再起動するようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211986/d540d192-f837-317c-850d-d263a8edab0f.png)

# おわりに - そして現在へ
第5段階の改善が行われてから4年近くが経ちますが、2021年現在、この環境でWebサイトは安定運用を続けています。
ここまでの取り組みを振り返って、感じたことをまとめてみました。

## ベストプラクティスに逆らうには妥協が伴うということ
AWSのベストプラクティスに逆らっていることの罪悪感はありますが、例えばデータベースがスポットインスタンスで起動していても、工夫と**妥協**次第で安定して運用できると分かりました。
ベストプラクティスに逆らってコスト削減という言い方は、感じの悪いものですが、補足すると、そこには**必ず茨の道があり、また、妥協する勇気が必要だと感じます。**
逆に言えば、妥協できるのであれば、どんどん妥協して、コスト削減に突き進めるということかとも感じました。

## 業務改善・自動化も含めて低コストを評価されたこと
その後、アーキテクチャの変更を伴わない小さな改修としては、EBSの容量不足が2度発生したため、2度拡張しており、AWS利用料が少し増えています。

それでも、2021年現在、この第5段階の環境を維持するための月額利用料は約$50で、運営チームからは、このコストで**業務の自動化を実現しつつ比較的安価に運用できていること**を評価されています。
この移行を経てAWSへの理解が深まったのか、最近は運営チーム側から **「Systems Manager使えないの?」** などと、私よりも先に改善提案をしてくることもあり、少し焦りも感じます。
また、先にも述べた**データの2次活用に応用できる環境であること**に期待を持たれており、今後はデータ分析環境の整備が始まりそうです。

ゆくゆくはEC2を離れて完全サーバレス化を目指せばさらなるコスト削減に繋がるとは思いますが、現在のWordPressでの記事の管理が便利だったり、運営チーム側でもプラグインに依存した業務があったりするようで、これはもう少し先になりそうです。

## 「コスト最優先」は一筋縄ではいかないこと
但し、最初期から全く変わらないのは、**この運営チームが最重要視するのが「コストが最優先」であることです。**
**「AWSだったらこうした方が良いですよ」と私が言えば「いらない、これだけで良い」と言われ「分かりましたが、リスクは頭に入れておいてください」と言付けする**という、そんなコミュニケーションを何度繰り返したか思い出せないほどしました。

本件に関わったことで、**本当にコスト削減を追い求めようとするとベストプラクティスに背く場面が多々あり、また考えることが非常に多く、更にトラブルが起きた時の運用改善にも非常に頭を使う**ことが分かり、今振り返っても大変に鍛えられたと感じます。
また、**本当にコストを削ろうと思うと妥協しなければならないものが山ほどあり、そのことを事前に全員で理解して合意しなければならないことと、この合意形成が思っていたよりも難しいこと**も痛感しました。

## 状況に応じた柔軟な対応が大事であること
私自身、業務でAWSを長く使ってきましたが、最近は「APN Ambassadors」に選ばれたこともあり、社内で有識者として持ち上げられることが増えてきて、相談者に対してはいわゆるAWSのベストプラクティスを咀嚼して推奨する場面が非常に増えたと感じます。

しかし、この経験を振り返ると、**AWSを使おうとしている各自の状況をコミュニケーションの中で的確に把握し、制約を曲げられないかを一緒に考え、どうしても曲がらなければその中で最大の成果を上げる提案をすることが大事だ**ということを、改めて感じました。

ベストプラクティスはある意味答えのようなものだと思いますが、**ベストプラクティスを諦めなければならなかったり、意図して道を踏み外しに行くケースもあると思います**し、私も実際に直面しました。
このようなケースに直面した時に **「その場合はこうなります。但しベストプラクティスと比べると、ここがこうなり、このようなリスクがあります。」と説明でき、さらには関係者全員が納得してAWSを利用している状態に導けること** は、SIer並びにAWSパートナー企業に所属しているエンジニアにとって大事なことの1つではないかと思います。

私自身、これからも今の立場や状況に満足することなく、理想を追い求めながらも、時に頭を柔らかくして、状況に応じた柔軟な発想ができることを意識し続けたいと思います。