【簡単3ステップ】Terraform で AWS Lambda + API Gateway + Docker (FastAPI) を自動デプロイ

## はじめに

この記事では、AWS Lambda、API Gateway、そして Docker コンテナ化された FastAPI アプリケーションを Terraform を使用して自動デプロイする方法を紹介します。わずか3ステップで、API キーで保護されたサーバーレス API を構築できる方法をご覧いただけます。

<blockquote class="twitter-tweet" data-media-max-width="800"><p lang="ja" dir="ltr">ラムダ＋API Gateway(REST API＋API KEY)＋ Docker(FastAPI)が3⃣Stepで終わるように自動化で起動するようにしました！<br>✅DockerでAPIのイメージをビルド<br>✅Terraformでラムダ＋API Gateway＋イメージのプッシュを自動で実行<br>✅環境変数にAPI KEYの設定<br>終了！！ <a href="https://t.co/QSkOZlDr5T">https://t.co/QSkOZlDr5T</a> <a href="https://t.co/YVANwFyctc">pic.twitter.com/YVANwFyctc</a></p>&mdash; Maki@Sunwood AI Labs. (@hAru_mAki_ch) <a href="https://twitter.com/hAru_mAki_ch/status/1822937930358497519?ref_src=twsrc%5Etfw">August 12, 2024</a></blockquote> 

## 前提条件

- AWS アカウント
- Terraform がインストールされていること
- Docker がインストールされていること
- AWS CLI がインストールされ、設定されていること
- Python 3.9 以上がインストールされていること

## ステップ1: Docker で API のイメージをビルド

まず、FastAPI アプリケーションのコードと Dockerfile を作成します。

`app.py`:

```python
from fastapi import FastAPI
from mangum import Mangum

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}

@app.get("/test")
async def test():
    return {"message": "This is a test endpoint"}

# Mangum handler for AWS Lambda
handler = Mangum(app)
```

`Dockerfile`:

```dockerfile
FROM public.ecr.aws/lambda/python:3.9

WORKDIR /var/task

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD [ "app.handler" ]
```

`requirements.txt`:

```bash
fastapi
mangum
```

次に、Docker イメージをビルドします：

```bash
docker build -t fastapi-lambda .
```

## ステップ2: Terraform でラムダ＋API Gateway＋イメージのプッシュを自動で実行

Terraform の設定ファイルを作成し、AWS リソースとイメージのプッシュを自動化します。

`main.tf`:

https://github.com/Sunwood-ai-labs/aws-terraform-sandbox/blob/main/sandbox/s06_fastapi_lambda/main.tf

```hcl
# AWSプロバイダーの設定
provider "aws" {
  region = var.aws_region
}

# Lambda実行用のIAMロール
resource "aws_iam_role" "lambda_role" {
  name = "fastapi_lambda_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# Lambda実行用のIAMポリシー
resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# ECRリポジトリ
resource "aws_ecr_repository" "fastapi_lambda_repo" {
  name         = "fastapi-lambda-repo"
  force_delete = true
}

# Dockerイメージのビルドとプッシュを行うnull_resource
resource "null_resource" "docker_push" {
  triggers = {
    always_run = "${timestamp()}"
  }

  provisioner "local-exec" {
    command = <<EOT
      echo Building the Docker image
      echo ${var.aws_region}
      echo ${aws_ecr_repository.fastapi_lambda_repo.repository_url}
      
      echo Logging in to ECR
      aws ecr get-login-password --region ${var.aws_region} | docker login --username AWS --password-stdin ${aws_ecr_repository.fastapi_lambda_repo.repository_url}
      
      echo Tagging the image
      docker tag fastapi-lambda:latest ${aws_ecr_repository.fastapi_lambda_repo.repository_url}:latest
      
      echo Pushing the image to ECR
      docker push ${aws_ecr_repository.fastapi_lambda_repo.repository_url}:latest
      Start-Sleep 10
    EOT

    interpreter = ["PowerShell", "-Command"]
  }

  depends_on = [aws_ecr_repository.fastapi_lambda_repo]
}

# Lambda関数
resource "aws_lambda_function" "fastapi_lambda" {
  function_name = var.lambda_function_name
  role          = aws_iam_role.lambda_role.arn
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.fastapi_lambda_repo.repository_url}:latest"

  environment {
    variables = {
      STAGE = var.stage
    }
  }
  depends_on = [null_resource.docker_push]
}

# API Gateway (REST API)
resource "aws_api_gateway_rest_api" "lambda_api" {
  name        = "fastapi-lambda-api"
  description = "FastAPI Lambda API"
}

# API Gatewayリソース
resource "aws_api_gateway_resource" "proxy" {
  rest_api_id = aws_api_gateway_rest_api.lambda_api.id
  parent_id   = aws_api_gateway_rest_api.lambda_api.root_resource_id
  path_part   = "{proxy+}"
}

# API Gatewayメソッド (ANY)
resource "aws_api_gateway_method" "proxy_method" {
  rest_api_id      = aws_api_gateway_rest_api.lambda_api.id
  resource_id      = aws_api_gateway_resource.proxy.id
  http_method      = "ANY"
  authorization    = "NONE"
  api_key_required = true
}

# Lambda関数のAPI Gateway呼び出し許可
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.fastapi_lambda.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.lambda_api.execution_arn}/*/*"
}

# API Gateway統合
resource "aws_api_gateway_integration" "lambda_integration" {
  rest_api_id             = aws_api_gateway_rest_api.lambda_api.id
  resource_id             = aws_api_gateway_resource.proxy.id
  http_method             = aws_api_gateway_method.proxy_method.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.fastapi_lambda.invoke_arn
}

# API Gatewayデプロイ
resource "aws_api_gateway_deployment" "lambda_deployment" {
  depends_on = [
    aws_api_gateway_integration.lambda_integration,
  ]
  rest_api_id = aws_api_gateway_rest_api.lambda_api.id
  stage_name  = var.stage
}

# CloudWatch Logs
resource "aws_cloudwatch_log_group" "api_gw" {
  name              = "/aws/api_gw/${aws_api_gateway_rest_api.lambda_api.name}"
  retention_in_days = 30
}

# APIキーの作成
resource "aws_api_gateway_api_key" "fastapi_lambda_api_key" {
  name = "fastapi-lambda-api-key"
}

# 使用量プランの作成
resource "aws_api_gateway_usage_plan" "fastapi_lambda_usage_plan" {
  name        = "fastapi-lambda-usage-plan"
  description = "Usage plan for FastAPI Lambda API"

  api_stages {
    api_id = aws_api_gateway_rest_api.lambda_api.id
    stage  = aws_api_gateway_deployment.lambda_deployment.stage_name
  }

  quota_settings {
    limit  = 1000
    offset = 0
    period = "MONTH"
  }

  throttle_settings {
    burst_limit = 5
    rate_limit  = 10
  }
}

# 使用量プランとAPIキーの関連付け
resource "aws_api_gateway_usage_plan_key" "fastapi_lambda_usage_plan_key" {
  key_id        = aws_api_gateway_api_key.fastapi_lambda_api_key.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.fastapi_lambda_usage_plan.id
}

# 出力
output "ecr_repository_url" {
  description = "ECR URL"
  value       = aws_ecr_repository.fastapi_lambda_repo.repository_url
}

output "lambda_function_name" {
  description = "Lambda関数名"
  value       = aws_lambda_function.fastapi_lambda.function_name
}

output "api_gateway_url" {
  description = "API Gateway URL"
  value       = aws_api_gateway_deployment.lambda_deployment.invoke_url
}

output "fastapi_lambda_api_key" {
  description = "FastAPI Lambda APIキー"
  value       = aws_api_gateway_api_key.fastapi_lambda_api_key.value
  sensitive   = true
}

```

この設定を適用するには、以下のコマンドを実行します：

```bash
terraform init
terraform apply
```

## ステップ3: 環境変数に API URL と API KEY の設定

Terraform の実行が完了したら、生成された API URL と API キーを環境変数に設定します。`.env` ファイルを作成し、以下の内容を記述します：

```bash
API_URL=https://XXXXX.execute-api.ap-northeast-1.amazonaws.com/dev
API_KEY=YYYYY
```

ここで、`XXXXX` は Terraform の出力から得られる API Gateway の URL、`YYYYY` は生成された API キーに置き換えてください。

## 動作確認

API の動作を確認するために、クライアントスクリプト `lambda_tester_api.py` を使用します：

```python
import requests
import os
from dotenv import load_dotenv

# .env ファイルから環境変数を読み込む
load_dotenv()

API_URL = os.getenv('API_URL')
API_KEY = os.getenv('API_KEY')

def invoke_lambda(path, http_method="GET"):
    url = f"{API_URL}{path}"
    headers = {
        'x-api-key': API_KEY
    }
    
    try:
        if http_method == "GET":
            response = requests.get(url, headers=headers)
        elif http_method == "POST":
            response = requests.post(url, headers=headers)
        else:
            raise ValueError(f"Unsupported HTTP method: {http_method}")
        
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"Lambda invocation error: {e}")
        return None

def main():
    paths = ["/", "/items/42", "/test"]
    
    for path in paths:
        result = invoke_lambda(path)
        print(f"Response from {path}:")
        print(result)
        print()

if __name__ == "__main__":
    main()
```

このスクリプトを実行して、API の各エンドポイントをテストします：

```bash
python lambda_tester_api.py
```

正常に動作していれば、各エンドポイントからのレスポンスが表示されます。

## まとめ

この記事では、以下の3ステップで AWS Lambda、API Gateway、そして Docker コンテナ化された FastAPI アプリケーションを自動デプロイする方法を紹介しました：

1. Docker で API のイメージをビルド
2. Terraform でラムダ＋API Gateway＋イメージのプッシュを自動で実行
3. 環境変数に API URL と API KEY の設定

この方法を活用することで、インフラストラクチャのプロビジョニングを自動化し、API キーで保護されたサーバーレス API を効率的にデプロイすることができます。また、クライアントスクリプトを使用することで、デプロイした API の動作を簡単に確認することができます。

最後に、本番環境での利用時にはさらなるセキュリティ設定やエラーハンドリングなど、追加の考慮が必要な点にご注意ください。

## リポジトリ

https://github.com/Sunwood-ai-labs/aws-terraform-sandbox/tree/main/sandbox/s06_fastapi_lambda

<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>