LESSON 40分

「アプリケーションコードにテストを書くのは当然だよね。では、インフラコードにテストを書いているかい?IaCもコードだ。テスト、静的解析、ポリシーチェック。これらを自動化して初めて『プロダクションレディ』と言える」と佐藤CTOが問いかけた。

推定読了時間: 40分


IaC テストのピラミッド

graph TD
    E2E["E2E テスト<br/>本番環境での検証<br/>(少数・高コスト)"]:::e2e
    INT["統合テスト<br/>Terratest / 実環境デプロイ<br/>(中程度)"]:::integration
    UNIT["ユニットテスト<br/>CDK assertions / Pulumi mock<br/>(多数・低コスト)"]:::unit
    STATIC["静的解析・ポリシー<br/>tfsec / Checkov / OPA<br/>(最多・最低コスト)"]:::static

    E2E --- INT --- UNIT --- STATIC

    classDef e2e fill:#E74C3C,stroke:#A93226,color:#FFFFFF
    classDef integration fill:#F39C12,stroke:#B7770D,color:#FFFFFF
    classDef unit fill:#3498DB,stroke:#21618C,color:#FFFFFF
    classDef static fill:#2ECC71,stroke:#1E8449,color:#FFFFFF
レベルツール速度信頼性
静的解析tfsec, Checkov, OPAセキュリティ設定の検証
ユニットテストCDK assertions, Pulumi mock生成されるリソースの検証
統合テストTerratest, LocalStack実リソースの動作検証
E2E テスト本番環境テスト時間エンドツーエンドの動作検証

Terratest による統合テスト

基本構造

// test/vpc_test.go
package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVpcModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/networking/vpc",
        Vars: map[string]interface{}{
            "vpc_cidr":     "10.99.0.0/16",
            "environment":  "test",
            "azs":          []string{"ap-northeast-1a", "ap-northeast-1c"},
        },
        // テスト用に一意のリソース名を生成
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": "ap-northeast-1",
        },
    }

    // テスト終了時にリソースを削除
    defer terraform.Destroy(t, terraformOptions)

    // Terraform を適用
    terraform.InitAndApply(t, terraformOptions)

    // Output の検証
    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcId)

    // AWS API で実際のリソースを検証
    vpc := aws.GetVpcById(t, vpcId, "ap-northeast-1")
    assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)

    // サブネットの検証
    privateSubnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    assert.Equal(t, 2, len(privateSubnets))

    publicSubnets := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
    assert.Equal(t, 2, len(publicSubnets))

    // プライベートサブネットにインターネットへの直接ルートがないことを確認
    for _, subnetId := range privateSubnets {
        subnet := aws.GetSubnetById(t, subnetId, "ap-northeast-1")
        assert.False(t, subnet.MapPublicIpOnLaunch)
    }
}

ECS サービスのテスト

func TestEcsService(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/compute/ecs-service",
        Vars: map[string]interface{}{
            "service_name":    "test-api",
            "container_image": "nginx:latest",
            "desired_count":   1,
            "cpu":             256,
            "memory":          512,
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // ALB の DNS 名を取得
    albDns := terraform.Output(t, terraformOptions, "alb_dns_name")

    // HTTP リクエストでヘルスチェック
    url := fmt.Sprintf("http://%s/health", albDns)
    maxRetries := 30
    timeBetweenRetries := 10 * time.Second

    http_helper.HttpGetWithRetry(
        t, url, nil, 200, "OK",
        maxRetries, timeBetweenRetries,
    )
}

Policy as Code

Open Policy Agent (OPA) / Conftest

# policy/terraform/deny_public_s3.rego
package terraform

deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket"
    resource.change.after.acl == "public-read"
    msg := sprintf("S3 bucket '%s' must not be publicly readable", [resource.address])
}

deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket"
    not resource.change.after.server_side_encryption_configuration
    msg := sprintf("S3 bucket '%s' must have encryption enabled", [resource.address])
}

deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_security_group_rule"
    resource.change.after.type == "ingress"
    resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
    resource.change.after.from_port == 22
    msg := sprintf("Security group rule '%s' must not allow SSH from 0.0.0.0/0", [resource.address])
}
# Conftest で Terraform plan を検証
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/terraform/

Sentinel (Terraform Cloud / Enterprise)

# sentinel/enforce-encryption.sentinel
import "tfplan/v2" as tfplan

# 全ての RDS インスタンスが暗号化されていること
rds_instances = filter tfplan.resource_changes as _, rc {
    rc.type is "aws_db_instance" and
    rc.mode is "managed" and
    (rc.change.actions contains "create" or rc.change.actions contains "update")
}

main = rule {
    all rds_instances as _, instance {
        instance.change.after.storage_encrypted is true
    }
}

セキュリティスキャンツール

tfsec

# tfsec の実行
tfsec ./terraform/environments/prod/

# 出力例:
# Result 1
#   [AWS002] Resource 'aws_s3_bucket.data' does not have encryption enabled
#   /terraform/environments/prod/main.tf:15-20
#
# Result 2
#   [AWS009] Resource 'aws_security_group.web' has ingress rule open to 0.0.0.0/0
#   /terraform/environments/prod/main.tf:35-45
# .tfsec/config.yml
severity_overrides:
  AWS002: CRITICAL    # S3暗号化なしをCRITICALに
  AWS009: HIGH        # SG 0.0.0.0/0 をHIGHに

exclude:
  - AWS018           # このルールは無視

Checkov

# Checkov の実行
checkov -d ./terraform/ --framework terraform

# CDK の CloudFormation テンプレートにも対応
cdk synth > template.yaml
checkov -f template.yaml --framework cloudformation
# .checkov.yml
framework:
  - terraform
  - cloudformation
check:
  - CKV_AWS_18    # S3 アクセスログの有効化
  - CKV_AWS_19    # S3 暗号化
  - CKV_AWS_21    # S3 バージョニング
  - CKV_AWS_145   # KMS 暗号化
skip-check:
  - CKV_AWS_144   # S3 クロスリージョンレプリケーション(不要)

CI/CD パイプラインへの統合

# .github/workflows/iac-quality.yml
name: IaC Quality Check
on:
  pull_request:
    paths:
      - "terraform/**"
      - "cdk/**"

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: tfsec
        uses: aquasecurity/tfsec-action@v1.0.3
        with:
          working_directory: terraform/
          soft_fail: false

      - name: Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          framework: terraform
          output_format: sarif
          output_file_path: checkov-results.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: checkov-results.sarif

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: CDK Unit Tests
        working-directory: cdk/
        run: |
          npm ci
          npm test

  plan:
    runs-on: ubuntu-latest
    needs: [static-analysis, unit-test]
    steps:
      - uses: actions/checkout@v4

      - name: Terraform Plan
        working-directory: terraform/environments/staging/
        run: |
          terraform init
          terraform plan -out=tfplan
          terraform show -json tfplan > tfplan.json

      - name: OPA Policy Check
        run: |
          conftest test terraform/environments/staging/tfplan.json \
            --policy policy/terraform/

CDK assertions の詳細

import { Template, Match, Capture } from "aws-cdk-lib/assertions";

describe("Security Compliance", () => {
  const template = Template.fromStack(stack);

  test("全ての S3 バケットが暗号化されている", () => {
    const buckets = template.findResources("AWS::S3::Bucket");
    for (const [logicalId, bucket] of Object.entries(buckets)) {
      expect(bucket.Properties.BucketEncryption).toBeDefined();
    }
  });

  test("RDS インスタンスがマルチ AZ で暗号化されている", () => {
    template.hasResourceProperties("AWS::RDS::DBInstance", {
      MultiAZ: true,
      StorageEncrypted: true,
      DeletionProtection: true,
    });
  });

  test("Lambda 関数が VPC 内に配置されている", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      VpcConfig: Match.objectLike({
        SubnetIds: Match.anyValue(),
        SecurityGroupIds: Match.anyValue(),
      }),
    });
  });

  test("IAM ポリシーに * リソースが含まれていない", () => {
    const capture = new Capture();
    template.hasResourceProperties("AWS::IAM::Policy", {
      PolicyDocument: {
        Statement: Match.arrayWith([
          Match.objectLike({
            Resource: capture,
          }),
        ]),
      },
    });

    // キャプチャした値を検証
    const resource = capture.asString();
    expect(resource).not.toBe("*");
  });
});

まとめ

ツールカテゴリ検証対象実行タイミング
tfsec静的解析セキュリティ設定PR 時
Checkov静的解析コンプライアンスPR 時
OPA / ConftestポリシーカスタムポリシーPlan 後
CDK assertionsユニットテストリソース構成ローカル / CI
Terratest統合テスト実環境の動作マージ後 / 定期
SentinelポリシーガバナンスApply 前

チェックリスト

  • IaC テストピラミッドの各層を説明できる
  • Terratest で統合テストを書ける
  • tfsec / Checkov でセキュリティスキャンを実行できる
  • OPA / Conftest でカスタムポリシーを定義できる
  • CI/CD パイプラインに IaC 品質チェックを組み込める

次のステップへ

次のレッスンでは、マルチ環境・マルチアカウント戦略を学び、AWS Organizations やランディングゾーンの設計を扱います。