SQLサーバーに接続してSQLを実行するlambdaをTerraformで作成してみる

## はじめに
こんにちは！[Fusic Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fusic)の12日目を担当します@pensuke628です。
最近AWSのお仕事でLambdaを使い始めましたが、Lambdaって便利だな〜と感じる日々です（感想が薄い）
今回は、SQLサーバーに対してSQLを実行するlambdaをTerraformを作ってみたので、記事にしたいと思います。使用したterraformのバージョンは以下になります。

```
$ terraform -v
Terraform v1.5.4
```

また、最終的なフォルダの構造は以下のようになっています。
```
.
├── lambda_function.py
├── lambda_function.zip
├── pyodbc.zip
└── terraform
    ├── backend.tf
    ├── main.tf
    ├── provider.tf
    ├── terraform.tfstate
    ├── terraform.tfstate.backup
    └── variables.tf
```

## 今回構築するもの
![create_db_lambda.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1382306/6bdce404-7880-5574-a64d-196ebf7b7446.png)

今回はこのような構成でAWSサービスを構築していこうと思います。
SQLサーバーの接続に必要なエンドポイントやpasswordなどをParameter Storeから取得してきて、それらを基にSQLサーバーに接続し、SQLを実行するといった構成になっています。

## STEP1 lambdaで使用するライブラリを用意する
今回、SQLサーバーに接続するために、pyodbcというライブラリを使用します。
lambdaで標準で用意されているライブラリを使用する場合、特に気にせずlambdaのソースコード内でimportすれば良いのですが、pyodbcはlambdaに標準で用意されていないライブラリです。そのため、ライブラリをzip化したものをlambdaのlayersに追加する必要があります。以下の記事を参考にさせていただきました。ありがとうございます！

https://qiita.com/Todate/items/03c5d3911b52b93d39af

今回は、`pyodbc.zip`というファイル名でzip化しています。

## STEP2 lambdaで実行する関数を記述する
lambdaで実行したい処理を記述したソースコードを用意します。
今回はSQLサーバー内のデータベース一覧を出力するSQLを実行するような簡単なコードを実装します。ランタイムは使ったことがあまりないpythonを使用してみました。
boto3の部分は以下のドキュメントを参考にしました。

https://boto3.amazonaws.com/v1/documentation/api/latest/index.html

```lambda_function.py
import boto3
import pyodbc

def list_databases(cursor):
    cursor.execute("SELECT name FROM sys.databases;")
    databases = cursor.fetchall()
    print("Databases on server:")
    for db in databases:
        print(db[0])

def lambda_handler(event, context):
    ssm = boto3.client('ssm')
    
    # SQLサーバーへの接続情報が格納されたParameter Storeのpathを取得
    sql_server_endpoint_param = event.get('endpoint', '')
    sql_server_username_param = event.get('username', '')
    sql_server_password_param = event.get('password', '')

    # ParameterStoreから値を取得
    response = ssm.get_parameters(
        Names = [
            sql_server_endpoint_param,
            sql_server_username_param,
            sql_server_password_param,
        ],
        WithDecryption=True
    )
    values = {param['Name']: param['Value'] for param in response['Parameters']}
    
    driver = '{ODBC Driver 17 for SQL Server}'
    server =  values[sql_server_endpoint_param]
    port = '1433'
    username = values[sql_server_username_param]
    password = values[sql_server_password_param]

    connection_string = f'DRIVER={driver};SERVER={server},{port};UID={username};PWD={password}'
    
    with pyodbc.connect(connection_string, autocommit=True) as connection:
        with connection.cursor() as cursor:
            list_databases(cursor)
```

こちらを以下のコマンドでzip化しておきます。

```
zip lambda_function.zip lambda_function.py
```

## STEP3 TerraformでAWSサービスを構築する
ここからやっとこさ、terraformでAWSのサービスを構築していきます。本当はmoduleとしてサービス毎にまとめて書こうとも思ったですが、そんなに構築するリソースはないだろうと思って、main.tfにベタ書きしております。（結構多くなってしまい反省しています。。）いくつかのリソースごとに分けて書いていこうと思います。

### VPCとサブネット

```main.tf
resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"
  enable_dns_support = true
  enable_dns_hostnames = true
}

# SQLSeverとLambdaを設置するsubnet
resource "aws_subnet" "private_db_subnet_1a" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = "10.0.1.0/24"
  availability_zone = "ap-northeast-1a"
  map_public_ip_on_launch = true
}

resource "aws_subnet" "private_db_subnet_1c" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = "10.0.2.0/24"
  availability_zone = "ap-northeast-1c"
  map_public_ip_on_launch = true
}

resource "aws_subnet" "vpce" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = "10.0.3.0/24"
  availability_zone = "ap-northeast-1a"
  map_public_ip_on_launch = true  
}

resource "aws_db_subnet_group" "db_subnet_group" {
  subnet_ids = [ 
    aws_subnet.private_db_subnet_1a.id,
    aws_subnet.private_db_subnet_1c.id
  ]
}
```
VPCとそれぞれのリソースを設置するためのサブネットを作成しています。
SQLサーバー用のサブネットを2つ作っている理由は、サブネットグループを作成するためです。

### Security Groups

```　main.tf
# SQLServerにアタッチするSecurity Group
resource "aws_security_group" "db" {
  vpc_id = aws_vpc.vpc.id
  tags = {
    "Name" = "SG attached DB"
  }

  ingress {
    from_port = 1433
    to_port = 1433
    protocol = "TCP"
    security_groups = [
      aws_security_group.lambda.id,
    ]
  }
}

# LambdaにアタッチするSecurity Group
resource "aws_security_group" "lambda" {
  vpc_id = aws_vpc.vpc.id
  tags = {
    "Name" = "SG attached Lambda"
  }

  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [
      "0.0.0.0/0"
    ]
  }
}

# ssmのエンドポイントのSecurity Group
resource "aws_security_group" "vpce_ssm" {
  vpc_id = aws_vpc.vpc.id
  tags = {
    "Name" = "SG attached Endpoint"
  }

  ingress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    security_groups = [
      aws_security_group.lambda.id
    ]
  }

  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = [
      "0.0.0.0/0"
    ]
  }
}
```
本当はlambdaのインバウンドルールとかも絞った方が良いのですが、DBのセキュリティグループを参照しようとした場合、循環参照になると怒られたのでこうなりました（terraform applyを分けて実行したらできると思います）。

### VPC Endpoint

```main.tf
# ssmのエンドポイント
resource "aws_vpc_endpoint" "ssm" {
  vpc_id = aws_vpc.vpc.id
  service_name = "com.amazonaws.ap-northeast-1.ssm"
  vpc_endpoint_type = "Interface"

  subnet_ids = [ aws_subnet.vpce.id ]
  security_group_ids = [ aws_security_group.vpce_ssm.id ]

  private_dns_enabled = true
}
```
こちらは特に言うことはなさそうです。

### RDSとパラメータストア

```main.tf
# SQLServerのインスタンス
resource "aws_db_instance" "sql_server" {
  allocated_storage = 20
  engine = "sqlserver-web"
  instance_class = "db.t3.small"
  username = "admin" # 任意のusername
  password = "password" # 任意のpassword
  skip_final_snapshot = true
  db_subnet_group_name = aws_db_subnet_group.db_subnet_group.id
  vpc_security_group_ids = [ aws_security_group.db.id ]
}

# DBの秘匿情報をParameterStoreに保存する
resource "aws_ssm_parameter" "sql_server_endpoint" {
  name = "sql_server_endpoint"
  type = "SecureString"
  value =  aws_db_instance.sql_server.address
}

resource "aws_ssm_parameter" "sql_server_username" {
  name = "sql_server_username"
  type = "SecureString"
  value =  aws_db_instance.sql_server.username
}

resource "aws_ssm_parameter" "sql_server_master_password" {
  name = "sql_server_password"
  type = "SecureString"
  value =  aws_db_instance.sql_server.password
}
```

SQLサーバーのusernameとpasswordは任意の値をセットします。
作成したRDSのaddressとusername、passwordをParameterStoreに保存しています。
instance_classは使用するengineによって選択できるものが決まっているため、以下を参照しています。
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/CHAP_SQLServer.html#SQLServer.Concepts.General.InstanceClasses


### IAMロール

```main.tf
# LambdaにアタッチするIAMロール
resource "aws_iam_role" "lambda_role" {
  name = "lambda_role"
  assume_role_policy = data.aws_iam_policy_document.lambda.json
}

resource "aws_iam_role_policy_attachment" "rds_full_access" {
  role = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonRDSFullAccess"
}

resource "aws_iam_role_policy_attachment" "ssm_full_access" {
  role = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMFullAccess"  
}

resource "aws_iam_role_policy_attachment" "lambda_vpc_access" {
  role = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

data "aws_iam_policy_document" "lambda" {
  statement {
    effect = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}
```
本当はよくないのですが、今回はRDSとSSMのFullAccess権限をIAMロールに与えています（ごめんなさい）。実際には必要なアクションやリソースに限定したポリシーの設定が求められます（ごめんなさい）。

###  lambdaのレイヤーと関数

```main.tf
resource "aws_lambda_layer_version" "pyodbc" {
  filename = "../pyodbc.zip"
  layer_name = "pyodbc"
  compatible_runtimes = [ "python3.11" ]
}

resource "aws_lambda_function" "execute_sql" {
  filename = "../lambda_function.zip"
  function_name = "db_create"
  runtime = "python3.11"
  handler = "lambda_function.lambda_handler"
  role = aws_iam_role.lambda_role.arn
  layers = [aws_lambda_layer_version.pyodbc.arn]
  timeout = 10
  vpc_config {
    subnet_ids = [ aws_subnet.private_db_subnet_1a.id ]
    security_group_ids = [ aws_security_group.lambda.id ]
  }
  environment {
    variables = {
      "ODBCINI": "/opt/python/odbc.ini",
      "ODBCSYSINI": "/opt/python"
    }
  }
}
```
STEP1で作成したライブラリのzipファイルをlayerとして読み込みます。
STEP2で作成したソースコードはfilenameの箇所に記載しています。
今回はlambdaをVPC内で実行するため、`vpc_config`でVPCやsubnetの情報を記述しています。
また、環境変数が必要な場合は、`environment`で定義します。

Terraformで作成したリソースは以上になります。
`terraform plan`でリソースの差分を確認し、`terraform apply`で構築します。

## テストイベントを作成して実行
最後に、作成したlambdaがSQLサーバーにアクセスし、SQLを実行してくれるのかテストします。
lambdaのサービスのページにアクセスし、作成した関数を選択します。
作成したlambdaのページのタブのうち、「テスト」をクリックすると、下記のようなページが表示されます。こちらではlambdaを実行するときに引数として渡す値を設定することができます。今回はSQLサーバーと接続するために必要な情報を格納したParameterStoreのpathを記載します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1382306/cac96cc7-00be-c387-eb83-6ce444502778.png)

イベント記載ができたら、右上の「テスト」をクリックしてLambdaを実行します。
処理の内容にもよりますが、今回の場合は1~2秒で処理は終わりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1382306/c87c26f4-0c50-94bc-815a-b01504d68044.png)

実行したログを見てみると、SQLサーバーのデータベース一覧が表示されていそうです。
できました！万歳！

## まとめ
SQLサーバーに接続してSQL分を実行するLambdaをIaCで作成できました。
これをベースに、S3から実行するSQL分を取得したり、作成したデータベースの情報を格納したりできるLambdaも作っていけそうなので、改めて便利なサービスだなと感じました。
これからもAWSのサービスをいろいろ触ってみて、知見を深めていければと思っています。
[Fusic Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fusic)はまだまだ続きますので、皆さんの記事も楽しみです！

## 参考
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function