Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Kubernetes上に負荷試験基盤を構築した話

こんにちは、研究開発部Architectグループ ML Platformチームの藤岡です。今回はKubernetes上に負荷試験基盤を構築したので、その取り組みについて紹介しようと思います。

目次

背景

研究開発部ではマイクロサービスで開発することが多く、各事業部向けにさまざまなシステムをAPI形式で提供しています。 APIを作成した際には負荷試験を行っており、リリース前にAPIのパフォーマンスを確認しています。

しかし現状では、各々のローカル環境で負荷試験を実施しているため、次のような課題点がありました。

  • 人によって使っている負荷試験ツールが異なる
  • 人によって試験結果の記載方法、場所が異なる
  • 長時間の耐久テストを行う際に、セッション切れに気をつける必要がある
  • 試験結果を手でまとめるのが大変
  • テストシナリオが共有されていない場合があるため、同じテストを再現できないことがある

また、負荷試験の手法として統一された方法がないため、ノウハウを蓄積するのも難しい状況でした。 そこで、これらの課題を解決するために負荷試験基盤を開発することにしました。

負荷試験基盤の要件

負荷試験基盤を設計するに当たって、次のように要件を定めました。

  • Kubernetes上で負荷試験が実行できること
    • 研究開発部のアプリケーション基盤としてKubernetesが採用されている*1ため
    • クラウド上で負荷をかけることで、柔軟にスケールして、分散して負荷をかけられるため
  • サービスを横断して利用できること
  • 開発者が容易にシナリオを作成できること
  • 負荷試験のメトリクスがリアルタイムで確認できること
  • 負荷試験の結果が後から確認できること

研究開発部のアプリケーション基盤Circuitは以下のスライドを参照ください。

speakerdeck.com

負荷試験ツールの選定

上記の要件を考慮した結果、最終的にk6*2を使うことにしました。 主な理由は次の通りです。

  • Kubernetesとの相性がいい
    • Datadogのインテグレーション*3があるため、リアルタイムのメトリクスを取得可能。
    • Grafana公式の Kubernetes operator *4がある。
  • パフォーマンスが良い
    • Grafanaの公式ブログ*5 で言及されているように、k6は少ないコンピューティングリソースで高いパフォーマンスを出せる。
  • 社内でk6の利用実績がある。
  • シナリオ実装言語がJavaScript/TypeScriptなので開発者になじみがある。

負荷試験基盤のシステム概要

主なアーキテクチャとしては次の通りです。

  1. GitHub Actionsから負荷試験を実行
  2. シナリオファイルを含むDockerイメージをECRにpush
  3. k6のCustom resourcesをクラスターに作成
  4. k6-operatorが3.を検知し、負荷をかけるJobを作成
  5. 任意のAPIに対して負荷をかける(リアルタイムでDatadogにメトリクスを送信)
  6. テスト完了後サマリをSlackに通知

負荷試験基盤のアーキテクチャ

ポイントとなるのは次の4点です。

  • シナリオファイルの管理方法
  • Kubernetes operator patternの利用
  • GitHub Actionsのワークフロー共通化
  • Slack通知用のAPIを用意

それぞれ詳しく説明します。

シナリオファイルの管理方法

k6により負荷試験を実施する際に必要となるのがシナリオファイル*6です。k6-operatorによるシナリオファイルの管理方法として、3つの選択肢があります。

  • ConfigMap
  • VolumeClaim
  • LocalFile

GitHub - grafana/k6-operator: An operator for running distributed k6 tests.

この中ではConfigMapがアーキテクチャ的に最もシンプルなため、当初はConfigMapにシナリオファイルを保存しようかと考えていましたが、

  • ファイルサイズの上限が1MBであること
  • 他ファイルの参照ができないため、コードの共通化がしづらいこと

などの理由から、別の方法をとる必要がありました。 VolumeClaimはPVCにシナリオを書き込む必要があり、必要以上にアーキテクチャが複雑になりそうだったので、今回はLocalFileにより管理する設計にしました。

そのため、負荷試験を実行するPodのDockerイメージにシナリオファイルを含める必要があり、それを 2. シナリオファイルを含むDockerイメージをECRにpush でやっています。

Kubernetes operator pattern の利用

Kubernetes operator pattern *7 を利用することで、クラスターの振る舞いを拡張できます。k6用のKubernetes operatorであるk6-operatorを利用することで、TestRunというCustom resourceを適用すると、k6-operatorがその変更を検知し、クラスタ内に負荷試験用のJobを作成してくれます。 詳しい挙動は以下の記事で説明されています。

grafana.com

GitHub Actionsの共通化

冒頭でも述べた通り、研究開発部ではマイクロサービスが採用されています。 そのため、数多くのリポジトリから使われることを想定して設計する必要がありました。

そこで、以下の記事で紹介されているreusable workflowsを使い、ワークフローを共通化しました。

buildersbox.corp-sansan.com

reusable workflowsを使うことで、利用する側は非常にシンプルなワークフローを書くだけで再利用することが可能となります。

name: Run Load Testing

on:
  workflow_dispatch:
    inputs:
      scenario_file:
        type: choice
        description: "Scenario file to run"
        required: true
        default: "smoke-test.js"
        options:
          - "smoke-test.js"
          - "average-load-test.js"
          - "breakpoint-test.js"
          - "soak-test.js"
          - "stress-test.js"
          - "spike-test.js"

jobs:
  run-load-test:
    uses: reusable_workflows_defined_repository/.github/workflows/run-load-testing.yml@v1
    with:
      service_name: "sample"
      namespace: "samples"
      scenario_file: ${{ inputs.scenario_file }}
      working_dir: "load_testing"

共通化によりブラックボックス化することで、利用者が実装を理解する必要がなくなり、利用するハードルを下げることができます。

Slack通知用のAPIを用意

負荷試験が完了した後、Slackにサマリを通知する仕組みになっていますが、負荷をかけるPodから直接Slackに通知するのではなく、 別途立てているSlack通知用のAPIに対してサマリデータを送り、そのAPIがSlackに通知するようになっています。

なぜこのような設計にしているかというと、シナリオファイルに直接通知のロジックを書いてしまうと、リリース後の変更が困難になるからです。 例えば通知の内容を変更したいときや、バグを修正する度に、導入先のリポジトリすべてを変更するのは運用コストが高いです。 そのため、通知用のAPIに切り出すことで変更容易性を高められました。

負荷試験の導入

負荷試験基盤の利用を開始するのは非常に簡単で、Cookiecutterによりテンプレートを生成できます。Cookiecutterは以下の記事を参照ください。

buildersbox.corp-sansan.com

Cookiecutterを利用することで、コマンド1つで以下のファイルを生成できます。

  • 負荷試験を開始するワークフロー
  • 負荷試験を停止するワークフロー
  • シナリオファイルのテンプレート

ディレクトリ構造

.
├── .github
│   └── workflows
│       ├── run-load-testing.yml
│       └── stop-load-testing.yml
└── load_testing
    ├── README.md
    ├── _config.js
    ├── average-load-test.js
    ├── base-test.js
    ├── breakpoint-test.js
    ├── data
    │   └── basic.json
    ├── smoke-test.js
    ├── soak-test.js
    ├── spike-test.js
    ├── stress-test.js
    └── utils.js

負荷試験シナリオの詳細

一般的な負荷試験のシナリオとして、目的別に次のような種類のテストがあります。

  • システムの安定動作を評価する目的
    • Smoke tests:システムが最小限の負荷で動作するか確認するテスト
    • Average-load tests:平均的な負荷がかかる場合のテスト
    • Soak tests:長時間負荷をかけ続けた際の耐久テスト
  • 高負荷時のシステムの動作を評価する目的
    • Breakpoint tests:システムの限界性能を調べるためのテスト
    • Stress tests:通常よりも負荷が高い場合のテスト
    • Spike tests:突発的に大量のリクエストがきた場合のテスト

grafana.com

これらをそれぞれテンプレート化し、利用するデータやパラメータを修正すれば使える状態にしています。 例えばAverage-load testsのシナリオは以下のようなイメージです。

import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';

const payloads = new SharedArray('payloads', function () {
  return JSON.parse(open('./data/basic.json'))
});

export const options = {
  stages: [
    { duration: '2m', target: 10 }, // traffic ramp-up from 1 to a higher 10 users over 2 minutes.
    { duration: '10m', target: 10 }, // stay at higher 10 users for 10 minutes
    { duration: '2m', target: 0 }, // ramp-down to 0 users
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'], // http errors should be less than 1%
    http_req_duration: ['p(95)<100'], // duration of 95% requests should be less than 100ms
  },
};

export default function () {
  const params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  // Get a random item from the array
  const payload = randomItem(payloads);
  const res = http.post(POST_URL, JSON.stringify(payload), params);
  check(res, {
    'status is 200': (r) => r.status === 200,
  });
  sleep(1);
}

export function handleSummary(data) {
  let requestBody = { context: __ENV, summary: data };
  let res = http.post(__ENV.LOAD_TESTING_URL + "/notify/slack", JSON.stringify(requestBody), {
    headers: { 'Content-Type': 'application/json' },
  });
  if (res.status !== 200) {
    console.error("Failed to send notification to Slack, got status " + res.status);
  }
}

k6はoptionsという変数で条件を設定でき、stages*8により徐々に負荷を増加させていくような現実的なシナリオを書けます。

  stages: [
    { duration: '2m', target: 10 },
    { duration: '10m', target: 10 },
    { duration: '2m', target: 0 },
  ],

とすると、2分間で10VUs(virtual users)まで徐々に負荷が増加していき、10VUsの状態を10分間キープ、その後2分間で徐々に減少していくシナリオとなります。

また、 thresholds *9 を設定することで、パフォーマンス要件を明確にすることできます。 例えば以下の例では、レイテンシの95パーセンタイルが100ミリ秒未満であればチェックが通ります。

  thresholds: {
    http_req_duration: ['p(95)<100'],
  },

1VUの振る舞いは以下の無名関数で定義できます。

export default function () {
  const params = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  // Get a random item from the array
  const payload = randomItem(payloads);
  const res = http.post(POST_URL, JSON.stringify(payload), params);
  check(res, {
    'status is 200': (r) => r.status === 200,
  });
  sleep(1);
}

ここでは、POST_URLにPOSTリクエストを投げ、ステータスが200か確認後、1秒間sleepし、再度POSTリクエストを投げるような挙動となります。

データは以下で別ファイルから取得しており、 SharedArray *10 を使うことによりVU間で共有しています。

const payloads = new SharedArray('payloads', function () {
  return JSON.parse(open('./data/basic.json'))
});

データの中身は次のようなリスト形式になっており、ここからrandomItem() *11により要素を取り出すことで、バリエーションのあるリクエストを実現できます。

[
    {
        "key1": "value1"
    },
    {
        "key2": "value2"
    },
    {
        "key3": "value3"
    }
]

最後に handleSummary() *12 により、テスト終了後のサマリをカスタマイズできます。 dataに負荷試験のサマリデータが入ってくるので、そのデータを先ほど説明したSlack通知APIに投げることでSlack通知を実現しています。

export function handleSummary(data) {
  let requestBody = { context: __ENV, summary: data };
  let res = http.post(__ENV.LOAD_TESTING_URL + "/notify/slack", JSON.stringify(requestBody), {
    headers: { 'Content-Type': 'application/json' },
  });
  if (res.status !== 200) {
    console.error("Failed to send notification to Slack, got status " + res.status);
  }
}

負荷試験の実行

GitHub Actionsにより、特定のブランチと、テストしたいシナリオファイルを選択して負荷試験を実行できます。

GitHub Actionsの実行

負荷試験の結果

負荷試験の結果はSlackに通知されます。

Slack通知

  • 誰が実行したか
  • 実行時間
  • リクエスト数、失敗数の合計
  • レイテンシの平均、90パーセンタイル、95パーセンタイル

などの統計情報を確認できるのはもちろん、実行時のコミットハッシュのシナリオファイルを確認できるので、条件と結果のマッピングがわかりやすくなっています。 また、thresholdsが設定されている場合は、設定した閾値を満たしているかを確認できます。

さらに、Datadogダッシュボードのリンクもあるので、テスト時の詳細なメトリクスを確認可能です。

Datadogのダッシュボード

おわりに

Kubernetes上に負荷試験基盤を構築し、マイクロサービスにおける共通化手法について紹介しました。 負荷試験基盤を導入することで、負荷試験が手軽に実施できるようになり、負荷試験に対するハードルが下がりました。 今後も利用者がより使いやすくなるように、継続的に改善していきたいと思います。

研究開発部ではMLOps/DevOpsエンジニア・プラットフォームエンジニアを募集しています。

open.talentio.com

open.talentio.com

© Sansan, Inc.