ニューズピックス SREユニットリーダーの武藤です。私はここ数年は開発環境を近代化するために働いているのですが、最近では新しいメンバーから技術スタックについて「モダンですね」と言っていただけることが増えてきました。技術スタックの更新は最近ニューズピックスが会社として力を入れているところなので色々な分野でたくさんのエンジニアが関わっているのですが、SREに直接関係する範囲では特にChatOpsによるリリース作業について良い反応が多いので、ここで紹介します。
かつてどんな問題があったか
リリース作業は注意深く設計しなければさまざまな苦痛が伴うものです。みなさんもこのような経験はないでしょうか?
- 本番ネットワークで動いているマシンにログインして作業をする手順になっており、本番環境が壊せてしまうから気を遣う。本番にアクセスする権限がある限られた人しかデプロイ作業ができない。
- 集計バッチの実行中にリリースを行うとバッチが停止してしまい、データが壊れる。このためリリースできる時間に制約がある。
- リリース手順の中でアプリのビルドを行うのでリリースに時間がかかる。エンジニアはこの間本番に接続しているので他のことをする気にはなれず時間を無駄にする。
- スケールアウトをしているときにリリースしようとするとリソース制限に引っかかるので、プッシュ通知を送る前後はリリースできない。
これらは全て実際に過去にニューズピックスで起きていたことです。リリースにすごく気を遣うのでリリース作業は敬遠されていましたし、修正を本番に反映するまでのタイムラグもどうしても大きくなってしまう状況でした。
SREではリリース方式について2019年後半から1年半ほどかけて徐々に改善を行い、2021年初頭には上記の問題は解消しました。 改善の方向性として意識していたことをリストアップしてみます:
- いつでもリリース可能にする
- 精神負荷を下げる
- 特別な権限なくデプロイできるようにする
- 手間をできるだけ少なくする
- 作業ミスの危険性を減らす
- 待ち時間をできるだけ少なくする
- リリースされているシステムの信頼性を上げる
何をしたか
リリース方式の変更は本番環境に対する破壊的な操作を伴うため慎重に進める必要があります。一度に大きな変更をしないこと、各ステップでは問題があれば戻せるようにすることを意識しながら少しづつ改善を行いました。どのようなステップで変更していったのかを時系列順で紹介します。
誰でも安全にリリース可能にする
最初にやったのは本番環境でのビルド&デプロイをマネージドサービスで置き換えることです。NewsPicksはAWSに完全に依存したシステムなので、AWSのマネージドサービスであるCodeシリーズを全力で使っていくことにしました。実装はTypeScriptでCDKを使いました。これ以降、このコードをベースに改善を積み重ねていくことになります。プロダクトチームの中でもCDKを本格的に使うのは初めての状況でしたが、SRE主導で他チームを含めてCDKの導入を推進するきっかけにもなりました。今振り返ると歴史的な転換点と言えます。
初期の構成はこのようになります。古い議論から引っ張ってきた図なので雰囲気だけ見てもらえればと思います。CodePipelineとCodeBuildがたくさんあるなということを見てもらえれば十分です。
特徴
- リリースを誰でも安全に開始できるようになりました。
- リリースのトリガーが「本番環境上でシェルスクリプトを実行する」から「githubにリリース用タグをプッシュする」になり、本番環境にアクセスせずにデプロイできるようになりました。
- リリースの準備が終わったタイミングで最終的なリリース可否を問う機能をCodePipelineの承認機能を使って実装しました。これまでは本番環境上のシェルスクリプトでプロンプトが出る作りになっていました。
改善点
- タグをプッシュしてからビルドを開始していたので、リリース可否を問うステップに到達するまで30分程度の待ち時間がかかりました。
- CodeDeployは基本的にはデプロイ先ごとに別々のアーティファクトを必要するので、ビルド結果をデプロイ先ごとにまとめ直す目的でCodeBuildを使っていました。CodeBuildは呼ばれるたびにコンテナを起動する作りなのでレイテンシが遅く、デプロイフロー全体の時間を引き伸ばす要因になっていました。またデプロイフローそのものや中間のファイル構造が複雑になる要因となっていました。
集計バッチ実行中にリリース可能にする
デプロイフローが一旦マネージド化・コード化されてしまえば後は改善あるのみです。ここで改善の目的を「Push通知の処理中でもデプロイ可能にする」にフォーカスすることにしました。ここに注力すれば後のことは必要に応じて満たされるはずだからです。このプロジェクトは半年程度の長期間にわたることが予想されたため、とある映画の名前をもじって「Pushを止めるな!」というプロジェクト名を付けました。
「Pushを止めるな!」という題名はつけたものの、やりたいことは時間を気にせずにリリースできるようになることです。そこで最初のステップとして取り掛かったのは集計バッチの実行中でもリリース作業を可能にすることでした。リリース時間を最大の幅で制約する要因であり、比較的簡単な作業でありながらも効果が大きいので、開発者の皆さんにプロジェクトの意義を感じてもらうことも目論んでいました。
集計バッチの実行中にデプロイできなかったのはデプロイフローの中でバッチをkillしていたからです。この時点ではNewsPicksの本体はコンテナではなくEC2で動いていたので、バッチのデプロイの本質はEC2上のjarファイルの置き換えでした。デプロイ時にバッチをkillする理由は実行中にjarファイルが書き変わることを避けるためでしたが、大事な集計バッチがkillされては困ります。そこでデプロイ時点では何もせず、次回の実行時に新しいjarファイルが使われるようにしました。
ファイル名の工夫やハードリンクを使ってファイルの入れ替えをすることで、以下を実現しました
- jarの書き換えを高速に終わらせる
- 既存のjarファイルには影響がない
- ストレージの消費を最小限に抑える
CanaryRelease+デプロイ高速化
「Pushを止めるな!」のためにやるべきことはたくさんあるため、ワークフローの制御をカスタマイズ性の低いCodePipelineに依存し続けることはできなくなってきました。そこでCodePipelineをStep Functionsに置き換えることにしました。全く同じフローをStep Functionsに置き換えるのではなく、無駄な処理を省いてデプロイを高速化することも同時に行い開発者に喜んでもらうことにしました。ステップを細かく刻む意味では一旦全く同じフローを実装してから内容を変更するのがいいやり方のように思えますが、今回はデプロイ処理そのもの(CodeDeployの呼び出しとCodeDeployに渡すアーティファクトの中身)は全く変化させていないので、それ以前の処理をドラスティックに変えても大丈夫ということで自信を持ってバッサリいくことができました。もちろん毎回のことですがうまくいかなかった場合はすぐに切り戻せる準備をして切替を行いました。
この時に検討したメモです。こちらも古い資料から引っ張ってきたものなので、雰囲気だけ見てもらえればと思います。複雑なステップを削除して簡単にしても、新しい機能の追加により複雑さが増えてしまっています。しかし総合的に見てシステムの複雑さが増えすぎなければいいのです!
このステップでは以下を行いました。
リリースフローのStep Functionsでの置き換え
CDKで作ってあったので、ビルドのためのCodeBuildとデプロイに使うCodeDeployの定義は既存のものをそのまま残し、制御だけをStep Functionsに置き換えることができました。CodeBuildはmasterへのマージのタイミングで呼び出されるようにし、一回のビルドで複数あるCodeDeploy用のアーティファクトを全て用意するようにしました。これによりリリース時には事前にビルドされたアーティファクトをCodeDeployに渡すだけになり、待ち時間が最小限に抑えられるようになりました。
Canary Releaseの導入
NewsPicksではリリース後に本番の動作が問題ないかの動作確認を行なっています。このステップまでは本番のインスタンスが全て入れ替わった後で動作確認を行なっていました。バグがあった場合には影響範囲はその時アクセスしていた全ユーザに及んでいました。
このステップでは影響範囲を最小限に抑えるためcanary releaseを導入しました。一度に全インスタンスを更新するのではなくcanaryインスタンスだけを更新して動作確認を行ない、確認が終わったのちに他のインスタンスに適用するようなフローに変更しました。canaryが更新されるとSlackに以下のような通知がくるので、動作確認のタイミングは簡単にわかるようになっています。
緑のOKを押すと他のインスタンスに変更が適用され、赤のNGを押すとcanaryがロールバックされます。これでより安全にリリースができるようになりました。この投稿はこれまでCodePipelineの承認機能を使って実装していた「最終的なリリース可否を問う」機能を継承したものです。push通知とリリースのタイミングが重ならないようにこれで制御するのです。
リリース作業の高速化
先述した通りリリース時には事前にビルドしたものを配布するだけにするようにしたことで動作確認の開始までの待ち時間は30分から10分に短縮されました。
リリース開始シェルを叩いてリリースを開始する
このステップまではgithubにタグをpushすることでリリースを開始していましたが、ここからはリリース用シェルをローカルで叩いてリリースを開始するようになりました。このシェルはタグのpushとStep Functionsのワークフローの開始を行うだけの単純なものです。
push通知処理中にリリース可能にする
ついに「Pushを止めるな!」の本命です。このステップまではpush通知の処理中にはリリースができませんでした。push通知は定時だけではく任意のタイミングで発生する速報もあるため、リリースのタイミングを見計らうのは編成チームと息を合わせて行う必要がある繊細な注意を要する作業でした。
push通知を送ると顕著にアクセスが集中するため、これを送る際のオペレーションとして事前にサーバーの数を増やす(スケールアウトさせる)ようにしています。push通知の処理中にblue-greenデプロイを実施していなかったのは次の二つの理由によります。
- push通知の処理中にリリースを行うためには全部でピーク時の台数の2倍のサーバーが準備されることになり、バックエンドのデータストレージ(主にRDS)へのコネクションが大量に必要になる
- 入れ替え直後にキャッシュが十分温まっていない状態で高トラフィックを受けることになる
このステップではblue-greenデプロイをやめてローリングアップデートを採用することで、一時的に大量のリソースが必要になることを避けつつ、キャッシュが不十分なインスタンスでの処理を時間的に分散させることができるようにしました。これによって念願だったリリースタイミングの制約をなくすことが達成できました!
ChatOpsの導入
ここまでで当初の問題は解決したのですが、更なる使いやすさの向上のためにデプロイ開始のスクリプトを廃止してChatOpsを導入しました。
すでに前段でslackへの通知とボタンへの応答ができていたので、追加の作業はコマンド行の解析とgithub apiを呼び出してタグをpushする程度で、比較的簡単に終わりました。
E2Eテストの導入
リリース手順の中でもう一つの大きな手間は動作確認でした。Web/iOS/Androidの3パターンでテストする必要があるのですが、大抵の人はiOSかAndroidのどちらかのモバイル端末しか持っていないので、リリースの際には自分の持っていない方の端末を持っているメンバーを探す必要がありました。また手順も慣れるまでは面倒だし、人によってテストケースの解釈がぶれてしまう問題もありました。
このステップではcanaryリリース後に自動テストを実行し、手作業の動作確認をやらなくても済むようにしました。Webはwebdriverを使ったE2EテストをCodeBuildで実行し、iOS/AndroidはそれぞれのプラットフォームのテストをFirebase TestLabで実行するようにしています。テスト結果はslackに通知されるので、待つだけです。canaryリリースが完了した通知を受け取った後、必要に応じて手動の動作確認を実施し、自動テストの成功を確認したら変更を全体に適用します。E2Eテストからのアクセスはcanaryに対してだけ行くようになっています。
今ではこのテスト基盤に各チームが新しくテストを追加してくれるようになったので、リリースの手間を増やすことなくリリースの安全性を高めることができるようになっています。
ECS化
リリースフローが落ち着いた後、昨年中はEC2で動いていたアプリをECSに載せ替える作業を行なっていました。これに伴ってEC2向けの仕組みは作り替えられることになりました。内部的な変更なのでリリースの手順や使用感はかわりませんが、ワークフローのステートを調整することで並行稼働をおこなったり切り替えを済ませたりということが柔軟にできました。
現在の状態はECS化完了時点からは少し変化しているもののほぼ同じなので、ここで現在のワークフローの定義をご紹介します。
- チャットボットがreleaseコマンドを受け取ると、最新のメインブランチのコミットIDにリリースタグをpushしつつ、コミットIDを引数としてStep Functionsのワークフローを呼び出します。
- ワークフローではまずビルド成果物が出来上がるのを待ちます(Check Artifactあたり)。CodeDeployやDB Migrationで使う成果物(S3)とECS用のコンテナイメージ(ECR)が出来上がっているかを確認しています。
- 成果物ができたらDB Migrationを実行します。S3にある成果物を指定してCodeBuildを呼び出しています。
- マイグレーションが完了したらcanaryを更新し、更新の完了を待ちます(Deploy ECS Canary)。この処理はサブワークフローとして実装されています。
- canaryが更新できたらE2Eテストを実行し、Slackに動作確認開始の案内を出します(Start ... testとNotify Start E2E Test)。E2Eテストの実行もサブワークフローとして実装されていて、こちらは非同期で実行する(結果を待たずに次に進む)ようになっています。
- 動作確認が成功した場合、全体に変更を反映させます(Update ...、Deploy ...)。CodeDeployの呼び出しやECSのタスク定義の更新を行います。動作確認に失敗した場合はcanaryを元に戻します(Rollback ECS Canary)。
アイコンの設定
これはごく最近の話ですが、デプロイ用のslackボットに素敵なアイコンを設定しました。デザイナーのメンバーが作ってくれました。社内ツールがかっこいいとテンションあがる!
今のリリースフロー
長い変遷の歴史の果てに辿り着いた今のリリースフローをご紹介します!簡単です!
@deploybot release
と発言する しばらく待つとcanaryが更新され、テストも実行されてこのような通知がきます。 文言が微妙におかしいのはご愛嬌ということで、大事なのは進むべきか止まるべきかを選ぶボタンが出てくることです。- 問題なければ「全体適用に進みます」ボタンを押す
「全体的用に進みます」ボタンを押すと変更が全体に適用されます。canary以外のwebアプリが更新され、バッチも配布されます。ボタンを押した後のメッセージは以下のように変わります。
おわりに
若干端折りつつ現在のリリースフローに至るまでの経緯を説明しました。リリースフローの使い勝手は大体の形が定まった後も継続的に細かく手を入れていますし、インフラ構成の変更に応じてリリースフローの内部でやることは大きく変化し続けています。リリースのハードルは劇的に下がり、リリース頻度も明確に向上したので、いいプロジェクトだったと満足しています。
最終的にはプルリクをマージしたら自動的にリリースされることを目指しています。自動的にリリースする仕組みを作ること自体はやればできるのですが、問題はその運用に耐える品質だと自信を持てるかどうかです。プルリクの時点でE2Eテストを実行する、テストをより拡充させるなどの施策によってより自信を持ってリリースできるようにしていくのが今後の計画です。