はじめに
TerraformでLambdaを作るときに、いつもローカルテストが面倒だなと思っていました。
普通にTerraformだけでLambda開発をしていると、以下のような不満がありました:
- ローカルでテストできない(デプロイしないと動作確認できない)
- コードを変更するたびにデプロイが必要で時間がかかる
- 開発のフィードバックループが遅い
そんな時、AWS SAMにTerraformとの統合機能があることを知って、さっそく試してみることにしました。
というわけで、実際に作ってみた話をしたいと思います。
AWS SAMのTerraform統合機能って何?
調べてみると、AWS SAMには hook-name
というオプションがあって、これを使うとTerraformで定義したLambdaをSAMでローカル実行できるらしい。
これは便利そう。。。
詳しくは、以下のAWSドキュメントを参照してください。
要は、以下のような感じです:
- TerraformでLambdaリソースを定義
- SAMがTerraformの設定を読み取り
- ローカルでLambdaを実行・テストできる
さらに、今回はGoでLambdaを書きました。
Goはクロスコンパイルができるのでデプロイ元の環境に左右されず、デプロイ先の環境(Linux)に合わせることができるのが便利です。
実際に作ってみた
プロジェクト構成
今回は、S3の署名付きURL(Presigned URL)を生成するLambdaを作ってみました。
プロジェクト構成はこんな感じです:
infra/
├── src/
│ └── create-s3-presigned-url/
│ ├── main.go
│ ├── main_test.go
│ └── go.mod
├── modules/
│ └── lambda/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── enviroments/
│ ├── lambda.tf
│ ├── samconfig.yaml
│ ├── provider.tf
│ └── Makefile
Lambda関数の実装
S3 Presigned URLを作成するLambda関数を実装してみました。
Queryパラメーターでファイルアップロード用のS3署名付きURLを生成する処理です。
テスタビリティを向上させるため、S3操作をインターフェースで抽象化し、依存性注入パターンを採用しています:
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
)
// S3操作のインターフェース
type S3Presigner interface {
PresignPutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
}
// 実際のS3Presignerの実装
type RealS3Presigner struct {
client *s3.PresignClient
}
func (r *RealS3Presigner) PresignPutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
return r.client.PresignPutObject(ctx, input, opts...)
}
// ハンドラーの構造体
type Handler struct {
presigner S3Presigner
}
type Response events.APIGatewayV2HTTPResponse
type PresignedURLResponse struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Bucket string `json:"bucket"`
Key string `json:"key"`
Method string `json:"method"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func createErrorResponse(statusCode int, message string) (Response, error) {
errorResp := ErrorResponse{
Error: "Bad Request",
Message: message,
}
if statusCode >= 500 {
errorResp.Error = "Internal Server Error"
}
responseBody, _ := json.Marshal(errorResp)
return Response{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
Body: string(responseBody),
}, nil
}
func (h *Handler) handleRequest(ctx context.Context, event events.APIGatewayV2HTTPRequest) (Response, error) {
// 環境変数からS3バケット名を取得
bucketName := os.Getenv("S3_BUCKET_NAME")
if bucketName == "" {
return createErrorResponse(400, "S3_BUCKET_NAME environment variable is not set")
}
// クエリパラメータからオブジェクトキーを取得
objectKey := event.QueryStringParameters["key"]
if objectKey == "" {
return createErrorResponse(400, "Missing required query parameter: key")
}
// アップロード用presigned URLを生成(15分間有効)
presignedUrl, err := h.presigner.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
}, func(opts *s3.PresignOptions) {
opts.Expires = 15 * time.Minute
})
if err != nil {
fmt.Printf("Couldn't create presigned upload URL: %v\n", err)
return createErrorResponse(500, "Failed to create presigned upload URL")
}
// レスポンスを作成
response := PresignedURLResponse{
URL: presignedUrl.URL,
ExpiresIn: 900, // 15分 = 900秒
Bucket: bucketName,
Key: objectKey,
Method: presignedUrl.Method,
}
responseBody, err := json.Marshal(response)
if err != nil {
return createErrorResponse(500, "Failed to marshal response")
}
return Response{
StatusCode: 200,
Headers: map[string]string{
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
Body: string(responseBody),
}, nil
}
func handler(ctx context.Context, event events.APIGatewayV2HTTPRequest) (Response, error) {
// AWS SDK設定を読み込み
sdkConfig, err := config.LoadDefaultConfig(ctx)
if err != nil {
fmt.Printf("Couldn't load default configuration: %v\n", err)
return createErrorResponse(500, "Failed to load AWS configuration")
}
// S3クライアントを作成
s3Client := s3.NewFromConfig(sdkConfig)
presignClient := s3.NewPresignClient(s3Client)
// 実際のS3Presignerを使用してハンドラーを作成
h := &Handler{
presigner: &RealS3Presigner{client: presignClient},
}
return h.handleRequest(ctx, event)
}
func main() {
lambda.Start(handler)
}
Terraformモジュール
ここがキモなのですが、Terraformで以下のような仕組みを作りました:
- Goファイルが変更されたら自動でビルド
- SAMが認識できるようにメタデータを設定
- Lambda関数を作成
コードはこんな感じです:
resource "null_resource" "build_lambda_function_create_s3_presigned_url" {
triggers = {
# Goファイルの内容に変更があったときだけビルド
source_hash = sha256(join("", [for f in fileset("${path.module}/../../src/create-s3-presigned-url", "*.go") : filesha256("${path.module}/../../src/create-s3-presigned-url/${f}")]))
}
provisioner "local-exec" {
working_dir = "${path.module}/../../src/create-s3-presigned-url"
command = <<EOT
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bootstrap -tags lambda.norpc .
mkdir -p building
zip -j building/bootstrap.zip bootstrap
EOT
}
}
resource "null_resource" "sam_metadata_function_create_s3_presigned_url" {
triggers = {
resource_name = "aws_lambda_function.create_s3_presigned_url"
resource_type = "ZIP_LAMBDA_FUNCTION"
original_source_code = "${path.module}/../../src/create-s3-presigned-url"
built_output_path = "${path.module}/../../src/create-s3-presigned-url/building/bootstrap.zip"
}
depends_on = [
null_resource.build_lambda_function_create_s3_presigned_url
]
}
resource "aws_lambda_function" "create_s3_presigned_url" {
filename = null_resource.sam_metadata_function_create_s3_presigned_url.triggers.built_output_path
function_name = "colo-create-s3-presigned-url-handler"
role = aws_iam_role.lambda_function_role_create_s3_presigned_url.arn
handler = "bootstrap"
runtime = "provided.al2023"
environment {
variables = {
S3_BUCKET_NAME = var.s3_bucket_name
}
}
}
SAM設定
SAMがTerraformと連携するための設定ファイルです。
hook_name: terraform
を指定することで、SAMがTerraformの設定を読み取ってくれます:
version: 0.1
default:
global:
parameters:
hook_name: terraform
skip_prepare_infra: true
build:
parameters:
debug: true
terraform_project_root_path: ../
テストの実装
実は最後にも書いているのですが、Terraform + AWS SAMの組み合わせでは、現時点だとAWS SAM側に環境変数を渡しても正しく認識されない状態のようです。
そのため、AWS SAMのローカル環境上でのテストができるのはかなり限定的になっています💦
このような状態というのもあり、ちゃんとテストはする必要があると思ったので、テストも書いてみました。
なお、テストは以下のような設計にしています:
- モックテスト: インターフェースのモック実装を使った単体テスト
- エラーハンドリングテスト: 環境変数、パラメーター検証、API呼び出しエラーのテスト
- 統合テスト: 実際のAWS環境での動作確認(AWS認証情報が必要)
モックを使った単体テストの例:
// S3Presignerのモック
type MockS3Presigner struct {
mock.Mock
}
func (m *MockS3Presigner) PresignPutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
args := m.Called(ctx, input, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*v4.PresignedHTTPRequest), args.Error(1)
}
func TestHandler_Success_WithMock(t *testing.T) {
// 環境変数を設定
os.Setenv("S3_BUCKET_NAME", "test-bucket")
defer os.Unsetenv("S3_BUCKET_NAME")
// モックを作成
mockPresigner := new(MockS3Presigner)
// 期待される戻り値を設定
expectedResponse := &v4.PresignedHTTPRequest{
URL: "https://test-bucket.s3.amazonaws.com/test-key",
Method: "PUT",
}
mockPresigner.On("PresignPutObject",
mock.Anything, // context
&s3.PutObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String("test-key"),
},
mock.Anything, // opts
).Return(expectedResponse, nil)
// ハンドラーを作成
handler := &Handler{
presigner: mockPresigner,
}
// テスト用のイベントを作成
event := events.APIGatewayV2HTTPRequest{
QueryStringParameters: map[string]string{
"key": "test-key",
},
}
// テスト実行
response, err := handler.handleRequest(context.Background(), event)
// アサーション
assert.NoError(t, err)
assert.Equal(t, 200, response.StatusCode)
assert.Equal(t, "application/json", response.Headers["Content-Type"])
// レスポンスボディを検証
var responseBody PresignedURLResponse
err = json.Unmarshal([]byte(response.Body), &responseBody)
assert.NoError(t, err)
assert.Equal(t, "https://test-bucket.s3.amazonaws.com/test-key", responseBody.URL)
assert.Equal(t, "test-bucket", responseBody.Bucket)
assert.Equal(t, "test-key", responseBody.Key)
assert.Equal(t, "PUT", responseBody.Method)
assert.Equal(t, 900, responseBody.ExpiresIn)
// モックが期待通りに呼ばれたことを確認
mockPresigner.AssertExpectations(t)
}
// エラーケースのテスト
func TestHandler_MissingBucketName(t *testing.T) {
// 環境変数をクリア
os.Unsetenv("S3_BUCKET_NAME")
// モックを作成(呼ばれないはず)
mockPresigner := new(MockS3Presigner)
// ハンドラーを作成
handler := &Handler{
presigner: mockPresigner,
}
// テスト用のイベントを作成
event := events.APIGatewayV2HTTPRequest{
QueryStringParameters: map[string]string{
"key": "test-key",
},
}
// テスト実行
response, err := handler.handleRequest(context.Background(), event)
// アサーション
assert.NoError(t, err)
assert.Equal(t, 400, response.StatusCode)
// レスポンスボディを検証
var errorResponse ErrorResponse
err = json.Unmarshal([]byte(response.Body), &errorResponse)
assert.NoError(t, err)
assert.Equal(t, "Bad Request", errorResponse.Error)
assert.Equal(t, "S3_BUCKET_NAME environment variable is not set", errorResponse.Message)
// モックが呼ばれていないことを確認
mockPresigner.AssertExpectations(t)
}
開発用のMakefile
開発を楽にするためのMakefileも作成しました。
テスト実行、ビルド、カバレッジ取得などが簡単にできます:
.PHONY: test build clean deps
# 作業ディレクトリ
WORKDIR = ../src/create-s3-presigned-url
# デフォルトターゲット
all: deps test build
# 依存関係をインストール
deps:
cd $(WORKDIR) && go mod tidy
cd $(WORKDIR) && go mod download
# テストを実行
test:
cd $(WORKDIR) && go test -v ./...
# カバレッジ付きでテストを実行
test-coverage:
cd $(WORKDIR) && go test -v -coverprofile=coverage.out ./...
cd $(WORKDIR) && go tool cover -html=coverage.out -o coverage.html
# ビルド(Lambda用)
build:
cd $(WORKDIR) && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bootstrap -tags lambda.norpc .
cd $(WORKDIR) && mkdir -p building
cd $(WORKDIR) && zip -j building/bootstrap.zip bootstrap
# クリーンアップ
clean:
cd $(WORKDIR) && rm -f bootstrap
cd $(WORKDIR) && rm -rf building/
cd $(WORKDIR) && rm -f coverage.out coverage.html
実際に使ってみた
1. 単体テストを実行
# 単体テストを実行
make test
# カバレッジ付きテスト
make test-coverage
AWS認証情報を削除した状態でのエラーハンドリングもテストできます。
2. ローカルでLambdaを実行(SAM)
ここからが本番です。SAMでローカル実行してみます:
# SAMビルド
sam build --hook-name terraform --terraform-project-root-path ../
# ローカルでLambdaを実行
sam local invoke --hook-name terraform 'module.lambda.aws_lambda_function.lambda_function_create_s3_presigned_url'
これでローカルでLambdaが実行できました!
3. 本番デプロイ
テストが通ったらTerraformでデプロイします:
# Terraformで一括デプロイ
terraform init
terraform plan
terraform apply
やってみた感想
実際に使ってみた感想として、この構成はかなり便利だと感じました。
しかし、AWS SAMの動作に若干の不安定さを感じること、こちらのIssueでも報告されているように、sam local invoke
コマンドで環境変数を渡しても正しく認識されないことがあるため、完璧とは言えない状況です。
sam local invoke
コマンドで環境変数を渡しても正しく認識されないなら、Terraform + AWS SAMってメリットないんじゃない?と思われる方のために、一応メリット・デメリットを整理してみました。
メリット
- ローカルテストが可能: デプロイ前に動作確認ができ、開発効率が向上
- フィードバックサイクルの高速化: テスト → ローカル実行 → デプロイの流れがスムーズ
- デバッグの容易さ: ローカル環境でステップ実行やログ確認が可能
デメリット
- 学習コストの増加: Terraformに加えてSAMの設定方法も習得が必要
- トラブルシューティングの複雑化: 問題の原因がTerraformとSAMのどちらにあるか判断が難しい場合がある
- 初期セットアップの複雑さ: 環境構築に手間がかかる
- 環境変数の扱いの制限: ローカルテスト時に環境変数が正しく読み込まれない場合がある
さいごに
Terraformのみでサーバーレス開発をしていた時期と比較すると、開発体験は大幅に向上したと感じています。
とくに、ローカルでのテスト実行が可能になったことで、開発のフィードバックループが格段に速くなった点が最大のメリットです。
この構成での開発を継続し、さらなる改善点を見つけたら積極的に試していきたいと思います。