CloudFormationのベストプラクティス:テンプレートの分割と再利用
はじめに
CloudFormationを使ったインフラ管理において、初期段階では一つのテンプレートで複数のリソース(EC2インスタンス、S3バケット、VPC等)を管理することが多いでしょう。しかし、プロジェクトが成長するにつれて、この単一テンプレートアプローチには限界が見えてきます。
テンプレートが巨大化すると以下のような問題が発生します:
- 数百〜数千行のYAMLファイルになり、特定のリソースを見つけるのに時間がかかる
- 一部の変更でも全体のスタックを更新する必要があり、リスクが高い
- 複数の開発者が同時に編集すると、競合が発生しやすい
- 環境(開発、テスト、本番)ごとに微調整が困難
今回は、これらの課題を解決するテンプレート分割とOutputs/ImportValueを使った再利用のベストプラクティスを、実践的な例とともに解説します。
テンプレート分割の戦略
分割の考え方
テンプレートを分割する際は、以下の観点で整理することをお勧めします:
1. ライフサイクルによる分割
- 基盤インフラ:VPC、サブネット、インターネットゲートウェイ(変更頻度:低)
- 共有サービス:RDS、ElastiCache、ALB(変更頻度:中)
- アプリケーション:EC2、Lambda、Auto Scaling Group(変更頻度:高)
2. 責任範囲による分割
- ネットワークチーム:VPC、セキュリティグループ
- データベースチーム:RDS、DynamoDB
- アプリケーションチーム:EC2、Lambda、S3
分割のメリット
| 項目 | 単一テンプレート | 分割テンプレート |
|---|---|---|
| 保守性 | 変更箇所の特定が困難 | 変更範囲が明確 |
| 再利用性 | プロジェクト固有 | 複数プロジェクトで共用可能 |
| 並行開発 | 競合が発生しやすい | チームごとに独立開発可能 |
| デプロイリスク | 一部変更でも全体に影響 | 影響範囲を限定可能 |
| テスト効率 | 全体のテストが必要 | 変更部分のみテスト可能 |
実践:OutputsとImportValueを使ったテンプレート連携
ステップ1:基盤ネットワークテンプレートの作成
まず、再利用可能なVPCとサブネットを定義します。
ファイル名:01-network.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: "基盤ネットワークインフラ - VPC、サブネット、インターネットゲートウェイ"
Parameters:
ProjectName:
Type: String
Default: "MyProject"
Description: "プロジェクト名(リソースのタグとエクスポート名に使用)"
VpcCidr:
Type: String
Default: "10.0.0.0/16"
Description: "VPCのCIDRブロック"
Resources:
# VPC
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Sub "${ProjectName}-VPC"
- Key: Project
Value: !Ref ProjectName
# インターネットゲートウェイ
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub "${ProjectName}-IGW"
- Key: Project
Value: !Ref ProjectName
# VPCにインターネットゲートウェイをアタッチ
AttachGateway:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref InternetGateway
# パブリックサブネット(AZ-a)
PublicSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: "10.0.1.0/24"
AvailabilityZone: !Select [0, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub "${ProjectName}-Public-Subnet-A"
- Key: Type
Value: "Public"
# パブリックサブネット(AZ-c)
PublicSubnetC:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: "10.0.2.0/24"
AvailabilityZone: !Select [1, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub "${ProjectName}-Public-Subnet-C"
- Key: Type
Value: "Public"
# プライベートサブネット(AZ-a)
PrivateSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: "10.0.11.0/24"
AvailabilityZone: !Select [0, !GetAZs ""]
Tags:
- Key: Name
Value: !Sub "${ProjectName}-Private-Subnet-A"
- Key: Type
Value: "Private"
# プライベートサブネット(AZ-c)
PrivateSubnetC:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: "10.0.12.0/24"
AvailabilityZone: !Select [1, !GetAZs ""]
Tags:
- Key: Name
Value: !Sub "${ProjectName}-Private-Subnet-C"
- Key: Type
Value: "Private"
# パブリックルートテーブル
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub "${ProjectName}-Public-RouteTable"
# パブリックルート
PublicRoute:
Type: AWS::EC2::Route
DependsOn: AttachGateway
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref InternetGateway
# パブリックサブネットとルートテーブルの関連付け
PublicSubnetARouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetA
RouteTableId: !Ref PublicRouteTable
PublicSubnetCRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetC
RouteTableId: !Ref PublicRouteTable
# 重要:他のテンプレートで使用するためにエクスポート
Outputs:
VPCId:
Description: "作成されたVPCのID"
Value: !Ref VPC
Export:
Name: !Sub "${ProjectName}-VPC-ID"
PublicSubnetAId:
Description: "パブリックサブネット(AZ-a)のID"
Value: !Ref PublicSubnetA
Export:
Name: !Sub "${ProjectName}-PublicSubnetA-ID"
PublicSubnetCId:
Description: "パブリックサブネット(AZ-c)のID"
Value: !Ref PublicSubnetC
Export:
Name: !Sub "${ProjectName}-PublicSubnetC-ID"
PrivateSubnetAId:
Description: "プライベートサブネット(AZ-a)のID"
Value: !Ref PrivateSubnetA
Export:
Name: !Sub "${ProjectName}-PrivateSubnetA-ID"
PrivateSubnetCId:
Description: "プライベートサブネット(AZ-c)のID"
Value: !Ref PrivateSubnetC
Export:
Name: !Sub "${ProjectName}-PrivateSubnetC-ID"
# マルチAZ構成用のサブネットリスト
PublicSubnets:
Description: "パブリックサブネットのリスト"
Value: !Join [",", [!Ref PublicSubnetA, !Ref PublicSubnetC]]
Export:
Name: !Sub "${ProjectName}-PublicSubnets"
PrivateSubnets:
Description: "プライベートサブネットのリスト"
Value: !Join [",", [!Ref PrivateSubnetA, !Ref PrivateSubnetC]]
Export:
Name: !Sub "${ProjectName}-PrivateSubnets"
ステップ2:アプリケーションテンプレートの作成
ネットワークリソースを再利用するアプリケーションテンプレートを作成します。
ファイル名:02-application.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: "アプリケーションインフラ - EC2インスタンス、セキュリティグループ"
Parameters:
ProjectName:
Type: String
Default: "MyProject"
Description: "プロジェクト名(ネットワークテンプレートと同じ値を指定)"
InstanceType:
Type: String
Default: "t3.micro"
AllowedValues: ["t3.micro", "t3.small", "t3.medium"]
Description: "EC2インスタンスタイプ"
KeyPairName:
Type: AWS::EC2::KeyPair::KeyName
Description: "EC2インスタンスにSSH接続するためのキーペア名"
# 最新のAmazon Linux 2023 AMIを動的に取得
Mappings:
RegionMap:
us-east-1:
AMI: ami-0c02fb55956c7d316
ap-northeast-1:
AMI: ami-0d52744d6551d851e
Resources:
# Webサーバー用セキュリティグループ
WebServerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: !Sub "${ProjectName}-WebServer-SG"
GroupDescription: "Webサーバー用セキュリティグループ"
VpcId: !ImportValue
Fn::Sub: "${ProjectName}-VPC-ID" # ネットワークテンプレートからVPC IDをインポート
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: "0.0.0.0/0"
Description: "HTTP traffic"
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: "0.0.0.0/0"
Description: "HTTPS traffic"
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: "10.0.0.0/16"
Description: "SSH from VPC"
Tags:
- Key: Name
Value: !Sub "${ProjectName}-WebServer-SG"
# EC2インスタンス(パブリックサブネットに配置)
WebServerInstance:
Type: AWS::EC2::Instance
Properties:
InstanceType: !Ref InstanceType
ImageId: !FindInMap [RegionMap, !Ref "AWS::Region", AMI]
KeyName: !Ref KeyPairName
SubnetId: !ImportValue
Fn::Sub: "${ProjectName}-PublicSubnetA-ID" # パブリックサブネットIDをインポート
SecurityGroupIds:
- !Ref WebServerSecurityGroup
UserData:
Fn::Base64: !Sub |
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from ${ProjectName} Web Server!</h1>" > /var/www/html/index.html
echo "<p>Instance ID: $(curl http://169.254.169.254/latest/meta-data/instance-id)</p>" >> /var/www/html/index.html
Tags:
- Key: Name
Value: !Sub "${ProjectName}-WebServer"
- Key: Project
Value: !Ref ProjectName
# Application Load Balancer(マルチAZ構成)
ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: !Sub "${ProjectName}-ALB"
Type: application
Scheme: internet-facing
SecurityGroups:
- !Ref ALBSecurityGroup
Subnets: !Split
- ","
- !ImportValue
Fn::Sub: "${ProjectName}-PublicSubnets" # 複数サブネットをインポート
Tags:
- Key: Name
Value: !Sub "${ProjectName}-ALB"
# ALB用セキュリティグループ
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: !Sub "${ProjectName}-ALB-SG"
GroupDescription: "Application Load Balancer用セキュリティグループ"
VpcId: !ImportValue
Fn::Sub: "${ProjectName}-VPC-ID"
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: "0.0.0.0/0"
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: "0.0.0.0/0"
Tags:
- Key: Name
Value: !Sub "${ProjectName}-ALB-SG"
Outputs:
WebServerInstanceId:
Description: "作成されたWebサーバーのインスタンスID"
Value: !Ref WebServerInstance
Export:
Name: !Sub "${ProjectName}-WebServer-InstanceID"
WebServerPublicIP:
Description: "WebサーバーのパブリックIPアドレス"
Value: !GetAtt WebServerInstance.PublicIp
Export:
Name: !Sub "${ProjectName}-WebServer-PublicIP"
ApplicationLoadBalancerDNS:
Description: "Application Load BalancerのDNS名"
Value: !GetAtt ApplicationLoadBalancer.DNSName
Export:
Name: !Sub "${ProjectName}-ALB-DNS"
デプロイと運用のベストプラクティス
デプロイ順序の管理
# 1. ネットワークインフラを最初にデプロイ
aws cloudformation create-stack \
--stack-name myproject-network \
--template-body file://01-network.yaml \
--parameters ParameterKey=ProjectName,ParameterValue=MyProject
# 2. スタックの作成完了を待つ
aws cloudformation wait stack-create-complete \
--stack-name myproject-network
# 3. アプリケーションスタックをデプロイ
aws cloudformation create-stack \
--stack-name myproject-application \
--template-body file://02-application.yaml \
--parameters \
ParameterKey=ProjectName,ParameterValue=MyProject \
ParameterKey=KeyPairName,ParameterValue=my-keypair
エラーハンドリングと依存関係
よくある問題と対策:
-
エクスポート名の重複
# ❌ 悪い例:固定名を使用 Export: Name: "VPC-ID" # ✅ 良い例:パラメータを使って一意な名前を生成 Export: Name: !Sub "${ProjectName}-VPC-ID" -
削除順序の間違い
# ❌ 間違った順序(依存するスタックから削除するとエラー) aws cloudformation delete-stack --stack-name myproject-network # ✅ 正しい順序(依存されるスタックを最後に削除) aws cloudformation delete-stack --stack-name myproject-application aws cloudformation wait stack-delete-complete --stack-name myproject-application aws cloudformation delete-stack --stack-name myproject-network
まとめ:効果的なテンプレート分割のポイント
設計原則
- 単一責任原則:各テンプレートは一つの責務に集中する
- 疎結合:テンプレート間の依存関係を最小限に抑える
- 高凝集:関連するリソースは同じテンプレートにまとめる
- 再利用性:パラメータを活用して汎用的に設計する
運用面での考慮事項
- 命名規則:エクスポート名には必ずプロジェクト名やスタック名を含める
- バージョン管理:テンプレートはGitで管理し、変更履歴を追跡可能にする
- テスト戦略:各テンプレートを独立してテストできるようにする
- 監視とアラート:スタックの状態を監視し、異常時の対応手順を整備する
テンプレート分割は初期の学習コストはありますが、中長期的には開発効率とインフラの品質向上に大きく貢献します。小さなプロジェクトから実践を始めて、徐々にベストプラクティスを身につけていくことをお勧めします。
次回は、AWSの公式IaCツールであるAWS CDK(Cloud Development Kit)について、CloudFormationとの違いを交えて解説します。お楽しみに!