michimani.net

Go で実装した Lambda 関数から RDB に接続したときのコネクションの増え方

2021-08-20

Go で実装した Lambda 関数から RDB に接続した際に、DB 接続数の増え方が実装方法によってどのように変わるのかを確認してみました。

目次

前置き

Lambda + RDS はアンチパターンとして語られることも多いです。その理由としてよく話題にあがるのは、Lambda 関数が大量に起動することによるコネクションの枯渇問題です。今回は、その課題に対する実装側での対処法方法を実際に試してみました。

試したいこと

コネクション枯渇への対処方法としては、 RDS Proxy の利用や実装方法による対応がありますが、今回は実装方法による対応を試します。

具体的には、同じ実行環境での起動 (ウォームスタート) が連続したときに、 DB 接続数がどのように増えるのか、または増えないのかを確認します。実装方法のパターンとしては下記の 3 つです。

  1. handler 内で DB への接続を確立し、 DB 接続を明示的に Close しない
  2. handler 内で DB への接続を確立し、 handler の処理の最後で DB 接続を明示的に Close する
  3. 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.

sql · database/sql · pkg.go.dev

Lambda + RDS のアンチパターンが語られる際のメインの項目であろうコネクション枯渇の問題ですが、仮に RDS Proxy を使うにしても不要な接続を増やすのは良くないので、実装レベルでの対応は必須かなと思いました。


comments powered by Disqus