本記事は「Wantedly 新卒 Advent Calendar 2021」の4日目の記事です。
こんにちは! Wantedlyの推薦基盤チームのnasaです。今回はWanetdly Visitの検索・推薦のためのマイクロサービスでフォールトインジェクションを行ったのでその話をしようと思います。
フォールトインジェクションとは
フォールトインジェクションとは、システムに意図的に障害や誤りを擬似的に発生させてシステムの動作をテストすることです。これによって実際の障害に対応できるのかを検証することが出来ます。
今回のフォールトインジェクションは検索・推薦のためのマイクロサービスに対して行っており、このサービスの動作に依存しているRedis,ElasticSearchが落ちているときの挙動をテストしています。
背景
Wantedly Visitの募集検索
Visitではココロオドルシゴトに出会うために企業が出している募集を様々な条件で検索する機能があります。このとき募集を検索ユーザーにマッチしている順序で表示することで、ユーザーに良い募集を見てもらい企業とユーザーが多く出会えるようにしています。
ref: https://www.wantedly.com/
募集検索は以下2つのマイクロサービスが担当しています。
- wtd/wtdというVisitのコアロジックを担当するサーバー(Visitはもともとマイクロサービスアーキテクチャではなくこのwtd/wtdというモノリスのみだったという歴史的経緯があります)
- visit-recommendation-projectは検索条件とユーザー情報を元にいい感じの募集を返しています
visit-recommendation-projectは検索条件にヒットする募集をElasticSearchから探し出し、ユーザーに良さそうな順序で並び替えるということをやっています。このときの並べ替えに使うデータはRedisに格納されているものを使っています。
フォールトインジェクションのモチベ
今回フォールトインジェクションによる検証で達成したかったことは、障害時にはフォールバックが行われユーザー体験を損なわないかを確認することです。
これまでVisitではvisit-recommendation-projectが依存している、ElasticSearchやRedisが落ちたりしてユーザーが募集検索が出来なくなるというインシデントが何度か起きました。このとき募集の推薦機能の依存箇所(Redis)が壊れた際も募集検索自体が出来なくなるということが起きていましたが、本来であれば推薦が出来なくとも募集検索は出来るようにするべきですね。
障害時でもユーザー体験を極端に悪化させないために次の3つの障害点に関してはフォールバックを実装しています。
- visit-recommendation-projectが死んだ: wtd/wtdも募集データを持っているので取り敢えず条件にマッチする募集を出す(推薦は出来ていない)
- Redisが死んだ: 推薦は出来ないが、ElasticSearchで検索条件に合致する募集を取得し返す。このときElasticSearchのスコア順に並べて返している
- ElasticSearchが死んだ: visit-recommendation-projectでは募集を返せないのでエラーをwtd/wtdに返す。1と同様wtd/wtdでフォールバックを行う
これらのフォールバックにより依存箇所が落ちても動作を継続できると思ってコードを書いています。しかし、その検証を素早く行える基盤は無い状態です。
この検証を素早く行いフォールバックがきちんと行われていることを確認したいというのがフォールトインジェクションテストのモチベーションです。
実装
ここからはフォールトインジェクションテストを行うための実装について話していきます。
このときの非機能要として次の2つを満たす必要がありました。
- 実装を変更せずにオン・オフの切り替えが可能であること(検証のたびにデプロイを待つのは嫌なので)
2. 今後の施策として、障害時にユーザー体験がどれほど悪化するかを検証する可能性もあったのでそれを見越した設計にしておく (実際にはやらなかったが)
フォールトインジェクションのオン・オフ
1を満たす方法としてWantedly社内にあったcontext propagetionという仕組みを使いました。
詳細は省くのですがWantedly社内では、chrome extenstionで何かしらの値を設定しておくと、アクセス先のすべてのマイクロサービスにこの情報を伝搬することができます。(HTTPリクエストのHeader, gRPCリクエストのmetadataに設定値が勝手に付与されるようになっています。)
今回はfault injection用の設定値をchrome extenstionを使って募集検索の裏側に居るvisit-recommendation-projectに伝搬しました。
context propagetionに関しては次の記事に詳しく書いてあると思うので気になった人は見てみてください!
ここまででfault injection用の設定値をWebアプリケーション(ブラウザ)から対象とするマイクロサービスであるvisit-recommendation-projectまで伝えることが出来るようになりました。
擬似的に障害を発生させる
フォールトインジェクション機構はFault Injectorと名付けたものを使っています。
Fault Injectorにはconfigとしてオン・オフを決定する関数を渡し、それを実行するという流れになっています。オン・オフの条件を変更しやすくするためにこのような設計にしました(結構前なので覚えてないけどそうだったはず!)
Fault Injectorでやっていることはオン・オフを決定する関数を呼び出して、オンならば擬似的なエラーを返しているだけで特に話すことはないですね。。
// fault injection時に設定値の読み取りに失敗するなどエラー発生が想定されるので
// 実際に起きたエラーと疑似エラーを返す
type FaultInjectorResult struct {
InjectorError error
FakeError error
}
// configという形で、オン・オフを切り替える関数を渡してあげる実装にしました。
// これによってオン・オフの条件が変わったときでも渡す関数を返るだけなので、Fault Injectorを変更する必要がなくなります。
type FaultInjectorConfig struct {
CheckDownFunc func(context.Context) (bool, error)
}
func FaultInjection(ctx context.Context, cfg FaultInjectorConfig) FaultInjectorResult {
if cfg.CheckDownFunc == nil {
return FaultInjectorResult{}
}
ok, err := cfg.CheckDownFunc(ctx)
if err != nil {
return FaultInjectorResult{InjectorError: fail.Wrap(err)}
}
if ok {
return FaultInjectorResult{FakeError: errors.New("Down due to fault injection
")}
}
return FaultInjectorResult{}
}
擬似的に障害を発生させる機能のベースはできたので後は目標とするコンポーネント(RedisとElasticSearch)にこれを仕込んでいくことになります。
visit-recommendation-projectではRedisとElasticSearchへのリクエストはそれぞれRedisStore interface, EsClient interfaceという形でClient用のinterfaceを定義し、それを使用しています。そのためクライアントの実装にに手を入れる必要はなく、同じinterfaceを満たしたラッパークライアントを実装することで簡単にfault injectionを実施できました。ただし、それぞれのクライアントは10メソッドほど実装されているためそのすべてをラップしてFault Injectorを呼んであげるのは面倒ですね。
そこでgowrapというCliツールを使いラッパークライアントを自動生成することにしました。
このCliは次のようにラップ対象となるinterface名とテンプレートを渡すことでいい感じにラップークライアントを生成してくれます。便利!
$ gowrap gen -i Store -o ./lib/redisstore/fault_injector.generated.go -p github.com/wantedly/recommendation-fox/pkg/redisstore -t templates/fault_injector
Fault Injectionを行うラッパークライアントのテンプレートは次のようになっています。やっていることはラッパー元のinterfaceとfault injection用configを受け取っているのと、クライアントのメソッド呼び出しの前に擬似的に障害を発生させるFault Injectorを呼び出し疑似エラーが発生した場合はラップ元のメソッドを呼び出さずにそのまま疑似エラーを返すという単純なことです。
// New{{$decorator}} instruments an implementation of the {{.Interface.Type}} with fault injection
func Wrap{{$decorator}}(base {{.Interface.Type}}, cfg fault_injector.FaultInjectorConfig) {{ .Interface.Type }} {
if base == nil {
return nil
}
return {{$implName}}{
_base: base,
_cfg: cfg,
}
}
{{range $method := .Interface.Methods}}
// {{$method.Name}} implements {{$.Interface.Type}}
func (_d {{$implName}}) {{$method.Declaration}} {
result := fault_injector.FaultInjection(ctx, _d._cfg)
if result.InjectorError != nil {
hbx.Notify(fmt.Errorf("Failed inject fault on {{$implName}}.{{$method.Name}} method"), hbx.Context(hbx.H{"error": result.InjectorError}))
logx.Error("Failed inject fault on {{$implName}}.{{$method.Name}} method", result.InjectorError)
{{ $method.Pass "_d._base." }}
}
if result.FakeError != nil {
logx.Info("Success inject fault on {{$implName}}.{{$method.Name}} method. dummy error:", result.FakeError)
return {{range $param := $method.Results}} {{if eq $param.Type "error"}} fail.Wrap(result.FakeError) {{else}} {{$param.Name}}, {{end}} {{end}}
}
{{ $method.Pass "_d._base." }}
}
{{end}}
実装パートは駆け足になりましたが、このようにラッパークライアントを自動生成することでフォールトインジェクション機構を持ったクライアントが出来上がります。あとはクライアントの生成箇所をこのフォールトインジェクションの方に書き換えるだけですね。
まとめ
今回は推薦基盤チームで実施したフォールトインジェクションテストについて話しました。ここで達成したかったこととしてはフォールバックが想定通りに実施されているかを確認することでした。
chrome extenstionを使い値を設定するだけで、Redis、ElasticSearchというデータストアをそれぞれ落とした状態で検証ができるようになり、目的としていたところが達成できました。
以上フォールトインジェクションテストについての話でした >_<