この記事は ZOZO Advent Calendar 2023 シリーズ9の22日目の記事です。
はじめに
こんにちは、計測プラットフォーム開発本部 SRE ブロックの yamagai です。
タイトルがドラゴンボールのアニメタイトルのようになってしまいましたが、今回は 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 側の負荷試験が日本チーム側の別の検証を妨げないようにするため
方法
実装の流れを大まかにまとめると、以下の通りです。
- 負荷試験の実装
- 負荷試験のためのマニフェスト整備
- GitHub Actions の整備
それぞれ、実装内容を元にポイントを書いていきます。
負荷試験の実装
タイトルや冒頭にもある通り、vegeta というツールを利用しました。(このベジータはあのベジータです)
日本チームが書いているアプリケーションが主に Scala で書かれているというのもあり、弊チームでは負荷試験に Gatling を利用することが多かったのですが、
- ビルドに時間がかかる
- 導入の手間が割とある
などの点から、今回は簡単に導入できビルドも早い vegeta を利用してみることにしました。
vegeta は CLI としても Go ライブラリとしても利用できます。
今回は、負荷をかける際の request 内容を動的に設定したい(詳しくは後述)という要件があり、シェルで頑張ることも出来たんですが、保守性の観点から素直に Go ライブラリとして vegeta を利用する方針を取りました。
(詳しい用法などは README や Go Docs を参照してください。)
最終的な全体の実装は以下のようになりました。
(なお、以下のコードでは分かりやすさのため、適宜コードを省略しつつ全ての処理を main 関数にまとめています。)
前提として、/hoge
に対して fileName
というクエリパラメータを付けてリクエストする API を対象にしています。
// 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
)を設定しリクエストを投げるシナリオとなっています。
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 は以下のような負荷試験の結果をまとめたものです。(いわゆるレポートですね)
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にもある通り、json など様々な形式で吐き出すことが出来ます。
次に plot ですが、これはレイテンシと時間の関係をグラフにプロットしたものです。
(vegeta の README より引用)
これらは CLI として vegeta を利用する場合はコマンドひとつで生成できるのですが、ライブラリとして vegeta を使う場合は以下のようにすることで生成できます。
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
として返ってきます。
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 のマニフェストはアプリケーションとして動かす際の設定と同様で良いので省略します。
以下のように環境変数に必要な情報を設定したマニフェストになりました。
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 に関するリソースの削除
全体としては以下のようになりました。
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 にログが吐き出されるように工夫しています。
- 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
ID と S3 のリンクと共に report が Slack に通知されています。いい感じ。
plot
S3 のリンクを踏むと上記画像のように plot のグラフが見れます。これもいい感じ。
最後に
以上が GitHub Actions で簡単に負荷試験が実行できる仕組みを作る上で行った内容です。
愚直に実装しているなあと感じる方が多いと思いますが、実は自分の入社前に別のプロダクトでも似たような要件(というかほぼ同じ)で負荷試験の仕組みを作る必要があったようで、専用の Custom Controller が管理する Custom Resource を GitHub Actions 経由で apply すると負荷試験が動くような仕組みになっていたのですが、該当の Custom Controller のメンテが大変で原点回帰した結果今回のような形に落ち着いた、という経緯があります。
今回はスポット的に負荷試験を行いたい際に使う仕組みを実装していますが、弊チームではデプロイから負荷試験・リリースまでを自動化しており、カナリアリリースも使いながら安全でありながらほとんどの工程が自動化されたデプロイフローを構築しています。
この辺りの話は以下のテックブログで紹介しているので、ご興味のある方はぜひご覧ください。
- GitHub Flow with GitOpsの導入
- Argo CD Resource Hookを活用したKubernetes環境での負荷試験自動化の取り組み
- Argo Rolloutsを導入してカナリアリリースを実現する
紹介した内容が少しでもお役に立てば幸いです。