個人プロジェクトの一環で、Amazon DocumentDB を試してみることにしました。Amazon DocumentDB も MongoDB も初めて使うため、ChatGPT と Claude に手順を聞きながら作業を進めました。ある程度ハルシネーションがあることは普段から認識していましたが、やはりいくつかの問題に直面しました。
ゴール
API Gateway のユーザー作成 endpoint にリクエストが送信されると、Lambda が発火して、DocumentDB にデータを書き込むというシナリオを構築しました。
DynamoDB のインフラ
インフラは Terraform を使用して、以下のように生成しました。最初は subnet が一つだけであるなど、最適な設定ではありませんでしたが、後ほど修正を加えることで解決する範囲でした。
provider "aws" { region = "us-west-2" # or your preferred region } resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" } resource "aws_subnet" "main" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "us-west-2a" } resource "aws_security_group" "main" { vpc_id = aws_vpc.main.id ingress { from_port = 27017 to_port = 27017 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_docdb_subnet_group" "main" { name = "main" subnet_ids = [aws_subnet.main.id] } resource "aws_docdb_cluster" "main" { cluster_identifier = "chat-app-cluster" master_username = "masteruser" master_password = "securepassword" db_subnet_group_name = aws_docdb_subnet_group.main.name vpc_security_group_ids = [aws_security_group.main.id] } resource "aws_docdb_cluster_instance" "main" { count = 1 identifier = "chat-app-cluster-instance-${count.index}" cluster_identifier = aws_docdb_cluster.main.id instance_class = "db.r5.large" }
この生成結果に以下の修正を加えました
- subnet を3つにして、
aws_docdb_subnet_group
に指定する instance_class
を小さくて安いタイプにする- Region を tokyo にする
Lambda Function の生成
Lambda 関数は JavaScript で生成されました。最初に生成されたコードでは、
と、不十分な生成結果になりました
const AWS = require('aws-sdk'); const { v4: uuidv4 } = require('uuid'); const docClient = new AWS.DynamoDB.DocumentClient(); exports.handler = async (event) => { const { username, email } = JSON.parse(event.body); const params = { TableName: process.env.TABLE_NAME, Item: { id: uuidv4(), username, email, createdAt: new Date().toISOString() } }; await docClient.put(params).promise(); return { statusCode: 201, body: JSON.stringify({ message: "User created successfully" }), }; };
改めて DocumentDB を使うことを明示的に指定しながら、今度は Claude に生成させた結果、完璧ではないものの、基盤として使えそうなコードが生成されました。
import { MongoClient } from 'mongodb'; import { v4 as uuidv4 } from 'uuid'; let cachedDb = null; async function connectToDatabase() { if (cachedDb) { return cachedDb; } const client = new MongoClient(process.env.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, }); await client.connect(); const db = client.db(process.env.MONGODB_DATABASE); cachedDb = db; return db; } export const handler = async (event) => { try { const db = await connectToDatabase(); const collection = db.collection('users'); let body; try { body = JSON.parse(event.body); } catch (error) { return { statusCode: 400, body: JSON.stringify({ message: "Invalid request body" }), }; } const { username, email } = body; if (!username || !email) { return { statusCode: 400, body: JSON.stringify({ message: "Missing required fields" }), }; } const user = { _id: uuidv4(), username, email, createdAt: new Date().toISOString() }; await collection.insertOne(user); return { statusCode: 201, body: JSON.stringify({ message: "User created successfully", userId: user._id }), }; } catch (error) { console.error('Error:', error); return { statusCode: 500, body: JSON.stringify({ message: "Error creating user" }), }; } };
すでに JSON オブジェクトである event.body をさらに parse しようとしている問題は残っていたので、手動でコード修正しました。
また、MongoClient のオプション引数が deprecated だったので、これを削除する対応も入れました。
const client = new MongoClient(process.env.MONGODB_URI, { useNewUrlParser: true, // 消した useUnifiedTopology: true, // 消した });
次に、必要なファイルを用意します
package.json
{ "dependencies": { "mongodb": "^6.7.0", "uuid": "^9.0.1" }, "type": "module" }
- Lambda 関数のデプロイのために、以下の手順で zip ファイルを用意します。
npm install
zip -r create_user.zip package.json node_modules create_user.js
lambda 関数を用意します。生成されるコードは nodejs 14 を指定していることが多いですが、既にサポートされていないので、nodejs 20 を指定します。
また、 DocumentDB に接続するためには lambda function を DocumentDB と同じ Subnet に配置する必要があるため、そのように設定しています。
terrraform apply
時に NetworkInterface 作成権限が不足していたため、権限も追加しています。
resource "aws_iam_policy" "lambda_vpc_access" { name = "lambda_vpc_access" path = "/" description = "IAM policy for Lambda VPC access" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:CreateNetworkInterface", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface" ] Resource = "*" } ] }) } resource "aws_lambda_function" "create_user" { filename = "lambda/create_user.zip" function_name = "create_user" role = aws_iam_role.lambda_role.arn handler = "create_user.handler" runtime = "nodejs20.x" source_code_hash = filebase64sha256("lambda/create_user.zip") timeout = 10 vpc_config { subnet_ids = [aws_subnet.subnet1.id, aws_subnet.subnet2.id, aws_subnet.subnet3.id] security_group_ids =[aws_security_group.main.id] } environment { variables = { MONGODB_URI = "mongodb://${aws_docdb_cluster.main.master_username}:${aws_docdb_cluster.main.master_password}@${aws_docdb_cluster.main.endpoint}:${aws_docdb_cluster.main.port}/chat_app?tls=true&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false" MONGODB_DATABASE = "chat_app" } } }
ここまで用意してデプロイした上で Lambda 関数をテスト実行すると、Lambda 関数がタイムアウトしました。
タイムアウトへの対応
このエラーでは他の具体的なエラーメッセージは表示されず、単にタイムアウトしていました。
LLM に助言を求めると、LLMは Security Group や ACL の設定を見直すように提案しました。自分の経験からも妥当な助言だったので調査しましたが、設定に問題は見当たりませんでした。 VPC Flow Log を設定して確認したところ、全ての接続が ACCEPT されていることが分かりました。
VPC Flow Log の設定
# CloudWatchロググループの作成 resource "aws_cloudwatch_log_group" "flow_logs" { name = "/aws/vpc/flow-logs" retention_in_days = 7 } # IAMロールの作成 resource "aws_iam_role" "flow_logs_role" { name = "flow_logs_role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "vpc-flow-logs.amazonaws.com" } }] }) } # IAMポリシーアタッチメント resource "aws_iam_role_policy_attachment" "flow_logs_role_policy_attachment" { role = aws_iam_role.flow_logs_role.name policy_arn = aws_iam_policy.flow_logs_policy.arn } resource "aws_iam_policy" "flow_logs_policy" { name = "flow_logs_policy" description = "Policy to allow VPC Flow Logs to write to CloudWatch Logs" policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams" ] Effect = "Allow" Resource = "*" }] }) } # VPC Flow Logsの作成 resource "aws_flow_log" "main" { log_destination = aws_cloudwatch_log_group.flow_logs.arn traffic_type = "ALL" iam_role_arn = aws_iam_role.flow_logs_role.arn vpc_id = aws_vpc.main.id log_destination_type = "cloud-watch-logs" }
VPC Flow log を設定して確認してみたが、ログを見る限りは全ての接続で ACCEPT が出ている。下記はログの例(数字はランダマイズしています)
2 003500007300 eni-000cf1cbe08100000 10.0.1.00 10.0.3.000 32332 27017 6 7 802 1719110922 1719110923 ACCEPT OK
LLM に何度も助言を求めましたが解決できなかったため、DocumentDB の公式ドキュメントを確認しました。すると、TLS 設定の有無によって接続方法が異なることが記載されていました。 生成されたコードでは TLS の設定など全く考慮されていないので、TLS の接続設定が原因だろうと予想できました。調査方針が決まりました。
MongoDB のドキュメントを確認すると、TLS 設定に関して複雑な手順が記載されていました。
... // Replace the filepaths with your certificate filepaths. const secureContext = tls.createSecureContext({ ca: fs.readFileSync(`<path to CA certificate>`), cert: fs.readFileSync(`<path to public client certificate>`), key: fs.readFileSync(`<path to private client key>`), }); ...
これを愚直に実装したくなかったので、 Amazon DocumentDB の TLS を無効化できないか マネコンをぽちぽちしてみたが、導線が見つかりませんでした。
TLS を無効化するのは terraform で指定できるような気はしましたが、DocumentDB リソースの再作成が必要になるかもしれない、など予想して、 TLS 有効な状態で動かす方針を先にもう少し調査することにしました。
Amazon DocumentDB の公式 document サンプルコードは nodejs の古い version で書かれており、動作するかあやしみつつ、他に見つけた Zenn の記事も照らし合わせつつ TLS 接続をするように書いたら、うまくいきました。
DocumentDBにローカルから接続するのは大変なのでLambda関数から接続してみた
最終的に出来上がったファイル
import { MongoClient } from 'mongodb'; import { v4 as uuidv4 } from 'uuid'; let cachedDb = null; async function connectToDatabase() { if (cachedDb) { return cachedDb; } const client = new MongoClient(process.env.MONGODB_URI, { tlsCAFile: './global-bundle.pem', }); await client.connect(); const db = client.db(process.env.MONGODB_DATABASE); cachedDb = db; return db; } export const handler = async (event) => { try { const db = await connectToDatabase(); const collection = db.collection('users'); const { username, email } = event.body; if (!username || !email) { return { statusCode: 400, body: JSON.stringify({ message: "Missing required fields" }), }; } const user = { _id: uuidv4(), username, email, createdAt: new Date().toISOString() }; await collection.insertOne(user); return { statusCode: 201, body: JSON.stringify({ message: "User created successfully", userId: user._id }), }; } catch (error) { console.error('Error:', error); return { statusCode: 500, body: JSON.stringify({ message: "Error creating user" }), }; } };
Directory 構成
% tree -L 2 . ├── document-db.tf ├── lambda │ ├── create_user.js │ ├── create_user.zip │ ├── global-bundle.pem │ ├── node_modules │ ├── package-lock.json │ └── package.json ├── lambda.tf ├── main.tf ├── provider.tf ├── terraform.tfstate ├── terraform.tfstate.backup └── versions.tf