はじめに
個人開発者向けのAWS×Terraform インフラパッケージングサービス「ヒトリ開発」を作成しています。
ヒトリ開発の販売基盤では、Stripe Checkoutを使って決済を行い、Webhookを起点に商品ZIPを自動納品する仕組みを作っています。
ざっくり流れはこんな感じです。
今回は、この販売基盤のLive E2Eテスト中に起きたトラブルについて書きます。
決済は成功。
Webhookも成功。
DynamoDBも更新済み。
商品ZIPの納品メールも送信済み。
なのに、購入完了画面だけ、
購入後処理を完了できませんでした
と表示される状態になりました。
俺の金返せ~(100円)w
起きたこと
Stripe Live決済のE2Eテストとして、一時的に100円のPriceを用意して実決済を行いました。
確認したかったのは以下です。
- Stripe Checkoutで決済できるか
- Webhookを正しく受信できるか
- DynamoDBの注文状態が更新されるか
- S3の商品ZIPを24時間URLで納品できるか
- SESで購入者へ納品メールを送れるか
結果として、バックエンド側の処理はすべて成功しました。
ところが、Stripe Checkout完了後に戻ってくる /thanks ページだけ、失敗表示になっていました。
購入後処理を完了できませんでした
最初に疑ったこと
最初は、普通にバックエンド側のどこかで失敗していると思いました。
見るべきポイントは大きく5つです。
- Stripeの決済状態
- Webhookの受信状態
- DynamoDBの注文状態
- Delivery Lambdaのログ
- SESの送信結果
確認してみると、どれも成功していました。
つまり、実際には商品納品まで完了しているのに、画面だけが失敗扱いになっている状態でした。
構成
今回の販売基盤は、画面側と決済・納品処理側が分かれています。
購入ページでは、API Gatewayのエンドポイントへリクエストを送り、Stripe Checkout Sessionを作成します。
一方で、決済完了後の納品処理は success_url ではなく、Stripe Webhookを起点にしています。
これは、ユーザーが完了画面を閉じても、決済確定イベントをもとに納品処理を進めるためです。
そのため、今回のように完了画面が失敗しても、Webhook側が成功していれば納品自体は完了します。
原因
原因は、React + Viteで作ったフロント側で、APIエンドポイントの取得方法がページごとにズレていたことです。
今回の販売サイトでは、同じReactアプリの中に以下のページがあります。
- 購入ページ
- 購入完了ページ
- 決済キャンセルページ
- 商品詳細ページ
このうち、購入ページでは実行時に /runtime-config.json を読み込み、そこからCheckout APIのエンドポイントを取得していました。
この方式なら、ビルド後にデプロイ先のAPI Gatewayエンドポイントを差し替えることができます。
一方で、購入完了ページの一部処理では、Viteの環境変数である VITE_CHECKOUT_API_ENDPOINT を参照していました。
ここが問題でした。
Viteの VITE_* 環境変数は、Reactアプリをビルドした時点でJavaScriptへ埋め込まれます。
つまり、EC2上であとから環境変数や設定ファイルを変更しても、すでにビルドされたJavaScript内の VITE_* の値は変わりません。
今回の本番ビルドでは、VITE_CHECKOUT_API_ENDPOINT が空の状態でした。
その結果、購入ページは正しいAPI Gatewayへアクセスできていた一方で、購入完了ページだけが古いFastAPI時代のAPIへ分岐してしまいました。
要するに、同じReact + Viteのフロント内で、
購入ページ -> runtime-config.jsonを見る
購入完了ページ -> VITE_CHECKOUT_API_ENDPOINTを見る
というズレが残っていたことが原因です。
バックエンドの決済・納品処理は成功していたのに、購入完了画面だけ失敗表示になったのは、この参照先の違いによるものでした。
Viteの環境変数でハマったポイント
今回のポイントは、Viteの環境変数は「実行時」ではなく「ビルド時」に決まるということです。
そのため、以下のような用途には向いています。
- ビルド時に決まっている固定値
- 環境ごとにビルドを分ける前提の設定
- 公開されても問題ないフロント用設定
逆に、今回のようにデプロイ先でAPIエンドポイントを差し替えたい場合は、ビルド時環境変数だけに頼ると事故りやすいです。
runtime-config.jsonを使う理由
販売サイトでは、フロントのビルド成果物をEC2へ配置し、起動時にAPI Gatewayのエンドポイントを注入する構成にしています。
そのため、ビルド時ではなく実行時に設定を読めるよう、runtime-config.json を使っています。
イメージとしてはこんな感じです。
{
"checkoutApiEndpoint": "https://example.execute-api.ap-northeast-1.amazonaws.com/create-checkout-session"
}
React側では、このJSONを読み込んでAPIエンドポイントを決定します。
この方式にしておくと、同じフロントビルド成果物を使いながら、Dev / PrdでAPIエンドポイントを差し替えやすくなります。
対応したこと
対応としては、APIエンドポイントの取得処理を共通化しました。
それまでは、ページごとに微妙に違う判定をしていました。
これを、共通関数を経由し、ビルド時設定または実行時設定からAPI接続先を解決する形に変更しました。
これにより、購入ページも購入完了ページも、同じ方法でAPIエンドポイントを解決するようにしました。
修正後は以下を確認しました。
- ESLint成功
- Vite build成功
- Dev / Prdへフロント再配布
-
/runtime-config.jsonがHTTP 200 - 新しいJavaScriptがHTTP 200
- 旧JavaScriptも互換用にHTTP 200
旧JSも残した理由
この少し前に、Reactのビルド成果物を更新したとき、ブラウザに残った古い index.html が存在しない旧JSを参照して白画面になるトラブルも経験していました。
詳しくは以下の記事を参照してください。
そのため、今回のフロント修正では、新しいJSだけでなく、直前の旧JSも残すようにしました。
あわせて、Nginx側では以下のようなキャッシュ方針にしました。
HTML / clean URL:
no-cache, no-store, must-revalidate
assets:
public, max-age=31536000, immutable
HTMLはキャッシュさせすぎず、ハッシュ付きassetsは長くキャッシュさせる方針です。
影響範囲
今回の障害は、完了画面の表示だけに影響していました。
つまり、ユーザー目線では失敗に見えるが、裏側では正常に納品されている状態です。
これはかなり危ないです。
なぜなら、購入者は画面を見て「失敗した」と思う可能性があるからです。
実際には納品メールが届いていても、不安にさせてしまいます。
学び
今回の学びは大きく3つです。
1. 決済成功と画面成功は別
Stripe Checkoutから戻ってきた画面が成功していることと、決済・納品処理が成功していることは別です。
特にWebhook起点で処理している場合、画面表示だけで決済状態を判断してはいけないと感じました。
2. Viteの環境変数はビルド時に固定される
VITE_* は便利ですが、実行時に変わる設定には向きません。
APIエンドポイントをデプロイ先で差し替えたい場合は、runtime-config.json のような実行時設定を使う方が扱いやすいです。
3. 設定取得処理は共通化した方がいい
購入ページと完了ページでAPIエンドポイントの取得方法がズレていたことが今回の原因でした。
同じ意味の設定を複数箇所で別々に判定すると、移行時に漏れやすいです。
再発防止
再発防止として、以下を意識するようにしました。
- APIエンドポイント取得処理を共通化する
- Viteのビルド時環境変数に依存しすぎない
- 決済、納品、画面表示を分けて確認する
- 完了画面だけで成功判定しない
- E2Eテストではユーザー画面まで確認する
特に、販売基盤では以下の確認が必要だと感じました。
バックエンドだけ成功しても、画面が決済失敗になっていたら商品を提供する側としてダメです。
個人開発なら「メール届いたしヨシ!」で済ませたくなりますが
販売基盤なのでヨシではありませんでした(笑)
まとめ
今回は、決済と納品は成功したのに購入完了画面だけ失敗した話を書きました。
原因は、React + Vite側でAPIエンドポイントの取得方法が統一されていなかったことです。
購入ページは runtime-config.json を見ていた一方で、購入完了ページの一部処理は VITE_CHECKOUT_API_ENDPOINT を参照していました。
Viteの環境変数はビルド時に固定されるため、本番ビルド時に値が空だと、意図しない旧API側へ分岐してしまいます。
今回の対応では、APIエンドポイント取得処理を共通化し、購入ページと購入完了ページで同じ実行時設定を使うようにしました。
販売基盤では、決済・納品・画面表示のどれか1つだけ見ても不十分です。
実際にユーザーが購入して、完了画面を見て、メールを受け取り、ZIPをダウンロードできるところまで確認して、ようやくE2Eテストと言えるのだと実感しました。
補足
今回のような販売基盤や、個人開発向けAWS公開環境を作る中で得た知見をもとに、ヒトリ開発というTerraform Packを作っています。
個人開発向けAWS公開環境の構成図や、検証環境で利用できる無料版TerraformのGitHubリンクを以下にまとめていますので、よければ参考にしてみてください。
Free版を触ってみた感想をもらえたらめちゃくちゃ喜びます(笑)