5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TerraformでLocalStackに構築するサーバーレスアプリケーション

Last updated at Posted at 2025-12-17

はじめに

本記事では、LocalStack上にサーバーレスアプリケーションを構築する手順を書きます。インフラ定義にはTerraformを使用します。
無料で構築できるので、ぜひ見てみてください。

前提条件

  • Terraform v1.14.0
  • Docker
  • Docker Compose (version 1.9.0+)
  • pnpm v8.15.4

概要

本記事では、LocalStackの無料版で実現できる範囲で以下のような構成のサーバーレスアプリケーションを構築していきます。

architecture.png

具体的には、以下のサービスを使用します。

  • 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を以下のように書きます。

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"

コンテナを立ち上げます。

bash
docker compose up -d

参考:

バックエンド実装

今回は主にterraform/main.tfにインフラを定義するコードを書いていきます。

Terraform自身とProviderの設定

まずはTerraformとAWS providerのバージョン指定です。

terraform/main.tf
terraform {
  required_version = ">= 1.0.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>6.0"
    }
  }
}

次に、AWS provider の設定です。

terraform/main.tf
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のハンドラを定義していきます。

bash
mkdir lambda && cd lambda
pnpm init
pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

lambda/index.js にハンドラを実装します。
これからDynamoDBに作る Threads テーブルをScanして全データを取得するハンドラを定義します。

lambda/index.js
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のリソースを定義していきます。

terraform/main.tf
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関数を定義していきます。

terraform/main.tf
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のドキュメントが参考になりました。

terraform/main.tf
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 に以下のように記述します。

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 はフロントエンド実装で使うので、手元に控えてください。

bash
cd terraform
terraform init
terraform apply

# 以下のような出力が得られる
# api_gateway_invoke_url = "http://<hoge>.execute-api.localhost.localstack.cloud:4566/dev"

参考:

DynamoDB

次に、DynamoDB のリソースを定義していきましょう。
今回はダミー用のデータを3つ入れています。

terraform/main.tf
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アプリケーションを作っていきます。

bash
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を置きましょう。

frontend/.env
VITE_API_BASE_URL="http://<hoge>.execute-api.localhost.localstack.cloud:4566/dev"

Lambdaの呼び出しは frontend/src/pages/Home.tsx で行っています。

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 に作成されます。

bash
pnpm run build

次に、ビルドで生成されたコードをS3に置きましょう。

terraform/main.tf
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 です。詳細はドキュメントを参照してください。

それではリソースを作成しましょう。

bash
cd terraform
terraform apply

動作確認

ブラウザで http://testbucket.s3-website.localhost.localstack.cloud:4566/ にアクセスすると以下のような画面がみれます。期待通り、リソース作成時にDynamoDBに入れられたThreadsのデータが表示されています。

image.png

リソースを削除したい場合は

bash
cd terraform
terraform destroy

を実行してください。

まとめ

LocalStack無料版でDynamoDB・Lambda・API Gateway・S3を組み合わせた最小構成のサーバーレス環境をTerraformで構築しました。

まだまだ拡張の余地があると思うので、試してみてください。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?