CI の修正をリリース前に本番と同じ条件下で検証出来る仕組みを構築した話

SRE の鈴木です。 CI のコードの修正を安全にリリースするために、リリース前に本番と同じ条件下で動かして検証できる仕組みを構築した話について書きます。

背景

最初に背景を説明します。 Quipper では様々なプロダクトのソースコードがモノレポ、つまり同じリポジトリで管理されています。 モノレポにはメリット・デメリットがありますが、複数のプロダクトにまたがる変更を一つの Pull Request (以下 PR) でできる、CI などのコードも含めコードを共有しやすいというメリットがあります。

CIでは各プロダクトのテスト・ビルドをしていますが、毎回すべてのプロダクトのテスト・ビルドをしていると時間もかかりますし、 Flaky test に悩まされたりしますし、 Developer Experience (以下 DX) がよくありません。

そこでどのプロダクトが更新されたかを検出し、更新されたものだけテスト・ビルドする仕組みを自前で構築しています。 git diff --name-only master とかだけで済むなら話は簡単ですが、もっと複雑なことをやっています。

仕組みについて簡単に説明すると、CI には CircleCI が使われており、 変更を検知する job (ここでいう job は CircleCI の workflow の構成要素のことです) が実行され、更新されたプロダクト名の一覧がファイルに書き出され、 workspace に永続化されます。 後続の各プロダクトのテスト・ビルドの job では workspace をアタッチし、更新されたプロダクト名の一覧のファイルにそのプロダクトが含まれているかチェックし、 含まれていなかったら circleci step halt で job を停止します。

f:id:quipper-ja:20200403093713p:plain

この機能は元々シェルスクリプトで実装されていたのですが、高度なシェルスクリプトの知識とリリースの仕組みに関するドメイン知識が必要であり、改善したい部分があっても気軽にいじれる感じではありませんでした。 そこで今回 Go で書き直し、よりメンテナンス性を高め、安全に改善していけるようにする、修正のハードルを下げるということをやっています。

前置きが長くなりましたが、次からいよいよ本題に入ります。

課題

Go による実装が一通りでき、いざリリースするとなったときに、どうやって安心してリリースするかが問題になりました。 Go に書き直したことでユニットテストカバレッジはそれなりにありますし、型もあるし lint もしてるしという意味では元々シェルスクリプトで実装でされたものよりだいぶ安心感がありますが、それでもバグがないとは当然言えません。 プロダクトのコードであれば本番にリリースされる前に staging 環境で検証され、QA もありますが、 CI のコードに関してはそのような仕組みはありませんでした。 CI のコードにバグがあると最悪全プロダクトの開発・リリースに影響が出てしまいます。

解決策

そこで次のような仕組みでリリース前に本番と同じ条件下で動かして検証することにしました。

従来の更新検知の CircleCI job と並列に Go で実装した更新検知の CircleCI job を動かします。 結果として更新されたプロダクトのリストが生成され、 workspace に永続されますが、それは実際には使いません。 従来の job と 新しい job で生成されたそれぞれのファイルを比較する job を用意することで Go の実装が正しいか検証します。 ファイルの内容に差分があったり、 Go で実装した更新検知が異常終了したりした場合、 Sentry に通知します。 Go で実装した更新検知のコマンド及び job の結果の比較検証するコマンドが異常終了しても CircleCI job は成功するようにすることでバグがあっても CI には影響が出ないようにします。

コマンドが異常終了しても job は成功するようにするとはどういうことかというと、簡単にシェルスクリプトで表現すると次のような感じです。

if ! コマンド; then
  sentry に通知
fi

f:id:quipper-ja:20200403093809p:plain

結果

このような仕組みを導入することで、リリース前にユニットテストでは拾えなかったいくつかのバグを潰し、安心してリリースすることができました。

Sentry の通知には tag として PR や CircleCI の job の URL がついてるため、すぐに CircleCI の job のログを確認したりでき、とても助かりました。 同じエラーは一個の issue にまとめられるのも分かりやすくてよかったです。

リリースしてから今の所問題は表向き全く起こっていません。

応用

無事 Go で実装した更新検知をリリースできましたが、 Go に置き換えたから終わりというわけでは当然なく、今後も開発を続けていきます。 そこで上記の仕組みを応用して修正をリリース前に検証する仕組みを構築しました。

元々 Go で実装した更新検知のコードは1つしかありませんでしたが、それをコピーして dev と prod という 2 つのバージョンを作りました。

ディレクトリ構成的には次のようになります。

AS IS

goci/
  cmd/
  go.mod
  ...

TO BE

goci/
  prod/
    cmd/
    go.mod
    …
  dev/
    cmd/
    go.mod
    …

goci とは何かというと CI を Go で実装したもののコードネーム的なものです。

察しの良い人にはもう説明する必要もないかもしれませんが、基本的には dev を開発し、 dev と prod を両方 CircleCI job として動かし、結果を比較・検証し、問題なければ dev の変更を prod に反映させてリリースするということをやっています。

もう一つのメリット

上記の仕組みによって安心・安全に CI のコードの修正をリリースできたわけですが、 別のメリットにも触れておきます。

reviewer が PR を approve する心理的なハードルが一気に下るというメリットです。 自分が approve してリリースされた結果トラブルが起こるかもしれないと思うと、 approve することに心理的なハードルがあり、結果として reviewer のストレスになる、中々マージされず PR 投げてる人のストレスにもなるという悪循環が起こりえます。 マージしてもすぐにリリースされるわけではなく、リリースされる前にちゃんと検証されるのであれば、とりあえずマージしてしまっても問題ないとも言えます。 心理的ハードルが一気に下がり、 approve されるのが早まり、開発をスピーディに進められるということが期待できます。

さいごに

今回 CI のコードの修正をリリースする前に検証する仕組みについて紹介しました。 結果として安心・安全にリリースすることができ、加えて review の心理的ハードルを下げ、開発をスピーディに進められるということが期待できます。 Sentry に通知するのは便利なので今後も活用していきたいです。

今回の更新検知は更新されたプロダクトのリストをファイルとして生成するだけなので、比較・検証が楽でしたが、 ここまで簡単でない場合も少なくないでしょう。 それでもリリースされたものと同じ条件下で動かして検証するということが極めて重要であると今回実感しました。

今後 CI に関して機能改善をしていき、更に DX を高めていきたいと思っています。