rainを使ったCloudFormationスタックおよびテンプレートの運用方法

2021.09.28

はじめに

前回紹介したrain、rainの操作は理解したのですが、どうやって更新していく?テンプレートは集約する?分割する?このあたりも含めて、もうひと工夫した使い方を紹介します。

テンプレートの集約or分割

CloudFormation(以下、CFn)で必ず出てくる(?)テンプレートの管理問題ですが、どちらが良いのか論争はここでは語らず、集約と分割、それぞれのパターンに合わせたrainの活用方法をご案内します。

以下のCFnテンプレートを例に進めていきます。

sample.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  vpcCidr:
    Type: String

  domainName:
    Type: String

  hostedZoneId:
    Type: String

  ### Ec2 Parameters

  ec2Ami:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2

  instanceType:
    Type: String

  ### Rds Parameters

  instanceClass:
    Type: String

  masterPassword:
    Type: String
    NoEcho: true

Resources:
  ### VPC

  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref vpcCidr
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-vpc
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Internet Gateway

  MyInternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-igw
        - Key: BillingGroup
          Value: !Ref billingTag

  MyVPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref MyInternetGateway
      VpcId: !Ref MyVPC

  ### Public Subnet 

  MyPublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [0, !Cidr [!GetAtt MyVPC.CidrBlock, 2, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-subnet-1
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 1
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [1, !Cidr [!GetAtt MyVPC.CidrBlock, 2, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-subnet-2
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  ### Public Route Table

  MyPublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-rtb
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPublicRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref MyInternetGateway
      RouteTableId: !Ref MyPublicRouteTable

  MyPublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPublicRouteTable
      SubnetId: !Ref MyPublicSubnet1

  MyPublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPublicRouteTable
      SubnetId: !Ref MyPublicSubnet2

  ### Private Subnet 

  MyPrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [10, !Cidr [!GetAtt MyVPC.CidrBlock, 12, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-subnet-1
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 1
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [11, !Cidr [!GetAtt MyVPC.CidrBlock, 12, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-subnet-2
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  ### Private Route Table

  MyPrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-rtb
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref MyInternetGateway
      RouteTableId: !Ref MyPrivateRouteTable

  MyPrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPrivateRouteTable
      SubnetId: !Ref MyPrivateSubnet1

  MyPrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPrivateRouteTable
      SubnetId: !Ref MyPrivateSubnet2

  ### Security Group Alb

  AlbSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Application load barancer.
      GroupName: !Sub ${env}-${sysName}-alb-sg
      VpcId: !Ref MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-alb-sg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Security Group ec2

  ec2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ec2 Instances.
      GroupName: !Sub ${env}-${sysName}-ec2-sg
      VpcId: !Ref MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref AlbSG
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-ec2-sg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Security Group Rds

  RdsSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Rds Instances.
      GroupName: !Sub ${env}-${sysName}-rds-sg
      VpcId: !Ref MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !Ref ec2SG
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-sg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Alb Acm

  AlbAcm:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref domainName
      DomainValidationOptions:
        - DomainName: !Ref domainName
          HostedZoneId: !Ref hostedZoneId
      ValidationMethod: DNS
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-albacm
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Alb

  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      IpAddressType: ipv4
      Name: !Sub ${env}-${sysName}-alb
      Scheme: internet-facing
      SecurityGroups:
        - !Ref AlbSG
      Subnets:
        - !Ref MyPublicSubnet1
        - !Ref MyPublicSubnet2
      Type: application
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-api
        - Key: BillingGroup
          Value: !Ref billingTag

  AlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      Certificates:
        - CertificateArn: !Ref AlbAcm
      DefaultActions:
        - FixedResponseConfig:
            ContentType: text/plain
            MessageBody: Unauthorized Access
            StatusCode: "403"
          Type: fixed-response
      LoadBalancerArn: !Ref Alb
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-2016-08

  AlbListnerRule1:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      Conditions:
        - Field: path-pattern
          PathPatternConfig:
            Values:
              - '*'
      ListenerArn: !Ref AlbListener
      Priority: 1

  ### Alb DNS Record

  AlbRecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Ref domainName
      Type: A
      AliasTarget:
        HostedZoneId: Z14GRHDCWA56QT
        DNSName: !Join
          - ""
          - - dualstack.
            - !GetAtt Alb.DNSName
        EvaluateTargetHealth: false
      HostedZoneId: !Ref hostedZoneId

  ### Target Group for ec2 Instance

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub ${env}-${sysName}-api
      Port: 80
      Protocol: HTTP
      TargetType: instance
      Targets:
        - Id: !Ref Instance1
        - Id: !Ref Instance2
      VpcId: !Ref MyVPC
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-tg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### ec2 Profilee

  ec2Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

  ec2Profile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref ec2Role

  ### ec2 Instance

  Instance1:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ec2Ami
      InstanceType: !Ref instanceType
      CreditSpecification:
        CPUCredits: standard
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            DeleteOnTermination: true
            VolumeType: gp3
            Iops: 3000
            VolumeSize: 8
            Encrypted: true
      SecurityGroupIds:
        - !Ref ec2SG
      SubnetId: !Ref MyPrivateSubnet1
      IamInstanceProfile: !Ref ec2Profile
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-1
        - Key: BillingGroup
          Value: !Ref billingTag

  Instance2:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ec2Ami
      InstanceType: !Ref instanceType
      CreditSpecification:
        CPUCredits: standard
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            DeleteOnTermination: true
            VolumeType: gp3
            Iops: 3000
            VolumeSize: 8
            Encrypted: true
      SecurityGroupIds:
        - !Ref ec2SG
      SubnetId: !Ref MyPrivateSubnet2
      IamInstanceProfile: !Ref ec2Profile
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-2
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Rds Subnet Group

  SubnetGroupRds:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: !Sub ${env}-${sysName}-rds-SubnetGroup
      DBSubnetGroupName: !Sub ${env}-${sysName}-rds-subnetgroup
      SubnetIds:
        - !Ref MyPrivateSubnet1
        - !Ref MyPrivateSubnet2
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-SubnetGroup
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Rds Parameter Group

  ParameterGroupAurora:
    Type: AWS::RDS::DBParameterGroup
    Properties:
      Description: !Sub ${env}-${sysName}-parametergroup
      Family: aurora-mysql5.7

  ## Rds Cluster Parameter Group

  clusterParameterGroupAurora:
    Type: AWS::RDS::DBClusterParameterGroup
    Properties:
      Description: !Sub ${env}-${sysName}-cluster-parametergroup
      Family: aurora-mysql5.7
      Parameters:
        character_set_client: utf8
        character_set_connection: utf8
        character_set_database: utf8
        character_set_results: utf8
        character_set_server: utf8
        general_log: 1
        server_audit_events: CONNECT,QUERY,QUERY_DCL,QUERY_DDL,QUERY_DML,TABLE
        server_audit_logging: 1
        slow_query_log: 1
        time_zone: Asia/Tokyo

  ### Rds Aurora Cluster

  clusterAurora:
    Type: AWS::RDS::DBCluster
    Properties:
      MasterUsername: root
      MasterUserPassword: !Ref masterPassword
      DBClusterIdentifier: !Sub ${env}-${sysName}-cluster
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DBClusterParameterGroupName: !Ref clusterParameterGroupAurora
      DBSubnetGroupName: !Ref SubnetGroupRds
      Port: 3306
      PreferredBackupWindow: 17:00-18:00
      BackupRetentionPeriod: 7
      PreferredMaintenanceWindow: tue:18:00-tue:19:00
      EnableCloudwatchLogsExports:
        - audit
        - error
        - general
        - slowquery
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - !Ref RdsSG
      DeletionProtection: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-cluster
        - Key: BillingGroup
          Value: !Ref billingTag

  # Rds Aurora Instance1

  DbInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceIdentifier: !Sub ${env}-${sysName}-db1
      DBClusterIdentifier: !Ref clusterAurora
      DBInstanceClass: !Ref instanceClass
      Engine: aurora-mysql
      DBParameterGroupName: !Ref ParameterGroupAurora
      AutoMinorVersionUpgrade: false
      PubliclyAccessible: false
      MonitoringInterval: 0
      EnablePerformanceInsights: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-db1
        - Key: BillingGroup
          Value: !Ref billingTag

  # Rds Aurora Instance2

  DbInstance2:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceIdentifier: !Sub ${env}-${sysName}-db2
      DBClusterIdentifier: !Ref clusterAurora
      DBInstanceClass: !Ref instanceClass
      Engine: aurora-mysql
      DBParameterGroupName: !Ref ParameterGroupAurora
      AutoMinorVersionUpgrade: false
      PubliclyAccessible: false
      MonitoringInterval: 0
      EnablePerformanceInsights: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-db2
        - Key: BillingGroup
          Value: !Ref billingTag

テンプレート集約パターン

CFnテンプレートを集約している場合は、特に意識する必要ことは無いと思います。 rain deploy 実行時にテンプレートがfmtされる為、テンプレートをfmtし、チェックします。

# fmtしてテンプレートに書き込む
rain fmt -w *tenplatefile*

# fmtされているかチェック
rain fmt -v *tenplatefile*
*tenplatefile*: formatted OK

fmtしたテンプレートを指定してスタックを作成します。

rain deploy -y ./sample.yml dev-stack --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          vpcCidr=$CIDR,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID,\
          instanceType=$INSTANCETYPE.\
          instanceClass=$INSTANCECLASS,\
          masterPassword=$MASTERPASSWORD
.
.
.
Successfully deployed dev-stack

作成したスタックを更新するには↑と同じコマンドです。 -y, --yes オプションがあると差分確認できずに更新されてしまいます。差分確認するには2種類の方法があります。

-y, --yes オプションをはずす

対話式のデプロイとなります。具体的には rain deploy実行後に変更セットが作成し、変更内容を出力します。 (y/N) でスタックの更新可否を入力します。意図したリソースのが表示されれば Y 、そうでなければ n で実行します。

rain deploy ./sample.yml dev-stack --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          vpcCidr=$CIDR,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID,\
          instanceType=$INSTANCETYPE.\
          instanceClass=$INSTANCECLASS,\
          masterPassword=$MASTERPASSWORD
Enter a value for parameter 'ec2Ami' (existing value: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2):
CloudFormation will make the following changes:
Stack dev-stack:
  > AWS::RDS::DBCluster clusterAurora
Do you wish to continue? (Y/n)

diffによるテンプレート比較

対話式ではなく、テンプレートから差分を抽出できます。先程のテンプレートの clusterAurora の削除保護を有効にします。

.
.
.
  ### Rds Aurora Cluster

  clusterAurora:
    Type: AWS::RDS::DBCluster
    Properties:
      MasterUsername: root
      MasterUserPassword: !Ref masterPassword
      DBClusterIdentifier: !Sub ${env}-${sysName}-cluster
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DBClusterParameterGroupName: !Ref clusterParameterGroupAurora
      DBSubnetGroupName: !Ref SubnetGroupRds
      Port: 3306
      PreferredBackupWindow: 17:00-18:00
      BackupRetentionPeriod: 7
      PreferredMaintenanceWindow: tue:18:00-tue:19:00
      EnableCloudwatchLogsExports:
        - audit
        - error
        - general
        - slowquery
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - !Ref RdsSG
      DeletionProtection: true # ←削除保護を有効
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-cluster
        - Key: BillingGroup
          Value: !Ref billingTag
.
.
.

テンプレートを修正したらfmtも忘れずに。

# fmtしてテンプレートに書き込む
rain fmt -w *tenplatefile*

# fmtされているかチェック
rain fmt -v *tenplatefile*
*tenplatefile*: formatted OK

次に対象スタックからテンプレートを出力します。デプロイしたであろうテンプレートがローカルにあったとしてもそのテンプレートが本当に正しいか?ということはわからないので必ず対象スタックから出力したテンプレートと比較します。

# デプロイ済みスタックからテンプレートを出力。ファイル(tmp-dev-stack.yml)に書き出し
rain cat dev-stack > ./tmp-dev-stack.yml

# テンプレートを比較
% rain diff tmp-dev-stack.yml sample.yml
(|) Resources:
(|)   clusterAurora:
(|)     Properties:
(>)       DeletionProtection: true

rain diff は差分があるリソースのみ出力します。変更セットと違ってどのリソースのパラメータが変更されるか、確認できます。めっちゃわかりやすいです。

意図した変更であれば rain deploy -yでスタックを更新します。

テンプレート分割パターン

テンプレートを分割して管理する場合、デプロイ方法が異なります。まずは例で用意したsample.ymlを分割します。

vpc.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Vpc Parameters 

  vpcCidr:
    Type: String

Resources:
  ### VPC

  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref vpcCidr
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-vpc
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Internet Gateway

  MyInternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-igw
        - Key: BillingGroup
          Value: !Ref billingTag

  MyVPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref MyInternetGateway
      VpcId: !Ref MyVPC

  ### Public Subnet 

  MyPublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [0, !Cidr [!GetAtt MyVPC.CidrBlock, 2, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-subnet-1
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 1
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [1, !Cidr [!GetAtt MyVPC.CidrBlock, 2, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-subnet-2
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  ### Public Route Table

  MyPublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-public-rtb
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPublicRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref MyInternetGateway
      RouteTableId: !Ref MyPublicRouteTable

  MyPublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPublicRouteTable
      SubnetId: !Ref MyPublicSubnet1

  MyPublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPublicRouteTable
      SubnetId: !Ref MyPublicSubnet2

  ### Private Subnet 

  MyPrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [10, !Cidr [!GetAtt MyVPC.CidrBlock, 12, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-subnet-1
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 1
        - !GetAZs
          Ref: AWS::Region
      CidrBlock: !Select [11, !Cidr [!GetAtt MyVPC.CidrBlock, 12, 8]]
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-subnet-2
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  ### Private Route Table

  MyPrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-private-rtb
        - Key: BillingGroup
          Value: !Ref billingTag
      VpcId: !Ref MyVPC

  MyPrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref MyInternetGateway
      RouteTableId: !Ref MyPrivateRouteTable

  MyPrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPrivateRouteTable
      SubnetId: !Ref MyPrivateSubnet1

  MyPrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref MyPrivateRouteTable
      SubnetId: !Ref MyPrivateSubnet2
      ### Outputs

Outputs:
  MyVPC:
    Value: !Ref MyVPC
    Export:
      Name: !Sub ${env}-${sysName}-MyVPC

  MyPublicSubnet1:
    Value: !Ref MyPublicSubnet1
    Export:
      Name: !Sub ${env}-${sysName}-MyPublicSubnet1

  MyPublicSubnet2:
    Value: !Ref MyPublicSubnet2
    Export:
      Name: !Sub ${env}-${sysName}-MyPublicSubnet2

  MyPrivateSubnet1:
    Value: !Ref MyPrivateSubnet1
    Export:
      Name: !Sub ${env}-${sysName}-MyPrivateSubnet1

  MyPrivateSubnet2:
    Value: !Ref MyPrivateSubnet2
    Export:
      Name: !Sub ${env}-${sysName}-MyPrivateSubnet2
securitygroup.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

Resources:
  ### Security Group Alb

  AlbSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Application load barancer.
      GroupName: !Sub ${env}-${sysName}-alb-sg
      VpcId: !ImportValue
        Fn::Sub: ${env}-${sysName}-MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-alb-sg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Security Group ec2

  ec2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ec2 Instances.
      GroupName: !Sub ${env}-${sysName}-ec2-sg
      VpcId: !ImportValue
        Fn::Sub: ${env}-${sysName}-MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref AlbSG
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-ec2-sg
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Security Group Rds

  RdsSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Rds Instances.
      GroupName: !Sub ${env}-${sysName}-rds-sg
      VpcId: !ImportValue
        Fn::Sub: ${env}-${sysName}-MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !Ref ec2SG
      SecurityGroupEgress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-sg
        - Key: BillingGroup
          Value: !Ref billingTag
          ### Outputs

Outputs:
  AlbSG:
    Value: !Ref AlbSG
    Export:
      Name: !Sub ${env}-${sysName}-alb-sg

  ec2SG:
    Value: !Ref ec2SG
    Export:
      Name: !Sub ${env}-${sysName}-ec2-sg

  RdsSG:
    Value: !Ref RdsSG
    Export:
      Name: !Sub ${env}-${sysName}-rds-sg
rds.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Rds Parameters

  instanceClass:
    Type: String

  masterPassword:
    Type: String
    NoEcho: true

Resources:
  ### Rds Subnet Group

  SubnetGroupRds:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: !Sub ${env}-${sysName}-rds-SubnetGroup
      DBSubnetGroupName: !Sub ${env}-${sysName}-rds-subnetgroup
      SubnetIds:
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-MyPrivateSubnet1
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-MyPrivateSubnet2
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-SubnetGroup
        - Key: BillingGroup
          Value: !Ref billingTag

  ### Rds Parameter Group

  ParameterGroupAurora:
    Type: AWS::RDS::DBParameterGroup
    Properties:
      Description: !Sub ${env}-${sysName}-parametergroup
      Family: aurora-mysql5.7

  ## Rds Cluster Parameter Group

  clusterParameterGroupAurora:
    Type: AWS::RDS::DBClusterParameterGroup
    Properties:
      Description: !Sub ${env}-${sysName}-cluster-parametergroup
      Family: aurora-mysql5.7
      Parameters:
        character_set_client: utf8
        character_set_connection: utf8
        character_set_database: utf8
        character_set_results: utf8
        character_set_server: utf8
        general_log: 1
        server_audit_events: CONNECT,QUERY,QUERY_DCL,QUERY_DDL,QUERY_DML,TABLE
        server_audit_logging: 1
        slow_query_log: 1
        time_zone: Asia/Tokyo

  ### Rds Aurora Cluster

  clusterAurora:
    Type: AWS::RDS::DBCluster
    Properties:
      MasterUsername: root
      MasterUserPassword: !Ref masterPassword
      DBClusterIdentifier: !Sub ${env}-${sysName}-cluster
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DBClusterParameterGroupName: !Ref clusterParameterGroupAurora
      DBSubnetGroupName: !Ref SubnetGroupRds
      Port: 3306
      PreferredBackupWindow: 17:00-18:00
      BackupRetentionPeriod: 7
      PreferredMaintenanceWindow: tue:18:00-tue:19:00
      EnableCloudwatchLogsExports:
        - audit
        - error
        - general
        - slowquery
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - !ImportValue
          Fn::Sub: "${env}-${sysName}-rds-sg"
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-cluster
        - Key: BillingGroup
          Value: !Ref billingTag

  # Rds Aurora Instance1

  DbInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceIdentifier: !Sub ${env}-${sysName}-db1
      DBClusterIdentifier: !Ref clusterAurora
      DBInstanceClass: !Ref instanceClass
      Engine: aurora-mysql
      DBParameterGroupName: !Ref ParameterGroupAurora
      AutoMinorVersionUpgrade: false
      PubliclyAccessible: false
      MonitoringInterval: 0
      EnablePerformanceInsights: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-db1
        - Key: BillingGroup
          Value: !Ref billingTag

  # Rds Aurora Instance2

  DbInstance2:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceIdentifier: !Sub ${env}-${sysName}-db2
      DBClusterIdentifier: !Ref clusterAurora
      DBInstanceClass: !Ref instanceClass
      Engine: aurora-mysql
      DBParameterGroupName: !Ref ParameterGroupAurora
      AutoMinorVersionUpgrade: false
      PubliclyAccessible: false
      MonitoringInterval: 0
      EnablePerformanceInsights: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-db2
        - Key: BillingGroup
          Value: !Ref billingTag
acm.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Acm And Route53 Parameters

  domainName:
    Type: String

  hostedZoneId:
    Type: String

Resources:
  ### Alb Acm

  AlbAcm:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref domainName
      DomainValidationOptions:
        - DomainName: !Ref domainName
          HostedZoneId: !Ref hostedZoneId
      ValidationMethod: DNS
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-albacm
        - Key: BillingGroup
          Value: !Ref billingTag
          ### Outputs

Outputs:
  AlbAcm:
    Value: !Ref AlbAcm
    Export:
      Name: !Sub ${env}-${sysName}-albacm
iam.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

Resources:
  ### ec2 Profilee

  ec2Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

  ec2Profile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref ec2Role

Outputs:
  ec2Profile:
    Value: !Ref ec2Profile
    Export:
      Name: !Sub ${env}-${sysName}-ec2Profile
ec2.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Ec2 Parameters

  ec2Ami:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2

  instanceType:
    Type: String

Resources:
  ### ec2 Instance

  Instance1:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ec2Ami
      InstanceType: !Ref instanceType
      CreditSpecification:
        CPUCredits: standard
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            DeleteOnTermination: true
            VolumeType: gp3
            Iops: 3000
            VolumeSize: 8
            Encrypted: true
      SecurityGroupIds:
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-ec2-sg
      SubnetId: !ImportValue
        Fn::Sub: ${env}-${sysName}-MyPrivateSubnet1
      IamInstanceProfile: !ImportValue
        Fn::Sub: ${env}-${sysName}-ec2Profile
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-instance1
        - Key: BillingGroup
          Value: !Ref billingTag

  Instance2:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ec2Ami
      InstanceType: !Ref instanceType
      CreditSpecification:
        CPUCredits: standard
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            DeleteOnTermination: true
            VolumeType: gp3
            Iops: 3000
            VolumeSize: 8
            Encrypted: true
      SecurityGroupIds:
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-ec2-sg
      SubnetId: !ImportValue
        Fn::Sub: ${env}-${sysName}-MyPrivateSubnet2
      IamInstanceProfile: !ImportValue
        Fn::Sub: ${env}-${sysName}-ec2Profile
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-instance2
        - Key: BillingGroup
          Value: !Ref billingTag

Outputs:
  Instance1:
    Value: !Ref Instance1
    Export:
      Name: !Sub ${env}-${sysName}-instance1

  Instance2:
    Value: !Ref Instance2
    Export:
      Name: !Sub ${env}-${sysName}-instance2
alb.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

Resources:
  ### Alb

  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      IpAddressType: ipv4
      Name: !Sub ${env}-${sysName}-alb
      Scheme: internet-facing
      SecurityGroups:
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-alb-sg
      Subnets:
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-MyPublicSubnet1
        - !ImportValue
          Fn::Sub: ${env}-${sysName}-MyPublicSubnet2
      Type: application
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-api
        - Key: BillingGroup
          Value: !Ref billingTag

  AlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      Certificates:
        - CertificateArn: !ImportValue
            Fn::Sub: ${env}-${sysName}-albacm
      DefaultActions:
        - FixedResponseConfig:
            ContentType: text/plain
            MessageBody: Unauthorized Access
            StatusCode: "403"
          Type: fixed-response
      LoadBalancerArn: !Ref Alb
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-2016-08

  AlbListnerRule1:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      Conditions:
        - Field: path-pattern
          PathPatternConfig:
            Values:
              - '*'
      ListenerArn: !Ref AlbListener
      Priority: 1

  ### Target Group for ec2 Instance

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub ${env}-${sysName}-ec2
      Port: 80
      Protocol: HTTP
      TargetType: instance
      Targets:
        - Id: !ImportValue
            Fn::Sub: ${env}-${sysName}-instance1
        - Id: !ImportValue
            Fn::Sub: ${env}-${sysName}-instance2
      VpcId:
        Fn::ImportValue: !Sub ${env}-${sysName}-MyVPC
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-tg
        - Key: BillingGroup
          Value: !Ref billingTag
          ### Outputs

Outputs:
  AlbDomainName:
    Value: !Join
      - ""
      - - dualstack.
        - !GetAtt Alb.DNSName
    Export:
      Name: !Sub ${env}-${sysName}-alb-domain
route53.yml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  ### Acm And Route53 Parameters

  domainName:
    Type: String

  hostedZoneId:
    Type: String

Resources:
  ### Alb DNS Record

  AlbRecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Ref domainName
      Type: A
      AliasTarget:
        HostedZoneId: Z14GRHDCWA56QT
        DNSName: !ImportValue
          Fn::Sub: ${env}-${sysName}-alb-domain
        EvaluateTargetHealth: false
      HostedZoneId: !Ref hostedZoneId

集約パターンと同じ用にfmtしますが、ワイルドカードで指定することで複数のテンプレートを対象にfmtできます。(再帰的な実行不可)

# ディレクトリ配下にあるテンプレートをfmtしてテンプレートに書き込む
rain fmt -w ./template/*yml

# ディレクトリ配下にあるテンプレートがfmtされているかチェック
rain fmt -v ./template/*yml
./template/acm.yml: formatted OK
./template/alb.yml: formatted OK
./template/ec2.yml: formatted OK
./template/iam.yml: formatted OK
./template/rds.yml: formatted OK
./template/route53.yml: formatted OK
./template/securitygroup.yml: formatted OK
./template/vpc.yml: formatted OK

分割したテンプレートをデプロイしていきます。テンプレートによって指定するパラメータが異なります。

rain deploy -y ./template/vpc.yml dev-vpc --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          vpcCidr=$CIDR
rain deploy -y ./template/securitygroup.yml dev-securitygroup --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG
rain deploy -y ./template/rds.yml dev-rds --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          instanceClass=$INSTANCECLASS,\
          masterPassword=$MASTERPASSWORD
rain deploy -y ./template/acm.yml dev-acm --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID
rain deploy -y ./template/iam.yml dev-iam --params \
          env=$ENV,\
          sysName=$SYSNAME
rain deploy -y ./template/ec2.yml dev-ec2 --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          instanceType=$INSTANCETYPE
rain deploy -y ./template/alb.yml dev-alb --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG
rain deploy -y ./template/route53.yml dev-route53 --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID

分割したことで、テンプレート毎の可読性が良くなりました。ただ、テンプレートが増える度に実行するコマンドを用意するのも運用の手間となります。またデプロイコマンドが複数あると実行ミスの原因にもなります。
なのでNested Stackでデプロイします。Nested Stackは、親スタックに記述した子スタック(AWS::CloudFormation::Stack)を更新してくれます。子スタック(AWS::CloudFormation::Stack)を実行する為には、テンプレートをS3ケットに格納して呼び出すので、S3バケットの操作が必要になりますが、rainはそんなところもうまくやってくれます。

pkgによるアーティファクトのパッケージ化

rainは、ローカルにあるテンプレートをS3にアップロードできます。さらにrain独自のディレクティブを使ってS3バケットにアップロードされたS3URL or URIをテンプレートに埋め込んでくれます。アップロードしたテンプレートの名前など意識する必要がないのが特徴です。また、S3バケットはrainが自動で作成してくれるのでS3バケットも意識する必要がありません。(v1.2.0に実装された機能)
それでは、親スタックを作成します。

AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Vpc Parameters 

  vpcCidr:
    Type: String

  ### Acm And Route53 Parameters

  domainName:
    Type: String

  hostedZoneId:
    Type: String

  ### Ec2 Parameters

  instanceType:
    Type: String

  ### Rds Parameters

  instanceClass:
    Type: String

  masterPassword:
    Type: String
    NoEcho: true

Resources:
  ### VPC

  Vpc:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        vpcCidr: !Ref vpcCidr
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-vpc-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/vpc.yml

  ### Iam

  Iam:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-iam-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/iam.yml

  ### Acm

  Acm:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        domainName: !Ref domainName
        hostedZoneId: !Ref hostedZoneId
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-acm-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/acm.yml

  ### Security Group

  SecurityGroup:
    Type: AWS::CloudFormation::Stack
    DependsOn: Vpc
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-securitygroup-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/securitygroup.yml

  ### Rds

  Rds:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        instanceClass: !Ref instanceClass
        masterPassword: !Ref masterPassword
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/rds.yml

  ### Ec2

  Ec2:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
      - Iam
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        instanceType: !Ref instanceType
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-ec2-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/ec2.yml

  ### ALb

  Alb:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
      - Acm
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-alb-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/alb.yml

  ### Route 53

  route53:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - Alb
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        domainName: !Ref domainName
        hostedZoneId: !Ref hostedZoneId
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-route53-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: !Rain::S3Http ./template/route53.yml

各子スタックの TemplateURL: でrain独自のディレクティブ !Rain::S3Http ./template/securitygroup.yml を指定します。rain deploy を実行するとrainが作成したS3バケット(rain-artifacts-AWSアカウントID-リージョン)に !Rain::S3Http で指定したローカルテンプレートをアップロードしてS3URLを埋め込みます。 rain deploy した後のスタックのテンプレートは以下です。

% rain cat dev-stack
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Parameters:
  ### Common Parameters

  env:
    Type: String

  sysName:
    Type: String

  billingTag:
    Type: String

  ### Vpc Parameters 

  vpcCidr:
    Type: String

  ### Acm And Route53 Parameters

  domainName:
    Type: String

  hostedZoneId:
    Type: String

  ### Ec2 Parameters

  instanceType:
    Type: String

  ### Rds Parameters

  instanceClass:
    Type: String

  masterPassword:
    Type: String
    NoEcho: true

Resources:
  ### VPC

  Vpc:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        vpcCidr: !Ref vpcCidr
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-vpc-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/28bcbc2d2191b0c77a8107d8966058391d8f60c71a68783a5ea2a1e1f73d44e5

  ### Iam

  Iam:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-iam-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/d17946b5b9df3fba03d20c6991c90b6ed3d9087d288cddf926b9d0dddfdd09ed

  ### Acm

  Acm:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        domainName: !Ref domainName
        hostedZoneId: !Ref hostedZoneId
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-acm-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/cb5b53e05fc629eb50788b6cfdfd7c618de006d0f21ee249dfeadf4227fdb54d

  ### Security Group

  SecurityGroup:
    Type: AWS::CloudFormation::Stack
    DependsOn: Vpc
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-securitygroup-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/fcf9a6976c8c882599383e17502c797f851674c99cadd9dd0d2094fd10b70b85

  ### Rds

  Rds:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        instanceClass: !Ref instanceClass
        masterPassword: !Ref masterPassword
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-rds-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/ecfdc118cc69d27356e317e15a19236369e4b8673d7d60953c2f6bda9e513adf

  ### Ec2

  Ec2:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
      - Iam
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
        instanceType: !Ref instanceType
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-ec2-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/bf8c340891c3749c08e0cbb875b7dab568d022f0355c95c80924699d2f92c641

  ### ALb

  Alb:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - SecurityGroup
      - Acm
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        billingTag: !Ref billingTag
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-alb-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/a01e02df03aef2f72e3ada9deea7e7f14f18eefbf8f0acbe828e41dbc5b7921a

  ### Route 53

  route53:
    Type: AWS::CloudFormation::Stack
    DependsOn:
      - Alb
    Properties:
      Parameters:
        env: !Ref env
        sysName: !Ref sysName
        domainName: !Ref domainName
        hostedZoneId: !Ref hostedZoneId
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-route53-stack
        - Key: BillingGroup
          Value: !Ref billingTag
      TemplateURL: https://rain-artifacts-0123456789010-ap-northeast-1.s3.ap-northeast-1.amazonaws.com/ba9e53772ff334b22f2dcb3d483cff2264e6141cd6964e6883c809780642620c

TemplateURL: にS3URLが埋め込まれてますね。S3バケット、オブジェクトキーを意識せずにいい感じにやってくれます。注意事項として、rain独自のディレクティブ !Rain::S3Http!Rain::Include は、cfn-lintで不正な関数とみなされます。CI/CDやVSCodeのCloudFormation Linterでテンプレートを検証する場合はエラーが返されるので注意してください。

[cfn-lint] E3002: Property "Resources/VPC/Properties/TemplateURL" has an illegal function Fn::Rain::S3Http

また、deploy時の出力は以下の通りです。子スタック全体の状態が確認できるのが良いですね。

% rain deploy -y ./template/root-stack.yml dev-stack --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          vpcCidr=$CIDR,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID,\
          instanceType=$INSTANCETYPE.\
          instanceClass=$INSTANCECLASS,\
          masterPassword=$MASTERPASSWORD
Deploying template 'root-stack.yml' as stack 'dev-stack' in ap-northeast-1.
Stack dev-stack: CREATE_IN_PROGRESS - 5 resources pending, 3 resources in progress
  - Stack dev-stack-Acm-1CMVVWZXF93IP: CREATE_IN_PROGRESS - 1 resource in progress
  - Stack Alb: PENDING
  - Stack Ec2: PENDING
  - Stack dev-stack-Iam-URI8LQ30Z0A4: CREATE_IN_PROGRESS - 1 resource in progress
  - Stack Rds: PENDING
  - Stack SecurityGroup: PENDING
  - Stack dev-stack-Vpc-1FUNOD3KZSE6M: CREATE_IN_PROGRESS - 2 resources in progress
  - Stack route53: PENDING
 .˙ 19s

分割パターンでもスタック(子スタック)を更新してみます。集約パターンと同様に DeletionProtection: true を追記します。

.
.
.
  ### Rds Aurora Cluster

  clusterAurora:
    Type: AWS::RDS::DBCluster
    Properties:
      MasterUsername: root
      MasterUserPassword: !Ref masterPassword
      DBClusterIdentifier: !Sub ${env}-${sysName}-cluster
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DBClusterParameterGroupName: !Ref clusterParameterGroupAurora
      DBSubnetGroupName: !Ref SubnetGroupRds
      DeletionProtection: true # ←削除保護を有効
      Port: 3306
      PreferredBackupWindow: 17:00-18:00
      BackupRetentionPeriod: 7
      PreferredMaintenanceWindow: tue:18:00-tue:19:00
      EnableCloudwatchLogsExports:
        - audit
        - error
        - general
        - slowquery
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - !Ref RdsSG
      DeletionProtection: false
      Tags:
        - Key: Name
          Value: !Sub ${env}-${sysName}-cluster
        - Key: BillingGroup
          Value: !Ref billingTag
.
.
.

Nested Stackの更新だと仕様(?)によりすべての子スタックが更新対象と表示されます。何が更新対象かわかりません。

% rain deploy -y ./template/root-stack.yml dev-stack --params \
          env=$ENV,\
          sysName=$SYSNAME,\
          billingTag=$BILLINGTAG,\
          vpcCidr=$CIDR,\
          domainName=$DOMAINNAME,\
          hostedZoneId=$HOSTEDZONEID,\
          instanceType=$INSTANCETYPE.\
          instanceClass=$INSTANCECLASS,\
          masterPassword=$MASTERPASSWORD
CloudFormation will make the following changes:
Stack dev-stack:
  > AWS::CloudFormation::Stack Acm
  > AWS::CloudFormation::Stack Alb
  > AWS::CloudFormation::Stack Ec2
  > AWS::CloudFormation::Stack Iam
  > AWS::CloudFormation::Stack Rds
  > AWS::CloudFormation::Stack SecurityGroup
  > AWS::CloudFormation::Stack Vpc
  > AWS::CloudFormation::Stack route53
Do you wish to continue? (Y/n)

なので集約パターンでもやった更新対象の子スタックのテンプレートとローカルテンプレートを rain diff で地道に確認する方法しか思いつかず。。何かいい方法が見つかればブログにします。

# デプロイ済みスタックからテンプレートを出力。ファイルに書き出し
rain cat dev-stack-Iam-3EZQNF9G4NY6 > ./tmp-iam.yml

# テンプレートを比較
% rain diff tmp-dev-stack.yml sample.yml
(|) Resources:
(|)   clusterAurora:
(|)     Properties:
(>)       DeletionProtection: true

ちなみに、 !Rain::S3Http ./template/securitygroup.yml はスタックとの差分がなければテンプレートはアップロードされません。(更新対象の子スタックだけ出力してくれると嬉しいな)

まとめ

rainを使ったテンプレートの管理についてまとめました。集約パターンは、AWSリソースが少ないまたは運用でカバーできる行数(1000行まで)など上限を決める、分割パターンは、中規模〜大規模システムで権限分離を目的とする場合など、運用体制を鑑みて決めるのが良いと思います。