はじめに
こんにちは、TIG所属の大江です。
性能テストを実施する際、テスト用の大量データを投入する場合があります。本番相当のデータを入れるために、数千万ないしは数億のデータが必要になる場合もあり、データ生成には一工夫を要です。
本記事では、大量データをDynamoDB/OpenSearch Serviceに投入した際、直面した壁と対応を紹介します。
投入方針
様々な方法を調査した結果、以下の方法で投入するのが、効率が良いという結論に至りました。
これらの方法で投入するには、入力データをJSONファイル形式である必要があります。
また、対象システムでは数種のユニークIDを各項目に割り振る必要がありました。
そこで、Goのtext/templateパッケージを使いることにしました。
以下、生成スクリプトでデータ生成した際に直面した課題と解決策を紹介します。
※実際に使ったコードではOpenSearch用、DynamoDB用のJSONファイルを作成しましたが、記事の簡略化のためにOpenSearch用のもののみ記載いたします。また実際には1億5千万件のデータを生成しましたが、スクリプト実行時の軽量化のために、本記事のコード上では1万件で処理時間の計測をしています。
ダミーデータ生成スクリプト
まず1万件でどの程度の性能になるか計測します。
愚直な実装
ユニークなIDの採番としてrx/xid
を用います。テンプレートはシンプルに留めており、おそらく大体の方がこのような実装から始めると思います。
main.gopackage main
import ( "fmt" "log" "os" "text/template" "time"
"github.com/rs/xid" )
type Data struct { ItemID string HistoryID string }
func main() { start := time.Now()
if _, err := os.Create("output/index.json"); err != nil { }
outputFile, err := os.OpenFile("output/index.json", os.O_APPEND|os.O_WRONLY, 0666) if err != nil { } defer outputFile.Close()
tpl, err := template.ParseFiles("templates/index.tmpl") if err != nil { }
dataNum := 10000 for range dataNum { data := Data{ItemID: xid.New().String(), HistoryID: xid.New().String()} if err = tpl.Execute(outputFile, data); err != nil { } }
fmt.Printf("処理時間: %s\n", time.Since(start)) }
|
templates/index.tmpl{ "index": { "_index": "indexname" } } { "itemId": "{{.ItemId}}", "historyId": "{{.HistoryId}}"}
|
output/index.json(生成されたファイル){ "index": { "_index": "indexname" } } { "itemId": "crgn6i7m4csl5s5c6td0", "historyId": "crgn6i7m4csl5s5c6tdg"} { "index": { "_index": "indexname" } } { "itemId": "crgn6i7m4csl5s5c6te0", "historyId": "crgn6i7m4csl5s5c6teg"} { "index": { "_index": "indexname" } } { "itemId": "crgn6i7m4csl5s5c6tf0", "historyId": "crgn6i7m4csl5s5c6tfg"} …以降1万行繰り返し…
|
こちらの方法だと1万行に0.25秒で処理を終えることができ、1億5千万件のデータ生成を約1時間で終えられます1。
text/template実装
少しトリッキーな実装ですが、text/templateパッケージのrangeを用いて、テンプレート側でループを回した方が高速かも?と疑問に思ったので試してみます。
rangeはテンプレートに配列を渡し、テンプレート側で受け取った配列データを回してテキストを生成するものです。
main.go
func main() { start := time.Now()
dataNum := 10000
if _, err := os.Create("output/index.json"); err != nil { }
itemIds := make([]string, 0, dataNum) historyIds := make([]string, 0, dataNum) for range dataNum { itemIds = append(itemIds, xid.New().String()) historyIds = append(historyIds, xid.New().String()) } data := Data{ItemIds: itemIds, HistoryIds: historyIds}
tpl, err := template.ParseFiles("templates/index.tmpl") if err != nil { } outputFile, err := os.OpenFile("output/index.json", os.O_APPEND|os.O_WRONLY, 0666) if err != nil { } defer outputFile.Close()
if err = tpl.Execute(outputFile, data); err != nil { }
fmt.Printf("処理時間: %s\n", time.Since(start)) }
|
templates/index.tmpl{{range $i, $v := $.ItemIds -}} { "index": { "_index": "indexname" } } { "itemId": "{{index $.ItemIds $i}}", "historyId": "{{index $.HistoryIds $i}}" } {{end -}}
|
実行時間は1つ目の愚直な実装版と大差は無く、text/template版が少し遅いかも?という内容でした。どちらも1億5千万件のデータ生成を約1時間で終えられそうだと分かったため、せっかくですのでtext/templateのrangeを使ったコードを採用します。
想定外の問題
しかしいざ1億5千万件を生成しようとした際、想定外の問題が生じてしまいました…。それはローカルPCの容量不足です🔥。
1万件でも数GBにも上るテキストファイルが生成されます。1億5千万件のデータでは数TBにのぼり、とてもローカルPCで保存できませんでした。
そこで、Lambda関数を非同期で数万件ずつ複数実行し、そこからS3に直接アップロードする方法を取りました。
ローカルの容量不足への対応として、AWS Lambda上で動かす
Lambdaの処理の流れとしては以下です。
1.エフェメラルストレージにファイルを生成する
2.生成されたファイルをs3に上げる
3.エフェメラルストレージからファイルを削除する2
main.gopackage main
import ( "fmt" "log" "os" "text/template" "time"
"github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/rs/xid" )
type Data struct { ItemIDs []string HistoryIDs []string }
func handler() error {
start := time.Now()
dataNum := 10000 itemIds := make([]string, 0, dataNum) historyIds := make([]string, 0, dataNum) for range dataNum { itemIds = append(itemIds, xid.New().String()) historyIds = append(historyIds, xid.New().String()) } data := Data{ItemIDs: itemIds, HistoryIDs: historyIds}
tpl, err := template.ParseFiles("templates/opensearch-templates/index.tmpl") if err != nil { } outputFile, err := os.OpenFile("output/index.json", os.O_APPEND|os.O_WRONLY, 0666) if err != nil { } defer outputFile.Close()
err = tpl.Execute(outputFile, data) if err != nil { }
s3Client := s3.New(session.Must(session.NewSession()))
_, err = s3Client.PutObject(&s3.PutObjectInput{ Bucket: aws.String("bucketName"), Key: aws.String("objectKey"), Body: outputFile, }) if err != nil { }
err = os.Remove(outputFile.Name()) if err != nil { }
fmt.Printf("処理時間: %s\n", time.Since(start)) return nil }
func main() { lambda.Start(handler) }
|
こちらのLambdaを非同期で実行し、それぞれのLambdaで分割してテストデータファイルを生成することで、無事必要なテストデータを得ることが出来ました。
データ投入
最後に、S3に生成されたデータをOpenSearch、DynamoDBにそれぞれ投入します。
DynamoDBはコンソールよりS3バケットの対象ディレクトリを指定してインポートします。
OpenSearchに対しては以下のようなシェルを回してBulk APIを実行し、データを投入しました。
bulk_insert.sh#!/bin/bash export OPENSEARCH_ENDPOINT=openSearchEndpoint export OPENSEARCH_USERNAME=username export OPENSEARCH_PASSWORD=password export S3_BUCKET=bucketname export INDEX=indexname export LOCAL_DIRECTORY=/tmp
filenames=$(aws s3 ls s3://${S3_BUCKET}/tmp/created_opensearch_json/${INDEX}/ --recursive | awk '{print $4}')
for filename in ${filenames} do echo "Processing File: " ${filename} aws s3 cp s3://${S3_BUCKET}/${filename} ${LOCAL_DIRECTORY}/${filename} curl -u ${OPENSEARCH_USERNAME}:${OPENSEARCH_PASSWORD} -XPOST -H 'Content-Type: application/json' ${OPENSEARCH_ENDPOINT}/_bulk --data-binary @${LOCAL_DIRECTORY}/${filename} rm ${LOCAL_DIRECTORY}/${filename} done
|
無事1億5千万件のテストデータを投入することができました!
まとめ
大量のデータを作る、入れるというのはテストの準備段階ですが、意外と骨の折れることの多い作業です。
そんなときの一助になれれば幸いです。