Go で実装した Lambda 関数から RDB に接続したときのコネクションの増え方
2021-08-20Go で実装した Lambda 関数から RDB に接続した際に、DB 接続数の増え方が実装方法によってどのように変わるのかを確認してみました。
目次
前置き
Lambda + RDS はアンチパターンとして語られることも多いです。その理由としてよく話題にあがるのは、Lambda 関数が大量に起動することによるコネクションの枯渇問題です。今回は、その課題に対する実装側での対処法方法を実際に試してみました。
試したいこと
コネクション枯渇への対処方法としては、 RDS Proxy の利用や実装方法による対応がありますが、今回は実装方法による対応を試します。
具体的には、同じ実行環境での起動 (ウォームスタート) が連続したときに、 DB 接続数がどのように増えるのか、または増えないのかを確認します。実装方法のパターンとしては下記の 3 つです。
- handler 内で DB への接続を確立し、 DB 接続を明示的に Close しない
- handler 内で DB への接続を確立し、 handler の処理の最後で DB 接続を明示的に Close する
- handler 外で DB 接続インスタンスを定義し、 handler 内では接続の存在をチェックして無ければ確立する
検証環境
今回はローカルで Lambda と RDB (MySQL 8.0) のコンテナを立てて検証しました。
実際に Lambda + RDS の環境を構築するのは面倒だったのと、ローカルだと Lambda のコールドスタート/ウォームスタートを再現しやすいこともあって、ローカルでの検証としています。
具体的には下記のようなファイル群を用意して、 docker-compose
でまとめて起動します。
.
├── docker-compose.yml
├── lambda-close-db
│ ├── Dockerfile
│ ├── entry.sh
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── lambda-not-close-db
│ ├── Dockerfile
│ ├── entry.sh
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── lambda-reuse-db
│ ├── Dockerfile
│ ├── entry.sh
│ ├── go.mod
│ ├── go.sum
│ └── main.go
└── mysql
├── Dockerfile
└── init.sql
ソースコードは GitHub に置いてあるので、 clone して docker-compose up -d
を叩けばお手元で検証してもらえるはずです。
michimani/lambda-rdb-test | GitHub
やってみる
まずは必要なコンテナを起動します。
$ docker-compose up -d
$ docker-compose ps
Name Command State Ports
-------------------------------------------------------------------------------------------------------------
lambda-rdb docker-entrypoint.sh mysqld Up 0.0.0.0:8000->3306/tcp,:::8000->3306/tcp, 33060/tcp
lambda-rdb-func-1 /entry.sh /main Up 0.0.0.0:8001->8080/tcp,:::8001->8080/tcp
lambda-rdb-func-2 /entry.sh /main Up 0.0.0.0:8002->8080/tcp,:::8002->8080/tcp
lambda-rdb-func-3 /entry.sh /main Up 0.0.0.0:8003->8080/tcp,:::8003->8080/tcp
3 つの Lambda 関数と RDB のコンテナが起動しました。
別セッションで RDB のコンテナに入っておいて、 Lambda 関数を invoke するたびに接続数がどのように変化するかを見ていきます。
$ docker exec -it lambda-rdb bash
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 1 |
+-------------------+-------+
Lambda 関数を invoke する前の時点での接続数は 1 です。(この SQL を実行するための接続です)
1. handler 内で DB への接続を確立し、 DB 接続を明示的に Close しない場合
まずは handler 内で毎回 DB 接続を確立するパターンで試してみます。実装はこんな感じです。
package main
import (
"database/sql"
"fmt"
"net/http"
"os"
runtime "github.com/aws/aws-lambda-go/lambda"
_ "github.com/go-sql-driver/mysql"
)
type Response struct {
Message string `json:"message"`
StatusCode int `json:"statusCode"`
}
func handleRequest() (Response, error) {
// Create DB connection each invocation, and do not close it.
db, err := initDB()
if err != nil {
fmt.Println(err.Error())
return Response{Message: err.Error(), StatusCode: http.StatusInternalServerError}, err
}
title, err := getTitle(db)
if err != nil {
fmt.Println(err.Error())
return Response{Message: err.Error(), StatusCode: http.StatusInternalServerError}, err
}
return Response{
Message: fmt.Sprintf("title: %s", *title),
StatusCode: http.StatusOK,
}, nil
}
func initDB() (*sql.DB, error) {
dbName := os.Getenv("DB_DATABASE")
dbUser := os.Getenv("DB_USER")
dbPass := os.Getenv("DB_PASS")
d, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(db:3306)/%s", dbUser, dbPass, dbName))
if err != nil {
return nil, err
}
if err := d.Ping(); err != nil {
return nil, err
}
return d, nil
}
func getTitle(db *sql.DB) (*string, error) {
var title string
sql := "SELECT title FROM sample_table LIMIT 1"
err := db.QueryRow(sql).Scan(&title)
if err != nil {
return nil, err
}
return &title, nil
}
func main() {
runtime.Start(handleRequest)
}
この Lambda 関数は 8001
ポートで起動しているので、下記コマンドで invoke します。
$ curl "http://localhost:8001/2015-03-31/functions/function/invocations"
出力としては下記の通りです。実行時に特にエラーがなければ statusCode: 200
でのレスポンスが出力されます。
{"message":"title: This is a sample record","statusCode":200}
1 回実行した時点で DB 接続数を確認してみます。
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 2 |
+-------------------+-------+
2
になっています。続いて、 10 回実行してから接続数を確認してみます。
$ for i in `seq 10`; do
curl "http://localhost:8001/2015-03-31/functions/function/invocations"
done
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 12 |
+-------------------+-------+
実行回数分だけ増えてます。
この結果から、 Lambda 関数が同じ実行環境で実行されている間は、一度接続した DB 接続が残り続けているのがわかります。
実行環境を作り直す場合、ローカル環境では Lambda 関数のコンテナを再起動すれば OK です。(実際の Lambda では、実行環境が入れ替わるタイミングはわかりません)
$ docker-compose restart lambda1
再起動後に接続数を確認してみます。
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 1 |
+-------------------+-------+
1
に戻りました。
このパターンが、いわゆるコネクションの枯渇につながる実装方法です。
2. handler 内で DB への接続を確立し、 handler の処理の最後で DB 接続を明示的に Close する
続いては、 1 のパターンと同じく毎回 handler 内で DB 接続を確立するが、 handler 内で明示的に接続を Close するようにします。 1 との差分は下記のとおりで、 defer db.Close()
を追加しています。
--- ./lambda-not-close-db/main.go 2021-08-19 23:47:39.000000000 +0900
+++ ./lambda-close-db/main.go 2021-08-19 23:47:47.000000000 +0900
@@ -16,13 +16,16 @@
}
func handleRequest() (Response, error) {
- // Create DB connection each invocation, and do not close it.
+ // Create DB connection each invocation.
db, err := initDB()
if err != nil {
fmt.Println(err.Error())
return Response{Message: err.Error(), StatusCode: http.StatusInternalServerError}, err
}
+ // Close DB connection at end of each invocation.
+ defer db.Close()
+
title, err := getTitle(db)
if err != nil {
fmt.Println(err.Error())
この Lambda 関数は 8002
ポートで起動しているので、下記コマンドで invoke します。(出力は 1 と同じなので割愛します。)
$ curl "http://localhost:8002/2015-03-31/functions/function/invocations"
実行後に接続数を確認します。
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 1 |
+-------------------+-------+
1
のままです。
先ほどと同じように 10 回実行してから確認してみます。
$ for i in `seq 10`; do
curl "http://localhost:8002/2015-03-31/functions/function/invocations"
done
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 1 |
+-------------------+-------+
かわらず 1
のままです。
これがコネクション枯渇への実装方法による対処法のひとつです。毎回 明示的に DB 接続を Close することで、同じ実行環境での実行であっても DB の接続数が増えることはありません。
3. handler 外で DB 接続インスタンスを定義し、 handler 内では接続の存在をチェックして無ければ確立する
最後に、 DB 接続インスタンスを handler 外 で定義、つまりグローバル変数として定義しておいて、 handler 内では接続の存在チェックをして無ければ確立するような実装で試します。これは、コールドスタート時に接続が確立され、それ以降は同じコネクションを使いまわすような実装です。 1 との差分は下記のとおりです。
--- ./lambda-not-close-db/main.go 2021-08-19 23:47:39.000000000 +0900
+++ ./lambda-reuse-db/main.go 2021-08-19 23:49:43.000000000 +0900
@@ -15,12 +15,19 @@
StatusCode int `json:"statusCode"`
}
+// Define the DB connection as a global variable.
+var db *sql.DB
+
func handleRequest() (Response, error) {
- // Create DB connection each invocation, and do not close it.
- db, err := initDB()
- if err != nil {
- fmt.Println(err.Error())
- return Response{Message: err.Error(), StatusCode: http.StatusInternalServerError}, err
+ // Create a DB connection only if it is nil.
+ if db == nil || db.Ping() != nil {
+ d, err := initDB()
+ if err != nil {
+ fmt.Println(err.Error())
+ return Response{Message: err.Error(), StatusCode: http.StatusInternalServerError}, err
+ }
+
+ db = d
}
title, err := getTitle(db)
この Lambda 関数は 8003
ポートで起動しているので、下記コマンドで invoke します。 (出力は 1 と同じなので割愛します。)
$ curl "http://localhost:8003/2015-03-31/functions/function/invocations"
接続数を確認します。
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 2 |
+-------------------+-------+
2
になっています。これまでと同様に 10 回実行してから確認してみます。
$ for i in `seq 10`; do
curl "http://localhost:8003/2015-03-31/functions/function/invocations"
done
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 2 |
+-------------------+-------+
初回に確立された接続を再利用するので、 2
のままです。
Lamdba のコンテナを再起動 (または停止) すれば、初回に確立された 1 つの接続は解放され、 1
に戻ります。
$ docker-compose restart lambda3
root@c97cb098b401:/# mysql -proot -e'show status like "Threads_connected";'
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 1 |
+-------------------+-------+
まとめ
Go で実装した Lambda 関数から RDB に接続する際に、実装方法によって接続数がどのように変化するのかを試してみました。
Go の database/sql
パッケージには明示的に Close する必要はない旨の記載がありますが、 Lambda 関数の実装をする際には注意が必要そうです。
The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB.
Lambda + RDS のアンチパターンが語られる際のメインの項目であろうコネクション枯渇の問題ですが、仮に RDS Proxy を使うにしても不要な接続を増やすのは良くないので、実装レベルでの対応は必須かなと思いました。
comments powered by Disqus