ZOZOのプラットフォームSREによる負荷試験

## はじめに
本記事では、ZOZOのプラットフォームSREが実施しているマイクロサービスの負荷試験について紹介します。

## プラットフォームSREでの負荷試験
ZOZOでは、信頼性向上の取り組みの1つして、SREとバックエンドエンジニアが協力して負荷試験を実施しています。具体的には、バックエンドエンジニアが負荷試験のシナリオを作成し、そのシナリオに基づきSREが負荷試験の準備や実施を行い、結果を共有します。負荷試験の結果に問題がある場合は、SREとバックエンドエンジニアが共に、パフォーマンスチューニングの検討と実施をします。

ZOZOでは、複数のSREチームが存在しますが、SREチームごとに担当しているマイクロサービスの負荷試験を実施します。プラットフォームSREが担当しているマイクロサービスは、2024/12時点では以下です。

- zozo-api-gateway
  - 外部からプラットフォーム基盤上に存在するマイクロサービスへのほぼすべてのリクエストを経由するゲートウェイ
- zozo-web-gateway
  - フロントエンドのリプレイス面からプラットフォーム基盤上に存在するマイクロサービスへのリクエストを経由するゲートウェイ
- zozo-id
  - 一般ユーザー向けの認証機能を提供するマイクロサービス
- zozo-back-id
  - 倉庫作業者や提携ブランド様向けの認証機能を提供するマイクロサービス
- zozo-oidc-provider-api
  - OIDC機能を提供するマイクロサービス
- zozo-session-api
  - Session機能を提供するマイクロサービス
- zozo-member-api
  - 会員情報機能を提供するマイクロサービス

## 負荷試験での確認項目
負荷試験では以下を確認しています。基本的には、負荷をかけても問題ないことの確認と、Pod数見積もりのもとになる情報を取得しています。

- 目標レイテンシ（sec/req）で捌けることを確認する。
  - 99％タイルのレイテンシーが100ms以下であることを確認する。ただし、内部で別システムのAPIを呼び出している場合は200ms以下であることを確認する。
  - 遅い場合は、パフォーマンスチューニングを検討する。
- Podを垂直スケーリングすると線形にスループットが増えるかを確認する。
 - リクエスト増加時のサーバーのスケーリングが有効であることを確認するため。
 - 線形に増えない場合は、APMなどを使って、ボトルネックの発見と解消をする。たとえば、DBがボトルネックになっていないかなど。
- CPU使用率50％以下で捌ける1Podあたりの最大スループットを確認する。
 - このスループットと本番環境における見積りのリクエスト数から、平常時やセール時のPod数を見積もる。
 - CPU使用率50%という数字にそこまで大きな意味はない。これまでの経験上、CPU使用率50%であれば安定的にサービス稼働することが多いため、この数字を目安にしている。

## 負荷試験を実施するタイミング
基本的にはバックエンドチームからの依頼ベースで実施します。リプレイスフェーズの区切りや、新機能の開発、既存機能に大幅な改修が入った場合などに、依頼を受けることが多いです。

## 負荷試験ツール
ZOZOがOSSとして公開している[Gatling Operator](https://github.com/st-tech/gatling-operator)を使用します。
Gatling Operatorは、Gatlingをベースとした分散負荷試験のライフサイクルを自動化するKubernetes Operatorです。Gatlingカスタムリソースがapplyされると、Gatling Runner JobとそのPodが自動的に生成されます。Gatling Runner PodはGatlingのシナリオを実行し、その結果ログを任意のCloud Storage（AWS S3など）に保存します。また、同時にGatling Reporter JobとそのPodも生成し、Cloud Storageに保存された結果ログからレポートを作成し、再びCloud Storageに保存します。そして、終了後にSlackなどへの通知も行います。

Gatling Operatorを使用することで、Kubernetesクラスター上で効率的に負荷試験を実施できます。テックブログも公開していますので、よろしければ読んでください。
[Gatlingによる分散負荷試験を自動化するKubernetesオペレーターGatling Operatorの紹介](https://techblog.zozo.com/entry/gatling-operator-introduction)

## 負荷試験環境
ZOZOでは、主にdev/stg/prdの3種類の環境が存在します。それぞれの環境ですべてのマイクロサービスが稼働しています。負荷試験では、stg環境を利用します。

一方、Gatling Operatorとその関連のリソースは、「zozo-benchmarkクラスター」と呼ばれる、独自のAWSアカウントで管理しているKubernetesクラスター上にデプロイされます。
また、VPCとそのサブネット、EKSクラスター、S3、ECR、SecretManagerなどのAWSリソースで構成されています。
マイクロサービス側のstg環境とは、VPC Peeringで接続しています。zozo-benchmarkクラスターからマイクロサービス側のstg環境のEKSクラスターに対して、大量にAPIリクエストが送信されることになります。

## 負荷試験のやり方
### 事前準備
#### スケジュール決定
まずは、全体スケジュールを決定します。
「バックエンドエンジニアによるシナリオ作成」と「SREによる試験実施とまとめ」が主に工数がかかる部分です。おおよそ、それぞれが3〜5営業日程度かかります。なお、アプリケーションのパフォーマンスチューニングが必要だった場合の工数は含まれていません。

#### stg環境利用スケジュールの記入
ZOZOでは、stg環境は1つしかありません。prd環境とほぼ同じ構成であるため、インフラコストが大きくかかるからです。そのため、1つしかないstg環境を複数のチームがさまざまな目的で利用します。時には、お互いの検証や負荷試験が影響する可能性もあります。そこで、利用状況を可視化できるように、stg環境を利用する際はGoogleのスプレッドシートに各チームで利用スケジュールを記入するルールとしています。バッティングする場合は、時間の調整をしたりします。現状はなんとか、声のかけあいで回避できています。

#### シナリオ作成
負荷試験のシナリオを作成します。主に、バックエンドチームがリリース時のリクエスト数を見積もり、それをもとに作成します。見積もり方はいくつかありますが、リプレイスの場合は、直近ピーク時のリプレイス対象機能のリクエスト状況を調査し、その値を使用します。新機能の場合は、ビジネスサイドとコミュニケーションを取り、想定リクエストをざっくりで見積もります。
GatlingなのでScalaでシナリオを実装します。1sの間にどのエンドポイントを何回実行するかといった情報を実装します。必要に応じて、初期化処理でCSV形式のresourceファイルを読み込みます。これは通常のGatlingを使った負荷試験と同様です。

#### 対象マイクロサービスのPod数を1に固定する
負荷をかけて試験対象のマイクロサービスがオートスケーリングしてしまわないように、stgのminReplicasとmaxReplicasを1に設定します。ZOZOでは、KEDAを導入しているため、ScaledObjectリソースを修正します。

もし、1に固定しないと水平スケーリングしてしまい、Pod以外のボトルネック（DBやALBなど）にひっかかるまで無制限に負荷をかけられてしまいます。また、「CPU使用率50%程度で捌ける1Podあたりのスループットを調べる」という試験観点も満たせなくなります。

#### Gatlingリソースmanifestの準備
Gatling OperatorのCustom ResourceであるGatlingリソースのmanifestを準備します。主な項目だけ説明します。詳しくは上記のテックブログやGitHubリポジトリのページからご確認ください。
`spec.generateReport`で、Gatlingレポートを作成するかどうかを設定します。
`spec.notifyReport`で、slackなどによる通知を行うかを設定します。
`spec.cleanupAfterJobDone`で、実行完了した場合にpodを削除するかどうかを設定します。実行に失敗してしまい、podのログから原因を調査したい場合はfalseにします。
`spec.podSpec.image`で、Gatlingイメージを指定します。後述します。
`spec.cloudStorageSpec`で、レポートを保存するクラウドストレージを設定します。
`spec.notificationServiceSpec`で、通知先の設定をします。
`spec.simulationClass`で、用意したシナリオのクラス名を指定します。
`spec.testScenarioSpec.env`で、並列度（CONCURRENCY）やテスト時間（DURATION）などを設定します。CONCURRENCYは、試験の過程で徐々に上げていきます。DURATIONは、300sで実施することが多いです。

```yaml
apiVersion: gatling-operator.tech.zozo.com/v1alpha1
kind: Gatling
metadata:
  name: zozo-web-gateway
  annotations:
    ad.datadoghq.com/tags: '{"benchmark-service": "gatling"}'
spec:
  generateReport: true
  notifyReport: true
  cleanupAfterJobDone: true
  podSpec:
    serviceAccountName: "gatling-worker"
    gatlingImage: xxx.dkr.ecr.ap-northeast-1.amazonaws.com/zozo-gatling-scenario:zozo-web-gateway-20240229194627
    resources:
      limits:
        cpu: "500m"
        memory: "500Mi"
    affinity:
      nodeAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
          nodeSelectorTerms:
            - matchExpressions:
                - key: kubernetes.io/os
                  operator: In
                  values:
                    - linux
                - key: node.kubernetes.io/instance-type
                  operator: In
                  values:
                    - m6i.xlarge
  cloudStorageSpec:
    provider: "aws"
    bucket: "zozo-benchmark-gatling-report"
    region: "ap-northeast-1"
  notificationServiceSpec:
    provider: "slack"
    secretName: "gatling-notification-slack-secrets-20230929180000"
  testScenarioSpec:
    parallelism: 1
    simulationClass: "ZozoWebGatewayBasicLoadTest"
    env:
      - name: ENV
        value: "stg" # NOTE: Supported env is local, dev, stg
      - name: CONCURRENCY
        value: "1"
      - name: DURATION
        value: "300"
      - name: CLIENT_TOKEN
        valueFrom:
          secretKeyRef:
            name: gatling-api-client-tokens-secrets-20230929180000
            key: zozo-front-pcsp
```

#### Gatlingイメージの作成とECRへのpush
Gatling Runner Podのイメージを作成し、コンテナレジストリー（ECRなど）にpushし、GatlingリソースのgatlingImageフィールドをそのコンテナレジストリーのURIで置換します。
イメージをビルドする際は、以下のようなGatlingのDockerfileを使います。シナリオファイルやリソースファイルもイメージに含めます。
https://github.com/st-tech/gatling-operator/blob/main/gatling/Dockerfile

なお、上記作業は弊社ではスクリプト化されており、そのスクリプトを1回叩くだけで済むようになっています。

#### マイクロサービスモックの構築
OSSとして公開している[prism-in-k8s](https://github.com/gold-kou/prism-in-k8s)を使って、依存するマイクロサービスのモックをKubernetesクラスター上に構築します。
前述の通り、1つのstg環境を複数のチームがそれぞれの事情で利用しているため、他のチームのマイクロサービスへ想定外の負荷をかけて迷惑をかけないように、必要に応じてモックを構築します。
会社のテックブログでも紹介しているので、よろしければ読んでください。GitHubスターをしていただけるとすごく嬉しいです。
[GoでKubernetesクラスター上にモックリソースをサクッと構築するOSSを開発しました](https://techblog.zozo.com/entry/prism-in-kubernetes)

### 試験実施
Gatlingリソースのmanifestを `kubectl apply` します。

並列度（CONCURRENCY）を1から順にあげていき、エラーがで始めるまで繰り返します。

### 結果をまとめてバックエンドチームに共有する
試験が完了するとGatling Reportが作成されるので、それを参照して資料に結果をまとめます。
具体的には、並列度ごとに「99% tile latency・5xxエラーの有無・メモリとCPU使用率」などを記載します。試験観点にあった以下を確認します。

- 目標レイテンシー以下であったか
- マイクロサービスのスケーリングに応じてスループットが線形に増えるか
- CPU使用率50%以下で捌ける1Podあたりの最大スループットはいくつであったか、その結果、Pod数はいくつ必要か

なお、Gatling Reportに記載されているレイテインシーは、zozo-benchmarkクラスターとマイクロサービス側のクラスターを跨いだ計測結果となります。したがって、EKSクラスター間のネットワークレイテンシーも含まれることになります。これは、実際のユーザーのネットワーク環境と異なり、試験結果に含めたくありません。あくまで、負荷試験で見るのは、アプリケーション処理のレイテンシーのみです。そこで、代わりに、Datadogで `"p99:trace.http.request{$env AND service IN ($app_service.value)}"` のようなクエリを叩いて取得したものを試験結果として使用しています。なお、弊社ではこのへんの情報は、Datadog Dashboardにまとめて、簡単に参照できるようにしてあります。

## 最後に
ここまで読んでくださりありがとうございました。
今回は、ZOZOのプラットフォームSREチームが普段実施しているマイクロサービスの負荷試験について紹介しました。
負荷試験の観点ややり方などは、会社やチームによりさまざまだと思います。本記事の内容が1つの参考になれば幸いです。