技術部 SRE グループの mozamimy です。
クックパッドでは、 SRE が中心となって、サービスを動かす基盤の大部分である AWS のコスト最適化を組織的に取り組んでいるため、今回はそれについてご紹介します。
前半では、そもそもの話として「なぜコスト最適化が重要なのか」「何が難しいのか」「何をすべきなのか」といったことを述べます。これは、当たり前すぎて逆に陽に語られることが少ない (とわたしは感じています) トピックで、一度しっかり言語化しておいてもいいかなと考えたからです。内容のほとんどはわたしの脳内ダンプで、クックパッドという会社のコンテキストや組織としてのステージが前提になっているため、大多数の組織について当てはまる内容とは限りません。
後半では、コスト最適化の一例として、リザーブドインスタンス (以下 RI と略記) を維持管理するためのフローと、それを支えるモニタリングシステムについて述べます。こちらは AWS を利用するいかなる組織においても、今日から使えるテクニックとなるでしょう。もしかすると、似たような仕組みを整えている組織もあるかもしれません。
パブリッククラウドのコスト最適化の重要性
オンプレミスであれクラウドであれ、インフラにかかるコストを最適化し、できる限り無駄のない状態を保つことが重要なのは言うまでもないでしょう。とりわけクラウドでは、必要なときに必要な分だけお金を対価にリソースを得られる反面、簡単な操作でリソースを増やすことができるため、気づいたときには大きな無駄が発生していたという自体も起こりえます。
組織において、お金という限られたリソースは大変重要かつ貴重なものです。たとえば、インフラにかかるコストを年間 $10,000 節約できたとすれば、その $10,000 は投資やその他の重要な部分に回すことができます。インフラコストは投資と違って単に失われるだけなので、エンジニアリングリソースを割いて最適化することは、十分に理にかなっているでしょう。クラウドであれば、API を通してリソースを操作できるため、ソフトウェアエンジニアリングで解決できる部分が大きいです。
コスト最適化に見て見ぬふりをしていると、じわじわと組織の首を絞めていきますし、節約できていれば本来有意義に使えたお金が失われることになります。キャッシュが枯渇してから慌てていては、もう手遅れです。
コスト最適化を考えるにあたって、以下の 2 つの軸が存在するとわたしは考えています。
- リソースプールのコスト最適化: RI を適切に保つ、スポットインスタンスを積極的に利用する、など。
- リソースプールの利用に対するコスト最適化: 各サービスで利用しているインフラのキャパシティ (ECS サービスのタスク数、RDS インスタンス、ElastiCache Redis/Memcached クラスタなど) が、適切にプロビジョン・スケーリングしていて無駄遣いしていない状態に保つこと。
コスト最適化の基本的なポイント
たとえば AWSだと、以下のような取り組みがあげられます。これらは AWS の公式ページにも書かれています。
- スポットインスタンスを積極的に利用する。
- RI の購入によってある程度のキャパシティを予約・前払いし、オンデマンドインスタンスやマネージドサービスを割安で利用する。
- Cost Explorer や Trusted Advisor といったサービスを利用してオーバープロビジョニングなリソースを発見し、インスタンスサイズやその数を適切に保つようオペレーションする。
これらのうち、クックパッドでは 1 番と 2 番、すなわちリソースプールそのもののコスト最適化については非常に熟れており、理想に近い状態を維持することができています。3 番のリソースプールの利用については、これからも継続的に取り組んでいくべき課題となっています。
1. スポットインスタンスの積極的な利用
コンテナオーケストレーション基盤としてクックパッドでは早くから ECS を採用しており、SRE の鈴木 (id:eagletmt) による Cookpad Tech Kitchen #20 での「Amazon ECSを安定運用するためにやっていること」という発表にあるように、スポットインスタンスを利用した ECS クラスタを安定して運用できています。Rails を始めとする HTTP をしゃべるアプリケーションサーバなど、状態を持たないサービスのほとんどは、スポットインスタンスで構成された ECS クラスタで動いています。これは、コストの最適化に大きく寄与しています。
2. RI (リザーブドインスタンス) の運用
このトピックは、記事の後半で述べるためここではいったん隅に置いておきます。
3. キャパシティを過不足ない適切な状態に保つ
文字で書いただけでは「いいじゃんやろうよ」という感じなのですが (ですよね?)、実際にはその道のりは非常に険しいです。たとえば...
- インスタンスサイズの変更のためにどうしても停止メンテナンスが必要になったらどうするか? 開発チームとの《調整》が発生。
- アプリケーションの改善でコストを最適化できる場合 (たとえばスロークエリを改善するなど) だと、SRE よりもそのアプリケーションを普段から触っていてドメイン知識のある開発チームが対応したほうが早い。
- SRE の限られた人的リソースですべてのインフラのリソースの状況をウォッチして対応し続けるのは組織の拡大に対してスケールしない。
といった問題があります。
これは、SRE という概念が解決しようとしてる問題に密接に関連しています。Google による SRE 本にも、コスト最適化のためのオペレーションはトイルとして扱われています。
エラーバジェットや信頼性といった真髄からはやや外れるため、コスト最適化に SRE という概念を絡めることには議論があるかもしれません。ただ、わたしは場当たり的なコスト最適化は技芸だと思っていて、それをエンジニアリングで解決することは立派な SRE way だし、SLO を守れる範囲でコストを切り詰めていくことは信頼性に深く関わることだと信じています。トイルをなくそうという SRE の方針とも合致しますね。
この問題はクックパッドの SRE で取り組んでいる、開発チームへの権限と責任の移譲が進み、適切にコストをモニタリングできる仕組みが整えば解決できる見込みがあります。少し前の記事ですが、権限と責任の移譲については、クックパッドの開発基盤、インフラ環境での開発で心がけているラストワンマイルでも触れられています。
各開発チームが、自分たちのサービスにかかっているインフラコストを把握できるようなり、自律的にリソースプールの利用を最適化できると、組織の拡大に対してスケールするようになるでしょう。開発チーム自身の手で実装起因の無駄を改善することができ、スケールダウンのための停止メンテナンスが必要になった場合でも、チーム内で調整を完結して行うことができます。
SRE はリソースプールそのものの最適化と、リソースプールの利用をモニタリングできる仕組みを提供することに専念し、必要に応じて開発チームを手伝うことはありますが、基本的には開発チームにコスト最適化の責任と権限を持ってもらうのです。
また、プロジェクトや部署ごとに、どの程度サービス運用のためのインフラコストがかかっているかを把握できるようなダッシュボードを作ることができると、財務管理上でもメリットになるでしょう。まるっと「インフラ代金」として予算管理しているものが、開発チームや部署、プロジェクトごとに予算を細かく設定し、追跡することができるのです。財務などのバックオフィスもインフラコストの状況を追跡できるようにしておく重要性は Whitepaper: The guide to financial governance in the cloud でも触れられています。
リソースプールの最適化に限界が見え始めた今、やるべきこと
SRE が責任として持っている、スポットインスタンスの利用推進や RI の適切な運用によって、開発チームが利用するリソースプール自体のコスト最適化は限界に近づきつつあります。次にやるべきことは、リソースプールの利用を最適化していくことで、これは組織全体として取り組めるように SRE がエンジニアリングで解決していく必要があります。
リソースプールの利用に対するコスト最適化はこれからの課題として、後半では、リソースプール自体のコスト最適の取り組みの一つとして、RI の維持管理のフローと、それを支えるモニタリングシステムについて説明します。
クックパッドにおける RI (リザーブドインスタンス) の維持管理と対応フロー
RI の費用削減効果を最大限に発揮させるためには、その状況の変化を察知して、以下のようなオペレーションによって理想の状態に戻るようにメンテナンスし続けるのが一般的です。
- RI の追加購入
- 動いているインスタンスタイプの変更
- RI の exchange (2019-08-14 現在、convertible な EC2 の RI のみ可能)
これは CPU 利用率やキューの待ち行列の長さといったメトリクスに基づくキャパシティのスケールアウト・スケールインと似ています。RI の状況がしきい値を割ったときに、上述のリストにあげたようなオペレーションによって、理想的な状態に戻るようにメンテナンスし続けるのです。
クックパッドでは、後述の ri-utilization-plotter や github-issue-opener といった Lambda function を利用して、以下のような GitHub issue を使った対応フローをとれるようにしています。
- RI の状況を監視し、変化があったことを検知してアラートをあげる。
- アラートがあがると、自動的に GitHub のリポジトリに issue が作成される。
- RI の追加購入などのオペレーションをする。
- RI の状況が元に戻ったことを確認して issue を閉じる。
RI の状況ベースでの監視は、実際に状況が変わってからしか検知できないため、対応が後手に回ってしまいがちという弱点があります。RI の追加購入や exchange の際には、いくつ買っていくつ exchange するのかというプランを練ってチーム内でレビューする必要があるため、その作業が終わるまでは RI による費用削減効果が減ってしまうことになります。
少なくとも RI の失効は事前に知ることができるため、上述の RI の状況をベースとした監視に加え、以下のような対応フローも整えています。
- RI の失効が発生する 7 日前に PagerDuty の incident を発行してアラートをあげる。
- RI の追加購入を検討・実施し、incident を resolve 状態にする。
これらの仕組みにより、RI を理想に近い状態で運用することができています。
実際の対応フロー
百聞は一見にしかずということで、この仕組みを使った Grafana によるダッシュボードや、実際の対応フローを見てみましょう。
この仕組みでは、RI の状況は CloudWatch のカスタムメトリクスに蓄積されるため、以下のように Grafana から一覧することができます。
ちなみに、Cost Explorer API から取得できる値は、月に一回程度、変な値を示してしまう場合があるのですが、頻度も少ないですしそれは許容しています。このスクリーンショットでは、左上のグラフの谷になっている箇所ですね。
実際に RI の状況が変化してメトリクスがしきい値を割ると、GitHub に issue が自動的に立って場が作られ、そこで対応することになります。
このスクリーンショットのシナリオでは、Amazon ES の RI カバレッジがしきい値を割ったことが検知されており、わたしがその原因を調べてコメントし、オペレーションによる一時的なものであることを確認して CloudWatch Alarm のしきい値を調整し、メトリクスがもとに戻ったことを確認して issue をクローズとしました。別のケースでは、RI を追加で購入したり exchange したりといったオペレーションをしたこともありました。
さて、ここからは実装の話に移ります。以下のトピックについて説明していきましょう。
- RI の状況を知る
- RI の状況が変わったときにアラートをあげる
- RI の失効が近づいたときにアラートをあげる
RI (リザーブドインスタンス) の状況を知る
RI がどのような状況にあるかは、RI 利用率と RI カバレッジによって知ることができます。
RI 利用率は、購入した RI がどの程度有効になっているか、すなわちどの程度余らせることなく有効に使えているかを示す割合です。RI カバレッジは、オンデマンドで動いているインスタンスの総量に対して、RI によってどの程度カバーされているかを示す割合です。
RI 利用率は 100% を維持できている状態が望ましいです。RI カバレッジも同様に、できる限り 100% に近い状態を保っているのが理想です。しかしながら、ある時刻において RI カバレッジが 100% になるように買ってしまうと、将来的な利用の変化に対応することが難しくなります。アプリケーションの改善により必要なキャパシティが減ったり、EC2 であれば、スポットインスタンス化やマネージドサービスに寄せることで RI が余るといったことも起こるでしょう。あまり考えたくはないですが、サービスのクローズによって不要になるリソースもあるかもしれませんね。そこで、ターゲットとなるカバレッジを、たとえば 70% などと決めておき、その値を下回らないように RI をメンテナンスするのがよいでしょう。
RI 利用率や RI カバレッジは Cost Explorer のコンソールや API から確認でき、フィルタを駆使することでリージョンやサービスごとに値を絞り込めます。
この画面からは利用率だけでなく、RI によってどの程度コストを節約できたのかといった情報も表示できます。また、左側のメニューにある Reservation summary には、RI の情報をサマライズしたビューが用意されています。
この画面では、すべてのサービスを横断した RI のサマリを確認でき、30 日以内に失効する RI も確認することができて便利です。
Cost Explorer の基となっているデータ (RI の情報だけでなく、請求に関するすべての情報を含む) は AWS Cost and Usage Report (以下 CUR と略記) によって S3 バケットに出力することができ、Athena などでクエリすることで Cost Explorer では実現できないような集計結果を SQL によって自在に引き出すことができます。
クックパッドでは、CUR を Redshift Spectrum からクエリできるような仕組みがデータ基盤グループによって整備されており、Tableau を用いてダッシュボードを作ることも可能になっています。
RI (リザーブドインスタンス) の状況の変化に対してアラートを仕掛ける
ここまでの説明で、Cost Explorer やその API、CUR などを駆使することで、RI の状況を確認できることがわかりました。次はそれらのメトリクスに対してアラートを仕掛けることを考えていきましょう。
なるべく AWS 標準の機能で済ませたい場合は、AWS Budgets が利用できます。reservation budget をしきい値とともに作成し、アラートの設定に SNS トピックやメールアドレスを設定することで通知を HTTP エンドポイントや Lambda function 、特定のメールアドレスに送ることができます。試していませんが、少し前のアップデートによって、AWS Chatbot を利用して Slack や Chime に簡単に通知できるようになったようです。
Launch: AWS Budgets Integration with AWS Chatbot | AWS Cost Management
これはこれで便利ですが、以下の理由で今回の要件を満たせないと考え、自前で仕組みを用意しようと判断しました。
- reservation budget を daily で判定するように設定すると、しきい値を割っている間、毎日通知が届いてしまう。
- 通知の細かい制御ができない。たとえば「3 日以上しきい値を割り続けていたら」のようなアラートを設定できない。
- メールや Slack の通知だけでは見落としが発生する可能性が高く、後半の冒頭で述べたような対応フローを実現できない。
今回は、ri-utilization-plotter という Lambda function を中心とした、以下のような仕組みを構成しました。仕組みとしては非常に素朴ですが、よく動きます。
- 12 時間ごとに起動する Lambda function から、Cost Explorer API をたたき、取得した RI 利用率と RI カバレッジを CloudWatch のカスタムメトリクスにプロットする。
- そのカスタムメトリクスに CloudWatch Alarm を仕掛け、しきい値を割ったときに SNS topic 経由で Lambda function を起動し、GitHub の SRE のタスクをまとめるためのリポジトリに issue を立てる。
ちなみに、CloudWatch にメトリクスを寄せることで、Grafana を使って Prometheus といった他の様々なメトリクスと組み合わせたダッシュボードを作ることもできて便利というメリットもあります。
実際には、たとえば Redshift の RI はデータ基盤グループが管理しているため、SRE 用とは別のリポジトリに issue を立てるようにするなど、もう少し複雑な構成になっています。そのため、上記の構成図はエッセンスを抜き出したものとなっています。
ri-utilization-plotter は、AWS サーバーレスアプリケーションモデル (以下 SAM と略記) に則り、CloudFormation stackを aws-sam-cli を利用してデプロイしています。
メトリクスをプロットする ri-utilization-plotter と github-issue-opener の stack が分かれているのは、github-issue-opener は「SNS トピックに届いた通知をもとに GitHub に issue を作成する便利 Lambda function」として、ri-utilization-plotter からだけでなく、汎用的に利用できるように実装しているからです。このような構成をとることにより、他の Lambda を中心としたアプリケーションで GitHub App を用いたコードを自前で実装しなくても、SNS トピックに通知するというシンプルな操作だけで issue を立てることができるようになっています。
それでは、ri-utilization-plotter と github-issue-opener の中身を見ていきましょう。
ri-utilization-plotter
ソースコードはこちらにあります。https://github.com/mozamimy/ri-utilization-plotter
template.example.yml に記述されている RIUtilizationPlotter という論理名を持つ AWS::Serverless::Function
リソースが本体で、make コマンドを経由して SAM CLI を実行して手元で実行できるようになっています。
この function は、event.example.json にあるように、
{ "region": "us-east-1", "service": "Amazon ElastiCache", "linked_account": "123456789012", "granularity": "DAILY", "namespace": "RIUtilizationTest", "metric_name": "UtilizationPercentage", "ce_metric_type": "utilization" }
のようなイベントを、CloudWatch Events から受け取ります。namespace に CloudWatch カスタムメトリクスのネームスペースを指定し、metric_name にメトリクスの名前を指定します。この例では RI 使用率をプロットする設定になっていますが、metric_name を CoveragePercentage のようにし、ce_metric_type を coverage にすると、RI カバレッジもプロットすることができます。ce_metric_type は Cost Explorer API のオプションとして渡す文字列で、utilization (RI 使用率) と coverage (RI カバレッジ) に対応しています。
上述のリポジトリには開発時にテスト用途として SAM CLI を利用することが前提の CloudFormation テンプレートしか含まれていません。これは意図したもので、Lambda function のソースコードと AWS の実環境へのデプロイの設定を分離するためにこうなっています。したがって、AWS の実環境にデプロイする場合には、そのための CloudFormation テンプレートを別途用意する必要があり、これは社内固有の設定も含むため社内のリポジトリで管理されています。
社内で管理されている実環境用の CloudFormation テンプレートは、Jsonnet を利用して JSON に変換することで生成しています。
たとえば、この場合だとリージョンとメトリクスのタイプ (utilization/coverage) の組み合わせごとに CloudWatch Event ルールが必要となるため、以下のように event.rule
という関数を定義することで DRY になるようにしています。./lib/event.libsonnet というファイルに event.rule の定義を出力する rule という関数を作り、CloudFormation テンプレートの Resources
の中でそれを呼び出すイメージです。
また、実行ファイルを含む zip ファイルは GitHub の release にあるため、デプロイ時に https://github.com/mozamimy/ri-utilization-plotter/releases/download/v1.0.0/ri-utilization-plotter.zip から curl でダウンロードし、jsonnet コマンドを実行するときに jsonnet template.jsonnet --ext-str codeUri=${PWD}/tmp/ri-utilization-plotter-v1.0.0.zip
のようにオプションを与え、std.extVar('codeUri')
のようにして実行可能ファイルを含む zip ファイルのパスを差し込むようにしています。
{ AWSTemplateFormatVersion: '2010-09-09', Transform: 'AWS::Serverless-2016-10-31', Resources: { MainFunction: { // 省略 Properties: { CodeUri: std.extVar('codeUri'), // 省略 }, }, DLQ: { // 省略 }, EventRuleUtilizationApne1: event.rule(alias, 'ap-northeast-1', 'utilization'), EventRuleUtilizationUse1: event.rule(alias, 'us-east-1', 'utilization'), EventRuleCoverageApne1: event.rule(alias, 'ap-northeast-1', 'coverage'), EventRuleCoverageUse1: event.rule(alias, 'us-east-1', 'coverage'), }, }
Jsonnet はパワフルなテンプレート言語で、クックパッドでは ECS を使ったアプリケーションのデプロイを簡単に行うためのツールである Hako でも採用されています。
パワフルがゆえに何でもできてしまうという側面もあり、用法用量を間違えると複雑怪奇な設定ファイルができてしまうというダークサイドもありますが、個人的にはとても気に入っています。実は JSON は YAML として評価しても合法なため、設定ファイルに YAML を用いるソフトウェアでも Jsonnet を使うことができます。
github-issue-opener
ソースコードはこちらにあります。https://github.com/mozamimy/github-issue-opener
これは SNS topic で受けた通知をもとに GitHub に issue を立てる Lambda function です。
ISSUE_BODY_TEMPLATE
や ISSUE_SUBJECT_TEMPLATE
といった環境変数に issue のタイトルや本文の内容を Go のテンプレートして記述することができます。たとえば、{{.Message}}
のように設定した場合、SNS メッセージのメッセージがそのままレンダリングされます。 https://github.com/mozamimy/github-issue-opener/blob/master/template.example.json を見れば、雰囲気が掴めるでしょう。
通知を送る側から issue のタイトルや本文を指定したい場合は、message attribute が使えます。https://github.com/mozamimy/github-issue-opener/blob/5699d16606c4da13edc40c2c674c7110aaec43e7/event.example.json#L18-L42 を見れば雰囲気が掴めるかと思います。
SNS メッセージの本文に JSON 文字列が入っていて、それをパースして issue テンプレートで利用したい場合は、PARSE_JSON_MODE
環境変数を 1 に設定することで実現できます。その場合、
{ "Foo": "bar" }
のような JSON 文字列がメッセージ本文に含まれていた場合、テンプレートからは {{.ParsedMessage.Foo}}
のようにして値を取り出してレンダリングすることができます。
こちらも ri-utilization-plotter と同様に、AWS 実環境へのデプロイ用の CloudFormation テンプレートは社内のリポジトリで Jsonnet として管理されています。
issue テンプレートは
# RI Utilization Alert {{.ParsedMessage.NewStateReason}} - **Alarm Name**: {{.ParsedMessage.AlarmName}} - **Namespace**: {{.ParsedMessage.Trigger.Namespace}} - **Metric Name**: {{.ParsedMessage.Trigger.MetricName}} {{- range .ParsedMessage.Trigger.Dimensions }} - **{{.name}}**: {{.value}} {{- end }} Please check current utilization and fix it.
のような形で ./issue_template/body_ri_notify.md.tmpl に保存されており (一部実際のものから変更を加えています)、CloudFormation テンプレートからは
{ AWSTemplateFormatVersion: '2010-09-09', Transform: 'AWS::Serverless-2016-10-31', Resources: { MainFunction: { // 省略 Environment: { Variables: { // 省略 ISSUE_BODY_TEMPLATE: importstr './issue_template/body_ri_notify.md.tmpl', }, }, }, }, }
のような形で、Jsonnet の importstr でファイルを読んだそのままを ISSUE_BODY_TEMPLATE
環境変数に設定しています。
RI (リザーブドインスタンス) の失効が近づいたときにアラートをあげる
ri-utilization-plotter のように、RI の失効が近づいたときにアラートをあげる仕組みを内製しようとしていましたが、AWS Cost Explorer で予約の有効期限切れアラートを提供開始 にあるように、Cost Explorer 自体に 60 日前、30 日前、7 日前、当日にメールを送信する機能が実装されたため、それを利用することにしました。
ただし、単にメールを送るだけだと、後半の冒頭で述べたような対応フローをとることができません。見逃してしまうこともあるでしょう。
そこで、今回は PagerDuty の email integration のメールアドレスをこの通知に設定することで、low urgency でアラートを飛ばし SRE の誰かにアサインされるようにしました。週次のミーティングでも low urgency に設定されたアラートが残っていないか振り返りを行っているため、作業が漏れることもありません。万が一漏れた場合でも、ri-utilization-plotter を中心とした仕組みにより、後手になりつつも異常に気づくことができます。
RI (リザーブドインスタンス) の購入・exchange プランを練るときの細かい話
RI を追加購入したり exchange したりする場合は、現在どの程度オンデマンドインスタンスの利用があるのかを把握して、プランを立てる必要があります。
その際には、Cost Explorer の画面で、過去何日間かを指定してインスタンスタイプごとの RI 利用率や RI カバレッジを確認したり、今この瞬間に動いているインスタンスの一覧を得るために https://github.com/mozamimy/toolbox/blob/master/ruby/list_on_demand_ec2_instances/list.rb のようなスクリプトを利用したりしています。このスクリプトは、bundle exec ruby list.rb t3
のように実行すると、t3 ファミリの EC2 インスタンス一覧を出した上で、
Total instance count: 49 Normalized total count: 110.0 as t3.small 110.0 as t3.large 27.5
のように、トータルで何台オンデマンドインスタンスが動いているのかと、.small および .large に正規化した台数を算出します。
現状はこれらの値をもとに SRE が職人の技で丹精を込めてプランを立てていますが、将来的には自動化したいと考えています。素朴にやろうとするとすべての購入プランを網羅して最適な解を選ぶことになりますが、それはおそらく現実的ではありません。問題を適切にモデル化して既存のアルゴリズムを利用するなど、賢くやらねばなりません。ある意味で競技プログラミングのようですね。
まとめ
前半ではパブリッククラウドのコスト最適化についての考えを述べ、後半では、その取り組みの一部である RI の維持管理のための仕組みについて説明しました。
RI は、スポットインスタンスの積極的な利用に次ぐコスト最適化の強力な武器ですが、日々の手入れを怠るとその力を十分に発揮することができません。この記事では、クックパッドではその武器をどのように手入れしているかということについて説明しました。RI の管理にお悩みの方は、参考にしてみてはいかがでしょうか。