WEARアプリリニューアルにおける負荷試験事例(実施編)

WEARアプリリニューアルにおける負荷試験事例(実施編)

はじめに

こんにちは! WEARバックエンド部バックエンドブロックの小島(@KojimaNaoyuki)です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。

10周年を迎えたWEARは2024年5月9日に大規模なアプリリニューアルを行いました。アプリリニューアルに伴い負荷試験を行ったので、本記事ではどのように負荷試験を実施したか事例をご紹介します。

記事は計画編と実施編の2部構成で、本記事は後編の実施編です。前編の計画編は「WEARアプリリニューアルにおける負荷試験事例(計画編)」で公開していますので、まだ閲覧していない方はぜひご覧ください。

techblog.zozo.com

目次

背景

負荷試験を実施する背景については、計画編をご覧ください。

負荷試験の要件

計画編にて設定した負荷試験を実施するためには、以下の要件を満たす必要があります。

  • 約100個のAPIに対して、一斉に負荷をかけられること
  • それぞれのエンドポイントにかける負荷を変更できること
  • 指定の期間で負荷をかけられること
  • 本番環境で実施できること

次章からはこれらの負荷試験の要件を実現するために実施した事例と、その時に発生した課題とその解決策を記載します。

使用した負荷試験ツール

k6というツールを利用して負荷試験を実施しました。

今回の負荷試験に採用した理由は、WEARでは現在チーム内で共通の負荷試験基盤としてk6環境が整備されており、普段から利用しているためです。また、今回の負荷試験の要件もk6を利用することで十分に満たすことができると判断しました。

チームの負荷試験基盤としてk6を導入した経緯などについては、「WEARにおけるKubernetesネイティブな負荷試験基盤の導入とその効果」の記事にまとまっていますのでご興味あればご覧ください。

負荷試験シナリオ作成

初めに今回の負荷試験で使用したシナリオコード例を以下に記載します。その後に解説します。

コード例

  • main.js
import {
    get_v1_users,
    get_v1_articles,
    // ...省略
} from './exec_functions.js'

export {
    get_v1_users,
    get_v1_articles,
    // ...省略
}

const duration = '1s'
const coefficientRate = 1
const maxVus = 50

export const options = {
    summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.99)', 'count'],
    scenarios: {
        get_v1_users: {
            executor: 'constant-arrival-rate',      
            duration: duration,                     
            rate: Math.ceil(coefficientRate * 10),  
            timeUnit: '1s',                         
            preAllocatedVUs: 1,                     
            maxVUs: maxVus,                         
            exec: 'get_v1_users'                    
        },
        get_v1_articles: {
            // ...省略
        }
        // ...省略
    },
}
  • exec_functions.js
import http from 'k6/http'
import { check } from 'k6'

import {
  getMemberUserNameRandomly,
  // ...省略
} from './dynamic_parameters.js'

export const get_v1_users = () => {
    const headers =  { /* ...省略... */ }

    let response = http.get(`https://example.com/v1/users`, { headers: headers }, { tags: { my_custom_tag: get_v1_users } })
    let result = check(response, { "status is 200": (r) => r.status === 200 })

    if (result === false) {
        console.warn(`status: ${response.status}\turl: ${response.url}`)
    }
}

export const get_v1_articles = () => {
    // ...省略
}

// ...省略
  • dynamic_parameters.js
const getRandomly = (array) => {
  const randomIndex = Math.floor(Math.random() * array.length);

  return array[randomIndex];
}

export const getMemberUserNameRandomly = () => {
  const testUserNames = ['hoge1', 'hoge2', 'hoge3', 'hoge4', 'hoge5', /* ...省略... */]
  
  return getRandomly(testUserNames)
}

// ...省略

解説

今回の負荷試験では約100個のAPIに対して、一斉に負荷をかける必要があり、それぞれのエンドポイントにかける負荷を調節できることや負荷をかける期間を指定できる必要があります。

そこで、基本的に1シナリオには1エンドポイントを呼び出し、それら複数のシナリオをまとめて実行することにしました。そして、指定期間に指定の負荷(RPS)をかける必要があったため、executorはconstant-arrival-rateを利用しました。executorについての詳細は後述しています。また、動的にパラメータの値を変更したい箇所があったため、リソース毎に関数化し、それらのパラメータはファイルを分けて管理しました。

ファイル構成について

シナリオで実行する関数をまとめたexec_functions.jsと動的にしたいパラメータデータをまとめたdynamic_parameters.jsとシナリオを記述するmain.jsを作成しました。

今回の負荷試験は本番環境で実施していたため、実施する時間帯によって負荷を変える必要があり、複数のシナリオを作成する必要がありました。その際、実行する関数は共通で再利用できるため、exec_functions.jsに切り出して共通化しました。

動的にパラメータの値を変更したい箇所は、リソース毎に関数化してdynamic_parameters.jsにまとめ、それらを適宜exec_functions.jsの関数から呼び出すようにしました。

仮想ユーザー(VU)について

仮想ユーザー(VU)という概念があります。VUは、シナリオを実行するための仮想的なユーザーです。VUは任意の数を用意でき、VU数を増やすことで並列にリクエストを送信できる数を増やせます。

VU数の算出方法については、後述の「executorについて」で説明します。

executorについて

executorとは、VUがシナリオを実行する方法を制御するものです。詳しくはExecutors | Grafana k6 documentationをご覧ください。executorには様々な種類が存在し、実現したい負荷試験の要件によって適切なexecutorを選択する必要があります。今回の負荷試験ではconstant-arrival-rateを利用しました。

constant-arrival-rateは利用可能なVUが存在する限り、指定した期間に指定したレートで繰り返しシナリオを実行し続けます。そのため、今回の「指定負荷(RPS)を指定期間かけ続ける」という要件に適切と考えて利用しました。

注意点として、1回のシナリオの実行時間と指定したrate, timeUnit次第で、想定の負荷をかけるために必要な数のVUが増減するため適切なmaxVUsを指定する必要があります。もしもVUの数が枯渇すると想定の負荷がかからなくなります。今回の負荷試験では、k6のログに出力される使用しているVU数とk6実行環境のリソース使用量を確認しつつ適切な値を設定しました。

coefficientRateについて

シナリオのrate定義にMath.ceil(coefficientRate * 10)としている部分があります。これにより、いきなり負荷試験で確認したい想定負荷量の100%の負荷をかけるのではなく、係数をかけて段階的に負荷をかけられます。

rateとtimeUnitについて

今回の負荷試験ではRPSの単位で負荷をかける必要があったため、1秒間に何回繰り返すかを指定する必要がありました。そのため、timeUnitを1sに設定し、rateに繰り返す回数を設定しました。

発生した課題とその解決策

リソースの作成と削除を実施するAPIの負荷試験の問題

今回は本番環境で負荷試験を実施していたため、負荷試験で作成したテストデータはできるだけ残らないように削除する必要がありました。こちらは、リソースの作成と削除を1つのシナリオに順番で記載することで、負荷試験で発生したデータを本番環境に残すことなく実施できました。

実際のユーザーに紐付くデータとしては作成できないため、テスト用のアカウントを本番環境に用意し、そのアカウントで負荷試験を実施しました。

大量のシナリオ作成の問題

今回の負荷試験ではシナリオの総数は200程度と大量だったため、手作業で作成することが困難でした。そこで、計画段階にエンドポイント名や負荷量などの情報はGoogleスプレットシートに記載していたためそこからシナリオを生成するGoogle Apps Scriptを作成し、大部分を自動生成しました。

自動生成するにあたって、シナリオで実行する関数の命名は一意にする必要があったため、以下のルールで実施しました。

  • httpメソッド + apiのパス
    • 例: GET v1/articles → get_v1_articles

負荷試験実施

シナリオは本番環境の現行負荷を考慮して適切な負荷量になるように、時間帯毎に負荷量を調節したシナリオを複数用意して実施しました。

初めから想定される100%の負荷量で試験を実施するのではなく、都度結果を確認しながら10%→30%→50%→100%と負荷量を段階的に上げて実施しました。

また、負荷試験の実施中はエラーなどを常に監視し、ユーザ影響が出た場合にすぐに負荷を中止できるように準備しておきました。

発生した課題とその解決策

負荷試験中にロックが発生してしまう問題

負荷試験を実施したところ、デッドロックやロック起因のDBタイムアウトによるAPIエラーが多数発生してしまいました。監視体制を整えていたため、すぐに負荷試験を中断し、ロック原因の調査をしました。

ロックが発生したリソースとブロッカーとなるAPIを調査した結果、負荷試験シナリオの問題であることが分かりました。具体的には、とある親リソースに紐づいている子リソースをまとめて取得するAPIと、その子リソースを削除するAPIが同時に実行されるシナリオになっており、リソースの競合が発生していたためでした。

取得するAPIと削除するAPIとでリソースが競合しないように、それぞれパラメータに渡すリソースを調節することで解決しました。

十分なmaxVUsを指定していても想定のRPSにリクエスト数が届かない問題

負荷試験を実施中に、十分なmaxVUsを指定していたにもかかわらず、予想したRPSに実際のリクエスト数が到達しない状況になることもありました。

原因としては、k6を実行していたコンピューターの性能が不足しており、maxVUsまでVUの数を増やすことができていなかったことが原因でした。

そのため、k6を実行するコンピューターの数を増やすことで解決しました。

まとめ

負荷試験では、想定した負荷量をかけることができ、問題なく本番環境で負荷試験を実施できました。そして、APIのレイテンシやCPU使用率などの問題を未然に発見でき、リリース前に改善できました。これは負荷試験を実施しなければ発見することが難しかったことと思われるため、負荷試験の成果と言えます。

一方で、今回の負荷試験には実際のユーザーへの影響が出てしまう危険性や、工数面などデメリットも存在しました。しかし、今回の負荷試験は安全なWEARリニューアルリリースを実現するために必要な作業であったと考えています。

おわりに

WEARアプリのリニューアルにおける負荷試験の実施事例についてご紹介しました。シナリオ作成時には、負荷試験を計画する時に設定した要件を満たすことを、負荷試験を実施する時には、安全で正確な負荷試験を実施することが求められます。WEARでの事例が参考になれば幸いです。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー