ギャリック砲で Pod を打ち抜け！vegeta で始める負荷試験 with GitHub Actions


この記事は [ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo) シリーズ9の22日目の記事です。


# はじめに
こんにちは、計測プラットフォーム開発本部 SRE ブロックの yamagai です。

タイトルがドラゴンボールのアニメタイトルのようになってしまいましたが、今回は [vegeta](https://github.com/tsenart/vegeta) という Go 製の負荷試験ツールを使って、EKS Fargate で動く Pod に対し GitHub Actions で簡単に負荷試験を実行する仕組みを作ったので、その方法や vegeta の使い方について記事にしました。

# 背景
今回の負荷試験の導入には以下のような背景がありました。

- EKS Fargate の Pod として動かしているアプリケーションのパフォーマンスを分析したかった
- 上記アプリケーションは、ZOZO New Zealand（以下、ZOZONZ）という日本とは別のチームによって開発されており、ZOZONZ のメンバーが自由に負荷試験を実行できる必要があった
- 権限管理や技術スタックの観点から、ZOZONZ 側が AWS(EKS) や Kubernetes を意識せずに負荷試験を実行でき、負荷試験の結果を Slack に通知できるようにしたかった

これにしたがって、以下のような構成にすることにしました。

- **GitHub Actions の workflow_dispatch で負荷試験を実行できるようにする**
    - ZOZONZ 側が AWS(EKS), Kubernetes を意識せず自由に負荷試験を実行できるようにするため
- **負荷を受ける側のアプリケーションの Service, Pod (以下、receiver) と、負荷を与える側の Pod (以下、attacker) を Job として用意し負荷をかける**
    - ZOZONZ 側の負荷試験が日本チーム側の別の検証を妨げないようにするため

# 方法
実装の流れを大まかにまとめると、以下の通りです。
1. 負荷試験の実装
2. 負荷試験のためのマニフェスト整備
3. GitHub Actions の整備

それぞれ、実装内容を元にポイントを書いていきます。

## 負荷試験の実装
タイトルや冒頭にもある通り、[vegeta](https://github.com/tsenart/vegeta) というツールを利用しました。(このベジータはあのベジータです)

日本チームが書いているアプリケーションが主に Scala で書かれているというのもあり、弊チームでは負荷試験に [Gatling](https://gatling.io/) を利用することが多かったのですが、
- ビルドに時間がかかる
- 導入の手間が割とある

などの点から、今回は簡単に導入できビルドも早い vegeta を利用してみることにしました。

vegeta は CLI としても Go ライブラリとしても利用できます。
今回は、負荷をかける際の request 内容を動的に設定したい(詳しくは後述)という要件があり、シェルで頑張ることも出来たんですが、保守性の観点から素直に Go ライブラリとして vegeta を利用する方針を取りました。
(詳しい用法などは [README](https://github.com/tsenart/vegeta?tab=readme-ov-file#usage-library) や [Go Docs](https://pkg.go.dev/github.com/tsenart/vegeta/lib) を参照してください。)

最終的な全体の実装は以下のようになりました。
(なお、以下のコードでは分かりやすさのため、適宜コードを省略しつつ全ての処理を main 関数にまとめています。)
前提として、`/hoge` に対して `fileName` というクエリパラメータを付けてリクエストする API を対象にしています。
```golang
// main.go
var targetFileNames = []string{
	"loadtest1.bin",
	"loadtest2.bin",
	"loadtest3.bin",
	"loadtest4.bin",
}

func main() {
    // s3, slack client の用意
    // s3Client := s3.NewClient()
    // slackClient := slack.NewClient()

    // 環境変数を元に設定読み込み
    cfg, err := config.New(ctx)
    if err != nil {
        return
    }

    // 1. 負荷試験の準備
    u, err := url.Parse(cfg.Destination) // 負荷の掛け先を指定
    if err != nil {
        return err
    }
    u.Path = path.Join(u.Path, "hoge") // "/hoge" が対象

    targeter := func(fileNames []string) vegeta.Targeter { // 動的にリクエスト内容を決定するため
        i := int64(-1)
        return func(t *vegeta.Target) (err error) {
            q := u.Query()
            q.Set("fileName", fileNames[atomic.AddInt64(&i, 1)%int64(len(fileNames))])
            u.RawQuery = q.Encode()
    
            t.Method = "GET"
            t.URL = u.String()
    
            return err
        }
    }(targetFileNames)

    rate := vegeta.Rate{Freq: cfg.Rps, Per: time.Second}
    attacker := vegeta.NewAttacker() // timeout 等の設定も可能

    fr, err := os.Create("report.txt") // report の書き込み先の作成
	if err != nil {
		return err
	}
	defer fr.Close()
 
	fp, err := os.Create("plot.html") // plot の書き込み先の作成
	if err != nil {
		return err
	}
	defer fp.Close()

	var metrics vegeta.Metrics
	p := plot.New(
		plot.Title(fmt.Sprintf("loadtest-%s-rps-%d-duration-%d", cfg.RunID, cfg.Rps, cfg.Duration)),
		plot.Downsample(4000), // default value of `vegeta plot` command
		plot.Label(plot.ErrorLabeler),
	)

	// 2. 負荷試験実行
	for res := range attacker.Attack(targeter, rate, cfg.Duration, cfg.ScenarioName) {
		metrics.Add(res)
		if err := p.Add(res); err != nil {
			return
		}
	}
	metrics.Close()
	p.Close()

    // 3. 結果をファイルに書き込み
	reporter := vegeta.NewTextReporter(&metrics)
	if err := reporter.Report(fr); err != nil { // report の書き込み
		return
	}
	_, err = p.WriteTo(fp)
	if err != nil { // plot の書き込み
		return
	}

    // 結果を s3 に (s3Client.Upload 的なことをする)
    // 結果を slack に (slackClient.Notify 的なことをする)
}

```

上記のポイントである
- 動的なリクエスト内容の決定
- report, plot の生成

に関して説明します。

### 動的なリクエスト内容の決定
今回は、`/hoge` に対して動的にクエリパラメータ(`fileName`)を設定しリクエストを投げるシナリオとなっています。

```golang
q.Set("fileName", fileNames[atomic.AddInt64(&i, 1)%int64(len(fileNames))])
```
上記のコードでは、i を1ずつ増加させた上で fileNames の長さで割ることで、i の値が fileNames のインデックス範囲内に常に収まるようにしています。
これにより、事前に変数定義している targetFiles から動的に対象を選択し、それをクエリパラメータに設定するということが実現できます。

これはクエリパラメータだけでなくリクエストボディに関しても同様のことが出来ます。
詳しくは https://github.com/tsenart/vegeta/issues/330 をご覧ください。


### report, plot の生成
vegeta には report と　plot という生成物があります。

report は以下のような負荷試験の結果をまとめたものです。(いわゆるレポートですね)
```text
Requests      [total, rate, throughput] 1200, 120.00, 65.87
Duration      [total, attack, wait]     10.094965987s, 9.949883921s, 145.082066ms
Latencies     [min, mean, 50, 95, 99, max]  90.438129ms, 113.172398ms, 108.272568ms, 140.18235ms, 247.771566ms, 264.815246ms
Bytes In      [total, mean]             3714690, 3095.57
Bytes Out     [total, mean]             0, 0.00
Success       [ratio]                   55.42%
Status Codes  [code:count]              0:535  200:665
Error Set:
Get http://localhost:6060: dial tcp 127.0.0.1:6060: connection refused
Get http://localhost:6060: read tcp 127.0.0.1:6060: connection reset by peer
Get http://localhost:6060: dial tcp 127.0.0.1:6060: connection reset by peer
Get http://localhost:6060: write tcp 127.0.0.1:6060: broken pipe
Get http://localhost:6060: net/http: transport closed before response was received
Get http://localhost:6060: http: can't write HTTP request on broken connection
```
[README](https://github.com/tsenart/vegeta?tab=readme-ov-file#report-command)にもある通り、json など様々な形式で吐き出すことが出来ます。


次に plot ですが、これはレイテンシと時間の関係をグラフにプロットしたものです。
![vegeta_plot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640444/d373c2b1-7f34-8f04-c8bd-15f25949c007.png)
(vegeta の [README](https://github.com/tsenart/vegeta?tab=readme-ov-file#plot-command) より引用)

これらは CLI として　vegeta を利用する場合はコマンドひとつで生成できるのですが、ライブラリとして vegeta を使う場合は以下のようにすることで生成できます。

```golang
    for res := range attacker.Attack(targeter, rate, cfg.Duration, cfg.ScenarioName) {
		metrics.Add(res)
		if err := p.Add(res); err != nil {
			return
		}
	}
	metrics.Close()
	p.Close()

	reporter := vegeta.NewTextReporter(&metrics)
	if err := reporter.Report(fr); err != nil { // report の書き込み
		return
	}
	_, err = p.WriteTo(fp)
	if err != nil { // plot の書き込み
		return
	}
```

まず、以下のように `attacker.Attack()` で vegeta が実際にリクエストを飛ばし、その結果が `<-chan` として返ってきます。
```golang
func (a *Attacker) Attack(tr Targeter, p Pacer, du time.Duration, name string) <-chan *Result
```

この channel に結果が入る度に `metrics.Add(res)` や `p.Add(res)` で情報を書き込み、最終的にはそれらをファイルに書き込めば生成完了です。

report, plot を S3 にアップロードする際にそれぞれ ContentType を `text/plain`, `text/html` に指定しないと、ContentType が `application/octet-stream` となり S3 のリンクを踏んだ際にダウンロードされるようになってしまいますので注意です。


## 負荷試験のためのマニフェスト整備
次に Job として vegeta を動かすためのマニフェストを作成します。
なお、receiver 側の Deployment, Service のマニフェストはアプリケーションとして動かす際の設定と同様で良いので省略します。

以下のように環境変数に必要な情報を設定したマニフェストになりました。

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: loadtest-attacker-job
  namespace: zozodayo
spec:
  template:
    metadata:
      labels:
        product: zozodayo
    spec:
      serviceAccountName: loadtest-attacker
      containers:
        - name: loadtest-attacker
          image: zozodayo/loadtest
          env:
            - name: RUN_ID
              value: "" # GitHub Actions 内で更新できるようにする
            - name: SCENARIO_NAME
              value: "hoge" # GitHub Actions 内で更新できるようにする
            - name: ATTACK_RPS
              value: "5" # GitHub Actions 内で更新できるようにする
            - name: ATTACK_DESTINATION
              value: "http://loadtest-receiver-service.zozodayo.svc.cluster.local:8080"
            - name: ATTACK_DURATION
              value: "5m" # GitHub Actions 内で更新できるようにする
            # その他、結果を保存する S3 Bucket や通知先の SlackChannel 名なども適宜設定する
      restartPolicy: Never
  backoffLimit: 0 # Pod の再起動はさせない
  ```

Kubernetes Job のマニフェストの書き方として特筆すべき点はあまり無いので、vegeta の負荷の掛け方に関して説明します。

### vegeta の負荷の掛け方
マニフェストにあるように `ATTACK_RPS` や `ATTACK_DURATION` を設定していますが、並列数の設定はされていないことがわかると思います。

vegeta では、負荷を与える worker は最大数(デフォルトでは 10)を超えない限り「要求された rate を維持するために必要であれば増加する」ような動きをします。

worker の初期値を設定することも可能ですが、今回は取り急ぎパフォーマンスを確認したいという状況だったので並列数というパラメータは設定しないようにしました。

なお、Job の parallelism による並列数の設定もありますが、結果をそれぞれの Pod が通知してしまうことになるので利用していません。

## GitHub Actions の整備
次に、ここまで準備した実装やマニフェストを使って負荷試験を発火する GitHub Actions を整備します。

やることは大まかに以下の通りです。
- receiver 側の docker image の build, push
- attacker 側の docker image の build, push
- receiver の apply
- attacker の apply
- 負荷試験が完了したら receiver, attacker に関するリソースの削除

全体としては以下のようになりました。

```yaml
name: loadtest

on:
  workflow_dispatch:
    inputs:
      scenario:
        type: choice
        options:
          - hoge
          - fuga
      attack-rps:
        type: string
        description: '(optional) request per second on loadtest. e.g. 10, 20 (default 5)'
      attack-duration:
        type: string
        description: '(optional) loadtest duration. e.g. 10s, 5m (default 5m, max 30m(1800s))'

permissions:
  id-token: write
  contents: read
  packages: write

jobs:
  build-and-push-receiver-images:
    # receiver 側の docker image を build & push する

  build-and-push-attacker-image:
    # attacker 側の docker image を build & push する

  loadtest:
    name: loadtest
    needs:
      - build-and-push-receiver-images
      - build-and-push-attacker-image
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
    
      - name: Install kubectl
        uses: azure/setup-kubectl@v3
        with:
          version: 'v1.26.10'
      
      - name: Install yq
        run: |
          wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O ./yq && \
          chmod +x ./yq

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_LOADTEST_ROLE_ARN }}
          aws-region: ap-northeast-1

      - name: Update image tags, and parameters
        shell: bash
        run: |
          # receiver の image tag を更新
          DEPLOYMENT_FILE="loadtest/kubernetes/loadtest-receiver/deployment.yml"
          RECEIVER_IMAGE="zozodayo/loadtest-receiver"
          ./yq "(.spec.template.spec.containers[] | select(.image | test(\"${RECEIVER_IMAGE}\")).image) |= \"${RECEIVER_IMAGE}:${{ github.sha }}\"" -i $DEPLOYMENT_FILE

          # attacker の image tag を更新
          JOB_FILE="loadtest/kubernetes/loadtest-attacker/job.yml"
          ATTACKER_IMAGE="zozodayo/loadtest-attacker"
          ./yq "(.spec.template.spec.containers[] | select(.image | test(\"${ATTACKER_IMAGE}\")).image) |= \"${ATTACKER_IMAGE}:${{ github.sha }}\"" -i $JOB_FILE

          # attacker job のパラメータを inputs に応じて設定
          RUN_ID=${{ github.run_id }}
          SCENARIO_NAME=${{ github.event.inputs.scenario }}
          ATTACK_RPS=${{ github.event.inputs.attack-rps }}
          ATTACK_DURATION=${{ github.event.inputs.attack-duration-seconds }}
          ./yq "(.spec.template.spec.containers[0].env[] | select(.name == \"RUN_ID\").value) = \"${RUN_ID}\"" -i $JOB_FILE
          ./yq "(.spec.template.spec.containers[0].env[] | select(.name == \"SCENARIO_NAME\").value) = \"${SCENARIO_NAME}\"" -i $JOB_FILE
          ./yq "(.spec.template.spec.containers[0].env[] | select(.name == \"ATTACK_RPS\").value) = \"${ATTACK_RPS}\"" -i $JOB_FILE
          ./yq "(.spec.template.spec.containers[0].env[] | select(.name == \"ATTACK_DURATION\").value) = \"${ATTACK_DURATION}\"" -i $JOB_FILE

      - name: Apply resources for loadtest-receiver
        run: |
          aws eks update-kubeconfig --name dev-cluster
          kubectl apply -f loadtest/kubernetes/loadtest-receiver

      - name: Wait until loadtest-receiver become ready
        run: kubectl wait -n zozodayo --timeout=180s --for condition=available deployment/loadtest-receiver-deployment

      - name: Apply resources for loadtest-attacker
        run: kubectl apply -f loadtest/kubernetes/loadtest-attacker

      - name: Output loadtest-receiver logs and Wait until loadtest-attacker job become complete
        run: |
          kubectl logs -n zozodayo deployment/loadtest-receiver-deployment -f &
          LOG_PID=$!
          kubectl wait -n zozodayo --timeout=2000s --for condition=complete job/loadtest-attacker-job
          kill $LOG_PID

      - name: Delete loadtest resources
        run: |
          kubectl delete -f loadtest/kubernetes/loadtest-receiver
          kubectl delete -f loadtest/kubernetes/loadtest-attacker
```

上記のポイントである
- Deployment と Job の実行順序を守るために `kubectl wait` を利用する 
- 完了を待ちつつ receiver 側のログを吐くようにする

に関して説明します。

### Deployment と Job の実行順序を守るために `kubectl wait` を利用する 
receiver 側の Deployment と attacker 側の Job を何も考えずに apply してしまうと、receiver が立ち上がっていないにも関わらず負荷試験が始まってしまうようなことが有り得ます。

これを防ぐために、対象のリソースが特定の状態になるまで待つことが出来る `kubectl wait` コマンドを利用しました。

ここでは、receiver の　Deployment が `Condition: available` になるまで待つようにしています。
なお、`--timeout` を設定しない場合、デフォルト値の 30s しか待ってくれないため注意しましょう。


### 完了を待ちつつ receiver 側のログを吐くようにする
今回の要件の一つである『ZOZONZ 側が AWS(EKS) や Kubernetes を意識せずに負荷試験を実行できるようにしたい』を満たすために、GitHub Actions にログが吐き出されるように工夫しています。

```yaml
- name: Output loadtest-receiver logs and Wait until loadtest-attacker job become complete
  run: |
    kubectl logs -n zozodayo deployment/loadtest-receiver-deployment -f &
    LOG_PID=$!
    kubectl wait -n zozodayo --timeout=2000s --for condition=complete job/loadtest-attacker-job
    kill $LOG_PID
```

ここでは、`kubectl logs` をバックグラウンドで実行しつつ Job の完了を待ち、完了し次第 `kubectl logs` のプロセスを kill するようにしています。

なお、attacker の Job の完了のタイムアウトは 2000s にしていますが、これは負荷試験実行時間の最大値としている 30min(=1800s) に Job 起動までの時間(=60~180s)を加え、若干余裕を持たせた値です。


# 実際に動かしてみた結果

### report
![スクリーンショット 2023-12-22 15.30.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640444/f2062e6b-00cf-c362-2849-9948f04857a5.png)

ID と S3 のリンクと共に report が　Slack に通知されています。いい感じ。

### plot
![vegeta-plot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640444/4d838b11-3a50-2877-38a3-f8dce32867df.png)

S3 のリンクを踏むと上記画像のように plot のグラフが見れます。これもいい感じ。

# 最後に

以上が GitHub Actions で簡単に負荷試験が実行できる仕組みを作る上で行った内容です。

愚直に実装しているなあと感じる方が多いと思いますが、実は自分の入社前に別のプロダクトでも似たような要件(というかほぼ同じ)で負荷試験の仕組みを作る必要があったようで、専用の Custom Controller が管理する Custom Resource を GitHub Actions 経由で apply すると負荷試験が動くような仕組みになっていたのですが、該当の Custom Controller のメンテが大変で原点回帰した結果今回のような形に落ち着いた、という経緯があります。

今回はスポット的に負荷試験を行いたい際に使う仕組みを実装していますが、弊チームではデプロイから負荷試験・リリースまでを自動化しており、カナリアリリースも使いながら安全でありながらほとんどの工程が自動化されたデプロイフローを構築しています。

この辺りの話は以下のテックブログで紹介しているので、ご興味のある方はぜひご覧ください。

- [GitHub Flow with GitOpsの導入](https://techblog.zozo.com/entry/github-flow-with-gitops)
- [Argo CD Resource Hookを活用したKubernetes環境での負荷試験自動化の取り組み](https://techblog.zozo.com/entry/auto-loadtest)
- [Argo Rolloutsを導入してカナリアリリースを実現する](https://techblog.zozo.com/entry/argo-rollouts-canary-release)

紹介した内容が少しでもお役に立てば幸いです。