本ページに掲載されているコードは、解説目的で実際の構成から抽出しています。AWS アカウント ID・メールアドレス・固有 ID など 個人特定可能 / 機密性のある情報はXXXXXXXXXXXXやyour-email@example.comで伏せて あります。
このサイト lab.iigtn.com は、フリーランス・インフラエンジニアのポートフォリオ基盤として AWS 上に構築されたサーバレス構成のウェブサイトです。
特徴は次の 3 つ:
0 円 に近い課金。サーバの OS パッチや常時稼働の心配が不要。| サービス | 役割 |
|---|---|
| S3 | HTML / CSS / JavaScript / 画像の保管庫 |
| CloudFront | 世界中の Edge から配信する CDN + HTTPS 終端 |
| ACM | 無料の TLS 証明書発行 (lab.iigtn.com 用) |
| API Gateway | 問い合わせフォーム用 HTTP エンドポイント |
| Lambda | API のサーバ側処理(Node.js) |
| DynamoDB | 問い合わせ内容の保存 |
| SES | 問い合わせ通知メール送信(オプション) |
| CloudWatch | ログ + アラーム + メトリクス |
| SNS | アラーム通知メール |
| IAM | 権限管理 (Role / OIDC) |
| Budgets | 月額予算超過のアラート |
11 サービスありますが、それぞれの責任範囲は明確で、ぼんやり大量に動かしているわけではありません。次の章で順番に解説します。
本ページで頻出する用語を最初にまとめておきます。意味が分からなくなったらここに戻ってください。
| 用語 | 意味 |
|---|---|
| サーバレス | 「サーバを自分で管理しない」アーキテクチャ。OS パッチ・容量・台数がクラウドベンダー側でケアされる。Lambda / API Gateway / S3 等 |
| マネージドサービス | AWS が裏側を運用してくれるサービスの総称。サーバレスに似た概念 |
| リージョン (Region) | AWS の地理的データセンター。ap-northeast-1 = 東京、us-east-1 = バージニア北部 |
| アカウント ID | AWS アカウントを識別する 12 桁の数字。例: XXXXXXXXXXXX |
| ARN (Amazon Resource Name) | AWS リソースを一意に指す文字列。arn:aws:<サービス>:<リージョン>:<アカウントID>:<リソース> の形 |
| VPC | 仮想ネットワーク。今回は VPC を使わない構成(Lambda は VPC 外で動く) |
| 用語 | 意味 |
|---|---|
| S3 (Simple Storage Service) | オブジェクトストレージ。「バケット」という入れ物にファイルを入れる |
| バケット (Bucket) | S3 のファイル置き場の単位。名前は AWS 全体で一意 |
| オブジェクト (Object) | S3 の中の 1 ファイル |
| CloudFront | AWS の CDN。世界中の Edge ロケーション にコピーをキャッシュして高速配信 |
| Distribution | CloudFront の設定単位 (1 ドメイン分の構成) |
| Origin | CloudFront が大元のファイルを取りに行く先(S3 や API GW など) |
| OAC (Origin Access Control) | CloudFront だけが S3 を読めるようにする最新の認証方式。S3 を完全非公開のまま CDN 配信できる |
| OAI (Origin Access Identity) | OAC の旧式。新規構築では OAC が推奨 |
| Behavior | CloudFront のルーティング規則 (/api/* は API GW へ など) |
| 用語 | 意味 |
|---|---|
| Lambda | サーバレス関数実行サービス。トラフィック 0 時は完全無料 |
| Cold Start | Lambda の初回呼び出しでコンテナ起動にかかる遅延 (数百 ms〜1 秒) |
| Runtime | Lambda が走らせる言語ランタイム。nodejs20.x など |
| API Gateway HTTP API | Lambda の前段で HTTP を喋れるようにする AWS のマネージド API。REST 版より約 1/3 価格 |
| DynamoDB | フルマネージド KVS。スキーマレス・自動スケール |
| PK (Partition Key) | DynamoDB のメインキー |
| On-demand | DynamoDB の課金モード。読み書き回数で課金 (アクセス薄なら最安) |
| 用語 | 意味 |
|---|---|
| IAM (Identity and Access Management) | AWS のアクセス権管理 |
| IAM User | 人間に紐付く永続的な識別子 |
| IAM Role | 「役割」。一時的に誰でも適切に AssumeRole すればなれる |
| Trust Policy | 「誰がこの Role を AssumeRole できるか」を定義 |
| Permissions Policy | 「Role になった結果、何ができるか」を定義 |
| OIDC (OpenID Connect) | 認証連携の業界標準プロトコル。GitHub <-> AWS で短期トークンをやりとりする |
| STS (Security Token Service) | AWS の一時認証発行サービス |
| AssumeRole | IAM Role になる API 呼び出し |
| ACM (AWS Certificate Manager) | 無料 TLS 証明書発行サービス |
| SSE (Server-Side Encryption) | S3 / DynamoDB の保存時自動暗号化 |
| 用語 | 意味 |
|---|---|
| CloudWatch | ログ・メトリクス・アラームの一元サービス |
| CloudWatch Logs | Lambda などのログ集約先 |
| CloudWatch Alarm | メトリクスが閾値を超えたら通知 |
| SNS (Simple Notification Service) | イベント通知のハブ。メール送信もできる |
| AWS Budgets | 月額予算超過アラート |
| 用語 | 意味 |
|---|---|
| Resource | resource "aws_s3_bucket" "this" { ... } 1 個 = AWS 上の 1 リソース |
| Module | リソース群を再利用可能にまとめたもの |
| Variable | モジュールの入力 |
| Output | モジュールの出力 |
| Provider | AWS / GCP などクラウド毎のプラグイン |
| Provider Alias | 同じ AWS でも別リージョンを並行で使う設定 (us-east-1 用など) |
| Data Source | 既存リソースを読むだけ (作らない) |
| Local | モジュール内の中間計算値 |
| State (tfstate) | Terraform が「現状 AWS に何があるか」を記録するファイル |
| Lock | 複数人が同時 apply するのを防ぐための排他制御 |
| Backend | state を保管する場所 (S3 + DynamoDB Lock の組合せが定番) |
このサイトは大きく分けて 3 つの流れ で構成されています。
このサイトのコードは GitHub リポジトリ iigtn/iigtn-platform(現在 private)に集約されています。中身は以下のような構造です。
iigtn-platform/
├── README.md # プロジェクト全体の概要
├── LICENSE # MIT
├── .gitignore # node_modules / *.tfstate / .env を除外
├── .terraform-version # tfenv 用 (1.10.3)
├── .tool-versions # asdf 用 (terraform 1.10.3, nodejs 20.18.1)
│
├── docs/ # 設計書 v5 (8 ファイル)
│ ├── motivation.md # なぜこの構成にしたか
│ ├── cost.md # コスト試算
│ ├── metrics.md # 実運用メトリクス計画
│ ├── runbook.md # 障害対応手順
│ ├── lessons.md # 失敗・反省・悩み
│ ├── business.md # クライアント案件展開
│ ├── interview.md # 面接 Q&A
│ ├── adr/ # アーキテクチャ判断記録 (空)
│ └── postmortem/ # 障害ポストモーテム (空)
│
├── frontend/ # 静的サイトファイル
│ ├── index.html # トップページ + 問い合わせフォーム
│ └── learn.html # 本解説書
│
├── backend/ # Lambda コード
│ └── functions/
│ └── contact/
│ └── index.mjs # 問い合わせ処理
│
├── terraform/ # IaC (Infrastructure as Code)
│ ├── bootstrap/ # 手動で 1 回だけ実行する初期化
│ │ ├── README.md
│ │ ├── setup.sh # bash 用
│ │ └── setup.ps1 # PowerShell 用
│ │
│ ├── modules/ # 再利用可能なモジュール 5 種
│ │ ├── network_dns/ # ACM 証明書発行 + DNS 検証待ち
│ │ ├── frontend_cdn/ # S3 + CloudFront + OAC
│ │ ├── backend_api/ # API GW + Lambda + DynamoDB
│ │ ├── ci_oidc/ # GitHub OIDC + IAM Role
│ │ └── observability/ # CloudWatch Alarms + SNS + Budgets
│ │
│ └── envs/
│ └── prod/ # 本番環境 (lab.iigtn.com)
│ ├── backend.tf # state を S3 に置く設定
│ ├── providers.tf # AWS provider 2 つ (default + us_east_1)
│ ├── versions.tf # Terraform / provider バージョン
│ ├── variables.tf # 本環境への入力変数
│ ├── terraform.tfvars # 入力変数の実値
│ ├── main.tf # モジュール呼び出し
│ └── outputs.tf # apply 後に表示する値
│
└── .github/
└── workflows/
└── frontend-deploy.yml # main push → 自動デプロイ
Terraform は階層構造を持ちます。 モジュール (terraform/modules/) は レゴブロック、環境定義 (terraform/envs/prod/) はそれを 組み立てる側。1 つのモジュールを dev / prod / staging で別々の値で呼び出せる仕組みになっています。
各モジュールは独立したディレクトリにあり、1 つの責務 を持っています。中身を順番に見ていきます。
network_dns モジュール — TLS 証明書発行lab.iigtn.com 用の TLS 証明書を ACM で発行し、検証完了まで待つ。
aws_acm_certificate — ACM 証明書 (us-east-1)aws_acm_certificate_validation — DNS 検証完了待ち (最大 75 分)terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
# ACM 証明書 (us-east-1) を扱うため、呼び出し側から
# aws.us_east_1 alias provider を渡してもらう前提。
configuration_aliases = [aws.us_east_1]
}
}
}
configuration_aliases は「このモジュールは default の AWS プロバイダに加えて、別リージョン用の aws.us_east_1 も外から渡してもらう必要がある」を宣言しています。
variable "domain_name" {
description = "Hosted zone のドメイン名。例: lab.iigtn.com"
type = string
validation {
condition = length(var.domain_name) > 0 && !can(regex("\\.$", var.domain_name))
error_message = "domain_name は空でなく、末尾のドット (.) を含まないこと。"
}
}
variable "tags" {
description = "全リソースに付ける共通タグ"
type = map(string)
default = {}
}
validation ブロックで「不正な値が来たら apply を止める」入力チェックができます。
resource "aws_acm_certificate" "this" {
provider = aws.us_east_1 # ← 必ず us-east-1 で発行
domain_name = var.domain_name # 例: lab.iigtn.com
subject_alternative_names = ["*.${var.domain_name}"] # ワイルドカード
validation_method = "DNS"
lifecycle {
create_before_destroy = true # 差し替え時に瞬断ゼロ
}
}
resource "aws_acm_certificate_validation" "this" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.this.arn
validation_record_fqdns = [
for dvo in aws_acm_certificate.this.domain_validation_options :
dvo.resource_record_name
]
timeouts {
create = "75m" # 親 DNS で CNAME 投入する時間を確保
}
}
var.domain_name ← 呼び出し側 (envs/prod/main.tf) から "lab.iigtn.com" が渡るaws_acm_certificate.this.arn ← Validation リソースが参照certificate_arn ← frontend_cdn モジュールが受け取る (CloudFront にアタッチ)output "certificate_arn" {
description = "Validated ACM certificate ARN (us-east-1)"
value = aws_acm_certificate_validation.this.certificate_arn
}
output "validation_records" {
description = "Squarespace の DNS に追加する CNAME レコード一覧"
value = {
for dvo in aws_acm_certificate.this.domain_validation_options :
dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
value = dvo.resource_record_value
}
}
}
validation_records は terraform apply の結果に表示され、その値を Squarespace の DNS 管理画面に手動で投入することで ACM 検証が完了する仕組み。
frontend_cdn モジュール — 静的サイト配信基盤S3 (非公開) + CloudFront (with OAC) で lab.iigtn.com の静的サイト配信を行う。/api/* は API Gateway に振り分ける。
aws_s3_bucket — 静的ファイル保管aws_s3_bucket_public_access_block — 公開ブロック 4 種すべて trueaws_s3_bucket_versioning — ロールバック用aws_s3_bucket_server_side_encryption_configuration — AES256aws_cloudfront_origin_access_control — OACaws_cloudfront_distribution — CDN 本体aws_s3_bucket_policy — CloudFront だけに GetObject 許可① S3 バケット + 安全装置
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name # 例: iigtn-lab-web-prod-XXXXXXXXXXXX
tags = var.tags
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true # 4 つのスイッチを全部 ON
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
}
}
② OAC (Origin Access Control)
resource "aws_cloudfront_origin_access_control" "this" {
name = "${var.bucket_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always" # 常に SigV4 署名
signing_protocol = "sigv4"
}
③ CloudFront Distribution
resource "aws_cloudfront_distribution" "this" {
enabled = true
is_ipv6_enabled = true
http_version = "http2and3" # HTTP/3 (QUIC) 有効化
aliases = [var.domain_name] # 例: lab.iigtn.com
price_class = "PriceClass_200" # 北米+欧州+アジア (日本最適)
default_root_object = "index.html"
# Origin (1): S3 バケット
origin {
domain_name = aws_s3_bucket.this.bucket_regional_domain_name
origin_id = "s3-${var.bucket_name}"
origin_access_control_id = aws_cloudfront_origin_access_control.this.id
}
# Origin (2): API Gateway (api_origin_domain_name が空でない時のみ)
dynamic "origin" {
for_each = var.api_origin_domain_name == "" ? [] : [1]
content {
domain_name = var.api_origin_domain_name
origin_id = "apigw-origin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
}
# デフォルト behavior: S3 用
default_cache_behavior {
target_origin_id = "s3-${var.bucket_name}"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
response_headers_policy_id = "67f7725c-6f97-4210-82d7-5512b31e9d03" # SecurityHeadersPolicy
}
# /api/* behavior: API Gateway 用
dynamic "ordered_cache_behavior" {
for_each = var.api_origin_domain_name == "" ? [] : [1]
content {
path_pattern = var.api_path_pattern # 既定 "/api/*"
target_origin_id = "apigw-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT"]
cached_methods = ["GET","HEAD"]
compress = true
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled
origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewerExceptHostHeader
}
}
# SPA 用エラーフォールバック
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
restrictions { geo_restriction { restriction_type = "none" } }
viewer_certificate {
acm_certificate_arn = var.certificate_arn # network_dns から受け取り
ssl_support_method = "sni-only" # vip だと月 $600
minimum_protocol_version = "TLSv1.2_2021" # 古いクライアント切り捨て
}
}
④ S3 Bucket Policy (OAC からの読み取り限定許可)
data "aws_iam_policy_document" "bucket" {
statement {
sid = "AllowCloudFrontReadViaOAC"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.this.arn}/*"]
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [aws_cloudfront_distribution.this.arn] # 自分の Distribution のみ
}
}
}
resource "aws_s3_bucket_policy" "this" {
bucket = aws_s3_bucket.this.id
policy = data.aws_iam_policy_document.bucket.json
}
| 変数 | 由来 | 使われ方 |
|---|---|---|
var.domain_name | envs/prod/main.tf "lab.iigtn.com" | CloudFront aliases |
var.certificate_arn | module.network_dns.certificate_arn | viewer_certificate |
var.bucket_name | local.web_bucket_name = "iigtn-lab-web-prod-${アカウントID}" | S3 バケット名 |
var.api_origin_domain_name | module.backend_api.api_endpoint_host | API GW origin |
distribution_domain_name — Squarespace に CNAME で登録する宛先 (dXXXXXXXXX.cloudfront.net 形式)distribution_id — キャッシュ無効化コマンド・アラームで使うbucket_name, bucket_arn — GitHub Actions の S3 sync 宛先 / IAM 制限で使うbackend_api モジュール — 問い合わせフォーム処理API Gateway HTTP API + Lambda + DynamoDB + (オプション) SES で問い合わせフォームを処理する。
aws_dynamodb_table — 問い合わせ保存 (PK=UUID)aws_lambda_function — フォーム処理 (Node.js 20 / arm64 / 256MB / 10s)data "archive_file" — Lambda コードの zip パッケージングaws_cloudwatch_log_group — Lambda ログ (14 日保持)aws_iam_role — Lambda 実行ロールaws_iam_role_policy — Lambda 用最小権限 inline ポリシーaws_apigatewayv2_api — HTTP APIaws_apigatewayv2_stage — $default + throttlingaws_apigatewayv2_integration — API GW → Lambda の統合aws_apigatewayv2_route × 2 — POST と OPTIONSaws_lambda_permission — API GW から Lambda 呼出許可import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { randomUUID } from "node:crypto";
// ハンドラ外で初期化 = 同一コンテナ再利用で高速化
const ddbClient = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(ddbClient);
const sesClient = new SESv2Client({});
// 環境変数 (Terraform で渡される)
const TABLE_NAME = process.env.DDB_TABLE;
const SES_FROM = process.env.SES_FROM;
const SES_TO = process.env.SES_TO;
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validate(payload) {
const errors = [];
// ... name/email/message のバリデーション
return errors;
}
export const handler = async (event) => {
// 1. POST 以外と OPTIONS 弾き
// 2. JSON パース
// 3. バリデーション
// 4. DDB に保存 (UUID + ISO datetime + IP + User-Agent)
// 5. SES でメール送信 (失敗しても継続)
// 6. 200 {"ok": true} 返す
};
| 環境変数 | Terraform 側の出処 |
|---|---|
DDB_TABLE | aws_dynamodb_table.contacts.name |
SES_FROM | var.ses_from (空なら SES スキップ) |
SES_TO | var.ses_to |
ALLOWED_ORIGIN | "https://${var.domain_name}" |
data "archive_file" "lambda" {
type = "zip"
source_dir = var.lambda_source_dir # ../../../backend/functions/contact
output_path = "${path.module}/.build/contact_lambda.zip"
}
resource "aws_lambda_function" "contact" {
function_name = "${var.name_prefix}-contact"
role = aws_iam_role.lambda.arn
handler = "index.handler"
runtime = "nodejs20.x"
architectures = ["arm64"] # 約 20% 安
timeout = 10
memory_size = 256
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
environment {
variables = {
DDB_TABLE = aws_dynamodb_table.contacts.name
SES_FROM = var.ses_from
SES_TO = var.ses_to
ALLOWED_ORIGIN = var.allowed_origin
}
}
}
data "aws_iam_policy_document" "lambda_policy" {
# CloudWatch Logs (このログストリームのみ)
statement {
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["${aws_cloudwatch_log_group.contact.arn}:*"]
}
# DynamoDB (この contacts テーブルのみ。Put のみ)
statement {
actions = ["dynamodb:PutItem"]
resources = [aws_dynamodb_table.contacts.arn]
}
# SES SendEmail
statement {
actions = ["ses:SendEmail", "ses:SendRawEmail"]
resources = ["*"]
}
}
resource "aws_apigatewayv2_api" "this" {
name = "${var.name_prefix}-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = [var.allowed_origin] # https://lab.iigtn.com
allow_methods = ["POST", "OPTIONS"]
allow_headers = ["Content-Type"]
max_age = 300
}
}
resource "aws_apigatewayv2_integration" "contact" {
api_id = aws_apigatewayv2_api.this.id
integration_type = "AWS_PROXY" # Lambda Proxy 統合
integration_uri = aws_lambda_function.contact.invoke_arn
payload_format_version = "2.0" # HTTP API 用
}
resource "aws_apigatewayv2_route" "contact_post" {
api_id = aws_apigatewayv2_api.this.id
route_key = "POST /api/contact"
target = "integrations/${aws_apigatewayv2_integration.contact.id}"
}
ci_oidc モジュール — GitHub Actions OIDC 認証GitHub Actions が 静的アクセスキーなし で AWS にデプロイできるように、OIDC Provider と IAM Role を作る。
aws_iam_openid_connect_provider — GitHub を IdP として登録aws_iam_role — GitHub Actions が AssumeRole する役割aws_iam_role_policy — 最小権限 inline ポリシー① OIDC Provider 登録
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
② Trust Policy — 誰が AssumeRole できるか
data "aws_iam_policy_document" "trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
# 条件 1: aud (audience) は AWS STS のみ
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# 条件 2: sub (subject) は指定 owner/repo の指定ブランチのみ
# JWT の sub claim は "repo:<owner>/<repo>:ref:refs/heads/<branch>" 形式
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
for branch in var.allowed_branches :
"repo:${var.github_owner}/${var.github_repo}:ref:refs/heads/${branch}"
]
}
}
}
③ Permissions Policy — Role になった結果、何ができるか
data "aws_iam_policy_document" "deploy" {
statement {
actions = ["s3:ListBucket", "s3:GetBucketLocation"]
resources = [var.deploy_bucket_arn] # この 1 バケットだけ
}
statement {
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl"]
resources = ["${var.deploy_bucket_arn}/*"]
}
statement {
actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:ListInvalidations"
]
resources = [var.cloudfront_distribution_arn] # この 1 Distribution だけ
}
}
| 変数 | 由来 |
|---|---|
var.github_owner | tfvars の "iigtn" |
var.github_repo | 既定 "iigtn-platform" |
var.allowed_branches | 既定 ["main"] |
var.deploy_bucket_arn | module.frontend_cdn.bucket_arn |
var.cloudfront_distribution_arn | module.frontend_cdn.distribution_arn |
deploy_role_arn — GitHub Actions ワークフローで role-to-assume に貼るobservability モジュール — 監視とアラートCloudWatch Alarm + SNS + AWS Budgets で「壊れたら気付ける」「コスト暴騰したら気付ける」仕組みを作る。
aws_sns_topic — アラーム通知のハブaws_sns_topic_subscription — メール購読 (alarm_email がセットされた時のみ)aws_budgets_budget — 月額予算 50% / 80%予測 / 100% で通知CloudFront 4xx アラームは us-east-1 必須 だが、CloudWatch アラームのalarm_actionsは同一 region の SNS しか参照できないため、CF 用はenvs/prod/main.tfで別リソースとして us-east-1 に直接定義している。
resource "aws_sns_topic" "alarms" {
name = "${var.name_prefix}-alarms"
}
resource "aws_sns_topic_subscription" "email" {
count = var.alarm_email == "" ? 0 : 1
topic_arn = aws_sns_topic.alarms.arn
protocol = "email"
endpoint = var.alarm_email
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "${var.name_prefix}-lambda-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
threshold = 1
period = 300
statistic = "Sum"
metric_name = "Errors"
namespace = "AWS/Lambda"
treat_missing_data = "notBreaching"
dimensions = { FunctionName = var.lambda_function_name }
alarm_actions = [aws_sns_topic.alarms.arn]
}
モジュールが「レゴブロック」だとすると、envs/prod/ はそれを 組み立てて 1 つの本番環境を作る 場所です。ここに 7 個のファイルがあります。
terraform {
backend "s3" {
bucket = "iigtn-tfstate-XXXXXXXXXXXX" # bootstrap で作成
key = "envs/prod/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "iigtn-tflock"
encrypt = true
}
}
「terraform apply で何を作ったか」の記録 (tfstate) を S3 に置き、複数人が同時に apply しないよう DynamoDB でロックを取る構成。
provider "aws" {
region = var.region # default: ap-northeast-1
default_tags {
tags = local.common_tags
}
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1" # CloudFront / ACM 用
default_tags {
tags = local.common_tags
}
}
locals {
common_tags = {
Project = "iigtn"
Environment = "prod"
ManagedBy = "Terraform"
Repo = "iigtn-platform"
}
}
default_tags は「すべての AWS リソースに自動で付くタグ」。個別 resource にタグを書かなくて済むので DRY。
# variables.tf
variable "domain_name" { type = string }
variable "github_owner" { type = string }
variable "ses_from" { type = string; default = "" }
variable "ses_to" { type = string; default = "" }
variable "alarm_email" { type = string; default = "" }
variable "monthly_budget_usd" { type = number; default = 10 }
# ...
# terraform.tfvars (実値)
domain_name = "lab.iigtn.com"
github_owner = "iigtn"
data "aws_caller_identity" "current" {} # 自分のアカウント ID 取得
locals {
web_bucket_name = "iigtn-lab-web-prod-${data.aws_caller_identity.current.account_id}"
}
module "network_dns" {
source = "../../modules/network_dns"
domain_name = var.domain_name
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
module "frontend_cdn" {
source = "../../modules/frontend_cdn"
domain_name = var.domain_name
certificate_arn = module.network_dns.certificate_arn
bucket_name = local.web_bucket_name
api_origin_domain_name = module.backend_api.api_endpoint_host
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
}
module "backend_api" {
source = "../../modules/backend_api"
name_prefix = "iigtn-lab-prod"
lambda_source_dir = "${path.root}/../../../backend/functions/contact"
allowed_origin = "https://${var.domain_name}"
ses_from = var.ses_from
ses_to = var.ses_to
}
module "ci_oidc" {
source = "../../modules/ci_oidc"
github_owner = var.github_owner
github_repo = var.github_repo
allowed_branches = var.github_allowed_branches
deploy_bucket_arn = module.frontend_cdn.bucket_arn
cloudfront_distribution_arn = module.frontend_cdn.distribution_arn
}
module "observability" {
source = "../../modules/observability"
name_prefix = "iigtn-lab-prod"
alarm_email = var.alarm_email
lambda_function_name = module.backend_api.lambda_function_name
api_id = module.backend_api.api_id
cloudfront_distribution_id = module.frontend_cdn.distribution_id
monthly_budget_usd = var.monthly_budget_usd
}
terraform apply の最後に画面に出る値。重要なのは:
distribution_domain_name — Squarespace に CNAME 登録する宛先deploy_role_arn — GitHub Actions ワークフローで使うvalidation_records — ACM 検証用 CNAMEGitHub Actions ワークフロー (.github/workflows/frontend-deploy.yml) は、main ブランチに push されたら自動的に S3 sync + CloudFront 無効化を実行します。
name: frontend-deploy
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/frontend-deploy.yml'
workflow_dispatch: {}
permissions:
id-token: write # OIDC token 発行に必須
contents: read # actions/checkout に必要
concurrency:
group: frontend-deploy-${{ github.ref }}
cancel-in-progress: true
env:
AWS_REGION: ap-northeast-1
AWS_ROLE_TO_ASSUME: arn:aws:iam::XXXXXXXXXXXX:role/iigtn-github-actions-deploy
S3_BUCKET: iigtn-lab-web-prod-XXXXXXXXXXXX
CF_DISTRIBUTION_ID: EXXXXXXXXXXXXX
SITE_DIR: frontend
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_TO_ASSUME }}
role-session-name: gha-frontend-deploy-${{ github.run_id }}
aws-region: ${{ env.AWS_REGION }}
- name: Verify caller identity
run: aws sts get-caller-identity
- name: Deploy to S3
run: |
aws s3 sync ./${{ env.SITE_DIR }}/ s3://${{ env.S3_BUCKET }}/ \
--delete \
--exclude ".git/*" --exclude "*.md" --exclude ".gitignore"
- name: Invalidate CloudFront
run: |
INV_ID=$(aws cloudfront create-invalidation \
--distribution-id ${{ env.CF_DISTRIBUTION_ID }} \
--paths "/index.html" "/" \
--query 'Invalidation.Id' --output text)
aws cloudfront wait invalidation-completed \
--distribution-id ${{ env.CF_DISTRIBUTION_ID }} \
--id "$INV_ID"
- name: Verify deployment
run: curl -sI https://lab.iigtn.com/ | head -10
| ステップ | 動作 |
|---|---|
| Checkout | リポジトリのソースを Runner にダウンロード |
| Configure AWS credentials (OIDC) | ① GitHub から OIDC token を発行 → ② AWS STS に渡して AssumeRole → ③ 1 時間有効の AccessKey/SecretKey/SessionToken を Runner の env に注入 |
| Verify caller identity | 「自分が誰として動いているか」を表示 (デバッグ用) |
| Deploy to S3 | aws s3 sync --delete で frontend/ → S3 を完全ミラー |
| Invalidate CloudFront | /index.html と / のキャッシュを無効化 → 即時反映 |
| Verify deployment | HTTPS で取得して動作確認 |
Terraform の データフロー を理解すると、変数を変える時に何が壊れるかが読めます。
domain_name ("lab.iigtn.com")terraform.tfvars
│ domain_name = "lab.iigtn.com"
▼
envs/prod/variables.tf (var.domain_name)
│
├──▶ module "network_dns" の var.domain_name
│ ▼
│ aws_acm_certificate.this の domain_name
│ (ACM が "lab.iigtn.com" の証明書を発行)
│
├──▶ module "frontend_cdn" の var.domain_name
│ ▼
│ aws_cloudfront_distribution.this の aliases
│ (CloudFront に "lab.iigtn.com" を紐付け)
│
└──▶ module "backend_api" の var.allowed_origin
("https://" + var.domain_name)
▼
API GW の cors_configuration.allow_origins
certificate_arn (動的に決まる値)module "network_dns" の出力 certificate_arn
│ apply 中に確定 (例 arn:aws:acm:us-east-1:XXX:certificate/...)
▼
module "frontend_cdn" の var.certificate_arn
▼
aws_cloudfront_distribution.this の viewer_certificate
data "aws_caller_identity" "current" {}
│ AWS API で取得 (例 XXXXXXXXXXXX)
▼
local.web_bucket_name = "iigtn-lab-web-prod-${アカウントID}"
▼
module "frontend_cdn" の var.bucket_name
▼
aws_s3_bucket.this の bucket
(S3 バケット名がアカウント ID で一意化される)
frontend/index.html を編集git add . && git commit && git push.github/workflows/frontend-deploy.yml の on.push.paths がマッチ → ワークフロー起動sub claim には repo:iigtn/iigtn-platform:ref:refs/heads/mainAWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN 注入 (1h 有効)--delete で S3 にあってローカルに無いファイルを削除/index.html と / を削除aws cloudfront wait で完了まで待機ポイント: AWS のアクセスキーは GitHub にも Runner にも永続的に保存されない。漏れたら困るキーを「最初から発行しない」設計。
| サービス | 無風時の月額(円) | 備考 |
|---|---|---|
| Route 53 | 0 | 使ってない |
| ACM | 0 | パブリック証明書は無料 |
| S3 (state バケット + web バケット) | ~5 | 容量数 MB レベル |
| DynamoDB (state lock + contacts) | ~2 | On-demand、ほぼ呼ばれない |
| CloudFront 転送量 | ~85 | 5GB/月想定 |
| CloudFront リクエスト | ~10 | 50,000 reqs/月 |
| API Gateway HTTP API | ~0.2 | 1,000 reqs/月 |
| Lambda 実行 | 0 | 無料枠で吸収 |
| Lambda CloudWatch Logs | ~10 | 取り込み数十 MB |
| SES | 0 | 未稼働 |
| CloudWatch Alarms (4 個) | ~60 | 1 alarm 約 15 円/月 |
| SNS | ~0 | 少額 |
| 合計 | 約 170〜200 円 | 個人運用の最安レンジ |
大幅に増えるシナリオ: ① 大量 Bot アクセス (CloudFront 転送量爆発) / ② Lambda 暴走 (無限ループ) / ③ DynamoDB スキャン無限 など。AWS Budgets で上限通知 を設定しておくのが必須。
# 1. 編集
vim frontend/index.html
# 2. ローカル確認 (お好みで)
# - 静的なら python -m http.server 8000
# 3. push
git add frontend/index.html
git commit -m "Update homepage copy"
git push
# 4. GitHub Actions が自動デプロイ
# 5. https://lab.iigtn.com/ でブラウザ確認
aws cloudfront create-invalidation \
--distribution-id EXXXXXXXXXXXXX \
--paths "/*"
/* 全体の無効化は CloudFront の月 1,000 paths 無料枠を消費する。日常的には個別 path を指定するのが安い。
aws dynamodb scan --table-name iigtn-lab-prod-contacts --max-items 10
aws logs tail /aws/lambda/iigtn-lab-prod-contact --since 30m --follow
cd terraform/envs/prod
# 1. .tf を編集
# 2. plan で何が変わるか確認
terraform plan
# 3. 問題なければ apply
terraform apply
cd terraform/envs/prod
# 全リソース削除 (state バケット・ロックは残る)
terraform destroy
# state バケット自体は手動削除 (rm -rf 注意)
terraform destroy は破壊的。S3 バケットの中身を消し、CloudFront を削除し、Distribution の URL も変わる。本番運用中は絶対に走らせないこと。
このページ自体も、上で説明した CI/CD パイプラインで配信されています。GitHub の frontend/learn.html を編集 → push すると、5 分以内にこのページが更新されます。