「あれ、チュートリアルから始まった」。僕とキャッシュとサイレントリリース。


ユーザー「チュートリアルから始まった」

ワイ「オウフ...」

チーム「チュートリアルから始まった...」

ワイ「ヤバい...キャッシュや...」

はじめに

こんにちは、@canon1kyです。
本番環境でやらかしちゃった人Advent Calendar 24日目を担当させていただきます。

本日はクリスマスイヴですね。
聖夜を迎える日ですが、お部屋を暖かくしてゆるっと「やらかしちゃった話」を読んでいってくれると嬉しいです。

元々は「爆弾仕掛けて旅行に行った話」を書こうとしていましたが、そちらはまた後日書きます。
今日はもう少し心が痛かったネタがあったので、当時のことを思い出しながら、できるだけ幸せな人が増えると良いなという心持ちでそっちを書きます。

キャッシュとサイレントリリースにより、ユーザーデータをリセットさせてしまった約2年前のお話です。

本記事で伝えたいこと

結論から述べますと下記の3つです。

  • 人の手による運用はなるべく減らしましょう
  • 設計に違和感を感じたシステムは早い段階で必ず治しましょう
  • リリース作業は如何なる場合も手順を必ず確立しておき、チーム全体でシミュレーションを行いましょう

当時の状況

  • 新卒1年目の時
  • 当時のプロジェクトは初回リリース直後のスマホゲーム
  • AndroidアプリとiOSアプリを出していた
  • 外注会社で依頼して開発していたが、初回リリース1~2ヶ月前から自社で巻き取り始めた
  • 初回リリース後からは、アップデートは自社のエンジニアが行っていた
  • バックエンドエンジニアの人数は2人(メインでコミットするエンジニアは私のみで、もう一人は他プロジェクトを兼任しており、約半分の時間でコミット)
  • バックエンドのアップデート担当者は私
  • バックエンドはPHPで、負荷を抑えるためにマスターデータのクエリ結果をAPCキャッシュに載せていた
  • 圧倒的な人数不足により、負債改修よりも新規実装で手一杯

悲劇が起きたのはアプリのアップデートで

いつものアップデート作業の流れ

下記のステップでアップデートを行います。

  1. リリースする最新バージョンのAPIサーバーの向き先を検証環境(公開URL)に向ける
  2. ストアに最新バージョンのアプリを提出し、申請を行う
  3. ストアから承認される
  4. アップデート当日、メンテナンスに入る
  5. 本番環境APIサーバーのソース更新を行う
  6. ストアで承認が通った最新バージョンのアプリをリリースする
  7. リリースしたアプリがアクセスするAPIサーバーの向き先を、検証環境から本番環境に切り替える
  8. マスターデータに更新がかかった場合は、APCのキャッシュをクリアする
  9. アプリがちゃんと動くかをチームで確認する
  10. メンテナンスを開ける

向き先制御のアーキテクチャ

上記ステップ1番と7番のお話です。
外注先の企業からサービスを巻き取った段階で、アプリの通信先APIサーバーの向き先制御が下記のようになっていました。

なお、サービスの巻き取り後も引き続きその仕組みを使用していました。

ゲームで使用するAPIサーバーの向き先は、アプリ起動後、最初に本番環境APIサーバーに1度アクセスして取得されます。
アプリバージョン(appVer)をGETリクエストで送り、その後ゲーム内でアクセスするAPI向き先のURLを取得する仕組みになっています。
この時のGETリクエストを行うURLは下記のイメージです。

https://api.xxx.jp?appVer=2.4.4&os=1&apiKey=yyy

すると、その後ゲーム全体を通してやり取りするAPIサーバーのURL(https://api.vrf.xxx.jphttps://api.xxx.jp)が返ってきます。

なお、APIサーバーの向き先を制御するテーブル(ここでは client_app_versions テーブルとします)は、下記のようなテーブル構造です。

[pk] OS(1=Android, 2=IOS) [pk] アプリバージョン 向き先URL
1 0.0.1 https://api.xxx.jp
2 0.0.1 https://api.xxx.jp
1 0.0.2 https://api.xxx.jp
2 0.0.2 https://api.xxx.jp
1 0.0.3 https://api.vrf.xxx.jp
2 0.0.3 https://api.vrf.xxx.jp

「このアプリバージョンではこのAPIサーバーにアクセスさせる」というロジックを実現します。
上記の表の場合だと、バージョン0.0.1と0.0.2のアプリではhttps://api.xxx.jp、バージョン0.0.3のアプリではhttps://api.vrf.xxx.jpのAPI URLがゲーム内で使われます。

この仕組みがある理由は下記の通りです。
ストアに申請を出すアプリのAPIは本番環境でなければならない
→ アプリ内にAPIサーバーの向き先としてhttps://api.xxx.jpが設定されている
→ ストアに申請を出すタイミングでサーバー側のソースに更新をかけたい場合、ユーザーが触っている本番環境(https://api.xxx.jpのサーバー)に更新をかけるとアプリをアップデートするまで動かなくなるので、更新をかけられない
→ 申請時にはリリースするアプリの向き先を一時的に、リリース時のソースコードが載っているAPIサーバーに向ける必要がある

悲劇当日

ストアでアプリの申請が通ったのでリリースを行います。
この日はサーバー側のソース更新が入らないので、メンテナンス入りはせず、ストアにてアプリのバージョンを上げるのみの更新でした。
ユーザーに通知もしない、いわゆる「サイレントリリース」というやつでした。

私は当日の朝、検証環境に向いているアプリ最新バージョンのサーバーの向き先を本番環境に向けるべく、client_app_versions テーブルの更新を行います。

そしてその後は、大量の新規実装のコードを書くのに専念していました。

しかし、この日はリリース以来初めてのサイレントリリース。
「サーバーの更新はかからないから特に厳密なチェックは大丈夫」と失念していたのが悲劇の始まりでした。

悲劇はアップデート後に発生

サイレントリリースから30分後くらいに、数名のユーザーからお問い合わせが来ました。

「アプリの更新をしたら、チュートリアルから始まってしまいました」

なんだか嫌な予感がします。

お問い合わせを受け、チームのメンバーが検証環境用のアプリをチェックしても、チュートリアルから始まることは特になく、「端末依存で何か起きたのかな?」と思いながら確認していました。

しかし、チームメンバーが本番用のアプリを確認し始めた途端、恐ろしい言葉が。

「あ、チュートリアルから始まった...」

緊急メンテ入りをし、チーム全体で調査に入ります。
まずは管理画面から、APIサーバーの向き先本番環境に向いているかをチェックしました。
しかし、バッチリ本番環境を向いています。

調査をさらに進めるべく本番環境でログを確認しました。

「何故か最新バージョンのログが流れてこない...」

さらに嫌な予感がし、検証環境のログを見てみます。

「今社内で誰も触っていないのにログが流れてきている...」

ここで、たった1つの原因となり得るものが閃きました。

「もしかして、キャッシュ...?」

悲劇の原因

サーバーとDBの負荷を減らすために、マスターデータ(アイテム情報など、運営が用意した全ユーザーで共通して使うデータ)は、一度読み込んだらAPCキャッシュに乗せるようにする作りになっていました。

ざっくり説明すると、PHPのアプリケーション側のキャッシュに、マスターデータのクエリ結果を乗せておくというものです。
そのためキャッシュをクリアしない限り、同じクエリを発行した時には、データの中身が変更されていても変更前と同じ結果がキャッシュから返ってきます

マスターデータに関しては滅多に更新をかけないものなので、負荷を減らす作りとしてはよくあるパターンですね。

当時のシステムでは、

  • ユーザーデータ
  • マスターデータ
  • ログデータ
  • 管理者用データ

がそれぞれ別のDBスキーマに分かれており、マスターデータのDBスキーマ全体に対してのみAPCキャッシュが有効になるようなアーキテクチャになっていました。

しかし、アプリバージョンによってサーバーの向き先URLを制御するclient_app_versionsテーブル。
このテーブルはマスターデータのDBスキーマに乗っていたのです。

つまり、「アプリ最新バージョンのAPIサーバーの向き先URLを検証環境から本番環境に変更した際、APCのキャッシュクリアをしない限り、最新バージョンのアプリは検証環境APIサーバーに向き続ける」という状態です。

これによって、ユーザーがアクセスした検証環境には本来のユーザーIDが存在しないため、端末に紐づくユーザーIDがリセットされるといったことが発生したのです。

「やらかした...」

いつもはメンテナンス時に手動でAPCキャッシュをクリアしていたのですが、今回のリリース時、APIの向き先URLを検証環境から本番環境に向けた私は、キャッシュをクリアすることを完全に忘れていました。

また、サイレントリリース後に新規ユーザーを作って動作確認はしつつも、既にチュートリアル以降に進んでいるデータで動作確認をしていなかったので、結局悲劇に気づいたのがユーザーからのお問い合わせでした。

なお、本番環境でAPCキャッシュクリアを行うと、無事に既存データでもチュートリアルから始まらず、ゲーム内では本番環境のAPIにアクセスが飛び、正常に動作することが確認できました。

復旧作業

既存データがある端末でチュートリアルから始めると、端末に紐づくユーザーIDが新規作成された新規ユーザーのIDに切り替わってしまうので、そのユーザーを元のIDに向け直す作業を行いました。

厳密には、元のユーザーIDに紐付け直す引継ぎコードを発行して、対象のユーザー様に送付するというものです。
不幸中の幸いなことに、サイレントアップデートを行ったのはアクセスが少ないタイミングだったので、被害があったユーザーは50名ほどでした。

恒久対応

その後は一旦新規実装の優先順位を落とし、下記の対応を行いました。

  • サイレントリリースのチーム全体における手順書の作成(通常のリリースの手順書は存在していた)
  • API向き先URLを変更すると同時にキャッシュをクリアする仕組みを入れる

同じ悲劇を起こさないために

APCキャッシュクリアは手動で行っており、リリース当初からメンテナンスでも「キャッシュの消し忘れ」問題は時々発生していました。
手動でキャッシュを消す仕組みには若干違和感を感じていたのですが、新規実装を優先してしまい、なかなか手をつけなかったことが一番良くなかったと思います。

この件から大きく下記のことを学びました。

  • 人の手による運用はなるべく減らす。今回の件であれば、client_app_versionsテーブルに変更を加えた際、自動的にAPCキャッシュがクリアされる仕組みを用意するなど。
  • 如何なるリリース方法の場合でも、チーム全体でリリース手順を確立し、シミュレーションを行う。
  • 違和感を感じた、もしくは負債と感じたシステムはなるべく早く治す。ビジネスである以上どんな時にも新規実装は発生するものなので、必ずどこかで治す時間を作る。
  • そもそもアプリ申請時に本番環境にソースの更新をかけても問題ない作りにする。

また、今回の件であればclient_app_versionsテーブルをシステム系情報が乗っているスキーマに置くなど、マスターデータとは別のスキーマに置く方法もあるかと思います。
この辺りは「データの読み書きが行われるタイミングは、ビジネスロジック全体の流れの中でのどの場所になるか」や、「どれくらいの頻度で更新がかかるか」を考慮することになるでしょう。

キャッシュ更新が必要になるのであれば、デプロイをCI/CDツールを使うなどして、CI/CDフローの中でキャッシュクリアを行うというやり方がスマートですね。

さいごに

今回は、サイレントリリースとキャッシュによって起きた悲劇をまとめました。

悲劇を起こさないよう設計するのが一番ですが、小さい問題でも、何か起きたら(今回で言えば既にメンテナンス時にキャッシュ消し忘れ問題が何度か起きていたこと)同じ問題が起きないように対処していくのが非常に大切です。

当時は「問題が起きたときはチームの責任であり、チーム全体で解決する」というチームのスタンスだったので、自分一人で背追い込まず、暫定対処と今後同じことを起こさない仕組みづくりに前向きに対処できました。当時のチームには非常に申し訳ない気持ちですが、本当に感謝しております。

「このままで良いのか?」という目線を持ちつつ、幸せなエンジニアライフを目指していきましょう。
ありがとうございました。