はじめに
本記事では、LocalStack上にサーバーレスアプリケーションを構築する手順を書きます。インフラ定義にはTerraformを使用します。
無料で構築できるので、ぜひ見てみてください。
前提条件
- Terraform v1.14.0
- Docker
- Docker Compose (version 1.9.0+)
- pnpm v8.15.4
概要
本記事では、LocalStackの無料版で実現できる範囲で以下のような構成のサーバーレスアプリケーションを構築していきます。
具体的には、以下のサービスを使用します。
- DynamoDB
- Lambda
- S3
- API Gateway REST API
LocalStackでのそれぞれのプランで使えるサービス一覧は以下のページに記載されています。
ディレクトリ構成は以下のようになっています。
.
├── docker-compose.yml
├── frontend
├── lambda
│ ├── index.js
├── Makefile
└── terraform
├── main.tf
└── outputs.tf
ソースコートは以下のリポジトリに置いています。この記事ではソースコードすべてを説明しないので、適宜参照してください。
ローカル環境セットアップ
LocalStackのセットアップをします。
docker-compose.yamlを以下のように書きます。
services:
localstack:
container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}"
image: localstack/localstack
ports:
- "127.0.0.1:4566:4566" # LocalStack Gateway
- "127.0.0.1:4510-4559:4510-4559" # external services port range
environment:
# LocalStack configuration: https://docs.localstack.cloud/references/configuration/
- DEBUG=${DEBUG:-0}
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
コンテナを立ち上げます。
docker compose up -d
参考:
バックエンド実装
今回は主にterraform/main.tfにインフラを定義するコードを書いていきます。
Terraform自身とProviderの設定
まずはTerraformとAWS providerのバージョン指定です。
terraform {
required_version = ">= 1.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>6.0"
}
}
}
次に、AWS provider の設定です。
provider "aws" {
region = "us-east-1"
access_key = "fake"
secret_key = "fake"
# only required for non virtual hosted-style endpoint use case.
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs#s3_force_path_style
s3_use_path_style = false
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {
sts = "http://localhost:4566"
s3 = "http://s3.localhost.localstack.cloud:4566"
lambda = "http://localhost:4566"
iam = "http://localhost:4566"
apigateway = "http://localhost:4566"
dynamodb = "http://localhost:4566"
}
}
LocalStack上に構築するので、AWSの認証情報はダミーのものでOKです。
今回使用するサービスをLocalStackで使用するために endpoint をローカルに立てたLocalStackのものに設定しています。これによって、TerraformがAPI呼び出しを本番のAWSではなくLocalStackへ送るようになります。
参考:
Lambda + APIGateway
まずはLambdaのハンドラを定義していきます。
mkdir lambda && cd lambda
pnpm init
pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
lambda/index.js にハンドラを実装します。
これからDynamoDBに作る Threads テーブルをScanして全データを取得するハンドラを定義します。
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, ScanCommand } = require('@aws-sdk/lib-dynamodb');
const dynamodbClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(dynamodbClient);
exports.all_threads_handler = async (_event) => {
const command = new ScanCommand({
ProjectionExpression: "#id, title, body, authorName",
ExpressionAttributeNames: { "#id": "id" },
TableName: "Threads",
});
try {
const res = await docClient.send(command);
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify({
status: "ok",
items: res.Items,
})
}
} catch(error) {
return {
statusCode: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify({
message: error.message ?? "Internal error"
})
}
}
};
ヘッダーに "Access-Control-Allow-Origin": "*" を追加しています。これはCORSを有効にするために必要なものです。さらに複雑なことをしようとすると、追加の設定が必要になる場合があります。詳しくはこちらを参照してください。
次に、このハンドラを使用してLambdaのリソースを定義していきます。
locals {
bucket_name = "testbucket"
lambda_dir = abspath("${path.module}/../lambda")
}
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = local.lambda_dir
output_path = "${path.module}/files/lambda.zip"
}
resource "aws_s3_bucket" "lambda_code_bucket" {
bucket = "lambda-code-bucket"
}
resource "aws_s3_object" "lambda_code" {
bucket = aws_s3_bucket.lambda_code_bucket.bucket
key = "lambda.zip"
source = data.archive_file.lambda_zip.output_path
}
locals でローカル値を定義しています。今後使用するものも含まれています。
lambda/index.js で定義したハンドラをzipファイルにしてS3に配置しています。
S3に置いたzipを使用してLambda関数を定義していきます。
resource "aws_lambda_function" "lambda_function_all_threads" {
function_name = "all_threads_handler"
handler = "index.all_threads_handler"
runtime = "nodejs20.x"
role = aws_iam_role.lambda_exec.arn
s3_bucket = aws_s3_bucket.lambda_code_bucket.id
s3_key = aws_s3_object.lambda_code.key
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
}
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "lambda_exec" {
name = "lambda_exec_role"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
aws_lambda_function のドキュメントが参考になりました。
次にAPIGatewayのリソース定義です。この辺はやり方がいくつかありそうなのですが、OpenAPIを使用するやり方でいきます。
aws_api_gateway_rest_apiのドキュメントが参考になりました。
resource "aws_api_gateway_rest_api" "api" {
name = "lambda-api"
body = jsonencode({
openapi = "3.0.1"
info = {
title = "api"
version = "1.0.0"
}
paths = {
"/threads/all" = {
get = {
x-amazon-apigateway-integration = {
httpMethod = "POST"
payloadFormatVersion = "1.0"
type = "aws_proxy"
uri = aws_lambda_function.lambda_function_all_threads.invoke_arn
}
}
}
}
})
}
resource "aws_api_gateway_deployment" "deployment" {
rest_api_id = aws_api_gateway_rest_api.api.id
triggers = {
redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api.body))
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_api_gateway_stage" "dev_stage" {
deployment_id = aws_api_gateway_deployment.deployment.id
rest_api_id = aws_api_gateway_rest_api.api.id
stage_name = "dev"
}
resource "aws_lambda_permission" "api_gw_all_threads" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lambda_function_all_threads.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}
OpenAPIの設定の中の x-amazon-apigateway-integration はCORS対応のために必要なものです。Lambdaのハンドラ定義のCORS対応で紹介したものに加えて、こちらも参考になりました。
APIGateway のエンドポイントを出力するために、 terraform/outputs.tf に以下のように記述します。
output "api_gateway_invoke_url" {
value = "http://${aws_api_gateway_rest_api.api.id}.execute-api.localhost.localstack.cloud:4566/${aws_api_gateway_stage.dev_stage.stage_name}"
}
ここで、一度 terraform apply でリソースを作成します。 api_gateway_invoke_url はフロントエンド実装で使うので、手元に控えてください。
cd terraform
terraform init
terraform apply
# 以下のような出力が得られる
# api_gateway_invoke_url = "http://<hoge>.execute-api.localhost.localstack.cloud:4566/dev"
参考:
DynamoDB
次に、DynamoDB のリソースを定義していきましょう。
今回はダミー用のデータを3つ入れています。
resource "aws_dynamodb_table" "dynamodb_table" {
name = "Threads"
billing_mode = "PROVISIONED"
read_capacity = 20
write_capacity = 20
hash_key = "id"
attribute {
name = "id"
type = "N"
}
tags = {
Name = "dynamodb-table"
Environment = "dev"
}
}
locals {
list = [
{
id = { N = "1" },
title = { S = "はじめての掲示板投稿" },
body = { S = "これはテスト用の投稿です。" },
authorName = { S = "名無し" },
},
{
id = { N = "2" },
title = { S = "DynamoDB完全に理解したったwwwwwwww" },
body = { S = "嘘です。ナニモワカリマセン" },
authorName = { S = "名無し" },
},
{
id = { N = "3" },
title = { S = "DynamoDB設計について" },
body = { S = "シングルテーブル設計は慣れると便利です。" },
authorName = { S = "匿名希望" },
},
]
}
resource "aws_dynamodb_table_item" "items" {
for_each = { for i in local.list : i.id.N => i }
table_name = aws_dynamodb_table.dynamodb_table.name
hash_key = aws_dynamodb_table.dynamodb_table.hash_key
item = jsonencode(each.value)
}
resource "aws_iam_role_policy" "lambda_dynamodb" {
name = "lambda_dynamodb"
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:Scan"
]
Resource = [
aws_dynamodb_table.dynamodb_table.arn
]
}
]
})
}
locals には、リソース作成の際に Threads テーブルにデータを入れるための配列を定義しています。これらのデータがLambdaハンドラでScanされ、最終的にフロントエンドに表示されます。
フロントエンド実装
次にフロントエンドを実装していきます。今回はViteを使用してReactアプリケーションを作っていきます。
mkdir frontend
cd frontend
pnpm create vite
# │
# ◇ Project name:
# │ .
# │
# ◇ Select a framework:
# │ React
# │
# ◇ Select a variant:
# │ TypeScript
# │
# ◇ Use rolldown-vite (Experimental)?:
# │ No
# │
# ◇ Install with pnpm and start now?
# │ Yes
# │
# ◇ Scaffolding project in /path/to/dir/...
# │
# ◇ Installing dependencies with pnpm...
pnpm add react-router
この記事ではフロントエンドの実装はすべて説明せずに、大事な部分のみを説明します。
詳しくはこちらを参照してください。
まずは、 frontend/.env にAPIGateway のエンドポイントのURLを置きましょう。
VITE_API_BASE_URL="http://<hoge>.execute-api.localhost.localstack.cloud:4566/dev"
Lambdaの呼び出しは frontend/src/pages/Home.tsx で行っています。
import { useState, useEffect } from "react"
import { type Thread } from "../types/thread";
export const Home = () => {
const [threads, setThreads] = useState<Thread[]>([]);
useEffect(() => {
const fetchThreads = async () => {
try {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/threads/all`);
if (!res.ok) {
console.error(`Failed to fetch threads: ${res.status} ${res.statusText}`);
return;
}
const data = await res.json();
setThreads(data.items);
} catch (error) {
console.error("An error occurred while fetching threads: ", error);
}
};
fetchThreads();
}, [])
return (
<div>
<h1>Home</h1>
<div>
{threads.map((thread) => (
<div key={thread.id}>
<h2>{thread.title}</h2>
<span>投稿者: {thread.authorName}</span>
<p>{thread.body}</p>
<hr />
</div>
))}
</div>
</div>
)
}
やっていることは簡単で、コンポーネントの描画時にAPIGatewayのエンドポイントをフェッチし、DynamoDBに入っているデータをScanした結果を取得しています。
実装ができたらビルドしておきます。生成物は frontend/dist に作成されます。
pnpm run build
次に、ビルドで生成されたコードをS3に置きましょう。
locals {
dist_dir = abspath("${path.module}/../frontend/dist")
asset_files = {
for file in fileset(local.dist_dir, "assets/*") : file => lookup({
"js" = "text/javascript"
"css" = "text/css"
"svg" = "image/svg+xml"
}, replace(regex("\\.[^.]*$", file), ".", ""), "application/octet-stream")
}
}
resource "aws_s3_bucket" "s3_bucket" {
bucket = local.bucket_name
}
resource "aws_s3_bucket_website_configuration" "s3_bucket_website" {
bucket = aws_s3_bucket.s3_bucket.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
resource "aws_s3_bucket_acl" "s3_bucket" {
bucket = aws_s3_bucket.s3_bucket.id
acl = "public-read"
}
resource "aws_s3_bucket_policy" "s3_bucket" {
bucket = aws_s3_bucket.s3_bucket.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = [
"${aws_s3_bucket.s3_bucket.arn}/*",
]
}
]
})
}
resource "aws_s3_object" "object_www" {
depends_on = [aws_s3_bucket.s3_bucket]
for_each = fileset(local.dist_dir, "*.html")
bucket = local.bucket_name
key = basename(each.value)
source = "${local.dist_dir}/${each.value}"
etag = filemd5("${local.dist_dir}/${each.value}")
content_type = "text/html"
acl = "public-read"
}
resource "aws_s3_object" "object_assets" {
depends_on = [aws_s3_bucket.s3_bucket]
for_each = local.asset_files
bucket = local.bucket_name
key = each.key
source = "${local.dist_dir}/${each.key}"
etag = filemd5("${local.dist_dir}/${each.key}")
content_type = each.value
acl = "public-read"
}
Lambdaのリソース定義のところでもS3は出てきましたが、加えて websiteをHostingするための設定が追加で必要です。それが aws_s3_bucket_website_configuration です。詳細はドキュメントを参照してください。
それではリソースを作成しましょう。
cd terraform
terraform apply
動作確認
ブラウザで http://testbucket.s3-website.localhost.localstack.cloud:4566/ にアクセスすると以下のような画面がみれます。期待通り、リソース作成時にDynamoDBに入れられたThreadsのデータが表示されています。
リソースを削除したい場合は
cd terraform
terraform destroy
を実行してください。
まとめ
LocalStack無料版でDynamoDB・Lambda・API Gateway・S3を組み合わせた最小構成のサーバーレス環境をTerraformで構築しました。
まだまだ拡張の余地があると思うので、試してみてください。

