30 分で動く小さくて実用的な AWS パイプラインを作るハンズオン。 終わるころには S3 / Lambda / DynamoDB / IAM / CloudWatch / SNS / Budgets / AWS CLI を一通り触っている状態になる。 全部 Always Free 枠の中 で完結するように設計してあるので、 6 ヶ月の無料クレジットが切れた後も請求は 0 円のまま。
git clone https://github.com/kanywst/awscape.git && cd awscape
cp config.env.example config.env # BUCKET と EMAIL を書き換える
./scripts/setup.sh # パイプラインを一発構築
./scripts/cleanup.sh # 全部削除graph LR
User[👤 自分の PC]
S3[S3 バケット<br/>uploads/]
Lambda[Lambda 関数<br/>Python 3.13]
DDB[DynamoDB テーブル<br/>UploadLog]
Logs[CloudWatch Logs<br/>+ Metrics]
Alarm[CloudWatch Alarm<br/>+ Budgets]
User -->|1 ファイルアップロード| S3
S3 -->|2 イベント通知| Lambda
Lambda -->|3 アイテム書き込み| DDB
Lambda -.->|4 ログ出力| Logs
Logs -.->|5 監視| Alarm
classDef user fill:#fef3c7,stroke:#b45309,color:#000,stroke-width:2px
classDef s3 fill:#dbeafe,stroke:#1d4ed8,color:#000,stroke-width:2px
classDef compute fill:#fed7aa,stroke:#c2410c,color:#000,stroke-width:2px
classDef db fill:#dcfce7,stroke:#16a34a,color:#000,stroke-width:2px
classDef mon fill:#fce7f3,stroke:#be185d,color:#000,stroke-width:2px
class User user
class S3 s3
class Lambda compute
class DDB db
class Logs,Alarm mon
S3 にファイルを置く → Lambda が発火 → DynamoDB にレコードが入る → CloudWatch にログとメトリクスが残る。 Lambda が失敗すれば SNS からメールが飛ぶ。 上限を超えなければコストは 0 円。
| モード | こういうとき | やり方 |
|---|---|---|
| 手動 | 初回、 もしくはちゃんと理解したいとき | 下の「ハンズオン」 セクションを上から順にコピペで実行する |
| 自動 | 一度やった、 再度立てるだけでよいとき | cp config.env.example config.env で値を書き換えて ./scripts/setup.sh |
どちらでも片付けは ./scripts/cleanup.sh で一発。
必要なもの。
- AWS アカウント (Free Plan で OK、 詳細は次節)
- Root ユーザに MFA 設定済み、 アクセスキーは作らない
- 管理者権限を持つ IAM ユーザ + そのユーザの MFA 設定済み (作業はこちらでログイン、 Root では作業しない)
- AWS Budgets で最低でも Zero spend アラートを設定
- AWS CLI v2 をインストールして
aws configure済み - 操作する Region を 1 つに固定 (
AWS_REGION)
上から 3 つは必須。 アクセスキーを GitHub に commit してしまって 「AWS から数十万円請求が来た」 の事故は毎月どこかで起きている。
2025-07-15 にモデルが変わった。 それ以降に作ったアカウントは新しいクレジット制プラン。 それ以前は旧 12 ヶ月無料枠。 どちらの下にも Always Free が走っている。
graph TB
subgraph New["新フリープラン (2025-07-15 以降に作成)"]
FreePlan[Free Plan<br/>= クレジット制 + 6 ヶ月期限]
PaidPlan[Paid Plan<br/>= フル機能 + 通常課金]
end
subgraph Legacy["レガシー (2025-07-15 以前に作成)"]
Legacy12[12 ヶ月無料枠<br/>EC2 750h、 S3 5 GB など]
end
subgraph AF["Always Free (全アカウント共通)"]
Items[Lambda 100 万 req/月<br/>DynamoDB 25 GB<br/>S3 5 GB<br/>CloudWatch 10 メトリクス<br/>... 30 以上のサービス]
end
FreePlan -.->|6 ヶ月後 expire<br/>アカウント自動クローズ| AF
Legacy12 -.->|12 ヶ月後 expire<br/>アカウントは残る| AF
PaidPlan --> AF
classDef new fill:#dbeafe,stroke:#1d4ed8,color:#000,stroke-width:2px
classDef legacy fill:#fef3c7,stroke:#b45309,color:#000,stroke-width:2px
classDef always fill:#dcfce7,stroke:#16a34a,color:#000,stroke-width:3px
class FreePlan,PaidPlan new
class Legacy12 legacy
class Items always
このリポジトリは Always Free のみで完結する設計。 2026 年時点の枠は以下のとおり。
| サービス | Always Free 枠 |
|---|---|
| Lambda | 100 万 req + 40 万 GB 秒 |
| DynamoDB | 25 GB + 25 RCU + 25 WCU (プロビジョンド Standard クラスのみ、 オンデマンドは対象外) |
| S3 | 5 GB + 20,000 GET + 2,000 PUT |
| CloudWatch メトリクス/アラーム | カスタムメトリクス 10 + アラーム 10 + API 100 万 |
| CloudWatch Logs | 取込 5 GB + アーカイブ 5 GB + Logs Insights スキャン 5 GB |
| SNS | 100 万 publish |
DynamoDB の注釈に注意。 Always Free は プロビジョンドモードのみ。 オンデマンド (PAY_PER_REQUEST) は無料枠の対象外。 このリポジトリは 25/25 のプロビジョンドで作る。
Region と グローバル一意なバケット名を決めて、 シェルに export する。
export AWS_REGION=ap-northeast-1
export BUCKET=awscape-handson-YOUR_HANDLE
export TABLE=UploadLog
export FUNCTION=HandleUpload
export ROLE_NAME=HandleUploadRole
export TOPIC_NAME=awscape-alarms
export EMAIL=you@example.com
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "$ACCOUNT_ID"aws s3 mb "s3://${BUCKET}" --region "$AWS_REGION"新規バケットは Block Public Access がデフォルト ON のはず。 確認する。
aws s3api get-public-access-block --bucket "$BUCKET"4 つとも true なら OK。 1 つでも false だったら全部 ON にする。
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"プロビジョンド 25 WCU / 25 RCU、 Standard クラス。 これがちょうど Always Free 枠と一致する。
aws dynamodb create-table \
--table-name "$TABLE" \
--attribute-definitions AttributeName=ObjectKey,AttributeType=S \
--key-schema AttributeName=ObjectKey,KeyType=HASH \
--billing-mode PROVISIONED \
--provisioned-throughput ReadCapacityUnits=25,WriteCapacityUnits=25 \
--table-class STANDARD \
--region "$AWS_REGION"
aws dynamodb wait table-exists --table-name "$TABLE" --region "$AWS_REGION"Lambda 関数は自分のクレデンシャルを持たない。 関数が起動するたびに Lambda サービスが IAM Role を Assume して一時クレデンシャルを取り、 それを boto3 等が使う。 つまり Role には以下が必要。
- Trust Policy: 「Lambda サービスはこの Role を引き受けてよい」 を書く (リポジトリの
policies/trust-policy.json) - Permission Policy: 引き受けたあと何ができるか (AWS マネージドの
AWSLambdaBasicExecutionRoleで CloudWatch Logs 書込み + 自前 inline でdynamodb:PutItemとs3:GetObject)
aws iam create-role \
--role-name "$ROLE_NAME" \
--assume-role-policy-document file://policies/trust-policy.json
aws iam attach-role-policy \
--role-name "$ROLE_NAME" \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
cat > /tmp/inline-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "dynamodb:PutItem",
"Resource": "arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/${TABLE}"
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${BUCKET}/*"
}
]
}
EOF
aws iam put-role-policy \
--role-name "$ROLE_NAME" \
--policy-name HandleUploadInline \
--policy-document file:///tmp/inline-policy.json
ROLE_ARN=$(aws iam get-role --role-name "$ROLE_NAME" --query 'Role.Arn' --output text)inline ポリシーで「自分のテーブルへの PutItem だけ」「自分のバケットからの GetObject だけ」 に絞っている。 これが最小権限。
関数のソースは lambda-src/handler.py。 知っておくべき小ネタ:
boto3は AWS の Python SDK。 Lambda Python ランタイムに最初から同梱されている ので pip install 不要urllib.parse.unquote_plusは S3 イベントに入る URL エンコード済みオブジェクトキーを元に戻す関数 (空白が+、 日本語が%E3%83%...の状態から戻す)
(cd lambda-src && zip -q ../function.zip handler.py)
# IAM Role の伝播を数秒待ってから次の API を叩く
sleep 10
aws lambda create-function \
--function-name "$FUNCTION" \
--runtime python3.13 \
--role "$ROLE_ARN" \
--handler handler.lambda_handler \
--zip-file fileb://function.zip \
--environment "Variables={TABLE_NAME=${TABLE}}" \
--region "$AWS_REGION"S3 イベントを模した payload で動作確認。
cat > /tmp/test-event.json <<EOF
{
"Records": [{
"s3": {
"bucket": {"name": "${BUCKET}"},
"object": {"key": "uploads/manual-test.txt", "size": 12}
}
}]
}
EOF
aws lambda invoke \
--function-name "$FUNCTION" \
--payload fileb:///tmp/test-event.json \
--cli-binary-format raw-in-base64-out \
/tmp/response.json --region "$AWS_REGION"
cat /tmp/response.json
aws dynamodb scan --table-name "$TABLE" --region "$AWS_REGION"aws lambda add-permission \
--function-name "$FUNCTION" \
--statement-id AllowS3Invoke \
--action lambda:InvokeFunction \
--principal s3.amazonaws.com \
--source-arn "arn:aws:s3:::${BUCKET}" \
--region "$AWS_REGION"
LAMBDA_ARN=$(aws lambda get-function --function-name "$FUNCTION" \
--query 'Configuration.FunctionArn' --output text --region "$AWS_REGION")
cat > /tmp/notification.json <<EOF
{
"LambdaFunctionConfigurations": [{
"LambdaFunctionArn": "${LAMBDA_ARN}",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [{ "Name": "prefix", "Value": "uploads/" }]
}
}
}]
}
EOF
aws s3api put-bucket-notification-configuration \
--bucket "$BUCKET" \
--notification-configuration file:///tmp/notification.jsonパイプライン全体を試す。
echo "auto trigger" > /tmp/memo.txt
aws s3 cp /tmp/memo.txt "s3://${BUCKET}/uploads/memo.txt" --region "$AWS_REGION"
sleep 5
aws dynamodb scan --table-name "$TABLE" --region "$AWS_REGION"Lambda の print() は自動で CloudWatch Logs に流れる。 確認。
aws logs tail "/aws/lambda/${FUNCTION}" --since 10m --region "$AWS_REGION"アラームをメールに飛ばすには SNS トピックを作って自分のメアドを subscribe する。
TOPIC_ARN=$(aws sns create-topic --name "$TOPIC_NAME" \
--region "$AWS_REGION" --query 'TopicArn' --output text)
aws sns subscribe \
--topic-arn "$TOPIC_ARN" \
--protocol email \
--notification-endpoint "$EMAIL" \
--region "$AWS_REGION"
# 受信ボックスに AWS からの確認メールが届くので、 Confirm リンクをクリック
aws cloudwatch put-metric-alarm \
--alarm-name "${FUNCTION}-Errors" \
--metric-name Errors \
--namespace AWS/Lambda \
--statistic Sum \
--period 60 \
--evaluation-periods 1 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--dimensions Name=FunctionName,Value="$FUNCTION" \
--treat-missing-data notBreaching \
--alarm-actions "$TOPIC_ARN" \
--region "$AWS_REGION"Confirm リンクをクリックしないと、 アラームは ALARM 状態になってもメールは届かない。
スクリプトを叩く。 リソースがなくてもエラーにしない作りなので、 何度叩いても安全。
./scripts/cleanup.shまたは手動で:
aws s3 rm "s3://${BUCKET}" --recursive --region "$AWS_REGION"
aws s3 rb "s3://${BUCKET}" --region "$AWS_REGION"
aws lambda delete-function --function-name "$FUNCTION" --region "$AWS_REGION"
aws dynamodb delete-table --table-name "$TABLE" --region "$AWS_REGION"
aws cloudwatch delete-alarms --alarm-names "${FUNCTION}-Errors" --region "$AWS_REGION"
aws logs delete-log-group --log-group-name "/aws/lambda/${FUNCTION}" --region "$AWS_REGION"
aws sns delete-topic --topic-arn "$TOPIC_ARN" --region "$AWS_REGION"
aws iam delete-role-policy --role-name "$ROLE_NAME" --policy-name HandleUploadInline
aws iam detach-role-policy --role-name "$ROLE_NAME" \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name "$ROLE_NAME"翌日に Cost Explorer を覗いて $0.00 になっていれば完璧。
| レイヤ | サービス | 学んだこと |
|---|---|---|
| 認証認可 | IAM ユーザ + Role + Trust Policy + Permission Policy | Lambda はクレデンシャルを持たない。 起動時に Role を Assume する |
| ストレージ | S3 + Block Public Access | クラウドオブジェクトストア。 新規バケットは非公開、 解除しない |
| コンピュート | Lambda + Python 3.13 | サーバレス。 ランタイムに boto3 同梱、 自分が書くのはハンドラだけ |
| データベース | DynamoDB (プロビジョンド) | マネージド NoSQL。 Always Free に乗せるならプロビジョンド |
| 連携 | S3 → Lambda イベント通知 | サービス同士をイベントで疎結合に繋ぐ AWS の標準パターン |
| 可観測性 | CloudWatch Logs/Metrics/Alarms + SNS | 動いているか確認 + 失敗時の自動通知 |
| コスト | AWS Budgets + Always Free | サプライズ請求への二段の防御線 |
「S3 で入力を受ける → Lambda で処理 → DynamoDB に記録 → CloudWatch で監視」 という形は実戦でも頻出。 ここから API Gateway を足して REST API にする、 EventBridge / SQS で分岐させる、 CloudFormation / CDK で IaC 化、 と発展できる。
awscape/
├── README.md # 英語版 (デフォルト)
├── README.ja.md # このファイル
├── config.env.example # config.env にコピーして値を書き換える
├── lambda-src/
│ └── handler.py # Lambda 関数のコード
├── policies/
│ └── trust-policy.json # Lambda の Trust Policy
└── scripts/
├── setup.sh # 一発セットアップ
└── cleanup.sh # 一発片付け
| 症状 | 原因 | 対策 |
|---|---|---|
何やっても AccessDenied |
権限の狭い IAM ユーザで作業している | 学習中は admin ユーザに切り替え |
BucketAlreadyExists |
バケット名はグローバル一意 | BUCKET にランダムな文字列を足す |
Lambda 作成で InvalidParameterValueException |
IAM Role がまだ伝播していない | 10 秒待ってリトライ |
| S3 にアップロードしたのに Lambda が動かない | add-permission 忘れ、 もしくは prefix がずれている |
手順 5 を再実行 |
| Lambda は動くが DynamoDB 書き込みが失敗 | inline ポリシーに dynamodb:PutItem が無い |
inline ポリシーを再度 apply |
| アラームは ALARM になるのにメールが来ない | SNS subscribe を Confirm していない | AWS から届いたメールの Confirm リンクをクリック |
| 翌月いきなり請求 | 片付け忘れ | cleanup.sh を実行、 Cost Explorer で何が課金されたか確認 |
| 6 ヶ月後にアカウントが消えた | Free Plan の期限切れ | 期限前に Paid Plan に切り替えるか、 新規アカウントを作り直す |