Alternative Architecture DOJO

オルターブースのクラウドネイティブ特化型ブログです。

ArtilleryとGitHub Actionsを使った簡単負荷テスト

こんにちは!オルターブースのいけだです!

最近子供を連れてキッザニア東京に行ってきたのですが、1日中大忙し&立ちっぱなしで、足が棒のようになりました。 子どもの可愛い姿を見れて大満足だったのですが、親はそれなりにキッザニアのシステムを予習した上で行く事を強くオススメします😅

さて!今回はArtilleryとGithub Actionsを使って簡単に負荷テストを実行してみようと思います!!

負荷テストツールも多々あり、Apache JMeterやLocust等が有名でしょうか。 今回扱うArtilleryはyamlファイルでシナリオを作成し、負荷をかけることができるNode.js製のツールです。

www.artillery.io

今回初めて触ってみましたが、さくっと感があって非常に使いやすいツールと感じました。

また、公式のGitHub Actionがあるので、簡単に負荷テストをCIに組み込む事ができます。 ちなみにArtilleryは「砲兵団」や「大砲」という意味があるようです。

負荷テストのターゲットとなる環境

今回は以下のような負荷をかける環境を作成します。

AWS Lambda + API Gateway でAPIへの認証にCognitoオーソライザーを使用します。

最後にGitHub Actionsでワークフローを実行してターゲットに対して負荷をかけていきます。

それでは早速ターゲットになるAPI (Lambda + API Gateway + Cognito) を作成していきます。 こちらは以下のAWS CDKを流して構築しました。 Lambda自体のコードは「hello」と返すだけの簡易なものをデプロイしてます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as path from 'path';

export class TestLoadtestLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const cognitoUserPool = new cognito.UserPool(this, 'TestLoadtestUserPool', {
      userPoolName: 'TestLoadtestUserPool',
      selfSignUpEnabled: true,
      signInAliases: {
        email: true,
      },
      autoVerify: {
        email: true,
      },
      standardAttributes: {
        email: {
          required: true,
          mutable: false,
        },
      },
    });
    cognitoUserPool.addClient('TestLoadtestUserPoolClient', {
      userPoolClientName: 'TestUserPoolClient',
      generateSecret: false,
      authFlows: {
        adminUserPassword: true,
        userSrp: false,
      },
    });

    const testLambda = new NodejsFunction(this, 'TestLoadtestLambda', {
      entry: path.join(__dirname, '../lambda/index.ts'),
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'handler',
      bundling: {
        forceDockerBundling: false,
      },
    });

    const authorizer = new apigw.CognitoUserPoolsAuthorizer(this, `test-Authorizer`, {
      cognitoUserPools: [cognitoUserPool],
    });

    const restApi = new apigw.RestApi(this, 'TestLoadtestApi', {
      restApiName: 'TestLoadtestApi',
      defaultCorsPreflightOptions: {
        allowOrigins: apigw.Cors.ALL_ORIGINS,
        allowMethods: apigw.Cors.ALL_METHODS,
        allowHeaders: apigw.Cors.DEFAULT_HEADERS,
        statusCode: 200,
      },
      deployOptions: {
        stageName: 'test',
      },
      defaultMethodOptions: {
        authorizer: authorizer,
      },
    });
    restApi.root.addProxy({
      defaultIntegration: new apigw.LambdaIntegration(testLambda),
      anyMethod: true,
    });
  }
}

それではデプロイしたAPIへの疎通確認をしてみましょう。

curl $APIURL

{"message":"Unauthorized"}

未認証ためUnauthorized と返ってきます。

AWS CLIを使用してCognitoからIDトークンを取得しましょう。

 aws cognito-idp admin-initiate-auth \
 --user-pool-id $poolid --client-id $clientid \
 --auth-flow "ADMIN_USER_PASSWORD_AUTH" \
 --auth-parameters USERNAME=$email,PASSWORD=$password

※初回はNewPasswordフローが入るので続けて以下のコマンド

aws cognito-idp admin-respond-to-auth-challenge \
--user-pool-id $poolid --client-id $clientid \
--challenge-name NEW_PASSWORD_REQUIRED \
--challenge-responses USERNAME=$email,NEW_PASSWORD=$password \
--session $session

IDトークンが取得できたら、再度疎通確認を行ってみます。 トークンを渡すことで想定通り「hello」と返ってきました。

curl $APIURL -H "Authorization:Bearer $token"

hello

Artilleryのセットアップ

ターゲットになる環境が整ったのでArtilleyの実行環境を準備していきます。 とはいうものの、npmでインストールするだけです。

npm install artillery@latest

# 確認
npx artillery version

テストシナリオはymlで記述していきます。 load-test.yml というymlファイルを作成し、今回は下記のように記載しました。

config:
  target: https://<API Gateway ID>.execute-api.ap-northeast-1.amazonaws.com
  phases:
    - duration: 60
      arrivalRate: 10
      rampTo: 50
  ensure:
    maxErrorRate: 1
  processor: "./functions.js"

scenarios:
  - name: "API Gateway"
    flow:
      - function: "generateIdToken"
      - post:
          url: "/test/huga"
          headers:
            content-type: "application/json"
            Authorization: "Bearer {{idToken}}"

これは、60 秒間実行し 1 秒あたり 10 人の新しい仮想ユーザーを作成し、フェーズが終了するまでに 1 秒あたり 50 人の新しい仮想ユーザーまで徐々に増やしていく。というシナリオです。 エラー割合が1%超えたらテスト失敗としています。ターゲットには先程準備したAPI Gatewayのエンドポイントを指定しています。

ここで1つポイントなのが、Cognitoへの認証をおこなってIDトークンを取得する動作をfunction.jsというファイルで実行させている事です。

そこで得られたIDトークンをヘッダーにセットしています。

以下はその function.js です。

const AWS = require('aws-sdk');

module.exports = {
    generateIdToken: function(context, done) {
      const cognito = new AWS.CognitoIdentityServiceProvider({
        region: 'ap-northeast-1'
    });
      const params = {
        AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
        ClientId: process.env.CLIENT_ID,
        UserPoolId: process.env.USER_POOL_ID,
        AuthParameters: {
          USERNAME: process.env.USERNAME,
          PASSWORD: process.env.PASSWORD
        }
      };
      
      cognito.adminInitiateAuth(params, function(err, data) {
        if (err) {
          console.log(err, err.stack);
        } else {
          context.vars.idToken = data.AuthenticationResult.IdToken;
          return done();
        }
      });
    }
  };

このようにする事でテストの中で動的にIDトークンを取得する事ができます。

それでは必要な環境変数を設定し、ローカルで実行してみましょう。 ここでは実行確認の為、先程のシナリオから仮想ユーザーを少し減らした状態にして実行しています。

export AWS_ACCESS_KEY_ID=***
export AWS_SECRET_ACCESS_KEY=***
export CLIENT_ID=<Cognito Application Client ID>
export USER_POOL_ID=<Cognito UserPool ID>
export USERNAME=***
export PASSWORD=***
npx artillery run load-test.yml

実行結果として以下のような結果が得られました。

http.codes.200: ................... 47
http.downloaded_bytes: ................... 235
http.request_rate: ................... 14/sec
http.requests: ................... 47
http.response_time:
  min: ................... 189
  max: ................... 886
  mean: ................... 434.4
  median: ................... 518.1
  p95: ................... 804.5
  p99: ................... 854.2
http.responses: ................... 47
vusers.completed: ................... 47
vusers.created: ................... 47
vusers.created_by_name.API Gateway: ................... 47
vusers.failed: ................... 0
vusers.session_length:
  min: ................... 952
  max: ...................1924.7
  mean: ................... 290.3
  median: ................... 1326.4
  p95: ................... 1755
  p99: ...................1863.5
  1. http.codes.200: 47
    • HTTP 200ステータスコード(成功)を返したレスポンスの数が47です。
  2. http.downloaded_bytes: 235
    • テスト中にダウンロードされた合計バイト数は235バイトです。
  3. http.request_rate: 14/sec
    • リクエストの発生率が秒間14回です。
  4. http.requests: 47
    • テスト中に合計47回のHTTPリクエストが発生しました。
  5. http.response_time:
    • 応答時間の統計情報です。
      • 最小応答時間が189ミリ秒
      • 最大応答時間が886ミリ秒
      • 平均応答時間が434.4ミリ秒
      • 中央値が518.1ミリ秒
      • 95パーセンタイル応答時間は804.5ミリ秒
      • 99パーセンタイル応答時間は854.2ミリ秒。
  6. http.responses: 47
    • テスト中に合計47回のHTTPレスポンスが返されました。
  7. vusers.completed: 47
    • テストを完了した仮想ユーザーの数が47です。
  8. vusers.created: 47
    • 作成された仮想ユーザーの合計数は47です。
  9. vusers.failed: 0
    • 失敗した仮想ユーザーは0です。これは、エラーなしにテストが完了したことを示しています。
  10. vusers.session_length:
    • 仮想ユーザーのセッションの長さの分布を示しています。

上記の通りに読み取ることができました。 想定通り実行できたようです。

GitHub Actionsで実行

最後にGitHub Actionsで動かしてみようと思います。

以下のようなworkflowファイルを作成しました。

name: Artillery Load Test

on:
  push:
    branches:
      - main
 
jobs:
  artillery:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
        
      - name: Install dependencies
        run: cd ./artillery-test && npm ci

      - name: Execute load tests
        uses: artilleryio/action-cli@v1
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          CLIENT_ID: ${{ secrets.CLIENT_ID }}
          USER_POOL_ID: ${{ secrets.USER_POOL_ID }}
          USERNAME: ${{ secrets.USERNAME }}
          PASSWORD: ${{ secrets.PASSWORD }}
        with:
          working-directory: ./artillery-test # 実行ディレクトリ
          command: run load-test.yml

ほぼ、以下の公式記載のとおりに記述しています。

www.artillery.io

シークレットを設定し、ワークフローを実行する事で結果が確認できました。

All VUs finished. Total time: 1 minute, 1 second

--------------------------------
Summary report @ 03:42:56(+0000)
--------------------------------

http.codes.200: ................... 3300
http.downloaded_bytes: ...................16500
http.request_rate: ................... 55/sec
http.requests: ................... 3300
http.response_time:
  min: ................... 177
  max: ................... 886
  mean: ................... 239.9
  median: ...................194.4
  p95: ................... 528.6
  p99: ................... 550.1
http.responses: ................... 3300
vusers.completed: ................... 3300
vusers.created: ................... 3300
vusers.created_by_name.API Gateway: ................... 3300
vusers.failed: ................... 0
vusers.session_length:
  min: ................... 576.7
  max: ...................1924.7
  mean: ................... 937.5
  median: ................... 944
  p95: ................... 1300.1
  p99: ................... 1380.5

作成されたすべての仮想ユーザーがタスクを正常に完了し、 リクエストの全てが成功している事が読み取れます。 また平均して、毎秒55回のHTTPリクエストが発生した事が記録できています。こちらもシナリオの意図した通りです。

さいごに

CI/CD パイプラインの一部として負荷テストを実行する事で、パフォーマンス低下の早期検出、SLOの検証に有効です。 Artilleryはテストシナリオを簡単に作成でき実行できるので、パイプラインに組み込む際の良い選択肢になり得ると思いました。

また、高負荷時のシナリオを実行する際にはAWS LambdaやECS Fargateを使用した実行方法があるようです。 こちらも今度試してみようと思います。

以上、拙い内容で恐縮ですが、最後までご覧頂き有難うございました!


サービス一覧 www.alterbooth.com www.alterbooth.com www.alterbooth.com