こんにちは、22新卒で、PR TIMES開発本部でバックエンド開発をしている宮崎です。
先日、デジタル創作同好会traPさんと、社内ISUCONイベントを開催しました。今回は、その準備で作成したLambda関数の紹介をします。
「ISUCON」は、LINE株式会社の商標または登録商標です。
当日の様子
会社にtraPの方々をお招きし、オフラインで行いました。traPからは20名以上参加いただきました。今回使用した問題はこちらです。
最終的に30万点を超えたチームが2つあり、398,912点を獲得したチームが優勝しました🎉

問題を解き終わった後に懇親会を行い、実際にどんな改善をしたかなどを話しました。PR TIMESの開発本部の社員もtraPの方々とコミュニケーションをとり、お互い刺激的な1日になったと思います。

Lambda関数の実装
構成
ISUCONのベンチマーカーをLambdaで構築する記事はいくつかありますが、今回はLambda 関数 URL (Lambda function URLs)を使って、API Gatewayを挟まずに直接Lambdaで専用のHTTPエンドポイントを用意し構築しました。
こうすることで、API Gatewayを用意せずに済みます。また、API Gateway は30秒でタイムアウト(2023年1月時点)になってしまう制限がありますが、Lambda 関数 URL は Lambda の タイムアウトの制限が適用されるため、最大15分(2023年1月時点)まで伸ばすことが可能という利点があります。
しかし、この仕組みだと、ある程度スコアの高いチームはLambdaのファイルディスクリプタが足りなくなり、ベンチマーカーが動かなくなってしまうという欠点もあります。実際に今回も発生しました。そのため、この事象が発生したチームのために、ベンチマーカー用のインスタンスをEC2で起動し、MackerelにPOSTするだけのCLIをGoで作り、シェルスクリプトでベンチマーカーを実行し、結果をPOSTする仕組みを突貫で作り対応しました。
競技者はcurlでLambda 関数 URL で作成したURLを叩くとベンチマークが走るようになっています。
グラフでのスコア表示はMackerelにしました。また、普段からLambdaはGoで書いているので、Goを採用しました。
参考記事

構成をまとめると以下のようになります。

参考記事の構成と比較して、API Gatewayがないことがわかります。
Lambda関数
Lambdaでは以下の項目を実装します
- リクエストを送ってきたインスタンスのIPアドレスを取得する
- スプレッドシートにIPアドレスとチーム名を書いておいて、チーム名を取得
- ベンチマークを実行
- Mackerelにチーム名とスコアを送る
- ベンチマークの結果を返す
詳しく見ていきます。
リクエストを送ってきたインスタンスのIPアドレスを取得する
LambdaFunctionURLRequest を使い、IPアドレスを取得します。
func lambdaHandler(req events.LambdaFunctionURLRequest) (events.LambdaFunctionURLResponse, error) {
clientIP := req.RequestContext.HTTP.SourceIP
}
スプレッドシートにIPアドレスとチーム名を書いておいて、チーム名を取得
IPアドレスとチーム名を列とするシートを用意します。
スプレッドシート側で、Google Sheets APIを有効化する必要がある点に注意してください。設定を進めていくとJSONファイルがダウンロードできるので、option.WithCredentialsJSONを使い、環境変数から取得しています。
このように実装しました。
func getTeamNameFromSpreadSheets(clientIP string) (string, error) {
spreadsheetID := os.Getenv("SPREADSHEETID")
credential := option.WithCredentialsJSON([]byte(os.Getenv("SPREADSHEET_CREDENTIALS_JSON")))
srv, err := sheets.NewService(context.TODO(), credential)
if err != nil {
log.Println(err)
return "", err
}
readRange := "シート1!A2:B50"
res, err := srv.Spreadsheets.Values.Get(spreadsheetID, readRange).Do()
if err != nil {
log.Println(err)
return "", err
}
if len(res.Values) == 0 {
err := errors.New("could not get data")
log.Println(err)
return "", err
}
for _, row := range res.Values {
if row[0] == clientIP {
s, ok := row[1].(string)
if !ok {
return "", errors.New("failed to convert team name")
}
return s, nil
}
}
return "", errors.New("client ip address and team name did not match")
}
呼び出し側で以下のようにします。
teamName, err := getTeamNameFromSpreadSheets(clientIP)
if err != nil {
return getError(err)
}
ベンチマークを実行
private-isu/benchmarker/をbuildし、そのファイルを任意のディレクトリに配置します。この時、GOOSの指定を忘れないようにしてください。
GOOS=linux GOARCH=amd64 go build -o benchmarker
また、ベンチマークの実行には、userdataを渡す必要があるので、こちらを参照して必要なファイルをあらかじめ配置しておきます。この時、Lambdaにデプロイする時にデプロイパッケージの制限を超えないように注意してください。Lambdaから直接アップロードの場合は50MBまで、S3からは解凍後250MBまで(2023年1月時点)。そのため、画像ファイルをいくつか削除することで制限に収まるように対応しました。
構造体を定義します。
type Output struct {
Pass bool `json:"pass"`
Score int64 `json:"score"`
Suceess int64 `json:"success"`
Fail int64 `json:"fail"`
Messages []string `json:"messages"`
}
exec.Commandを使い、ベンチマークを実行し、結果のJSONを構造体に変換します。
targetURL := "http://" + clientIP
result, err := exec.Command("./bin/benchmarker", "-t", targetURL, "-u", "./userdata").Output()
if err != nil {
return getError(err)
}
var output Output
err = json.Unmarshal(result, &output)
if err != nil {
return getError(err)
}
Mackerelにチーム名とスコアを送る
公式が提供しているライブラリを使いました。サービスメトリックの投稿のAPIを呼び出します。
apiKey := os.Getenv("MACKEREL_API_KEY")
client := mackerel.NewClient(apiKey)
err = client.PostServiceMetricValues("My-Service", []*mackerel.MetricValue{
{
Name: "Score." + teamName,
Time: time.Now().Unix(),
Value: output.Score,
},
})
if err != nil {
return getError(err)
}
ベンチマークの結果を返す
ベンチマークの実行は完了しているので、あとは結果を返すだけです。
return events.LambdaFunctionURLResponse{
Body: string(result),
StatusCode: 200,
}, nil
実際に動かす
序盤は順調でしたが、途中で {"message":"exit status 2"}
とだけ返ってくる事象が発生しました。これはベンチマークでFailした時に出てくるもので、ログを見てみると、このメッセージ以外何も出ていませんでした。この記事を参考に標準出力とエラーを区別して取得するようにし、ログを出すように修正しました。
cmd := exec.Command("./bin/benchmarker", "-t", targetURL, "-u", "./userdata")
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
log.Printf("Stdout: %s\n", stdout.String())
log.Printf("Stderr: %s\n", stderr.String())
return getError(err)
}
結果を返すところも修正します。
err = json.Unmarshal(stdout.Bytes(), &output)
...(中略)
return events.LambdaFunctionURLResponse{
Body: stdout.String(),
StatusCode: 200,
}, nil
出るようになったログの例がこちらです。
2023/01/13 06:33:35 Stdout: {
"pass": false,
"score": 78,
"success": 72,
"fail": 1,
"messages": [
"ユーザー名が表示されていません (GET /)"
]
}
最後に
社内ISUCONを開催する時に参考にしていただければと思います。
PR TIMESでは、普段はPHPを用いたアプリケーション開発や開発環境の改善を行なっていますが、サーバレスで開発する時はGoを用いてこのような開発をしています。
会社の雰囲気や技術スタックにご興味がある方は、ぜひ一度カジュアル面談に応募してくださると幸いです。
また、2月に新卒向けにハッカソンを行います!こちらの応募もお待ちしております。