LLM の力を借りながら Lambda から DocumentDB を呼び出す実装を書いた

個人プロジェクトの一環で、Amazon DocumentDB を試してみることにしました。Amazon DocumentDB も MongoDB も初めて使うため、ChatGPT と Claude に手順を聞きながら作業を進めました。ある程度ハルシネーションがあることは普段から認識していましたが、やはりいくつかの問題に直面しました。

ゴール

API Gateway のユーザー作成 endpoint にリクエストが送信されると、Lambda が発火して、DocumentDB にデータを書き込むというシナリオを構築しました。

AWS のインフラ図

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 で生成されました。最初に生成されたコードでは、

  • すでに JSON オブジェクトである event.body をさらに parse しようとしている
  • なぜか DynamoDB の client を利用している(古い SDK の書き方?)

と、不十分な生成結果になりました

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 設定に関して複雑な手順が記載されていました。

www.mongodb.com

...
// 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