肉球でキーボード

MLエンジニアの技術ブログです

プライベートサブネット内のlambdaからVPC Endpoints経由でSESを実行する

構成

プライベートサブネット内で起動したlambda関数から、インターネットを経由せずSESを呼び出そうと思います。
本記事では以下の構成でlambda関数からSESを呼び出します。

acrhitecture

本文中コード.
github.com

lambdaからSESにアクセスする手段

プライベートサブネット内のlambdaからSESにメールを送信する場合、2通りのインフラ構成が考えれられます。
参考: [アップデート] Amazon SES が SMTP エンドポイントの VPC エンドポイントをサポートしました | DevelopersIO

また、lambdaようなアプリケーションからSESにリクエストを送る場合、SES APISMTPインターフェイスの2通りの方法があります。
Set up email sending with Amazon SES - Amazon Simple Email Service

インターネット経由でSESにアクセス(SES API / SMTPインターフェイス)

1つ目はNAT GatewayとInternet Gatewayを設置し、インターネット経由でSESを呼び出す方法です。
この方法ではSES APISMTPインターフェイス両方の方法でSESにアクセスすることができます。
今回はインターネット接続を行わない方法を検討するので、この構成は採用しません。

Use Nat Gateway

VPC Endpoints経由でSESにアクセス(SMTPインターフェイス)

VPC Endpoints経由でプライベートサブネットのクライアントから直接SESへアクセスできます。
注意点として、VPC Endpointsを利用したSESへのアクセスはSMTPインターフェイスのみサポートされています
AWSサポートに問い合わせてくれている方がいます。
VPC内のLambdaからはSES向けのVPCエンドポイントを使用してもBoto3ではメールを送信できない #Python - Qiita

インターネットを経由しないため、今回はこの構成を採用します。

Use VPC Endpoints

VPC Endpoints経由でSTMPインターフェイスでメール送信する

Terraform

AWSリソースを作成するTerraformを用意しました

github.com

$ git clone https://github.com/nsakki55/lambda-ses-vpc-endpoints
$ cd lambda-ses-vpc-endpoints

AWSアカウントとSESで送るメールの値を環境変数に設定します。

$ export AWS_ACCOUNT_ID=<aws_account_id> 
$ export AWS_REGION=<aws_region>
$ export DOMAIN=<domain>
$ export FROM_ADDRESS=<from_address>
$ export TO_ADDRESS=<to_address>

今回のインフラ構成をterraformで作成します。

$ cd terraform
$ terraform init
$ terraform apply -var="aws_account_id=$AWS_ACCOUNT_ID" -var="aws_region=$AWS_REGION" -var="domain=$DOMAIN" 
$ cd ..

lambda関数のDocker imageをビルドしECRにデプロイします

$ make build
$ make push

SMTP認証情報の作成

SMTPインターフェイスでSESにアクセスする場合、SMTP認証情報を取得する必要があります。
AWSコンソールからSMTP認証ユーザーを作成する手順が公式ドキュメントで紹介されています。
Obtaining Amazon SES SMTP credentials - Amazon Simple Email Service.

IAMユーザーを作成し、SMTP認証に必要な2つの値をSecretManagerに保存しています。

  • アクセスキー
  • シークレットアクセスキーから作成したSMTPパスワード

シークレットアクセスキーとSMTPパスワードは異なる値ですので注意してください。

SMTP認証用のIAMユーザーに、SESでメールを送信するためのポリシーをアタッチしています。

resource "aws_iam_user" "smtp_user" {
  name = "smtp-user"
}

resource "aws_iam_access_key" "smtp_user_key" {
  user = aws_iam_user.smtp_user.name
}

resource "aws_secretsmanager_secret" "smtp_credential" {
  name = "smtp-credential"
}

resource "aws_secretsmanager_secret_version" "smtp_credentials" {
 secret_id = aws_secretsmanager_secret.smtp_credential.id
 secret_string = jsonencode({
   username = aws_iam_access_key.smtp_user_key.id
   password = aws_iam_access_key.smtp_user_key.ses_smtp_password_v4
 })
}

resource "aws_iam_user_policy" "smtp_user_policy" {
  name = "smtp-user-policy"
  user = aws_iam_user.smtp_user.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ses:SendEmail",
          "ses:SendRawEmail"
        ]
        Resource = "*"
      }
    ]
  })
}

SMTPインターフェイスVPC Endpointsの作成

SMTPインターフェイスでSESにアクセスするための、VPC Endpoints、セキュリティグループを作成します。

com.amazonaws.ap-northeast-1.email-smtp がSES用のVPC Endpointsサービスです。

SMTPプロトコルで通信するために、587ポートのインバウンドルールをVPC Endpointsのセキュリティグループに設定します。

resource "aws_vpc" "main" {
  cidr_block           = "10.1.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
}

resource "aws_subnet" "private" {
  vpc_id            = aws_vpc.main.id
  availability_zone = "${var.aws_region}a"
  cidr_block        = "10.1.10.0/24"
}

locals {
  aws_services = [
    "com.amazonaws.${var.aws_region}.email-smtp",
    "com.amazonaws.${var.aws_region}.secretsmanager",
  ]
}

resource "aws_vpc_endpoint" "aws_services_interface_type" {
  for_each            = toset(local.aws_services)
  vpc_id              = aws_vpc.main.id
  service_name        = each.value
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [aws_security_group.aws_services_interface_endpoints.id]
  tags = {
    Name = "Invoke SES via SMTP"
  }
}

resource "aws_security_group" "aws_services_interface_endpoints" {
  name   = "aws-services-interface-endpoints-sg"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "invoke_ses" {
  name   = "invoke-ses-sg"
  vpc_id = aws_vpc.main.id
}

resource "aws_vpc_security_group_egress_rule" "invoke_ses" {
  security_group_id = aws_security_group.invoke_ses.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
  from_port         = -1
  to_port           = -1
}

resource "aws_vpc_security_group_ingress_rule" "allows_access_to_interface_endpoints" {
  security_group_id            = aws_security_group.aws_services_interface_endpoints.id
  referenced_security_group_id = aws_security_group.invoke_ses.id
  from_port                    = 443
  to_port                      = 443
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "allows_smtp_access_to_interface_endpoints" {
  security_group_id            = aws_security_group.aws_services_interface_endpoints.id
  referenced_security_group_id = aws_security_group.invoke_ses.id
  from_port                    = 587
  to_port                      = 587
  ip_protocol                  = "tcp"
}

SMTPインターフェイスでSESにアクセスするlambda関数の作成

SecretManagerからSMTP認証情報を取得し、SMTPインターフェイスでSESにアクセスする処理を記述したlambda関数です。

SMTPサーバーにはSESのVPC EndpointsのプライベートDNSemail-smtp.ap-northeast-1.amazonaws.com を指定します。

import os
import json
import boto3
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

def get_smtp_credential() -> tuple[str, str]:
    session = boto3.session.Session()
    client = session.client(service_name="secretsmanager", region_name="ap-northeast-1")
    response = client.get_secret_value(SecretId="smtp-credential")
    secret = json.loads(response["SecretString"])

    smtp_username = secret["username"]
    smtp_password = secret["password"]
    return smtp_username, smtp_password

def handler(event, context) -> None:
    # メールの作成
    msg = MIMEMultipart()
    msg["From"] = os.environ['FROM_ADDRESS']
    msg["To"] = os.environ['TO_ADDRESS']
    msg["Subject"] = "test title"
    email_body = "This is test email via smtp."
    msg.attach(MIMEText(email_body, "plain"))

    # SMTPサーバーにメール送信
    smtp_server = "email-smtp.ap-northeast-1.amazonaws.com"
    smtp_port = 587
    smtp_username, smtp_password = get_smtp_credential()
    with smtplib.SMTP(smtp_server, smtp_port) as server:
        server.starttls()
        server.login(smtp_username, smtp_password)
        server.send_message(msg)

    return {"statusCode": 200, "body": json.dumps("Email sent successfully")}

動作確認

lambda関数をテスト実行すると、実行が成功します。

lambda test

設定した送信元メールアドレスから、メールが届いてることを確認できます。

mail from ses

参考