サービスの信頼性の大半は、それが動くクラスターではなく、リポジトリの中で、各関数が遅い依存先に対してどう振る舞うかで決まる。したがってSREは、アプリケーションコードをレビューし変更できる程度には読めなければならない。タイムアウトのないネットワーク呼び出し、障害の最中に負荷を3倍にするリトライループ、全インスタンスを一斉にサービスから外すヘルスチェックを見つけるのだ。この記事はそのレビューリストと修正であり、最後にSREが完全に所有する唯一のコードで締める。エラーバジェットのバーンレートでページするアラートを、Terraformで書いたものだ。

これは Budget for Failure の第3回で、最初の201レベルの回だ。第1回が役割を定義し、第2回がそれに数字を与えた。Application Load Balancerで測定する、30日間99.9パーセントの可用性SLOだ。その例をここでも引き継ぐ。コードサンプルはPythonだが、パターンはどの言語でも同じだ。

SREにどれだけのコードが必要か

チームで最も強い機能開発者である必要はない。必要なのは3つの能力だ。リクエスト経路を読むこと。ハンドラーから始めて、ひとつのユーザーリクエストが行うすべてのネットワーク呼び出しを追い、各呼び出しが30秒かかったら何が起きるかを言えること。プルリクエストを運用上の振る舞いの観点でレビューすること。これは正しさのレビューとは別の読み方だ。そして本番品質の変更を自分で書くこと。タイムアウト、メトリクス、ロールバックのフックだ。開発者にタイムアウトを追加してほしいとチケットを切るSREは、問題を記述しただけだ。プルリクエストを送るSREは、それを修正した。

すべてのネットワーク呼び出しに期限が要る

アプリケーションコードで最も多い信頼性の欠陥は、永遠に待ちうる呼び出しだ。Pythonのrequestsライブラリにはデフォルトのタイムアウトがまったくない。AWS SDK for Pythonのデフォルトは接続60秒、読み取り60秒で、その上に自動リトライが乗る。アイドルタイムアウトが60秒のロードバランサーの後ろで、そのデフォルトのままDynamoDBを呼ぶハンドラーは、ユーザーがすでに504を受け取った後も何分間もワーカーを占有しうる。

依存先が遅くなったとき、期限のない呼び出しは遅い依存先を死んだサービスに変える。すべてのワーカーが待機で止まり、プールが枯渇し、その遅い依存先を必要としなかったリクエストまで失敗する。上限は意図して設定すること:

import boto3
from botocore.config import Config

sdk = Config(
    connect_timeout=1,
    read_timeout=2,
    retries={"mode": "standard", "max_attempts": 3},
)
ddb = boto3.client("dynamodb", config=sdk)

読み取りタイムアウトは、切りのよい数字ではなく、依存先の測定済みレイテンシから、その99.9パーセンタイルの少し上に選ぶ。次に上限が入れ子になっているかを確認する。ユーザー向けの予算が3秒なら、ハンドラーはそれぞれ2秒かかりうる呼び出しを3回試行で3回連続して行うことはできない。外側の期限は内側へ渡し、もう誰も答えを待っていないときに内側の呼び出しが諦められるようにしなければならない。

リトライ: 障害を引き起こす修正

リトライは一時的な失敗を成功に変え、持続する失敗をより大きな失敗に変える。正しい側にとどめるルールが3つある。

リトライは一層だけ。 クライアントが3回リトライし、APIがデータベース呼び出しを3回リトライし、その下でSDKが3回リトライすれば、ひとつのユーザー操作がすでに苦しんでいる依存先への最大27リクエストになる。障害に最も近い層を選び、他の層は速く失敗させる。

ジッター付きでバックオフする。 固定スケジュールでリトライするクライアントは一斉に到着し、回復中のサービスを再び倒す。AWS Architecture Blogの分析が標準的な参照先で、その「full jitter」版は、ゼロから指数的に増える上限までの間のランダムな時間だけスリープする:

import random, time

def call_with_retry(fn, attempts=3, base=0.1, cap=2.0, deadline=None):
    for attempt in range(attempts):
        try:
            return fn()
        except RetryableError:
            if attempt == attempts - 1:
                raise
            pause = random.uniform(0, min(cap, base * 2 ** attempt))
            if deadline and time.monotonic() + pause > deadline:
                raise
            time.sleep(pause)

繰り返して安全なものだけリトライする。 タイムアウトした読み取りは安全だ。タイムアウトした「カードに課金」はそうではない。操作が冪等でない限りは。冪等とは、クライアントがリクエストに一意のキーを添え、サーバーがその下に結果を保存するので、繰り返しは二重課金ではなく最初の結果を返す、ということだ。レビューでは、書き込みを囲むリトライはすべて、冪等キーはどこにあるのかと尋ねる合図になる。

より小さく失敗する: ブレーカー、シェディング、縮退

タイムアウトとリトライは、一時的に調子の悪い依存先を扱う。落ちている依存先に対しては、コードに試行をやめる手段と、その間ユーザーに何を見せるかの判断が必要だ。

  • サーキットブレーカー。 失敗が続いたら、短い間隔のあいだ依存先を呼ぶのをやめ、即座に失敗する。ワーカープールを守り、依存先に回復の余地を与える。
  • ロードシェディング。 サービスが飽和したら、すべてを受け入れて全部を遅く処理するのではなく、超過分を429や503で早めに拒否する。20パーセントのリクエストを安く速く拒否するほうが、100パーセントのタイムアウトに勝る。
  • グレースフルデグラデーション。 依存先ごとにハードかソフトかを決める。レコメンデーションサービスが落ちているなら、商品ページは500を返すのではなく、レコメンデーションなしで描画されるべきだ。ハードからソフトに変えた依存先はひとつずつ可用性の掛け算から抜ける。その掛け算は第4回で全部やる。

真実を語るヘルスチェック

ヘルスチェックはコードであり、2つの方向のどちらかに間違っていることが多い。プロセスが生きていれば200を返す浅いチェックは、壊れたインスタンスをローテーションに残す。すべてのインスタンスからデータベースをテストする深いチェックには、もっとひどい失敗がある。データベースが一瞬詰まると、すべてのインスタンスが同じ瞬間にチェックに失敗し、ロードバランサーには健全なターゲットがひとつも残らない。その状態のApplication Load Balancerはフェイルオープンして全ターゲットにルーティングするので救われるが、不健全なインスタンスを置き換えるよう設定されたオーケストレーターは、データベースのインシデントの最中に健全なフリートを殺し始める。

通常の答えはエンドポイントを2つにすることだ。Livenessはプロセスが動いていて再起動すべきでないと言う。Readinessはこのインスタンスが有用な仕事をできると言い、そのインスタンスにローカルなものだけを確認する。設定が読み込まれ、接続プールが作られ、キャッシュが温まっていること。共有の依存先はインスタンスごとのヘルスチェックではなくSLIで監視する。

SLIをコードから発行する

第2回のロードバランサーSLIはステータスコードを見ている。成功したチェックアウトと、本文にエラーメッセージを含む200を区別できないし、顧客ティアや操作で分割することもできない。アプリケーションにはできる。リクエストごとに、操作、ビジネス上の定義による結果、所要時間、トレースIDを含む構造化ログを1行書く。AWSでは、CloudWatch埋め込みメトリクスフォーマットがその同じログ行を、追加のAPI呼び出しなしでメトリクスに変える:

{
  "_aws": {
    "Timestamp": 1787130000000,
    "CloudWatchMetrics": [{
      "Namespace": "Checkout",
      "Dimensions": [["Operation"]],
      "Metrics": [
        { "Name": "Requests", "Unit": "Count" },
        { "Name": "Failures", "Unit": "Count" },
        { "Name": "Duration", "Unit": "Milliseconds" }
      ]
    }]
  },
  "Operation": "PlaceOrder",
  "Requests": 1,
  "Failures": 0,
  "Duration": 184,
  "trace_id": "1-66c2f1d0-3f1b2c4d5e6f7a8b9c0d1e2f",
  "customer_tier": "enterprise"
}

トレースIDや顧客のような高カーディナリティのフィールドは、メトリクスのディメンションではなくログのプロパティとして保持すること。一意なディメンション値はそれぞれ別々に課金されるメトリクスになる。これは第1回のサービステンプレートに入っているべき計装で、開発者が頼まなくても手に入るようにする。

変更を安全な道にする

第2回の台帳は、1回のデプロイが月のバジェットの31パーセントを消費したことを示した。コード側の防御は、このリストの中で最も見返りの大きい作業だ。

  • 自動ロールバック付きの段階的ロールアウト。 新バージョンにトラフィックの10パーセントを送り、そのエラー比率とレイテンシを旧バージョンと比較し、悪ければ人手なしでロールバックする。CodeDeploy、ECSのデプロイ、Lambdaのエイリアスはいずれも、CloudWatchアラームで停止してロールバックできる。そうなれば悪いリリースのコストは、10パーセントのトラフィックの数分間で済む。
  • デプロイとリリースを分ける。 フィーチャーフラグの裏でコードをダークに出荷し、徐々に有効にする。フラグを切るのはどんなロールバックより速い。
  • 後方互換なスキーマ変更。 拡張、移行、縮小。新しいカラムを追加し、両方に書き、読み手を移し、後のリリースで古いほうを削除する。新旧のコードが決して並走できないマイグレーションは、すべてのロールアウトを障害にし、すべてのロールバックを不可能にする。

コードとしてのアラート: CPUではなくバーンレートでページする

SREが所有する最後のコードはアラートそのものだ。典型的なAWSアカウントに設定されたアラームは原因でページする。CPUが80パーセント超、メモリが90超、5xxが10件超。何も悪くないところでうるさく(レイテンシ目標を満たしているCPU95パーセントのオートスケーリンググループ)、何かが悪いところで沈黙する(ホストメトリクスがすべて平坦なまま、チェックアウトの2パーセントを失敗させるデプロイ)。第2回のSLOはページするためのより良い条件を与える。放置すれば目標を外すほど速くエラーバジェットを使っているか? その速さがバーンレート(burn rate)だ。

burn rate = observed error ratio / (1 - SLO)

SLO 99.9%  ->  budget ratio 0.001
error ratio 0.1%   ->  burn rate 1      ->  budget lasts 30 days
error ratio 0.6%   ->  burn rate 6      ->  budget lasts 5 days
error ratio 1.44%  ->  burn rate 14.4   ->  budget lasts 50 hours
error ratio 100%   ->  burn rate 1000   ->  budget lasts 43 minutes

アラートにはしきい値と時間枠が必要で、この2つの組み合わせが、発火した時点でどれだけのバジェットが失われているかを決める:

budget consumed at alert = burn rate x alert window / SLO window

14.4 x 1 hour  / 720 hours =  2%
6    x 6 hours / 720 hours =  5%
1    x 3 days  / 30 days   = 10%

この3行はGoogle SRE Workbookのアラートの章の推奨であり、30日SLOの合理的なデフォルトだ。1時間でバジェットの2パーセント、または6時間で5パーセントが消えたらページし、3日で10パーセントが消えたらチケットを開く。速く深刻な問題には速いページが届く。しきい値アラームでは決して捕まえられない遅い漏れには、翌営業日のチケットが届く。

なぜ2つの時間枠か

単一の長い時間枠にはリセットの問題がある。エラーが10分間15パーセントで走って止まった場合、1時間平均はその後の1時間の大半で14.4倍のしきい値を上回ったままになる。健全なシステムを見ているエンジニアに向けて、アラームが鳴り続ける。

修正は、2つ目の短い時間枠が同時に同じしきい値を超えていることを要求することだ。Workbookの指針は、長い枠の12分の1の短い枠だ。長い枠は消費が有意だと言い、短い枠はそれがまだ続いていると言う。合わせると:

アクション長い枠短い枠バーンレート99.9%でのエラー比率消費バジェット
ページ1時間5分14.41.44%2%
ページ6時間30分60.6%5%
チケット3日6時間10.1%10%

検知は必要なところで速い。完全停止では1時間平均がおよそ52秒後に1.44パーセントを超え、5分枠はすでに超えている。停止が終わると、5分枠は数分以内にしきい値を下回ってページは解消する。1時間のほうがまだ汚染されていてもだ。

TerraformでCloudWatchに組む

CloudWatchには必要な2つの部品がある。生の件数ではなくエラー比率でアラームするためのメトリクス数式アラームと、長い枠と短い枠をANDするための複合アラームだ。枠ごとに1つのメトリクスアラームを、枠と同じ長さの評価期間1つで、アクションは付けずに:

locals {
  slo    = 0.999
  budget = 1 - local.slo
  lb     = "app/checkout/0123456789abcdef"

  windows = {
    fast_long  = { period = 3600,  burn = 14.4 }
    fast_short = { period = 300,   burn = 14.4 }
    slow_long  = { period = 21600, burn = 6 }
    slow_short = { period = 1800,  burn = 6 }
  }

  elb_metrics = {
    req  = "RequestCount"
    t5xx = "HTTPCode_Target_5XX_Count"
    e5xx = "HTTPCode_ELB_5XX_Count"
  }
}

resource "aws_cloudwatch_metric_alarm" "burn" {
  for_each = local.windows

  alarm_name          = "checkout-burn-${each.key}"
  alarm_description   = "Error ratio above ${each.value.burn}x burn over ${each.value.period}s"
  comparison_operator = "GreaterThanThreshold"
  threshold           = each.value.burn * local.budget
  evaluation_periods  = 1
  treat_missing_data  = "notBreaching"

  metric_query {
    id          = "ratio"
    expression  = "(FILL(t5xx, 0) + FILL(e5xx, 0)) / req"
    label       = "error ratio"
    return_data = true
  }

  dynamic "metric_query" {
    for_each = local.elb_metrics
    content {
      id = metric_query.key
      metric {
        namespace   = "AWS/ApplicationELB"
        metric_name = metric_query.value
        stat        = "Sum"
        period      = each.value.period
        dimensions  = { LoadBalancer = local.lb }
      }
    }
  }
}

しきい値はバーンレートにバジェット比率を掛けたものだ。速いペアは14.4 x 0.001 = 0.0144、遅いペアは0.006。sloを0.9995に変えれば、すべてのしきい値が追従する。次に複合アラームで、これだけがページする:

resource "aws_cloudwatch_composite_alarm" "slo_page" {
  alarm_name        = "checkout-availability-slo-page"
  alarm_description = "Burning 30-day error budget at 14.4x (1h) or 6x (6h). Runbook: https://wiki.example.com/runbooks/checkout-slo"

  alarm_rule = join(" OR ", [
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["fast_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["fast_short"].alarm_name}))",
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["slow_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["slow_short"].alarm_name}))",
  ])

  alarm_actions = [aws_sns_topic.pager.arn]
  ok_actions    = [aws_sns_topic.pager.arn]
}

ひとつの複合アラーム内のORは抑制も解決する。深刻な障害は速いペアと遅いペアの両方を満たすが、ひとつのアラームに流れ込むので、エンジニアが受けるページは2つではなく1つだ。

3日のチケット層はマネージドの経路のほうが簡単だ。SLOがCloudWatch Application Signalsにあるなら、そこにバーンレートのルックバック枠(最大10,080分、つまり7日)を付け、それが発行するバーンレートメトリクスでアラームする。AWSのしきい値の式はX% x SLO interval length / look-back windowで、罠がひとつある。14.4は30日のインターバルでのみ正しい。28日分のバジェットの2パーセントを1時間で、なら13.44だ。しきい値はパーセンテージから導出すること。乗数を決してコピーしないこと。

本番準備レビュー

まとめると、これがサービスが本物のトラフィックを受ける前にSREが開発者と行い、アーキテクチャが変わるたびに繰り返すレビューだ:

  • すべての外向き呼び出しにタイムアウトがあり、それらは呼び出し元の期限の内側に収まっているか?
  • リトライ層はちょうど1つで、バックオフとジッターがあり、リトライされる書き込みは冪等か?
  • 各依存先について: ハードかソフトか、落ちたときユーザーは何を見るか?
  • ヘルスチェックは共有の依存先を避けているか?
  • サービスは操作ごとにリクエスト、失敗、所要時間のメトリクスを発行し、トレースIDを伝播しているか?
  • SLOと、そのオーナーと、ランブックへのリンク付きで名指しのローテーションにページするバーンレートアラートはあるか?
  • リリースはスキーマ変更なしに5分未満でロールバックできるか?
  • 10倍のトラフィックで最初に達する上限は何で、そのとき何が起きるか?

障害モードと注視すべきこと

低トラフィックはバーンレートの算術を壊す。 1時間に100リクエストなら、99.9パーセントSLOでの14.4倍のバーンは失敗1.44件なので、運の悪いタイムアウト2件で誰かがページされる。SRE Workbookにある選択肢は、合成トラフィック、小さなサービスをひとつのSLOに集約する、より低い目標、より長い枠だ。メトリクス数式ではIF(req > 200, ratio, 0)でボリュームによるゲートをかけられるが、ゲートの下では盲目になる代償がある。

欠損データはデフォルトでは良い知らせではない。 treat_missing_data = "notBreaching"は、トラフィックがなくて消える比率には正しく、DNSが壊れてトラフィックが消えたなら間違いだ。SLOアラームには、決してゼロになるはずのない時間帯にRequestCountがゼロに落ちたことを見る素朴なアラームを組み合わせること。

バケットはスライディングウィンドウではない。 1時間の期間を持つCloudWatchのデータポイントはバケットなので、長い枠のアラームは理想的なスライディング計算より遅く反応しうる。短い枠が深刻なインシデントの検知を速く保つ。遅いバーンのタイミングは近似として扱うこと。

一度も動かしたことのないレジリエンスコード。 本番で一度も開いたことのないサーキットブレーカーは推測だ。まずテスト環境で、次にエラーバジェットの範囲内で本番で行うフォルトインジェクションが、フォールバック経路が信頼を得る方法だ。

上のすべてはプラットフォームが持ちこたえることを前提にしている。 タイムアウトとリトライは、おおむね動いているプロバイダーを相手に書かれる。そのプロバイダーが実際に何を約束し、障害ドメインがどこにあり、プロバイダーが自前のデータセンターになったとき何が変わるかは第4回だ。

次に読む

AWS、クラウドアーキテクチャ、SREプラクティス、CI/CDパイプライン、Linuxなどのコンサルティングは、ercanermis.comからどうぞ。

参考資料