Unityアプリを無差別タップで自動テストする

画面写真をクリックするとWebGLビルドに飛びます。ソースコードはGitHubに置いてあります。かつて製品で使ったものに著しい拡張を施した直後なため、 まだ実戦経験が浅いコードです。バグがあったら教えていただけると助かります。

こんにちは。技術部平山です。

今回はゲーム開発における耐久テストを行うための支援ツールについてお話します。 技術的には「EventSystemをコードからつついて勝手にタップさせる」だけのことですが、 単に技術的な問題と捉えるよりも、品質保証という広い問題の一部として捉える方が 実りが多いことと思います。

使い方

いきなりですが使い方から示します。

まず、GitHubから必要なファイル を持っていってください。debugTapMark.pngは便宜のために用意しただけで、別途好きな画像を使っていただいてもかまいません。 サイズもお好みで良いですが、16x16から32x32くらいで良いかと思います。

そして、DefaultDebugTapperをどこかのGameObjectにつけます。

f:id:hirasho0:20190729124855p:plain

MarkSpriteにテキトーなSpriteを指定し(debugTapMark.pngでも良いです)、 Auto Start Enabledをチェックします。

これで、実行開始と共に8つのポインタが暴れ回って画面中をタップ、ドラッグ、長押しするようになります。 どこかにEventSystemが存在している必要があるので、ご注意ください。

なお、起動と同時に乱打が始まるのはおそらく不便でしょうから、 コンポーネントのenabledはfalseにしておくのが良いかと思います。

除外オブジェクト設定

DefaultDebugTapperはGameObject名にDebugUiという文字列が入っているオブジェクトと、 その子孫は叩かないようにしてあります。このまま使ってもかまいませんが、 DefaultTapperを継承して、bool ToBeIgnored(GameObject)を実装すれば、 プロジェクトごとの事情に合わせられます。

どこを叩くかカスタマイズしたい

DefaultDebugTapperは全力で画面をランダムに叩きます。 しかし、場面によってカスタマイズしたい事もあるでしょう。

例えば、

  • 技選択モードでは技カードを優先的に叩きたい
  • 戦闘中に撤退ボタンは滅多に押したくない
  • キャッシュクリアボタンを押されるのは困る
  • ドラッグがメインの入力系なので、ランダムだとゲームが進まない

といったことが考えられます。 ランダムに任せておくよりも良いテストにするために、 カスタマイズするのは良い考えです。 ゲームにAIが実装されているのであれば、AIと組み合わせることで 「実際に人が画面をタップした場合と同じ経路を通して自動プレイさせる」 ということも可能になります。UIが複雑だったり、通信が絡んだり、 アニメーションが絡んだりする場合は、UIの状態制御が複雑になって どうしてもバグが出やすくなりますから、 人間が触る場合と極力同じ経路を通してテストすることが重要です。

この場合、DebugTapperを継承して、 void UpdateTap(int tapIndex)を実装すれば望みのことができます。 例えばDefaultDebugTapperの実装は以下のようになっています。

protected override void UpdateTap(int tapIndex)
{
    const float durationMedian = 0.1f;
    const float durationLog10Sigma = 0.5f; // 3SDで1.5==3.3秒
    const float distanceMedian = 0.01f;
    const float distanceLog10Sigma = 2; // 上下100倍
    var fromPosition = new Vector2(
        Random.Range(0f, (float)Screen.width),
        Random.Range(0f, (float)Screen.height));
    var distanceLog = NormalDistributionRandom();
    distanceLog *= distanceLog10Sigma;
    var distance = Mathf.Pow(10f, distanceLog) * distanceMedian * Mathf.Max(Screen.width, Screen.height);
    var rad = Mathf.PI * 2f * Random.value;
    var v = new Vector2(
        Mathf.Cos(rad) * distance,
        Mathf.Sin(rad) * distance);
    var toPosition = fromPosition + v;
    var durationLog = NormalDistributionRandom();
    durationLog *= durationLog10Sigma;
    var duration = Mathf.Pow(10f, durationLog) * durationMedian;
    Fire(tapIndex, fromPosition, toPosition, duration);
}

重要なのは最後の行で、つまり、「どこで押すか」「どこで離すか」「どれくらいの時間をかけるか」 の3つの情報を指定してFire()を呼びます。場所はスクリーン座標です。 場面に応じてきちんと分岐させれば、全画面を順番に移動するようなシナリオを実装することも可能ですし、 さらに凝って、そのシナリオを外部からスクリプトで与えることもできるでしょう。

なお、上のコードでは、 「ほとんどは短距離短時間でタップになるが、たまに長距離長時間のものが混ざる」 いう実装になっています。 ほどよく長押しやドラッグが発生する匙加減にしており対数正規分布を使っています。 非常に便利な分布なので、いずれ別の機会にブログにするかと思います。

デバグ用のオブジェクトをシーンに置きたくない

DebugTapperがついたgameObjectをシーンに置いてしまうのは楽ですが、 デバグ用のオブジェクトをシーンに置くと製品版にも入ってしまいます。 私は極力これを避けるために、デバグ用のオブジェクトはコードで動的生成するようにしています。 その場合、だいたい以下のようなコードを書くことになります。

var go = new GameObject("DebugTapper");
tapper = go.AddComponent<DefaultDebugTapper>();
tapper.ManualStart(8, tapMark);
tapper.enabled = false;

適当にgameObjectをnewして、DefaultDebugTapperをAddComponentし、 ManualStart()を呼びます。enabledをfalseにしているのは、 デバグ機能から有効化するまでは無効にしておきたいからです。 起動と同時に乱打させたいなら不要ですが、そういうことはないでしょう。

運用

さて、これをどう使うか?

私のおすすめは、夜会社を出る前に、PCのエディタで製品を実行して、自動タップを有効化し、 そのまま帰ることです。朝までエラーが出ないで生きていれば一安心、 死んでいれば慌てて直す、ということになります。 チームのプログラマ全員がこれを習慣にできれば、 ほとんどゼロコストで、10時間以上×人数分のテストが毎日行われることになります。 時給○○円でテストをお願いする、と仮定して計算してみてはいかがでしょう。 いくら節約できますか?

私の乏しい経験での話ですが、これを初めて実行したアプリは、 以下のようなエラーで数秒のうちに死ぬこともあります。

  • アニメーション中に想定外のものを叩いて予想外の挙動をする
  • オブジェクト破棄後にイベントが飛んでnull死する
  • シーン遷移直前、直後に想定外のところを叩かれて破棄後/初期化前でnull死
  • 通信中に入力を禁止するためのオブジェクトが出しっぱなしになって進行不能
  • 通信中の入力制限を忘れていてAPIを連打、あるいは結果が返る前にシーン遷移

マルチプレイの対戦中であれば、 通信タイミングとUI入力の絡み合いで不正な状態になるエラーも起きやすいでしょう。 人によるテストプレイがある程度行われて安心できている状況ですら、 連打やマルチタップ、特定タイミングでの入力、といったものをケアしたコードが書けていることは稀であり、 自動タップをつっこむと大抵はひどいことになります。

また、お客さんに出した後に「たまにクラッシュレポートが来るけど再現できる気がしない」 ということは結構あると思いますが、 自動テストであれば率が低いバグでも低コストで再現させられる可能性があります。

なお、運用に際しては、できるだけ大量にAssertを入れて、ログに溜めておくと良いかと思います。 かつての東京プリズンの開発では、1回のゲームで数百KBのログを吐き、 それが一晩でだいたい150回くらい回っていました。 毎朝数十MBのログから「Assert」という文字列を探して、 一つでもあれば異常として修正する、ということを繰り返しました。 開発末期にはAmazon Web Service(AWS)のEC2でマシンを6台借りて、3対3のマルチプレイ対戦を一晩自動で回し続け、 一度もエラーが出ないことを確認する、というところまでやりました。 さらにスマホ実機を並べて行えればさらに頑健になるでしょうが、 スマホの場合はビルドの手間、インストールの手間、電池の問題などが絡んで一気に面倒になりますので、 予算と相談ということになるかと思います。

なお、セキュリテイ上の理由から、PCを回しっぱなしで帰宅できないケースもおありかと思いますが、 可能であれば開発に普段使っているPCそのもので回して帰るのが理想です。 どうやってそれに近づけるかは、ご所属の組織によっても違ってくるでしょう。 「ボタン押して帰るだけ」という手軽さが失われれば、結果面倒くさくなって誰も回さなくなり、 「ほとんどタダで濃厚なテスト(QA)ができる」というコスト削減効果を捨てることになります。 別のPCを用意するだけでも、「最新をpullしてから実行」と手間が一つ増えてしまい、 途端に面倒くさくなりました。AWSのEC2はリモートログインの手間があるせいで さらに面倒でした。数秒手間が増えるだけで顕著にやる気が失せますので、 手間の問題は軽視しない方が良いと思います。

もう一つ工夫として、standaloneビルドを作って、1台で複数アプリを起動して並列テスト、 というのも有効です。スマホ用のアプリであれば、ノートPCでも4つくらいは同時に起動できるはずです。 そうすれば、テストの強度が4倍になります。 standaloneビルドが動作する状況を作っておくと良いでしょう。 standalone用のAssetBundleをビルドできるようにする、というのが一番面倒なところでしょうか。 エディタで実行する時と遜色なく情報が取れるように、 ログ機能は充実させておくことをおすすめします。 Slackを利用したデバグに関する記事に書いたように、 ログは自動でslack等に集積すると良いでしょう。

ログ機能

デバグ支援のために、ログ機能を用意してあります。 いつ、何番のポインタが、どのgameObjectに、何のイベントを発火させたか、 ということがログに溜まっており、LogItemsLogTextで取れます。

プロファイリング支援

大抵の場合、イベントが発火した先でやる処理は重くなりがちです。 「ボタンを押されたらポーズメニューのプレハブをInstantiate」 のような書き方はスパイクの原因になります。

スパイクを防ぐために、使うかどうかわからないデータも全部シーンに並べておく ようにすれば、今度はシーンの初期化が遅くなりますし、 余計にメモリも食います。ある程度は動的に初期化する作りの方が妥当です。

とはいえあまりスパイクが激しいのも辛いので、それを調べる助けになるように、 プロファイラで出るようにしておきました。 イベント関数の呼出しの前後でCustomSampler を使っているだけです。

DeepProfilingを有効にしなくても、 OnPointerClickやOnPointerDownの下に重い処理があるかないかくらいはわかります。

実装

DebugTapperのコードを見ていただければそれが全てなのですが、 残念ながら実装は結構面倒くさいので、多少は説明しておこうと思います。

概要

基本はEventSystemに乗ることです。そうすることでコード量を減らせますし、 実際に人がタップする時とできるだけ同じ経路を通すことができます (もちろんEventSystemで入力を取っているアプリでの話ですが)。

基本的な流れは、

となります。こう書くと簡単そうですが、PointerEventDataに何が入っているのか、 イベントの発火条件、発火順、などに関しては何も文書がありません。 公式のコード を見るしかない状態です。

公式を見て完全に再現すれば同じになるのでしょうが、全部忠実に作るのはあまりに面倒くさいので、 今回の実装は完全再現はしていません。

再現度

どれくらい再現しなければならないかは、アプリがどれくらい標準の挙動に依存しているかによります。 例えばアプリがPointerEventData.rawPointerPressを使っているなら、 公式と同じようにデータを入れねばなりません。 また、Clickよりも先にUpが発火することに依存しているのであれば、合わせないといけません。 そうしないとテストの価値が落ちてしまいます。

今回の実装で標準に合わせないで手抜きをしたのは、主に以下です。 必要なら標準に準拠させる作業をやっていただけると良いかと思います(是非私にください)。

  • EnterとExitは発火しない
  • Move,Scrollは発火しない
  • eligibleForClick、button、scrollDeltaを入れてない
  • worldPosition, worldNormalはobsoleteなこともあって入れてない
  • pointerPressとpointerDragの中身が微妙に違う
  • 公式だとdownのハンドラがなければupも来なくなるが、本実装はupだけでも発火する。

中には意図して合わせていないものもあります。 「公式の挙動が少し変わっただけで死ぬ潜在的な危険」も検出したいからです。 公式の挙動は文書化されておらず、あくまで「今の実装」にすぎません。 up、click、dragEndの順序や、各イベントのタイミングでPointerEventDataのフィールドがどうなっているかに関しては 何ら仕様がないのです。「EndDragでdraggingはまだtrueなのか?もうfalseなのか?」 みたいなことが多数あります。

また、EnterとExitですが、実装を見て初めて知ったのですが、 タッチによるドラッグ中には発火しません。押した時にEnter、離した時にExitです。 タッチでは事実上使い物にならないし、実装も面倒くさいので、そもそも発火させないことにしました。 「カードから指が外れた時にExitが来る」ことを期待するコードは危険です。 エディタでマウスで操作していると発火するので、そういうものだと思ってしまいます(過去の私)。 同様に「downのハンドラがないとupが来ない」も今回初めて知りました。

途中で破棄!

実装で一つ注意がいるのは、途中でオブジェクトが消える可能性を考えて書くことです。 Downが発火したオブジェクトが、Upまで生きている保証はありません。 途中でnullに化けた時のケアが必要です。

さらに厄介なことに、イベントは親に伝播します。 例えばボタンはImageの子にTextがある構成で、 Raycastが当たったのがTextでも、Textを持つgameObjectがイベントハンドラを持っていなければ、 上のImageに伝播します。

では、Downした直後にそれが破棄されたり、Drag中に破棄されたりしたらどうすべきでしょうか? Downの後のUpは親に送るべきでしょうか?Dragの続きやEndDragは親に送るべきでしょうか? それによって、ExecuteEvents.ExecuteHierarchy() を使うか、 ExecuteEvents.Execute() を使うかが変わってきます。 このあたりも意識が必要です。 公式ではUpはDownが発火したオブジェクトにしか発火しません。 これに合わせるならExecute()の方を使うことになります(nullチェックしてないけどExecuteの第一引数ってnullでも大丈夫なんですかね?)。

おわりに

一番お伝えしたいことは「面倒くさいことは機械にやらせよう」ということです。 この密度でタップを執拗に繰り返すテストは人間にはできません。 しかし、お客さんが何十万人もいて毎日触っていれば、 数人で数時間触るのとは比較にならないほどの合計時間になります。 少しでもそれに近い状態を前もって再現するためにも、機械化が必要なのです。

弊社のQA(品質保証)エンジニアにこの記事について感想を求めた所、

  • 落ちるまでの平均時間をグラフ化してKPIに盛り込む
  • 座標の統計データを取って、「このあたりが危険」というヒートマップを作る

といったツールの発展もありそうだ、とのこと。 自動テストを育てていくことを通して、チームの品質保証体制そのものを成長させて 行けると良いのでは?という話でした。

というわけで夢は広がるのですが、実装は結構面倒ですので多少の気合が必要です。 東京プリズンの時点ではDown,Up,Clickしか対応していなかったので楽でしたが、 その後ドラッグ必須の製品に導入することになり、 EventSystemの公式実装を読む羽目になってしまいました。 私は「コードを読んだら負け」だと思うのですが、他にやりようがないのです。 自分で参加したアプリであれば、Down,Up,Clickだけで足りると確信を持って言えますが、 自分が参加していない製品の場合「どういう組み方をしているかわからない」 という前提に立つ必要があります。最終的には完全再現が求められるのでしょう。

実のところ、InputModuleは自作した方がいいんじゃないかと思ったりします。 そうすれば正確な仕様を文書化してからアプリを作れます。 さらに言えば、EventSystemごと自作してしまえば、 PointerEventDataのような「実装を見ないといつ何が入っているかわからない型」 をアプリに渡さずに済みます。 さらに、欲しくて欲しくてたまらない 「このオブジェクトではイベントを消費しないで下のオブジェクトにイベントを流す」 という機能も足せます。

でも、やめておいた方がいいですね。だましだまし使うことにしましょう。 標準であることの利点は大きいですし、 もし自作が標準と混ぜて使われたりしたら、どんな事故が起こるかわかったものではありませんから。

Lobiの画像変換サーバーをImageFlux+Lambda@Edgeで置き換えたはなし

SREチームの長田です。 先日Lobiの画像変換サーバーをImageFluxに移行したので、その過程を紹介します。

画像変換って?

Lobiはチャットを主軸としたコミュニティサービスです。 ユーザーはチャットメッセージに画像を添付することができます。 また、ユーザーアイコンやチャットグループの壁紙などもスマホやPCからアップロードして設定することができます。

アップロードされた画像は利用箇所に応じて適切なサイズ・フォーマットに変換する必要があります。 投稿画像のプレビューでは表示速度と通信量削減を優先して解像度低めの画像を、 拡大表示する場合はオリジナルサイズの画像を表示します。

複数サイズの画像を用意する手段として、Lobiでは内製画像変換アプリケーションであるmagcian1を運用していました。 Amazon EC2上で動作するNodeJS製のアプリケーションで、AWS CloudFrontのOriginとして設定していました。 クライアントからのリクエストを受け取り、URL中のパラメータを元に、 Amazon S3に保存されているオリジナル画像を適切なサイズ・フォーマットに変換して返します。

グループアイコンのリサイズパターン例:

48x48 100x100 144x144
https://assets.nakamap.com/img/grp/894691995e8bf119d1902178df2ccaec696d52c0_48.jpg https://assets.nakamap.com/img/grp/894691995e8bf119d1902178df2ccaec696d52c0_48.jpg https://assets.nakamap.com/img/grp/894691995e8bf119d1902178df2ccaec696d52c0_48.jpg

オンデマンドに画像変換するのでリクエストが発生した場合に変換処理のみを行えば良いというメリットがあります。 予めパターンごとに画像変換して静的な画像を用意する場合、用意したものの使用されない画像があったり、 変換後の画像の数だけストレージを圧迫するという問題があります。

変換処理の分レスポンスが遅れてしまうというデメリットがありますが、 一度AWS CloudFrontにキャッシュされてしまえば次回以降は高速にレスポンスを返すことができます。

f:id:handlename:20190722163441p:plain
magician時代の構成図

課題

magcianを廃止するモチベーションとして、以下の課題がありました。

古い

magicianがプロダクション環境に投入されたのは2012年でした。 そこから7年が経過し、様々な人が手を入れ、特に変換の入出力を定義した設定ファイルが魔境化してしまいました。

当時はサーバーサイドエンジニアチーム内でも使用人口が多かったNodeJS製なのですが、 年月経過とともにサーバーサイドでのNodeJS利用に精通したエンジニアがいなくなってしまいました。 造り自体はシンプルだったため、必要に応じて手を加えてはいたのですが、 コア機能である画像変換処理を担うImageMagickのNodeJSバインディングであるnode-imagemagick-nativeを メンテナンスするまでには至りませんでした。

ImageMagick自体もその多機能さゆえに、安全に運用するためには努力が必要でした。

qiita.com

NodeJS自体のバージョンも古く、最近のバージョンまであげようとすると使用できない依存パッケージがあり、 そのパッケージを別のものに置き換えるとまた別のものが動かなくなり・・・、 と影響範囲が広くなってしまい、結局は「動いているから現状維持で」という結論に落ち着いてしまっていました。

アプリケーション数が多い

magicianはいわゆるマイクロサービスです。 当時は利用可能な製品がなかったために自前で作成したアプリケーションなのですが、 いまでは同等の機能を備えたManegedなサービスが利用できます。 自前アプリにメリットが無くなっていたので、そろそろmagcianを廃止したいという声が前々から上がっていました。

Amazon S3 SigV4に対応していない

決定的だったのはこちら。 magcianは2019年6月末に廃止がアナウンスされたAmazon S3 SigV22を利用していました。

dev.classmethod.jp

docs.aws.amazon.com

Amazon S3との通信部分をアップグレードしなければ機能が停止する・・・! アップグレードのコストを払って延命するくらいならManaged Serviceに移行したい・・・!

ということで移行することにしました。

ImageFlux

移行先としてさくらインターネット社が提供しているImageFluxを選択しました。

www.sakura.ad.jp

magcianと同等の機能が利用できることと、 すでにLobi Tournamentで利用実績があったことが選択理由となりました。

vs.lobi.co

移行に必要な要素

リクエストパラメータの変換

magcianもImageFluxも、URL中のパラメータを元に画像を変換して返すことは共通していますが、 パラメータのフォーマットが異なります。 URL自体をImageFluxに寄せてしまうことができればシンプルなのですが、 Lobiのクライアントアプリは画像のURL自体をキャッシュするため、 URLを完全に移行することは難しいと判断しました。

そこで、CloudFrontのImageFlux用Distributionで、 Origin requestとしてLambda@Edgeを設定することにしました。 Lambda Functionがリクエストパラメータをmagcian用からImageFlux用に変換することで、 URLを変えることなくImageFluxを利用することができます。

オリジナル画像の取得先

magcianはAmazon S3からオリジナル画像を取得するアプリケーションです。 ImageFluxもS3もOriginとしてAmazon S3を指定できるので、移行にあたって実作業は発生しませんでした。

クライアントからの参照先URL切り替え

いきなり全リクエストをImageFluxに切り替えるのは、Managed Serviceとはいえ怖いものです。 今回はLambda@Edgeによるリクエストパラメータの変換も行っているため、 ImageFluxには初めは少量のリクエストを流して様子を見ることにしました。

DNSとしてAmazon Route 53を利用していたため、 その機能であるWeigted Routingを使って、 リクエストの一部をImageFluxをOriginとしたCloudFront Distributionに向けることにしました。 (この方法は実はうまくいかないのですが、それについては後述します)

docs.aws.amazon.com

監視

Lobiではサービスの監視にはてな社が提供するMackerelを利用しています。

mackerel.io

MackerelのAWSインテグレーションではLambda Functionも対応しています。 が、Lambda@EdgeのメトリクスはCloudWatch上に us-east-1.{Lambda Function名} というFunctionNameで保存されているため、 AWSインテグレーションでは収集できないようです。

MackerelとCloudWatch、ふたつのサービスを見なければならない状態は一覧性に欠けます。 そこでcloudwatch-to-mackerelを使ってCloudWatch上のメトリクスをMackerelに送信することにしました。

github.com

CloudWatch から Mackerel にメトリックを送る方法 2019年版 / Mackerel Meetup 13 - Speaker Deck

メトリクスはCloudFrontのエッジロケーションごとに分かれているので、 Mackerelの式グラフを使って全エッジロケーションの合計や平均をグラフ表示しています。

mackerel.io

sum(
  service(production, imageflux_proxy_trigger_orgreq.Invocations.Sum.*)
)

エッジロケーション名部分を * にすることで、すべてのロケーションを対象にしています。

つまづきポイント

AWS CloudFrontとAmazon Route 53のWeighted Routing

「移行に必要な要素」で書いたように、計画当初はWeighted Routingのweightを調整して、 まずは少量のリクエストをImageFlux用のCloudFront Distributionに流す予定でした。

ところが、CloudFrontには以下のような制限が設けられていました。

2 つのディストリビューションで代替ドメイン名が重複している場合、 CloudFront は、DNS レコードが指しているディストリビューションに関係なく、 より具体的な名前が一致しているディストリビューションにリクエストを送信します。

docs.aws.amazon.com

今回の場合、CloudFront Distributionに設定するCNAMEsとして、

  • もともとの画像配信用のドメインである assets.nakamap.com 3 をmagcian用のDistributionに
  • *.nakamap.com をImageFlux用のDistributionに

それぞれ設定していました。 ImageFlux用のDistributionにワイルドカードが指定してあるのは、 Distribution間で重複できないという制限があるためです。

Route 53でWeightの設定をして、一部のリクエストを流して・・・流れて・・・こない? 上記の制限のため、より具体的なドメイン名である assets.nakamap.com が設定されたmagician用のDistributionに すべてのリクエストが流れてしまっていたわけです。

代替策として、

  • 切り替え中はCloudFrontを通さずmagcianが直接リクエストを受ける
  • Weighted Routingはmagcianがぶら下がっているロードバランサーとImageFlux用Distribution間で行う

とういう方法をとりました。 magicianをを動かすAmazon EC2インスタンスのサイズを平時よりも大きくした上で数を減らし、 magicianの前段であるnginxのproxy_cacheにヒットしやすい状態にしました。 キャッシュヒット率を上げ変換処理の実行を抑えることでCloudFrontなしでもリクエストをさばくことができました。

・・・あとになって見返すと、「CNAMEsは重複できない」という制限のすぐ後に「より具体的な〜」という記述があるんですよね。 ドキュメントはよく読めというはなしです。

クライアントキャッシュ関連のヘッダーがつかない

ImageFluxはS3 Objectに設定されたヘッダーをそのまま返します。 S3 Objectとして保存されているほとんどの画像ファイルにはCache-ControlおよびExpiresヘッダーが設定されていたのですが、 ある種類の画像にはこのヘッダーがセットされていませんでした。

もともとはnginxでヘッダーをセットしていたようです。 対象画像ファイル数は一括処理でヘッダーをセットできる量ではなかったので、 Origin ResponseにもLambda@Edgeを設定し、そこでクライアントキャッシュ関連のヘッダーをセットすることにしました。

Lambda@EdgeによるLambda FunctionのInvokeにも費用は発生するため、 将来的には大量にある対象画像ファイルにヘッダーをセットし、Origin Responseも廃止する予定です。

TLSv1.0

現在、CloudFrontで新規Distributionを作成すると、セキュリティポリシーのデフォルト値は TLSv1.1_2016 なのですが、 このポリシーはTLSv1.0をサポートしていません。

docs.aws.amazon.com

執筆時点でLobiのAndroidサポートバージョンは4.2以上としていました。 AndroidがTLSv1.1および1.2に対応したのはバージョン5.0以降です。 このため、セキュリティポリシーはTLSv1.0をサポートした TLSv1_2016 に変更する必要がありました。

SNI

magcian用のCloudFront DistributionではSNIを無効にしていました。 当時のクライアントはSNIに対応していなかったためです。 CloudFrontでSNIを無効にしている場合、Distributionごとに毎月$600のコストがかかります。

aws.amazon.com

最近のクライアントはSNIに対応しているので、ImageFlux用のDistributionではSNIを有効にしました。 Lobiのクライアントアプリでは事前に画像の表示に問題がないことを確認し、実際に問題は発生しませんでした。

予期せぬ影響があったのが運営チームで使用している画像監視ツールでした。 使用しているクライアントライブラリが古く、SNIに対応していないものだったため、 一時的に画像監視がストップした状態になってしまいました。 クライアントライブラリをアップグレードすることで間もなく復旧しました。

複雑な設定ファイル

「魔境」と表現した設定ファイルですが、これもLambda@Edge用に移植する際も一筋縄ではいきませんでした。 完全にローカルトークになってしまうので詳しくは書きませんが、 設定ファイル内の別の場所を参照できるエイリアスがあったり、一部のパスはnginxでreverse proxyしていたり・・・。

独自プロダクトにはありがちな話で設定ファイルの仕様も存在しなかったため、 設定の種類ごとに実際のリクエストを1000パターンほど送ってみて動作確認→だめだったら原因調査して直す という作業を何度か行うことになりました。

移行してどうだったか

magcianからImageFluxに移行することで、自分たちで運用しなければならないアプリケーションをひとつ減らすことができました。 Managed Serviceを利用することでサービス運用の人的コストを下げることができ、満足度が高いです。 ImageFluxの利用料も、移行前のサーバーリソースと比べて大差ないレベルに抑えることができました。

f:id:handlename:20190722163503p:plain
ImageFlux+Lambda@Edgeの構成図

やりのこしたこともあります。

問題として挙げていた「設定ファイルの魔境化」については、magician時代のURLを変換しなければならない都合上ほぼそのままのかたちで Lambda@Edge用のLambda Function内に残っています。 これをリファクタリングするためにはクライアントアプリのリクエストを変更しなければなりません。

また、Lambda@Edgeでは現状NodeJSしか使えません。 リクエストパラメータを変換するだけのコードになったとはいえ、普段から利用していない言語で書かれたコードは手に余るものです。 Lambda@EdgeでもGoが使えるようにならないかな・・・。

おわり

今回の対象は「画像を変換する」というシンプルなシステムでしたが、複数のつまづきポイントがありました。 運用が長期化していると思わぬ影響があるものです。 レガシーシステムを刷新する場合は慎重に。


  1. 命名の理由はImageMagickを使っているからなんだとか。

  2. 結局既存Amazon S3 BucketのSigV2の廃止なくなったんですけどね https://aws.amazon.com/jp/blogs/news/amazon-s3-update-sigv2-deprecation-period-extended-modified/

  3. nakamap はLobiの旧サービス名です https://www.kayac.com/news/2013/05/lobi_release