LocalStackとaws-ses-v2-localを使って非同期のメール送信をローカルで再現する

おはこんばんちは。 Finatextグループのクレジット事業でソフトウェアエンジニアをしている Hanake です。
ところで、皆様はローカルでのメール送信をどのようにテストされていますか?
SMTP サーバを立ててテストしていますか?もしくは開発環境用にマネージドなメール送信サービスを利用していますか?
いずれにせよ、本番との環境の差異やコスト面での課題があるかと思います。
今回は、LocalStack と aws-ses-v2-local を使って可能な限り本番に近い環境で非同期のメール送信をローカルで再現する方法をご紹介します。
LocalStack とは
LocalStack は、Amazon Web Services(以下 AWS) のクラウドサービスをローカルでエミュレートするためのツールです。
AWS の主要サービスはほぼ全てサポートされており、Docker Imageとして提供されているため、簡単にローカル環境で AWS のサービスを利用することができます。
一方で有料版と無料版があり、メール送信のマネージドサービスである Amazon Simple Email Service(以下 SES) のメール送信機能は有料版でしか利用することができません。
また Apache License 2.0 で提供されているため、無料版でも商用利用が可能です。
aws-ses-v2-local とは
AWS の SES を使ったメール送信をローカルで再現するための LocalStack とは別のツールです。
Node.js で実装されており、Nodemailer を ラップする形で実装がされています。
aws-ses-v2-local は npm package として提供されており、簡単にインストールすることができます。
また MIT License で提供されているため、こちらも商用利用も可能です。
ディレクトリ構成
されそれでは構成の説明に入っていこうかと思います。
今回の検証では以下のディレクトリ構成を想定しています。
.
├── build
│ ├── api
│ │ └── Dockerfile
│ ├── aws_ses
│ │ └── Dockerfile
│ └── worker
│ └── Dockerfile
├── cmd
│ ├── api
│ │ └── main.go
│ └── worker
│ └── main.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal
│ ├── client
│ │ ├── ses
│ │ │ └── client.go
│ │ └── sqs
│ │ └── client.go
│ ├── config
│ │ └── config.go
│ └── job
│ └── send_email.go
└── scripts
└── localstack
└── init.sh
Docker Compose
まずは Docker Compose の構成ですが以下のようになっています。
version: "3.8"
services:
api:
build:
context: .
dockerfile: build/api/Dockerfile
platform: linux/amd64
container_name: ses_local_api
env_file:
- .env
ports:
- "8080:8080"
worker:
build:
context: .
dockerfile: build/worker/Dockerfile
platform: linux/amd64
container_name: ses_local_worker
env_file:
- .env
localstack:
image: localstack/localstack:3.5
container_name: ses_local_localstack
ports:
- "127.0.0.1:4566:4566"
- "127.0.0.1:4510-4559:4510-4559"
environment:
- DEBUG=${DEBUG:-0}
volumes:
- "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
- ./scripts/localstack:/etc/localstack/init/ready.d
aws_ses:
build:
context: .
dockerfile: build/aws_ses/Dockerfile
container_name: ses_local_aws_ses
ports:
- "8005:8005"
environment:
AWS_SES_ACCOUNT: '{"SendQuota":{"Max24HourSend":1000000,"MaxSendRate":250,"SentLast24Hours":0}}'
SMTP_TRANSPORT: '{"host":"smtp","port":25,"secure":false}'
大まかな流れは以下になります
- http://localhost:8080 をlistenしているapi コンテナにメール送信のリクエストを送信
- api コンテナは localstack コンテナの Amazon Simple Queue Service(以下 SQS) にメッセージを送信
- worker コンテナは SQS からメッセージを受信し、aws_sesコンテナ にメール送信をリクエストを送信
- ブラウザで http://localhost:8005/ にアクセスして aws_sesコンテナ に送信されたメールを確認
各種コンテナの説明
各種コンテナの説明は以下の通りです。
apiコンテナ
apiコンテナ は実際にメール送信を行うための API サーバコンテナです。
今回は Go にてメール送信をするだけの軽量な API サーバを実装しています。
実装
package main
import (
"log/slog"
"net/http"
"github.com/kelseyhightower/envconfig"
"github.com/khanake/ses_local/internal/client/sqs"
"github.com/khanake/ses_local/internal/config"
)
func enqueueEmailHandler(c config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
client := sqs.NewSQSClient(c)
if err := client.Enqueue(r.Context(), "Send Email"); err != nil {
slog.Error(err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func main() {
slog.Info("Starting server on port 8080")
var c config.Config
if err := envconfig.Process("", &c); err != nil {
panic(err)
}
http.HandleFunc("/send_mail", enqueueEmailHandler(c))
http.ListenAndServe(":8080", nil)
}
実際の業務でメール送信を実装される場合は、非同期でメール処理を行うことが多いかと思います。
そのため、今回の実装でも client.Enqueue
の部分で AWS SQS にメッセージを送信しています。
Dockerfile
FROM golang:1.22.4
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o api cmd/api/main.go
CMD ./api
workerコンテナ
非同期処理を実行するための worker コンテナです。
api コンテナ と同様に Go にて SQS からメッセージを受信してメール送信を行うだけの軽量な worker を実装しています。
package main
import (
"context"
"log/slog"
"time"
"github.com/kelseyhightower/envconfig"
"github.com/khanake/ses_local/internal/client/ses"
"github.com/khanake/ses_local/internal/client/sqs"
"github.com/khanake/ses_local/internal/config"
"github.com/khanake/ses_local/internal/job"
)
func main() {
var c config.Config
err := envconfig.Process("", &c)
if err != nil {
panic(err)
}
sqsClient := sqs.NewSQSClient(c)
sesClient := ses.NewSESClient(c)
sendEmailJob := job.NewSendEmailJob(sqsClient, sesClient, c)
slog.Info("Worker is running")
for {
slog.Info("Fetching new jobs")
ctx := context.Background()
if err := sendEmailJob.Execute(ctx); err != nil {
slog.Error(err.Error())
}
time.Sleep(5 * time.Second)
}
}
Dockerfile
FROM golang:1.22.4
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o worker cmd/worker/main.go
CMD ./worker
実際には受け取ったメッセージ内容によって処理を分岐させる必要がありますが、今回はメール送信のみを行うようにしています。
localstackコンテナ
LocalStack を動かすためのコンテナですが、公式のサンプルからほぼ変更はありません。
変更点としては 必要な AWS リソースをコンテナ起動時に作成するために volumes に ./scripts/localstack:/etc/localstack/init/ready.d
を追加しています。
これにより、コンテナ起動時に ./scripts/localstack/init.sh
が実行され、必要なリソースが作成されます。(今回の場合は SQS のキューを作成しています)
init.sh
#!/bin/bash
echo "localstack setup start"
awslocal sqs create-queue\
--queue-name sample-task-queue
aws_sesコンテナ
aws-ses-v2-local を動かすためのコンテナです。
aws-ses-v2-local は npm パッケージでは配布されていますが、Docker Image は提供されていないため、自前でビルドする必要があります。
Dockerfile
FROM node:18
WORKDIR /app
RUN npm install -g aws-ses-v2-local
ENTRYPOINT ["aws-ses-v2-local", "--host=0.0.0.0"]
AWS Client の設定
Docker Compse により、 api コンテナは 8080 ポートで、localstackコンテナ は 4566 ポート、aws_sesコンテナ は 8005 ポートで立ち上がるようになりました。
ただ、検証環境や本番環境では実際の AWS に接続を行う必要があるため、開発環境のみ SQS を localstackコンテナ に接続するように、SES を aws_ses に接続するように設定を行います。
今回は環境変数での分岐を採用しました。設定値は以下になります。
AWS_SES_QUEUE_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/sample-task-queue
AWS_SES_SENDER_EMAIL_ADDRESS=test@test.com
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
LOCAL_SES_ENABLED=1
LOCAL_SQS_ENABLED=1
LOCAL_SES_ENABLED
は SES を aws_sesコンテナ に接続するかどうかを判定するための環境変数となり、 同様に LOCAL_SQS_ENABLED
は SQS を localstackコンテナ に接続するかどうかを判定するための環境変数となります。
フラグが有効な場合にリクエスト先を分岐するために、以下のように SES Client を実装します。
package ses
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/khanake/ses_local/internal/config"
)
type SESClient struct {
client *sesv2.Client
}
func NewSESClient(c config.Config) *SESClient {
sdkConfig, err := aws_config.LoadDefaultConfig(context.Background())
if err != nil {
panic(err)
}
client := sesv2.NewFromConfig(sdkConfig, func(o *sesv2.Options) {
if c.LocalSESEnabled {
o.BaseEndpoint = aws.String("http://aws_ses:8005")
}
})
return &SESClient{client}
}
func (s *SESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput) (*sesv2.SendEmailOutput, error) {
return s.client.SendEmail(ctx, input)
}
これにより、LOCAL_SES_ENABLED
が有効な場合は http://aws_ses:8005
にリクエストを送信し、無効な場合は実際の AWS にリクエストを送信するようになります。
同様に SQS に対しても以下のように SQS Client を実装します。
package sqs
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/khanake/ses_local/internal/config"
)
type Cleanup func(context.Context) error
type SQSClient struct {
client *sqs.Client
queueURL string
}
func NewSQSClient(c config.Config) *SQSClient {
sdkConfig, err := aws_config.LoadDefaultConfig(context.Background())
if err != nil {
panic(err)
}
client := sqs.NewFromConfig(sdkConfig, func(o *sqs.Options) {
o.BaseEndpoint = aws.String("http://localstack:4566/")
})
return &SQSClient{client, c.AWSSESQueueURL}
}
func (s *SQSClient) Enqueue(ctx context.Context, task string) error {
_, err := s.client.SendMessage(ctx, &sqs.SendMessageInput{
MessageBody: aws.String(task),
QueueUrl: aws.String(s.queueURL),
})
if err != nil {
return err
}
return nil
}
func (s *SQSClient) Dequeue(ctx context.Context) (*string, Cleanup, error) {
var noop Cleanup = func(context.Context) error { return nil }
resp, err := s.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(s.queueURL),
MaxNumberOfMessages: 1,
})
if err != nil {
return nil, nil, err
}
if len(resp.Messages) == 0 {
return nil, noop, nil
}
var cu Cleanup = func(ctx context.Context) error {
_, err := s.client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(s.queueURL),
ReceiptHandle: resp.Messages[0].ReceiptHandle,
})
return err
}
return resp.Messages[0].Body, cu, nil
}
上記の Client を使ってメールを送信する非同期 job は以下のように実装します。
package job
import (
"context"
"log/slog"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"github.com/khanake/ses_local/internal/client/ses"
"github.com/khanake/ses_local/internal/client/sqs"
"github.com/khanake/ses_local/internal/config"
)
type sendEmailJob struct {
sqsClient *sqs.SQSClient
sesClient *ses.SESClient
config config.Config
}
func NewSendEmailJob(
sqsClient *sqs.SQSClient,
sesClient *ses.SESClient,
config config.Config,
) *sendEmailJob {
return &sendEmailJob{
sqsClient: sqsClient,
sesClient: sesClient,
config: config,
}
}
func (j *sendEmailJob) Execute(ctx context.Context) error {
res, cleanup, err := j.sqsClient.Dequeue(ctx)
if err != nil {
return err
}
if res == nil {
slog.Info("No jobs found")
return nil
}
slog.Info("Recieved message", slog.String("message", *res))
if err := j.sendEmail(ctx); err != nil {
return err
}
return cleanup(ctx)
}
func (j *sendEmailJob) sendEmail(ctx context.Context) error {
input := &sesv2.SendEmailInput{
FromEmailAddress: aws.String(j.config.AWSSESSenderEmailAddress),
Destination: &types.Destination{
ToAddresses: []string{"test@test.com"},
},
Content: &types.EmailContent{
Simple: &types.Message{
Subject: &types.Content{
Data: aws.String("Test email"),
},
Body: &types.Body{
Text: &types.Content{
Data: aws.String("This is a test email"),
},
},
},
},
}
_, err := j.sesClient.SendEmail(ctx, input)
if err != nil {
return err
}
return nil
}
実際には送信先や送信内容は DB や各種メールのテンプレートから取得するとはおもいますが、今回は固定値で送信しています。
動作確認
それでは実際に動作確認を行っていきます。
まずは以下のコマンドでコンテナを起動します。
docker-compose up
メール送信のリクエストは http://localhost:8080/send_mail に POSTリクエストを送信することで行うことができます。
以下のコマンドでリクエストを送信します。
curl -X POST http://localhost:8080/send_mail
すると、worker コンテナにて SQS からメッセージを受信し、aws_ses にメール送信のリクエストを送信します。
実際にメールが送信されたか aws_sesのweb UIにアクセスして確認するために http://localhost:8005 をブラウザで開きます。

まとめ
今回は LocalStack と aws-ses-v2-local を使って非同期のメール送信をローカルで再現する方法をご紹介しました。
昨今はパスワードレス認証の実装方法としてメール送信にリンクを埋め込むことも多いかと思いますが、こういった方法でローカル環境でのメール送信を再現することで、開発効率を向上させることができるかと思います。
仲間を募集中です!
Finatext グループでは一緒に働く仲間を募集中です!様々なエンジニア系のポジションがあるので気軽に覗いてみてください!