透過結合 Terraform 與 AWS SAM(Serverless Application Model),可以明確地分離基礎架構與應用程式的責任,實現針對團隊的適當權限管理以及安全的基礎架構即程式碼(IaC)運用。
本文將透過一個可運作的完整範例專案,解釋以下內容:
✅ Terraform 與 SAM 的適當分配
✅ VPC 內 Lambda 的安全設計
✅ DynamoDB 單表設計的實作
✅ 環境別(dev/staging/prod)的管理方法
✅ 透過 CloudWatch 設置監控與警報
✅ 使用 GitHub Actions 建立 CI/CD
✅ 實際發生的錯誤及其解決方案
完整的源代碼公開於以下連結:
https://github.com/higakikeita/test
架構圖(可編輯):
https://github.com/higakikeita/test/blob/main/docs/architecture.drawio
| 工具 | 擅長的事情 | 不擅長的事情 |
|---|---|---|
| Terraform | 全面管理基礎架構,與其他服務整合 | Lambda 的建置、本地測試 |
| SAM | Lambda 的開發與部署、本地測試 | VPC 等通用的基礎架構管理 |
┌─────────────────────────────────────┐
│ Terraform (基礎架構層) │
│ 👷 由基礎架構團隊管理 │
├─────────────────────────────────────┤
│ • VPC / 子網路 / 安全群組 │
│ • DynamoDB 表格 / IAM 角色 │
│ • S3 存儲桶 / CloudWatch 配置 │
│ │
│ 變更頻率: 低 (週次至月次) │
│ 影響範圍: 大 (安全與網路) │
└─────────────────────────────────────┘
↓ outputs
┌─────────────────────────────────────┐
│ SAM (應用層) │
│ 👨💻 由應用開發者管理 │
├─────────────────────────────────────┤
│ • Lambda 函式 / Lambda 層 │
│ • API Gateway / 事件來源 │
│ • 應用邏輯 │
│ │
│ 變更頻率: 高 (日次至小時) │
│ 影響範圍: 小 (應用內部) │
└─────────────────────────────────────┘
該架構的最大優點是能夠實現依據團隊角色的適當權限分離。
管理範圍:
特徵:
權限:
# 僅能由基礎架構團隊執行
terraform apply -var-file=environments/prod.tfvars
管理範圍:
特徵:
權限:
# 應用開發者可自由執行
sam deploy --stack-name my-app-dev
最小權限原則
變更管理的分離
減輕安全風險
提升開發速度
審核與合規性支援
# GitHub Actions - 基礎架構部署(僅限 main 分支)
deploy-infrastructure:
if: github.ref == 'refs/heads/main'
environment: production
# 僅能由基礎架構團隊批准
# GitHub Actions - 應用部署(feature 分支也可以)
deploy-application:
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
# 應用開發者可以自由部署
這樣的架構使得在維持安全性的同時,最大化開發速度。
此次構建的系統架構如下:
使用 Python 的 diagrams 資料庫自動生成,並使用 AWS 官方圖示的架構圖。
簡潔的圖示,一目瞭然基本資料流。

詳細顯示所有組件及其關係的圖。

分層顯示從請求到響應的資料流動。

terraform-sam-demo/
├── terraform/ # 基礎架構定義
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── iam.tf # IAM 角色與政策
│ ├── vpc.tf # VPC 設定
│ ├── dynamodb.tf # DynamoDB 表格
│ ├── cloudwatch.tf # 監控設定
│ └── environments/ # 環境別設定
│ ├── dev.tfvars
│ ├── staging.tfvars
│ └── prod.tfvars
├── sam/ # SAM 應用程式
│ ├── template.yaml # SAM 模板
│ ├── functions/ # Lambda 函式
│ │ ├── api/
│ │ │ ├── index.py
│ │ │ └── requirements.txt
│ │ └── processor/
│ │ ├── index.py
│ │ └── requirements.txt
│ ├── layers/ # Lambda 層
│ │ └── common/
│ └── events/ # 測試事件
├── scripts/ # 腳本
│ ├── deploy.sh # 部署腳本
│ ├── validate.sh # 驗證腳本
│ └── generate_diagrams.py # 圖的自動生成
├── .github/workflows/ # CI/CD
│ └── deploy.yml
└── docs/ # 文件
├── architecture.md
├── TROUBLESHOOTING.md
├── BEST_PRACTICES.md
└── images/ # 架構圖
├── architecture.png
├── architecture_simple.png
├── dataflow.png
└── README.md # 圖的生成方法
本專案使用 Python 的 diagrams 資料庫自動生成使用 AWS 官方圖示的架構圖。
# 安裝所需工具
brew install graphviz
pip3 install diagrams
# 生成圖
python3 scripts/generate_diagrams.py
執行後,docs/images/ 將會生成以下三個 PNG 圖片:
布局的巧妙:
graph_attr = {
"splines": "ortho", # 直角的美麗線條
"nodesep": "0.8", # 節點之間的間距
"ranksep": "1.0", # 階層間的空白
}
色彩區分的視覺化:
# 主流
users >> Edge(color="darkblue", style="bold", label="HTTPS") >> apigw
apigw >> Edge(color="darkgreen", style="bold", label="Invoke") >> lambda_api
# Stream 處理
dynamodb >> Edge(color="orange", style="bold", label="Streams") >> lambda_processor
# 日誌
lambda_api >> Edge(color="gray", style="dotted") >> cloudwatch
優勢:
透過編輯 scripts/generate_diagrams.py 可以輕鬆自定義:
# 新增 AWS 服務的範例
from diagrams.aws.network import CloudFront
from diagrams.aws.security import WAF
# 添加至圖中
cloudfront = CloudFront("CloudFront")
waf = WAF("WAF")
詳情請參閱 diagrams 官方文檔。
將 Lambda 放置在 VPC 內以實現安全環境。
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${local.resource_prefix}-vpc"
}
}
# 私有子網(用於 Lambda 的配置)
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${local.resource_prefix}-private-subnet-${count.index + 1}"
}
}
# NAT Gateway(用於 Lambda 連接外部 API)
resource "aws_nat_gateway" "main" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs)) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "${local.resource_prefix}-nat-${count.index + 1}"
}
}
# VPC 端點(降低成本)
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.s3"
route_table_ids = concat(
[aws_route_table.public.id],
aws_route_table.private[*].id
)
tags = {
Name = "${local.resource_prefix}-s3-endpoint"
}
}
重點:
通過單表設計高效管理多個實體。
resource "aws_dynamodb_table" "main" {
name = local.dynamodb_table_name
billing_mode = var.dynamodb_billing_mode
hash_key = "PK"
range_key = "SK"
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
attribute {
name = "EntityType"
type = "S"
}
attribute {
name = "CreatedAt"
type = "N"
}
# GSI: 用於依據實體類型查詢
global_secondary_index {
name = "EntityTypeIndex"
hash_key = "EntityType"
range_key = "CreatedAt"
projection_type = "ALL"
}
# DynamoDB Streams(用於處理器 Lambda)
stream_enabled = var.enable_dynamodb_streams
stream_view_type = "NEW_AND_OLD_IMAGES"
# 逐點恢復(僅在本番環境)
point_in_time_recovery {
enabled = var.enable_dynamodb_point_in_time_recovery
}
# TTL 設定
ttl {
enabled = true
attribute_name = "ExpiresAt"
}
}
單表設計範例:
# 使用者實體
PK: USER#123, SK: METADATA
EntityType: User
Name: "John Doe"
# 使用者的訂單
PK: USER#123, SK: ORDER#456
EntityType: Order
Amount: 1000
基於最小權限原則創建 Lambda 的 IAM 角色。
# Lambda API 函式用角色
resource "aws_iam_role" "lambda_api" {
name = "${local.resource_prefix}-lambda-api-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}
# DynamoDB 存取政策
data "aws_iam_policy_document" "lambda_dynamodb_access" {
statement {
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
]
resources = [
aws_dynamodb_table.main.arn,
"${aws_dynamodb_table.main.arn}/index/*"
]
}
}
resource "aws_iam_role_policy" "lambda_api_dynamodb" {
role = aws_iam_role.lambda_api.id
policy = data.aws_iam_policy_document.lambda_dynamodb_access.json
}
安全重點:
*)設定監控和警報。
# 日誌組
resource "aws_cloudwatch_log_group" "lambda_api" {
name = "/aws/lambda/${local.lambda_function_prefix}-api"
retention_in_days = var.log_retention_days
}
# Lambda 錯誤警報
resource "aws_cloudwatch_metric_alarm" "lambda_api_errors" {
alarm_name = "${local.resource_prefix}-lambda-api-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 300
statistic = "Sum"
threshold = 5
dimensions = {
FunctionName = "${local.lambda_function_prefix}-api"
}
}
# CloudWatch 儀表板
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "${local.resource_prefix}-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
properties = {
metrics = [
["AWS/Lambda", "Invocations"],
[".", "Errors"],
[".", "Duration"]
]
period = 300
stat = "Average"
region = var.aws_region
title = "Lambda Metrics"
}
}
]
})
}
輸出供 SAM 使用的值。
output "vpc_id" {
value = aws_vpc.main.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
output "lambda_security_group_id" {
value = aws_security_group.lambda.id
}
output "lambda_api_role_arn" {
value = aws_iam_role.lambda_api.arn
}
output "dynamodb_table_name" {
value = aws_dynamodb_table.main.name
}
output "sam_artifacts_bucket" {
value = aws_s3_bucket.sam_artifacts.id
}
# 生成 SAM 用部署命令
output "sam_deploy_command" {
value = <<-EOT
sam deploy \
--stack-name ${local.resource_prefix}-app \
--s3-bucket ${aws_s3_bucket.sam_artifacts.id} \
--parameter-overrides \
VpcId=${aws_vpc.main.id} \
SubnetIds=${join(",", aws_subnet.private[*].id)}
EOT
}
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
# 由 Terraform 傳遞參數
Parameters:
Environment:
Type: String
VpcId:
Type: String
SubnetIds:
Type: CommaDelimitedList
SecurityGroupId:
Type: String
LambdaApiRoleArn:
Type: String
DynamoDBTableName:
Type: String
# 全域設定
Globals:
Function:
Runtime: python3.11
Timeout: 30
MemorySize: 256
Architectures:
- arm64 # Graviton2(降低 20% 成本)
Environment:
Variables:
ENVIRONMENT: !Ref Environment
DYNAMODB_TABLE: !Ref DynamoDBTableName
LOG_LEVEL: INFO
VpcConfig:
SecurityGroupIds:
- !Ref SecurityGroupId
SubnetIds: !Ref SubnetIds
Tracing: Active # 啟用 X-Ray
Resources:
# Lambda 層(共通函式庫)
CommonLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: !Sub ${Environment}-common-layer
ContentUri: layers/common/
CompatibleRuntimes:
- python3.11
# API Lambda 函式
ApiFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub terraform-sam-demo-${Environment}-api
CodeUri: functions/api/
Handler: index.lambda_handler
Role: !Ref LambdaApiRoleArn
Layers:
- !Ref CommonLayer
Events:
GetItems:
Type: Api
Properties:
Path: /items
Method: GET
CreateItem:
Type: Api
Properties:
Path: /items
Method: POST
GetItem:
Type: Api
Properties:
Path: /items/{id}
Method: GET
# 處理器 Lambda 函式 (DynamoDB Streams)
ProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub terraform-sam-demo-${Environment}-processor
CodeUri: functions/processor/
Handler: index.lambda_handler
Role: !Ref LambdaProcessorRoleArn
Events:
DynamoDBStream:
Type: DynamoDB
Properties:
Stream: !Ref DynamoDBStreamArn
StartingPosition: LATEST
BatchSize: 10
Outputs:
ApiEndpoint:
Value: !Sub https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}
import json
import os
import boto3
from boto3.dynamodb.conditions import Key
import logging
logger = logging.getLogger()
logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO'))
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['DYNAMODB_TABLE'])
def create_response(status_code, body):
"""創建 API Gateway 響應"""
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps(body, default=str)
}
def get_items(event):
"""GET /items - 獲取項目列表"""
try:
response = table.query(
IndexName='EntityTypeIndex',
KeyConditionExpression=Key('EntityType').eq('Item'),
Limit=20
)
items = response.get('Items', [])
logger.info(f"Retrieved {len(items)} items")
return create_response(200, {
'items': items,
'count': len(items)
})
except Exception as e:
logger.error(f"Error: {str(e)}")
return create_response(500, {'error': str(e)})
def create_item(event):
"""POST /items - 創建項目"""
try:
body = json.loads(event['body'])
import uuid
item_id = str(uuid.uuid4())
item = {
'PK': f'ITEM#{item_id}',
'SK': 'METADATA',
'EntityType': 'Item',
'ItemId': item_id,
'Name': body['name'],
'CreatedAt': int(time.time())
}
table.put_item(Item=item)
logger.info(f"Created item: {item_id}")
return create_response(201, {
'message': 'Item created',
'item': item
})
except Exception as e:
logger.error(f"Error: {str(e)}")
return create_response(500, {'error': str(e)})
def lambda_handler(event, context):
"""Lambda 入口點"""
logger.info(f"Event: {json.dumps(event)}")
method = event['httpMethod']
path = event['path']
if path == '/items' and method == 'GET':
return get_items(event)
elif path == '/items' and method == 'POST':
return create_item(event)
else:
return create_response(404, {'error': 'Not found'})
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def process_insert(new_image):
"""INSERT 事件處理"""
logger.info(f"New item created: {new_image.get('ItemId')}")
# 發送通知,集計處理等
def process_modify(old_image, new_image):
"""MODIFY 事件處理"""
logger.info(f"Item modified: {new_image.get('ItemId')}")
# 變更內容分析,通知等
def lambda_handler(event, context):
"""DynamoDB Streams 事件處理"""
logger.info(f"Processing {len(event['Records'])} records")
for record in event['Records']:
event_name = record['eventName']
if event_name == 'INSERT':
new_image = record['dynamodb']['NewImage']
process_insert(new_image)
elif event_name == 'MODIFY':
old_image = record['dynamodb']['OldImage']
new_image = record['dynamodb']['NewImage']
process_modify(old_image, new_image)
return {'statusCode': 200}
# 確認安裝必要的工具
terraform --version # >= 1.5.0
sam --version # >= 1.100.0
aws --version # >= 2.0
# 設定 AWS 認證資訊
aws configure
cd terraform
# 初始化
terraform init
# 查看計畫
terraform plan -var-file=environments/dev.tfvars
# 應用
terraform apply -var-file=environments/dev.tfvars
# 儲存輸出值(供 SAM 使用)
terraform output -json > ../sam/terraform-outputs.json
執行結果:
Apply complete! Resources: 45 added, 0 changed, 0 destroyed.
Outputs:
vpc_id = "vpc-0123456789abcdef0"
private_subnet_ids = [
"subnet-0123456789abcdef0",
"subnet-0123456789abcdef1",
]
dynamodb_table_name = "terraform-sam-demo-dev-data"
sam_artifacts_bucket = "terraform-sam-demo-dev-sam-artifacts-123456789012"
cd ../sam
# 建置
sam build
# 本地測試(可選)
sam local invoke ApiFunction -e events/event.json
# 部署
sam deploy \
--stack-name terraform-sam-demo-dev-app \
--s3-bucket $(cat terraform-outputs.json | jq -r '.sam_artifacts_bucket.value') \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
Environment=dev \
VpcId=$(cat terraform-outputs.json | jq -r '.vpc_id.value') \
SubnetIds=$(cat terraform-outputs.json | jq -r '.private_subnet_ids.value | join(",")') \
# ...其他參數
執行結果:
Successfully created/updated stack - terraform-sam-demo-dev-app
Outputs:
Key ApiEndpoint
Description API Gateway 端點 URL
Value https://abc123def.execute-api.ap-northeast-1.amazonaws.com/dev
# 一次性部署
./scripts/deploy.sh dev
# 僅部屬 Terraform
./scripts/deploy.sh dev --tf-only
# 僅部屬 SAM
./scripts/deploy.sh dev --sam-only
# 健康檢查
curl https://abc123def.execute-api.ap-northeast-1.amazonaws.com/dev/health
# 獲取項目列表
curl https://abc123def.execute-api.ap-northeast-1.amazonaws.com/dev/items
# 創建項目
curl -X POST https://abc123def.execute-api.ap-northeast-1.amazonaws.com/dev/items \
-H "Content-Type: application/json" \
-d '{"name": "Test Item"}'
響應範例:
{
"message": "Item created",
"item": {
"PK": "ITEM#123e4567-e89b-12d3-a456-426614174000",
"SK": "METADATA",
"EntityType": "Item",
"ItemId": "123e4567-e89b-12d3-a456-426614174000",
"Name": "Test Item",
"CreatedAt": 1704067200
}
}
# 實時查看日誌
aws logs tail /aws/lambda/terraform-sam-demo-dev-api --follow
# 僅過濾錯誤日誌
aws logs tail /aws/lambda/terraform-sam-demo-dev-api --filter-pattern "ERROR"
# Lambda 執行次數
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name Invocations \
--dimensions Name=FunctionName,Value=terraform-sam-demo-dev-api \
--start-time 2024-01-01T00:00:00Z \
--end-time 2024-01-01T23:59:59Z \
--period 3600 \
--statistics Sum
# terraform/environments/dev.tfvars
environment = "dev"
# 降低成本配置
enable_nat_gateway = true
single_nat_gateway = true # 單一 NAT Gateway
dynamodb_billing_mode = "PAY_PER_REQUEST"
log_retention_days = 7
enable_lambda_insights = false
# terraform/environments/prod.tfvars
environment = "prod"
# 高可用性配置
enable_nat_gateway = true
single_nat_gateway = false # 每個 AZ 配置 NAT Gateway
dynamodb_billing_mode = "PAY_PER_REQUEST"
enable_dynamodb_point_in_time_recovery = true
log_retention_days = 90
enable_lambda_insights = true
# dev 環境
./scripts/deploy.sh dev
# staging 環境
./scripts/deploy.sh staging
# prod 環境
./scripts/deploy.sh prod
name: Deploy to AWS
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
validate:
runs-on
---
原文出處:https://qiita.com/keitah/items/d41b0888cf7b8b01616d