mybook

インフラの3R — AWSリソースの設計品質

インフラもコードである

「3Rはコードだけの話じゃない」

田中さんがAWSのマネジメントコンソールを開いた。本番環境のリソース一覧には、誰が作ったかわからないEC2インスタンス、意味不明な名前のセキュリティグループ、用途不明のS3バケットが散らばっていた。

「これは誰が作ったんですか?」ユイが聞いた。

「わからない。3年前の担当者が退職して、ドキュメントも残っていない。変えようとすれば何かが壊れるかもしれないので、誰も触れない。インフラのレガシーコードだ」

これはコードの世界で見たFat Modelと同じ問題だ。「何をしているかわからない」「変えると怖い」「再利用できない」——3Rの問題が、インフラにも現れている。

「インフラも可読性・再利用性・リファクタリング可能性が必要だ。それを実現するのが**IaC(Infrastructure as Code)**だ」

INFO

IaC(Infrastructure as Code)とは、インフラの構成をコードで管理する手法です。AWS CloudFormation、Terraform、AWS CDKなどがある。インフラをコードで管理することで、3Rをインフラにも適用できます。「コンソールでポチポチ作る」ClickOpsから卒業しましょう。

なぜClickOpsが問題なのか

ClickOps(マネジメントコンソールでの手動操作)の問題:

Readability: 誰もコンソールを見に行かないと設定がわからない
             コンソールのスクリーンショットはすぐ古くなる

Reusability: ステージング環境を作るとき、また同じ手順でポチポチ
             「確かこうだったはず」という記憶頼りの再現

Refactorability: 変更前の状態に戻せない(Undo がない)
                 変更の履歴が残らない(誰が何をいつ変えたかわからない)
                 変更をレビューできない(PRがない)
IaC(Terraform / CloudFormation)の利点:

Readability:  コードを読めばインフラ構成がわかる
              コードがドキュメント(ドキュメントが古くなる問題がない)

Reusability:  モジュール化して複数環境で再利用できる
              `staging` の環境を作るのが `terraform apply` の1コマンド

Refactorability: git で変更履歴が残る
                 PRでインフラ変更のレビューができる
                 `terraform destroy` で確実に削除できる

R1: インフラの可読性

悪いCloudFormation: 何をするかわからない

# Before: リソース名が意味を持たない(コードのx, y, zと同じ問題)
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  R1:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-0123456789abcdef0
      InstanceType: t3.medium
      SecurityGroupIds:
        - !Ref SG1
 
  SG1:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: sg
      VpcId: vpc-12345678
 
  B1:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-bucket-12345
 
# → R1がEC2サーバーで、SG1がセキュリティグループで、B1がS3...
#   読んでもすぐに意味がわからない
# After: リソース名と説明が意図を表す(コードのorder, userと同じ原則)
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECサイト本番環境 — アプリケーションサーバー層(ECS Fargate + ALB)'
 
Parameters:
  EnvironmentName:
    Type: String
    AllowedValues: [production, staging, development]
    Description: 'デプロイ環境名(リソース名に使用される)'
 
  AppImageUri:
    Type: String
    Description: 'ECRのコンテナイメージURI'
 
Resources:
  # アプリケーションサーバー用セキュリティグループ
  AppServerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'アプリサーバー用セキュリティグループ — ALBからのHTTPS通信のみ許可'
      VpcId: !ImportValue !Sub '${EnvironmentName}-VpcId'
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3000
          ToPort: 3000
          SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
          Description: 'ALBからのトラフィックのみ許可(直接アクセス不可)'
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-app-server-sg'
        - Key: Purpose
          Value: 'アプリケーションサーバーへのアクセス制御'
 
  # 静的ファイル用S3バケット
  StaticAssetsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${EnvironmentName}-ec-site-static-assets'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Enabled  # バージョン管理でアセットのロールバックが可能
      Tags:
        - Key: Purpose
          Value: 'フロントエンドの静的ファイル(JS, CSS, 画像)の配信用'

命名規則を決める:

リソース命名規則: {環境名}-{プロジェクト名}-{リソース種別}[-{用途}]

例:
production-ec-site-alb              # 本番のALB
staging-ec-site-ecs-app             # ステージングのECSタスク
production-ec-site-rds-primary      # 本番のRDSプライマリ
production-ec-site-sg-app-server    # 本番のアプリサーバー用SG
production-ec-site-s3-static        # 本番の静的ファイル用S3

R2: インフラの再利用性

Terraformモジュールで共通パターンを再利用する

RailsのService ObjectやGemと同様に、Terraformでも「よく使うパターン」をモジュール化する。

# modules/rails_fargate_app/main.tf
# 再利用可能なRails on ECS Fargateアプリモジュール
 
variable "app_name" {
  description = "アプリケーション名(例: ec-site, admin-panel)"
  type        = string
}
 
variable "environment" {
  description = "環境名 (production/staging/development)"
  type        = string
 
  validation {
    condition     = contains(["production", "staging", "development"], var.environment)
    error_message = "environmentはproduction, staging, developmentのいずれかにしてください"
  }
}
 
variable "cpu" {
  description = "ECSタスクのCPUユニット数(256=0.25vCPU, 512=0.5vCPU, 1024=1vCPU)"
  type        = number
  default     = 256
}
 
variable "memory" {
  description = "ECSタスクのメモリ(MB)"
  type        = number
  default     = 512
}
 
variable "container_image" {
  description = "ECRイメージURI"
  type        = string
}
 
variable "environment_variables" {
  description = "コンテナの環境変数"
  type        = map(string)
  default     = {}
}
 
variable "secrets" {
  description = "AWS Secrets Managerから取得するシークレット"
  type = list(object({
    name      = string
    valueFrom = string
  }))
  default = []
}
 
# ECSクラスター
resource "aws_ecs_cluster" "app" {
  name = "${var.app_name}-${var.environment}"
 
  setting {
    name  = "containerInsights"
    value = "enabled"  # CloudWatchダッシュボードで監視可能に
  }
 
  tags = {
    Name        = "${var.app_name}-${var.environment}-cluster"
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}
 
# CloudWatch ロググループ(コンテナログの保存先)
resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.app_name}-${var.environment}"
  retention_in_days = var.environment == "production" ? 90 : 7
 
  tags = {
    Environment = var.environment
  }
}
 
# ECSタスク定義
resource "aws_ecs_task_definition" "app" {
  family                   = "${var.app_name}-${var.environment}"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
 
  container_definitions = jsonencode([{
    name  = "app"
    image = var.container_image
 
    portMappings = [{
      containerPort = 3000
      protocol      = "tcp"
    }]
 
    environment = [
      for key, value in var.environment_variables : {
        name  = key
        value = value
      }
    ]
 
    secrets = var.secrets
 
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.app.name
        awslogs-region        = data.aws_region.current.name
        awslogs-stream-prefix = "ecs"
      }
    }
  }])
}
 
output "cluster_arn" {
  value       = aws_ecs_cluster.app.arn
  description = "ECSクラスターのARN(他のモジュールから参照可能)"
}
 
output "task_definition_arn" {
  value       = aws_ecs_task_definition.app.arn
  description = "タスク定義のARN"
}
# 本番環境でのモジュール使用(EC サイト)
# environments/production/main.tf
 
module "ec_site" {
  source = "../../modules/rails_fargate_app"
 
  app_name        = "ec-site"
  environment     = "production"
  cpu             = 1024  # 本番は1vCPU
  memory          = 2048  # 本番は2GB
  container_image = "${aws_ecr_repository.ec_site.repository_url}:${var.image_tag}"
 
  environment_variables = {
    RAILS_ENV       = "production"
    RAILS_LOG_LEVEL = "warn"
    DATABASE_POOL   = "20"
  }
 
  secrets = [
    {
      name      = "DATABASE_URL"
      valueFrom = "arn:aws:secretsmanager:ap-northeast-1:${data.aws_caller_identity.current.account_id}:secret:production/ec-site/database_url"
    },
    {
      name      = "RAILS_MASTER_KEY"
      valueFrom = "arn:aws:secretsmanager:ap-northeast-1:${data.aws_caller_identity.current.account_id}:secret:production/ec-site/rails_master_key"
    }
  ]
}
 
# ステージング環境での同じモジュール再利用
module "ec_site_staging" {
  source = "../../modules/rails_fargate_app"
 
  app_name        = "ec-site"
  environment     = "staging"
  cpu             = 256   # ステージングは最小構成
  memory          = 512   # コスト削減
  container_image = "${aws_ecr_repository.ec_site.repository_url}:${var.staging_image_tag}"
 
  environment_variables = {
    RAILS_ENV       = "staging"
    RAILS_LOG_LEVEL = "debug"  # ステージングは詳細ログ
    DATABASE_POOL   = "5"
  }
 
  secrets = [
    {
      name      = "DATABASE_URL"
      valueFrom = "arn:aws:secretsmanager:ap-northeast-1:${data.aws_caller_identity.current.account_id}:secret:staging/ec-site/database_url"
    }
  ]
}
Loading diagram...

Railsの discount_engine Gemと同じ考え方だ。「共通のパターンを1箇所にまとめ、変更は1箇所で済む」。ECSタスクのデフォルト設定を変えたいとき、rails_fargate_app モジュールを1箇所変えれば、全環境に反映される。

R3: インフラのリファクタリング可能性

Terraformの terraform plan でインフラ変更のプレビュー

コードのテストに相当するのが terraform plan だ。適用前に「何が変わるか」を確認できる。

# 現状の確認
terraform show
 
# 変更のプレビュー(実際には変更しない)
terraform plan -out=tfplan
 
# 出力例(変更内容が明確に表示される)
# Plan: 2 to add, 1 to change, 0 to destroy.
 
# Changes to Outputs:
#   ~ alb_dns_name = "production-ec-site-alb-1234567890.ap-northeast-1.elb.amazonaws.com"
#                     → "production-ec-site-alb-new.ap-northeast-1.elb.amazonaws.com"
 
# # aws_ecs_task_definition.app will be updated in-place
# ~ resource "aws_ecs_task_definition" "app" {
#     ~ cpu = "256" → "512"   # ← CPUを0.25vCPU → 0.5vCPUに変更
#     ~ memory = "512" → "1024"  # ← メモリを512MB → 1024MBに変更
#   }

インフラのCI/CDパイプライン

# .github/workflows/infrastructure.yml
name: Infrastructure CI/CD
 
on:
  push:
    branches: [main]
    paths: ['infrastructure/**']  # インフラ関連ファイルの変更時のみ実行
  pull_request:
    paths: ['infrastructure/**']
 
jobs:
  # PR時: plan の結果をPRにコメントする
  terraform_plan:
    name: Terraform Plan(変更プレビュー)
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
 
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.0'
 
      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/production/
 
      - name: Terraform Validate(構文チェック)
        run: terraform validate
        working-directory: infrastructure/production/
 
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
        working-directory: infrastructure/production/
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: ap-northeast-1
 
      - name: PRにPlanの結果をコメントする
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs')
            const plan = fs.readFileSync('infrastructure/production/plan_output.txt', 'utf8')
            const body = `## Terraform Plan(ステージング→本番)
            \`\`\`hcl
            ${plan.substring(0, 3000)}
            \`\`\`
            > 本番適用は main マージ後に自動実行されます`
 
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            })
 
  # mainマージ時: 実際に適用する
  terraform_apply:
    name: Terraform Apply(本番適用)
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production  # GitHub Environmentで承認者を設定可能
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
 
      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/production/
 
      - name: Terraform Apply
        run: terraform apply -auto-approve
        working-directory: infrastructure/production/
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: ap-northeast-1

Infrastructure Testingでインフラの変更を安全に

Rubyのテストと同様に、インフラにもテストがある。

# spec/infrastructure/production_spec.rb
# Awspec: AWSリソースをRSpecでテストするライブラリ
require 'awspec'
 
RSpec.describe 'Production インフラ' do
  describe security_group('production-ec-site-sg-app-server') do
    it 'セキュリティグループが存在する' do
      should exist
    end
 
    it 'ALBからのHTTP(3000番ポート)のみ許可している' do
      should have_ingress_rule(
        ip_protocol: 'tcp',
        from_port: 3000,
        to_port: 3000
      )
    end
 
    it 'SSHを直接許可していない(踏み台サーバー経由のみ)' do
      should_not have_ingress_rule(ip_protocol: 'tcp', from_port: 22, to_port: 22)
    end
 
    it '全開放(0.0.0.0/0)を許可していない' do
      should_not have_ingress_rule(cidr_blocks: ['0.0.0.0/0'])
    end
  end
 
  describe s3_bucket('production-ec-site-static-assets') do
    it '存在する' do
      should exist
    end
 
    it 'バージョニングが有効になっている' do
      should have_versioning_enabled
    end
 
    it 'パブリックアクセスがブロックされている' do
      should have_block_public_acls
      should have_block_public_policy
      should have_ignore_public_acls
      should have_restrict_public_buckets
    end
  end
 
  describe rds_db_instance('production-ec-site-rds-primary') do
    it 'マルチAZが有効になっている(本番の可用性要件)' do
      should be_multi_az
    end
 
    it '自動バックアップが7日間保存される' do
      should have_automated_backups_enabled(7)
    end
 
    it 'ストレージが暗号化されている' do
      should be_encrypted
    end
  end
end

AWS Config Rules で継続的なコンプライアンスチェック

# インフラが「正しい状態を維持しているか」を継続監視する
Resources:
  # 暗号化されていないEBSボリュームを検知
  EbsEncryptionCheck:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: !Sub '${EnvironmentName}-ebs-encryption-check'
      Source:
        Owner: AWS
        SourceIdentifier: ENCRYPTED_VOLUMES
      Description: 'すべてのEBSボリュームが暗号化されているか確認'
 
  # S3バケットがパブリックアクセスをブロックしているか確認
  S3PublicAccessCheck:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: !Sub '${EnvironmentName}-s3-public-access-check'
      Source:
        Owner: AWS
        SourceIdentifier: S3_BUCKET_PUBLIC_READ_PROHIBITED
      Description: 'S3バケットが意図せずパブリックアクセスを許可していないか確認'
 
  # RDSがマルチAZになっているか(本番のみ)
  RdsMultiAzCheck:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: !Sub '${EnvironmentName}-rds-multi-az-check'
      Source:
        Owner: AWS
        SourceIdentifier: RDS_MULTI_AZ_SUPPORT
      Scope:
        ComplianceResourceTypes:
          - AWS::RDS::DBInstance
 
  # IAMユーザーにMFAが設定されているか
  IamMfaCheck:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: !Sub '${EnvironmentName}-iam-mfa-check'
      Source:
        Owner: AWS
        SourceIdentifier: MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS

実践: ECサイトのインフラを3Rで整理する

ユイはAWSの現状を整理した。

Before: ClickOps(マネジメントコンソールで手動作成の惨状)

Readability:
  ❌ EC2インスタンスが5つ: 何のサーバーか不明(r1, r2, r3...)
  ❌ セキュリティグループ8つ: ルールの意図が不明
  ❌ RDS: バックアップ設定が各環境でバラバラ
  ❌ S3バケット: 命名規則なし(my-bucket-12345, temp-files-backup)

Reusability:
  ❌ ステージング環境の再構築: 数日かかる(手順書が古い)
  ❌ 新プロジェクトのインフラ: またゼロから手動構築

Refactorability:
  ❌ 変更の履歴がない(コンソール操作はログに残らない)
  ❌ PRレビューができない
  ❌ 「本番と同じ設定」でステージングを作れない
  ❌ 壊れた時に元に戻せない

After: IaC + CI/CDパイプライン

Readability:
  ✅ すべてのリソースはコードで定義(リソース名に意図がある)
  ✅ コードを読めばインフラ構成がわかる(コンソールを見に行く必要なし)
  ✅ タグで環境・用途・所有者が明確

Reusability:
  ✅ Terraformモジュールで新環境を数時間で構築
  ✅ 本番環境と同じ設定のステージングを `terraform apply` で作成

Refactorability:
  ✅ すべての変更はPRでレビュー
  ✅ `terraform plan` で変更内容を事前確認
  ✅ git でロールバック可能(git revert → terraform apply)
  ✅ インフラのテスト(Awspec)で変更後の確認が自動化

インフラの3Rサマリー

観点Before (ClickOps)After (IaC)
Readabilityコンソールを見て回るしかないコードを読めばわかる
Reusability毎回同じ設定を手動で繰り返すモジュールで瞬時に再現
Refactorability変更が怖い(戻せない)git管理でロールバック可能
レビューレビューできないPRでインフラ変更をレビュー
テスト本番で確認するしかないterraform plan + Awspecで事前確認
ドキュメントコンソールスクショが陳腐化コードがドキュメント

「コードとインフラは別物ではない」ユイは気づいた。「どちらも『変化に耐えるシステムを作る』という同じ課題を持っている。3Rはその答えだ」

「インフラをコードで管理すると、アプリと同じように3Rを適用できる」田中さんが言った。「最後の章で、これをどう習慣化するか話そう。3Rの旅のゴールは『一度やり遂げること』じゃなく、『毎日の習慣にすること』だ」