AWS KMS 暗号化リクエストパフォーマンスを検証する

こんにちは。Eng6G バックエンドエンジニアの土屋(@hrktcy)です。もうすぐ社会人2年目ということで月日の流れがとても早く感じます。

はじめに

現在開発中のプロダクトで、API リクエストパラメータに含まれるお客様の個人情報をデータベースに暗号化して格納するためにAWS Key Management Service(KMS) を導入することになりました。導入するにあたり、KMS での暗号化のリクエストパフォーマンスが、開発中のシステムに定めたSLA に耐えうる性能かを検証する必要がありました。本記事ではその結果を共有したいと思います。

検証項目

出力するものは以下の3つです。

  • 平均暗号化処理時間(ms)
  • 各暗号化処理時間を昇順にソートした際の90パーセンタイル値(ms)
  • 最大暗号化処理時間(ms)

環境

ローカル環境でGolang で検証用のサンプルコードを書きます。そしてクロスプラットフォームビルドによって生成されたバイナリファイルをS3 にPUT し、EC2から実行します。

  • 言語
    • Golang
  • SDK
    • aws-sdk-go-v2
  • 実行場所
    • EC2(t2.micro/x86)

暗号化する文字列を作成する関数を定義する

まず、検証用の文字列[0-9a-zA-Z](32文字)を生成する関数を定義します。生成にはmath/randパッケージを用いてランダムな文字列を生成するようにします。生成数はコマンドライン引数から指定できるようにしておきます。

package main

import (
    "math/rand"
)

func randomStr(n int) string {
    letters := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    lettersLen := len(letters)
    dst := make([]uint8, n)
    for i := 0; i < n; i++ {
        l := rand.Intn(lettersLen - 1)
        dst[i] = letters[l]
    }

    return string(dst)
}

暗号化処理を行う関数を定義する

暗号化処理を行うexec 関数を定義します。この関数をgoroutine で並行処理するようにします。sync.waitGroup のDone() をdefer することで、処理の最後に完了を知らせるようにしておきます。

var wg sync.WaitGroup
var keyID = "*******" // マスターキーのKeyID
var client *kms.Client


// data 結果格納用構造体
type data struct {
    Encrypted   []byte
    ProcessTime time.Duration
}

func exec(plain string, results *[]data) {
    defer wg.Done()

    // 計測開始
    start := time.Now()

    // keyIDと暗号化する文字列を代入
    input := &kms.EncryptInput{
        KeyId:     &keyID,
        Plaintext: []byte(plain),
    }

    // 暗号化リクエスト
    output, err := client.Encrypt(context.TODO(), input)
    if err != nil {
        fmt.Printf("Got error encrypting data:%v\n", err)
        return
    }

    // 暗号化文字列と処理時間をdata型の変数に代入する
    result := data{
        Encrypted:   output.CiphertextBlob,
        ProcessTime: time.Since(start),
    }
    *results = append(*results, result)
}

main 関数を実装する

前述の関数を用いてmain 関数を実装します。main 関数ではrandomStr 関数で生成した文字列を格納する配列を用意し、KMS クライアントを生成してから暗号化処理をgoroutine で並行処理させます。なお、for ループの中でtime.Sleep() させることでTPS を調整できるように設定し、ループ処理が終了したら検証項目を計算して出力するようにします。

package main

import (
    "context"
    "flag"
    "fmt"
    "log"
    "math/rand"
    "sort"
    "sync"
    "time"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/kms"
)

// data 結果格納用構造体
type data struct {
    Plain       string
    Encrypted   []byte
    ProcessTime time.Duration
}

var wg sync.WaitGroup
var keyID = "*******" // KeyID
var client *kms.Client
var num = flag.Int("num", 100, "暗号化実行回数")
var interval = flag.Int("interval", 1, "-interval ")

// randomStr ランダム文字列を生成する関数
func randomStr(n int) string {
    letters := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    lettersLen := len(letters)
    dst := make([]uint8, n)
    for i := 0; i < n; i++ {
        l := rand.Intn(lettersLen - 1)
        dst[i] = letters[l]
    }

    return string(dst)
}

// exec 並列処理する関数
func exec(plain string, results *[]data) {
    defer wg.Done()

    // 計測開始
    start := time.Now()

    // keyIDと暗号化する文字列を代入
    input := &kms.EncryptInput{
        KeyId:     &keyID,
        Plaintext: []byte(plain),
    }

    // 暗号化リクエスト
    output, err := client.Encrypt(context.TODO(), input)
    if err != nil {
        fmt.Printf("Got error encrypting data:%v\n", err)
        return
    }

    // 暗号化文字列と処理時間をdata型の変数に代入する
    result := data{
        Encrypted:   output.CiphertextBlob,
        ProcessTime: time.Since(start),
    }
    *results = append(*results, result)
}

func main() {
    flag.Parse()

    // 検証用文字列準備
    maxStrLen := 32
    plainList := make([]string, *num)
    for i := range plainList {
        plainList[i] = randomStr(maxStrLen)
    }

    // KMSのクライアントを作成
    loadOptions := []func(*config.LoadOptions) error{config.WithRegion("ap-northeast-1")}
    sdkCfg, err := config.LoadDefaultConfig(context.TODO(), loadOptions...)
    if err != nil {
        panic("configuration error, " + err.Error())
    }
    client = kms.NewFromConfig(sdkCfg)

    // 暗号化処理実行
    results := make([]data, 0)
    for _, v := range plainList {
        wg.Add(1)
        go exec(v, &results)
        time.Sleep(time.Millisecond * time.Duration(*interval))
    }

    // 終了を待つ
    wg.Wait()

    // 結果判定
    success := len(results)
    if success < 1 {
        log.Fatal("empty success result")
    }

    // あらかじめ昇順でソートしておく
    sort.Slice(results, func(i, j int) bool {
        return results[i].ProcessTime < results[j].ProcessTime
    })

    // 平均暗号化処理時間を計算
    total := int64(0)
    for _, v := range results {
        total = total + v.ProcessTime.Milliseconds()
    }
    fmt.Printf("avg:%dms\n", total/int64(success))

    // 90パーセンタイル値を計算
    percentile := int(float64(success)*0.9 - 1)
    fmt.Printf("Percentile:%dms\n", results[percentile].ProcessTime.Milliseconds())

    // 最大暗号化処理時間を計算
    fmt.Printf("max:%dms\n", results[success-1].ProcessTime.Milliseconds())
}

検証結果

500(req/sec)で400個の文字列暗号化を行った場合

avg(ms)90 percentile(ms)max(ms)
9968

500(req/sec)で1000個の文字列暗号化を行った場合

avg(ms)90 percentile(ms)max(ms)
7861

暗号化処理を増やした場合でも各暗号化処理時間に大きな差は生まれませんでした。しかし、最大暗号化処理時間は90パーセンタイル値よりも大幅に時間がかかっていることがわかりました。この値は初回暗号化処理時間の値でしたので、2回目以降はKMS クライアントを使い回しており、初回はKMS クライアントを読み込んでいる分時間がかかっているのではないかという推測をしました。

暗号化処理毎にKMSクライアントを読み込んでみたらどうか

main 関数内で行っているKMS クライアント作成処理をexec 関数内に移動し、暗号化リクエストの都度クライアントを作成するように変更してリクエストパフォーマンスを検証してみます。

同様の条件で検証を試みたところ、以下のIMDSの認証エラーが生じてしまい暗号化が行えませんでした。

Got error encrypting data:operation error KMS: Encrypt, failed to sign request: failed to retrieve credentials: no EC2 IMDS role found, operation error ec2imds: GetMetadata, http response error StatusCode: 429,request to EC2 IMDS failed

[参考]:429 Too Many Requests

そこで、暗号化処理数を減らして処理速度を計測してみます。

500(req/sec)で10個の文字列暗号化を行った場合

avg(ms)90 percentile(ms)max(ms)
526573

500(req/sec)で100個の文字列暗号化を行った場合

avg(ms)90 percentile(ms)max(ms)
74921132158

処理数を増やすと応答時間が長くなることが分かりました。TPS を下げてみたらどうなるでしょうか。

100(req/sec)で100個の文字列暗号化を行った場合

avg(ms)90 percentile(ms)max(ms)
283954

TPS を下げると各処理時間もだいぶ落ち着きました。

まとめ

KMS クライアントを使い回すパターンでは初回暗号化処理時間に少し時間がかかるものの、暗号化処理数を変えても各処理時間に大きな差は生まれませんでした。一方、都度KMS クライアントを読み込ませるパターンでは、応答時間が長くなる+大量の暗号化処理を行う場合はTPS を下げる必要がありそうです。