負荷試験用 Web コンソールの開発

技術部 Site Reliability (SR) グループの id:itkq です。2020 秋タイトルで一番期待しているのはおちこぼれフルーツタルトです。本エントリでは、Web サービスの負荷試験に対する障壁を下げるために、汎用的な Web コンソール開発に至ったまでの話を書きます。

Web サービスの負荷試験の障壁を下げたい

クックパッドでは、マイクロサービスを支える基盤が成熟しており、新規サービス開発や、サービスリニューアルなどの機能開発の場面では、疎結合な新規のマイクロサービスとして実装されることが多いです。このようなサービスをリリースする際は、予想されるトラフィックに対して、実際にそれを捌ききれるかどうかテストする、いわゆる負荷試験をすることは一般的です。これまで、サービスリリース時に、負荷試験をきちんと行うこともあれば、負荷試験を行わないこともありました。負荷試験が行われない理由は、そのコストの大きさにあると私は考えました。本質的であるテストシナリオの用意だけでなく、負荷試験ツールの選定・負荷試験環境の構築・負荷試験ツールの動作環境の構築などの手間がかかります。

SR グループでは、負荷試験の障壁を下げ、開発チームが気軽に負荷試験を行えるようにすることで、自分たちが開発するサービスのボトルネックの認識やキャパシティプランニングを行えるようにすることを考えました。そのために、SR グループがメンテナンスする負荷試験用プラットフォームの提供を目指しました。

Serverless-artillery を利用したプロトタイピング

このプラットフォームで用いる負荷試験ツールは、サーバーレスであることが理想だと考えていました。負荷試験ツール側のリソースが不足してしまうことはしばしばあり、リソースの不足の心配を減らすためです。また、pay-as-you-go であることは、負荷試験のユースケースにマッチしており、コストを最適化できる見込みがあったからです。サーバーレスで動作する現代的な負荷試験ツールを自前で実装することも考えましたが、同じような思想で作られた Serverless-artillery を見つけ、検証を行いました。このツールは、NodeJS 製の負荷試験ツール Artillery と、サーバーレスアプリケーションをデプロイするためのフレームワーク serverless framework を組み合わせ、Artillery を AWS Lambda 上で実行するものです。Lambda function の実行時間やリソースの制限に合わせてテストシナリオを分割し、同時並列に Lambda function を実行し、目標負荷を実現する仕組みです。テストシナリオはドキュメントが充実している Artillery の記法 (YAML) で書くことができます。例えば、https://example.com/ に対して 10 秒間、1 秒間に 1 仮想ユーザが GET リクエストする設定は以下のように記述します。

config:
  target: "https://example.com"
  phases:
    - duration: 10
      arrivalRate: 1
scenarios:
  - flow:
      - get:
          url: "/"

Serverless-artillery を使うには、上記の YAML (script.yml) に加えて、次のような内容の serverless.yml を用意します。

service: serverless-artillery-minimum
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
functions:
  loadGenerator:
    handler: handler.handler
    timeout: 300

次の操作で、負荷を発生させる Lambda function を含む CloudFormation stack がデプロイされます。

$ npm i -g slsart
(snip)

$ slsart deploy

        Deploying function...

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
........
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service serverless-artillery-minimum.zip file to S3 (17.91 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
...............
Serverless: Stack update finished...
Service Information
service: serverless-artillery-minimum
stage: dev
region: ap-northeast-1
stack: serverless-artillery-minimum-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  loadGenerator: serverless-artillery-minimum-dev-loadGenerator
layers:
  None
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

        Deploy complete.

負荷試験の実行は次のようになります。

$ slsart invoke --path script.yml

        Invoking test Lambda

{
    "timestamp": "2020-10-20T09:53:48.491Z",
    "scenariosCreated": 10,
    "scenariosCompleted": 10,
    "requestsCompleted": 10,
    "latency": {
        "min": 425.7,
        "max": 449.8,
        "median": 440.5,
        "p95": 449.8,
        "p99": 449.8
    },
    "rps": {
        "count": 10,
        "mean": 1.07
    },
    "scenarioDuration": {
        "min": 429.5,
        "max": 571.1,
        "median": 444.2,
        "p95": 571.1,
        "p99": 571.1
    },
    "scenarioCounts": {
        "0": 10
    },
    "errors": {},
    "codes": {
        "200": 10
    },
    "matches": 0,
    "customStats": {},
    "counters": {},
    "scenariosAvoided": 0,
    "phases": [
        {
            "duration": 10,
            "arrivalRate": 1
        }
    ]
}

        Your function invocation has completed.

(snip)

Serverless-artillery もドキュメントが充実しており、動作や設定について丁寧に説明されているため、詳しくは README を参照してください 。自分でいくつかテストシナリオを用意して想定通り実行できることを確認したり、長期間の ramp-up テストが期待通り動作することを確認しました。

次に、この Serverless-artillery を利用して、社内の環境で負荷試験をするための最低限の準備をしました。具体的には以下の項目です。

アクセストークンのハンドリング

社内の API サービスは OAuth2 をベースとした自前の認証認可サービスで認証認可を行うことが一般的です。これらの API サービスに対して負荷試験を行う場合、アクセストークンをリクエストヘッダにセットしたり、アクセストークンの有効期限が切れていた場合にリフレッシュする処理が必要になります。Artillery には、リクエストの前後やテストシナリオの前後でフックする仕組みがあり、これを使ってアクセストークンのハンドリングを実装しました。

リクエスト結果のメトリクスを扱うプラグインの改善

Artillery には、Plugin という仕組みがあり、Artillery 内部で扱うイベントに反応する処理を追加することができます。なかでも有用なのは、リクエスト結果をメトリクスとして外部に保存する monitoring plugin です。Serverless-artillery は AWS Lambda を前提としているため、手間が少ない monitoring plugin として artillery-plugin-cloudwatch をまず試しました。しかし他の plugin である artillery-plugin-influxdb などに比べ欲しい機能が不足していたため、自分でいくつか機能を追加しました。 それについて upstream にコメントを求めましたが、長らく反応がなかったため、現在はフォーク版を使っています。

Jsonnet による設定の抽象化

Serverless-artillery には、先述の通り 2 つの設定ファイルが必要で、1 つは serverless フレームワークの設定である YAML ファイル、もう 1 つは Artillery のテストシナリオである YAML ファイルです。それぞれの YAML ファイルは、対象が異なる負荷試験であっても共通部分が多いため、Jsonnet で抽象化するようにしました。Jsonnet は、社内で設定を記述するのに広く使われているテンプレート言語です。使用例として、ECS へのデプロイを行うための Hako の定義ファイルがあります 。

以下に抽象化のイメージを示します。

recipes/serverless.jsonnet

local serverless = import '../lib/serverless.libsonnet';
local config = serverless.productionConfig('recipes');
std.manifestYamlDoc(config)

lib/serverless.libsonnet

local tags = {
  Project: 'serverlss-artillery',
};

{
  productionConfig(name):: {
    service: std.format('serverless-artillery-%s', name),
    provider: {
      name: 'aws',
      region: 'ap-northeast-1',
      runtime: 'nodejs12.x',
      stage: 'prod',
      role: 'arn:aws:iam::XXXXXXXXXXXX:role/LambdaServerlessArtillery',
      deploymentBucket: {
        name: 'dummy-bucket',
      },
      stackTags: tags,
      logRetentionInDays: 7,
    },
    functions: {
      loadGenerator: {
        handler: 'handler.handler',
        timeout: 300,
      },
    },
  },
}

recipes/script.jsonnet

local script = import '../lib/script.libsonnet';

local config = {
  config: script.productionBase('recipes') {
    phases: [
      {
        duration: 1800,  // 30 min
        arrivalRate: 1,
        rampTo: 500,
      },
    ],
    variables: {
      recipe_id: [
      1, 2, 3, 4, 5,
      ],
    },
  },
  scenarios: [
    {
      flow: [
        {
          get: {
            url: '/v1/recipes/{{ recipe_id }}', // ランダムに variables.recipe_id が選ばれる
            beforeRequest: 'ConfigureAccessToken', // アクセストークンの処理
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

lib/script.libsonnet

{
  productionBase(name): {
    target: 'https://cookpad-dummy-api.com',
    processor: './custom-functions.js', // ConfigureAccessToken が定義されたファイル
    defaults: {
      headers: {
        'user-agent': 'serverless-artillery',
      },
    },
    http: {
      timeout: 10,
    },
    plugins: {
      cloudwatch: self.cloudwatchPlugin(name),
    },
  },
  cloudwatchPlugin(name):: {
    region: 'ap-northeast-1',
    namespace: 'serverless-artillery',
    dimensions: {
      name: name,
      stage: 'prod',
    },
  },
}

レシピサービスリニューアルリリースにおける負荷試験

実は Serverless-artillery を検証していた段階で、レシピサービスのリニューアルリリース前に負荷試験を行いたい、と開発チームから声がかかっており、プロトタイピングしたものを実際に利用することにしました。

レシピサービスは社内で最も歴史のあるサービスで、内部のマイクロサービス化やリファクタリングは進んでいるものの、それ専用の仕組みがあったりと複雑な構成です。レシピサービスについて、専用の負荷試験環境を構築することは非常に難しく、また大きな労力がかかることは予想できたため、細心の注意を払いながら「本番環境」で負荷試験を行いました 1。テストシナリオは基本的に開発チーム側で準備してもらいつつ、レビューは SR グループでも行いました。負荷試験はテストシナリオを微修正しつつ何度か実行し、ミドルウェアのボトルネックなどいくつかの脆弱な箇所が洗い出されました。

今回のリニューアルでは、新たに 2 つのマイクロサービスが BFF の下に追加されました。その 2 つのサービスが関わるエンドポイントをリストアップし、各エンドポイントの予想アクセスパターンとアクセス量を考慮しながら、開発チームを中心にテストシナリオを作ってもらいました。実際に使われたテストシナリオの 1 つは次のようになっていました (URL など一部加工しています)。

local constants = import '../lib/constants.libsonnet';
local script = import '../lib/script.libsonnet';

local name = 'renewal-0221';
local config = {
  config: script.productionBase(name) {
    phases: [
      {
        duration: 3600,  // 1 hour
        arrivalRate: 1,
        rampTo: 655,
        name: 'Warm up the application',
      },
      {
        duration: 900,  // 15 min
        arrivalRate: 655,
        name: 'Sustained max load',
      },
    ],
    payload: [
      {
        path: './data/payload.csv',
        fields: [
          'kiroku_image',
        ],
      },
    ],
    variables: {
      recipe_id: constants.recipe_ids,
      clipped_at: [
        '2020-02-18T12:33:22+09:00',
      ],
      time_zone: [
        'Asia/Tokyo',
      ],
      keyword: constants.keywords,
      order: [
        'date',
      ],
      tsukurepo_count: [
        10,
      ],
    },
  },
  scenarios: [
    {
      name: 'deau/sagasu',
      weight: 200,
      flow: [
        {
          get: {
            url: '/dummy/app_home/deau_contents',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
        {
          post: {
            url: '/dummy/app_home/sagasu_search_result',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              keyword: '{{ keyword }}',
              order: '{{ order }}',
            },
          },
        },
      ],
    },
    {
      name: 'clip',
      weight: 450,
      flow: [
        {
          post: {
            url: '/dummy/clip',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              clipped_at: '{{ clipped_at }}',
              time_zone: '{{ time_zone }}',
            },
          },
        },
        {
          get: {
            url: '/dummy/{{ resourceOwnerId }}/bookmarks?recipe_ids={{ recipe_id }}', // resourceOwnerId は ConfigureAccessToken により挿入される
            beforeRequest: 'ConfigureAccessToken',
            capture: [
              {
                json: '$[0].id',
                as: 'bookmark_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/{{ bookmark_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
    {
      name: 'kiroku',
      weight: 5,
      flow: [
        {
          post: {
            url: '/dummy/kirokus',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              items: [
                {
                  item_type: 'PHOTO',
                  data: '{{ kiroku_image }}',
                },
              ],
            },
            capture: [
              {
                json: '$.id',
                as: 'kiroku_id',
              },
              {
                json: '$.items[0].id',
                as: 'kiroku_item_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/kirokus/{{ kiroku_id }}/items/{{ kiroku_item_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

まずシナリオのフェーズは、最大 RPS を 655 として、1 時間かけて ramp-up した後にそれを 15 分維持するように設定されています。これは、ピークタイムに向けてアクセスが伸びる現実を反映し、増加する負荷をシステムがオートスケールで対処できることを試験するためです。シナリオは機能ごとに 3 つを用意しました。それぞれには weight で重み付けをして、予想アクセスパターンを反映しています。recipe_id などのテストデータは別途事前に準備しておきました。

負荷試験中は、関連するメトリクスのダッシュボードを注視していました。想定外の事態が起き、負荷をすぐに中断することが何度か起きました。しかし、サーキットブレイカーの発動や、デグラデーションの考慮がなされていたことにより、実ユーザに大きな影響を与えることはありませんでした。負荷試験により発見された脆弱な点と、その対応の例を以下に挙げます。

  • サービス X の ECS サービスのスケーリングポリシーの最大タスク数に当たってしまい、リソースが不足しレイテンシが増加した。最大タスク数を引き上げた
  • サービス Y のバックエンド Elasticsearch が CPU リソース不足になりレイテンシが増加した。Elasticsearch の Data ノードのスケールアウト、N+1 クエリの解消、追加でレスポンスのキャッシュを実装が行われた
  • サービス Z のバックエンド MySQL が CPU リソース不足になりレイテンシが増加した。Z 内でのキャッシュの実装の見直しが行われ、さらに MySQL 接続ユーザやコネクション周りの設定不備も見つかった

最初のうちは、開発チームと SR グループで一緒に負荷の見守りを行っていましたが、終盤はほとんど開発チームだけで負荷試験の実行や中断ができるようになっていました。結果的に想定のテストシナリオをすべてクリアし、キャパシティに自信を持って 100% リリースすることができ、キャパシティ起因の問題は発生しませんでした。本番環境での負荷試験は、それ自体が大きなテーマであるため、このエントリではあえて詳細を書いていません。近いうちに別のエントリとして公開したいと考えています。

Web コンソールの開発

レシピサービスのリニューアルリリースにおける負荷試験を通して、Serverless-artillery を利用したプロトタイプが実用に耐えそうなことが分かりました。このプロトタイプでは以下の問題点があることが分かっていました。

  • 負荷試験の操作が CLI で、かつ強い IAM 権限を持つ人しか実行できない
  • 負荷試験が今行われているのか、いないのかがすぐに分からない

これをより利用しやすくするため、Web コンソールを開発しました。普通の Rails アプリケーションとして実装し、F5 と名付けました。コスト最適化のため、データベースは Aurora Serverless (MySQL) を利用しています。例として、10 分間で 50 RPS まで ramp-up し、50 RPS を 3 分間継続するというデバッグ用の負荷試験を実行したときの様子が以下になります。

f:id:itkq:20201021212900p:plain
負荷試験中の F5 のスクリーンショット

f:id:itkq:20201021211348p:plain
itkq/artillery-plugin-cloudwatch により収集したメトリクスの Grafana ダッシュボード

f:id:itkq:20201021211408p:plain
負荷試験対象側の Grafana ダッシュボード

また、開発にあたり工夫した点は次の通りです。

開発チームへの移譲

この取り組みの当初の課題意識を解決するため、開発チームが自分たちで負荷試験を操作できる仕組みを入れることにしました。G Suite の OAuth2 を利用した認証認可を実装し、特定のサービス (エンドポイント) のオーナー権をユーザに与え、オーナーのユーザはそのサービスに対して負荷試験の実行や中止を行えるようにしました。また、すべての操作はログとして残すようにして、後から追跡可能にしました。

テストシナリオを GitHub で管理して同期する

テストシナリオは負荷試験において本質的で、ピアレビューしたい場面があります。標準的なレビューフローをとれるようにするため、テストシナリオは Jsonnet として GitHub で管理し、それを同期するようにしました。このように設定ファイルを GitHub で管理するのは社内でよくある手法のため、受け入れられやすいと考えました。

まとめ

社内での Web サービスの負荷試験について、現状と改善の余地を述べ、Serverless-artillery を使った負荷試験の検証、より利用しやすくするための Web コンソールの開発に至るまでを説明しました。開発した Web コンソールは、実際に数回負荷試験に利用されています。テストシナリオのレビューで SR グループが最初関わることもありますが、その後は開発チームがほとんど自分たちで負荷試験のサイクルを回せているという所感です。これ以外にも、負荷試験に利用できる周辺の仕組み2が整ってきており、負荷試験、さらにはキャパシティプランニングが開発において当たり前となっていくような開発体制になることを目指していきたいです。


  1. 先日のイベントの発表資料も参考になります https://speakerdeck.com/rrreeeyyy/cookpad-tech-kitchen-service-embedded-sres

  2. 例えば Aurora のクローンシステム https://techlife.cookpad.com/entry/2020/08/20/090000