TECHSTEP

ITインフラ関連の記事を公開してます。

Amazon EKSをAWS CloudFormationで作成する

今回はAmazon EKSをAWS CloudFormationで作成した例を紹介します。Web上で少し調べてもEKSをCloudFormationで作成する例があまり見当たらなかったので、今回作成してみました。

検証

作成は以下の流れで実施しました。

  • eksctlからクラスターを作成
  • CloudFormationスタックの定義をコピー、修正
  • 動作確認

eksctlAmazon EKSの作成、管理を実現するツールですが、実際にクラスターなどのリソースを管理するのはCloudFormationです。なのでeksctlが生成したテンプレートをほぼそのまま使っています。ただしネットワークリソースなどは既存のものを使う場合もあるかと思ったのでファイルを分けています。

なお、必要に応じてeksctlのアップデートを実施します。

# eksctlのアップデート
$ ARCH=amd64
$ PLATFORM=$(uname -s)_$ARCH
$ curl -sLO "https://github.com/eksctl-io/eksctl/releases/latest/download/eksctl_$PLATFORM.tar.gz"
$ tar -xzf eksctl_$PLATFORM.tar.gz -C /tmp && rm eksctl_$PLATFORM.tar.gz
$ sudo mv /tmp/eksctl /usr/local/bin
$ eksctl version
0.165.0

# eksctlからクラスターを作成
$ eksctl create cluster

(割愛)

$ eksctl get cluster
NAME                            REGION          EKSCTL CREATED
unique-mushroom-1702593766      ap-northeast-1  True

eksctlで作成されたスタックをベースに作成したテンプレートが以下になります。ここでは3つのファイルを作成しましたが、必要に応じて AWS::CloudFormation::Stack なども使って楽できると思います。

eks-nw.yaml

AWSTemplateFormatVersion: 2010-09-09
Description: 'Network resources for EKS cluster'
Resources:
  # VPC
  VPC:
    Type: 'AWS::EC2::VPC'
    Properties:
      CidrBlock: 192.168.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/VPC'
  # Subnet
  SubnetPrivateAPNORTHEAST1A:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: 192.168.96.0/19
      Tags:
        - Key: kubernetes.io/role/internal-elb
          Value: '1'
        - Key: Name
          Value: !Sub '${AWS::StackName}/SubnetPrivateAPNORTHEAST1A'
      VpcId: !Ref VPC
  SubnetPrivateAPNORTHEAST1C:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: 192.168.160.0/19
      Tags:
        - Key: kubernetes.io/role/internal-elb
          Value: '1'
        - Key: Name
          Value: !Sub '${AWS::StackName}/SubnetPrivateAPNORTHEAST1C'
      VpcId: !Ref VPC
  SubnetPublicAPNORTHEAST1A:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: 192.168.0.0/19
      MapPublicIpOnLaunch: true
      Tags:
        - Key: kubernetes.io/role/elb
          Value: '1'
        - Key: Name
          Value: !Sub '${AWS::StackName}/SubnetPublicAPNORTHEAST1A'
      VpcId: !Ref VPC
  SubnetPublicAPNORTHEAST1C:
    Type: 'AWS::EC2::Subnet'
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: 192.168.64.0/19
      MapPublicIpOnLaunch: true
      Tags:
        - Key: kubernetes.io/role/elb
          Value: '1'
        - Key: Name
          Value: !Sub '${AWS::StackName}/SubnetPublicAPNORTHEAST1C'
      VpcId: !Ref VPC
  PrivateRouteTableAPNORTHEAST1A:
    Type: 'AWS::EC2::RouteTable'
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/PrivateRouteTableAPNORTHEAST1A'
      VpcId: !Ref VPC
  PrivateRouteTableAPNORTHEAST1C:
    Type: 'AWS::EC2::RouteTable'
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/PrivateRouteTableAPNORTHEAST1C'
      VpcId: !Ref VPC
  PublicRouteTable:
    Type: 'AWS::EC2::RouteTable'
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/PublicRouteTable'
      VpcId: !Ref VPC
  # Route
  PublicSubnetRoute:
    Type: 'AWS::EC2::Route'
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
      RouteTableId: !Ref PublicRouteTable
    DependsOn:
      - VPCGatewayAttachment
  NATPrivateSubnetRouteAPNORTHEAST1A:
    Type: 'AWS::EC2::Route'
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NATGateway
      RouteTableId: !Ref PrivateRouteTableAPNORTHEAST1A
  NATPrivateSubnetRouteAPNORTHEAST1C:
    Type: 'AWS::EC2::Route'
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NATGateway
      RouteTableId: !Ref PrivateRouteTableAPNORTHEAST1C
  # Route Table Association
  RouteTableAssociationPrivateAPNORTHEAST1A:
    Type: 'AWS::EC2::SubnetRouteTableAssociation'
    Properties:
      RouteTableId: !Ref PrivateRouteTableAPNORTHEAST1A
      SubnetId: !Ref SubnetPrivateAPNORTHEAST1A
  RouteTableAssociationPrivateAPNORTHEAST1C:
    Type: 'AWS::EC2::SubnetRouteTableAssociation'
    Properties:
      RouteTableId: !Ref PrivateRouteTableAPNORTHEAST1C
      SubnetId: !Ref SubnetPrivateAPNORTHEAST1C
  RouteTableAssociationPublicAPNORTHEAST1A:
    Type: 'AWS::EC2::SubnetRouteTableAssociation'
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref SubnetPublicAPNORTHEAST1A
  RouteTableAssociationPublicAPNORTHEAST1C:
    Type: 'AWS::EC2::SubnetRouteTableAssociation'
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref SubnetPublicAPNORTHEAST1C
  # InternetGateway
  InternetGateway:
    Type: 'AWS::EC2::InternetGateway'
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/InternetGateway'
  VPCGatewayAttachment:
    Type: 'AWS::EC2::VPCGatewayAttachment'
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC
  # NAT Gateway
  NATGateway:
    Type: 'AWS::EC2::NatGateway'
    Properties:
      AllocationId: !GetAtt 
        - NATIP
        - AllocationId
      SubnetId: !Ref SubnetPublicAPNORTHEAST1A
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/NATGateway'
  NATIP:
    Type: 'AWS::EC2::EIP'
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/NATIP'
  # Security Group
  ClusterSharedNodeSecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: Communication between all nodes in the cluster
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/ClusterSharedNodeSecurityGroup'
      VpcId: !Ref VPC
  ControlPlaneSecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: Communication between the control plane and worker nodegroups
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/ControlPlaneSecurityGroup'
      VpcId: !Ref VPC
  IngressDefaultClusterToNodeSG:
    Type: 'AWS::EC2::SecurityGroupIngress'
    Properties:
      Description: Allow managed and unmanaged nodes to communicate with each other (all ports)
      FromPort: 0
      GroupId: !Ref ClusterSharedNodeSecurityGroup
      IpProtocol: '-1'
      SourceSecurityGroupId: !Ref ControlPlaneSecurityGroup
      ToPort: 65535
  IngressNodeToNode:
    Type: 'AWS::EC2::SecurityGroupIngress'
    Properties:
      Description: Allow unmanaged nodes to communicate with control plane (all ports)
      FromPort: 0
      GroupId: !Ref ControlPlaneSecurityGroup
      IpProtocol: '-1'
      SourceSecurityGroupId: !Ref ControlPlaneSecurityGroup
      ToPort: 65535
  IngressInterNodeGroupSG:
    Type: 'AWS::EC2::SecurityGroupIngress'
    Properties:
      Description: Allow nodes to communicate with each other (all ports)
      FromPort: 0
      GroupId: !Ref ClusterSharedNodeSecurityGroup
      IpProtocol: '-1'
      SourceSecurityGroupId: !Ref ClusterSharedNodeSecurityGroup
      ToPort: 65535
  IngressNodeToDefaultClusterSG:
    Type: 'AWS::EC2::SecurityGroupIngress'
    Properties:
      Description: Allow unmanaged nodes to communicate with control plane (all ports)
      FromPort: 0
      GroupId: !Ref ControlPlaneSecurityGroup
      IpProtocol: '-1'
      SourceSecurityGroupId: !Ref ClusterSharedNodeSecurityGroup
      ToPort: 65535

Outputs:
  ControlPlaneSecurityGroup:
    Value: !Ref ControlPlaneSecurityGroup
    Export:
      Name: ControlPlaneSecurityGroup
  SubnetPrivateAPNORTHEAST1A:
    Value: !Ref SubnetPrivateAPNORTHEAST1A
    Export:
      Name: SubnetPrivateAPNORTHEAST1A
  SubnetPrivateAPNORTHEAST1C:
    Value: !Ref SubnetPrivateAPNORTHEAST1C
    Export:
      Name: SubnetPrivateAPNORTHEAST1C
  SubnetPublicAPNORTHEAST1A:
    Value: !Ref SubnetPublicAPNORTHEAST1A
    Export:
      Name: SubnetPublicAPNORTHEAST1A
  SubnetPublicAPNORTHEAST1C:
    Value: !Ref SubnetPublicAPNORTHEAST1C
    Export:
      Name: SubnetPublicAPNORTHEAST1C

eks-cluster.yaml

AWSTemplateFormatVersion: 2010-09-09
Description: 'EKS cluster'
Parameters:
  ClusterName:
    Type: String
  
Resources:
  ControlPlane:
    Type: 'AWS::EKS::Cluster'
    Properties:
      KubernetesNetworkConfig:
        IpFamily: ipv4
      Name: !Ref ClusterName
      ResourcesVpcConfig:
        EndpointPrivateAccess: false
        EndpointPublicAccess: true
        SecurityGroupIds:
          - !ImportValue ControlPlaneSecurityGroup
        SubnetIds:
          - !ImportValue SubnetPublicAPNORTHEAST1A
          - !ImportValue SubnetPublicAPNORTHEAST1C
          - !ImportValue SubnetPrivateAPNORTHEAST1C
          - !ImportValue SubnetPrivateAPNORTHEAST1A
      RoleArn: !GetAtt 
        - ServiceRole
        - Arn
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/ControlPlane'
      Version: '1.27'
  # IAM
  PolicyCloudWatchMetrics:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - 'cloudwatch:PutMetricData'
            Effect: Allow
            Resource: '*'
        Version: 2012-10-17
      PolicyName: !Sub '${AWS::StackName}-PolicyCloudWatchMetrics'
      Roles:
        - !Ref ServiceRole
  PolicyELBPermissions:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - 'ec2:DescribeAccountAttributes'
              - 'ec2:DescribeAddresses'
              - 'ec2:DescribeInternetGateways'
            Effect: Allow
            Resource: '*'
        Version: 2012-10-17
      PolicyName: !Sub '${AWS::StackName}-PolicyELBPermissions'
      Roles:
        - !Ref ServiceRole
  ServiceRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action:
              - 'sts:AssumeRole'
            Effect: Allow
            Principal:
              Service:
                - eks.amazonaws.com
        Version: 2012-10-17
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonEKSClusterPolicy'
        - 'arn:aws:iam::aws:policy/AmazonEKSVPCResourceController'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/ServiceRole'

Outputs:
  ClusterName:
    Value: !Ref ClusterName
    Export: 
      Name: ClusterName

eks-ng.yaml

AWSTemplateFormatVersion: 2010-09-09
Description: 'EKS Managed Nodes'
Parameters:
  LaunchTemplateName:
    Type: String
  NodegroupName:
    Type: String
Resources:
  LaunchTemplate:
    Type: 'AWS::EC2::LaunchTemplate'
    Properties:
      LaunchTemplateData:
        BlockDeviceMappings:
          - DeviceName: /dev/xvda
            Ebs:
              Iops: 3000
              Throughput: 125
              VolumeSize: 80
              VolumeType: gp3
        MetadataOptions:
          HttpPutResponseHopLimit: 2
          HttpTokens: required
        SecurityGroupIds:
          - !ImportValue ControlPlaneSecurityGroup
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: Name
                Value: !Ref LaunchTemplateName
              - Key: alpha.eksctl.io/nodegroup-name
                Value: !Ref NodegroupName
              - Key: alpha.eksctl.io/nodegroup-type
                Value: managed
          - ResourceType: volume
            Tags:
              - Key: Name
                Value: !Ref LaunchTemplateName
              - Key: alpha.eksctl.io/nodegroup-name
                Value: !Ref NodegroupName
              - Key: alpha.eksctl.io/nodegroup-type
                Value: managed
          - ResourceType: network-interface
            Tags:
              - Key: Name
                Value: !Ref LaunchTemplateName
              - Key: alpha.eksctl.io/nodegroup-name
                Value: !Ref NodegroupName
              - Key: alpha.eksctl.io/nodegroup-type
                Value: managed
      LaunchTemplateName: !Ref LaunchTemplateName
  ManagedNodeGroup:
    Type: 'AWS::EKS::Nodegroup'
    Properties:
      AmiType: AL2_x86_64
      ClusterName: !ImportValue ClusterName
      InstanceTypes:
        - t3.large
      Labels:
        alpha.eksctl.io/cluster-name: !ImportValue ClusterName
        alpha.eksctl.io/nodegroup-name: !Ref NodegroupName
      LaunchTemplate:
        Id: !Ref LaunchTemplate
      NodeRole: !GetAtt 
        - NodeInstanceRole
        - Arn
      NodegroupName: !Ref NodegroupName
      ScalingConfig:
        DesiredSize: 2
        MaxSize: 5
        MinSize: 2
      Subnets:
        - !ImportValue SubnetPrivateAPNORTHEAST1C
        - !ImportValue SubnetPrivateAPNORTHEAST1A
      Tags:
        alpha.eksctl.io/nodegroup-name: !Ref NodegroupName
        alpha.eksctl.io/nodegroup-type: managed
  NodeInstanceRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action:
              - 'sts:AssumeRole'
            Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
        Version: 2012-10-17
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly'
        - 'arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy'
        - 'arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy'
        - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore'
      Path: /
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}/NodeInstanceRole'

上記ファイルを eks-nw.yaml eks-cluster.yaml eks-ng.yaml の順に利用しリソースを作成します。なおCloudFormationスタックの作成はマネジメントコンソールからでも問題ないですが、この後Amazon EKSにアクセスするため、作成時のIAMユーザーの権限などは事前に確認が必要です。

$ aws cloudformation deploy --template-file eks-nw.yaml --stack-name eks-nw --region ap-northeast-1 --profile <profile名>

$ aws cloudformation deploy --template-file eks-cluster.yaml --stack-name eks-cluster --parameter-overrides ClusterName=eks-cluster --capabilities CAPABILITY_IAM  --region ap-northeast-1 --profile <profile名>

$ aws cloudformation deploy --template-file eks-ng.yaml --stack-name eks-ng --parameter-overrides LaunchTemplateName=eks-lt NodegroupName=eks-ng --capabilities CAPABILITY_IAM  --region ap-northeast-1 --profile <profile名>

作成が完了したら、kubeconfigを更新します。

$ aws eks update-kubeconfig --name eks-cluster --profile <profile名> --region ap-northeast-1

あとはEKSを操作できることを確認して終了です。

$ kubectl run nginx --image=nginx:latest
pod/nginx created

$ kubectl get pods -w
NAME    READY   STATUS              RESTARTS   AGE
nginx   0/1     ContainerCreating   0          5s
nginx   1/1     Running             0          8s

^C

$ kubectl get pods -A
NAMESPACE     NAME                       READY   STATUS    RESTARTS        AGE
default       nginx                      1/1     Running   0               17s
kube-system   aws-node-8lmnn             1/1     Running   0               2m25s
kube-system   aws-node-smwhm             1/1     Running   8 (6m32s ago)   21m
kube-system   coredns-8496bbc677-n9gdh   1/1     Running   0               2m25s
kube-system   coredns-8496bbc677-pvxvb   1/1     Running   0               27m
kube-system   kube-proxy-9h4rs           1/1     Running   0               21m
kube-system   kube-proxy-z7wp9           1/1     Running   0               21m

さいごに

EKSの作成はいくつか方法がありますが、人気があるのは eksctl や Terraform あたりでしょうか。

Googleトレンドで雑に調べるとこんな感じ

EKSをCloudFormationで管理するユースケースはそれほどないのかもしれませんが、AWSリソースの管理をCloudFormationに寄せたいプロジェクトの場合は役に立つかもしれません。

「詳解Terraform」の感想と読書メモ

今回は 詳解 Terraform 第3版 を一通り読んだので、その感想を簡単に残しておこうと思います。

TerraformはIaCツールの中でもかなり市民権を得ていると感じるツールです。にもかかわらず、ここ最近個人的にTerraformを使う機会もなく、情報を追いかけられていませんでした。なので復習の意味も込みで購入しました。

www.oreilly.co.jp

読書中のメモも併せて載せていますが、読んでて「これは助かるなあ」と思ったポイントを抜粋しておきます。

Terraformの前段階から説明してくれて助かる

Terraformを学習する前段階として、「そもそもIaCとは何か?何のために使うのか?」「どんな種類があるのか?」「何を使うべきか?」といった情報を知りたい方に役立つ本だと感じました。

本書の1章は なぜTerraformを使うか という題名がついており、IaCツールのカテゴリ、各ツールの特徴、どういった場面で何を利用すべきか、といった情報が紹介されています。なので、IaCに親しくない人も、久しぶりにTerraformを学ぶ人も、IaCツールの全体像やTerraformの立ち位置がどこか、といった点を知れたのは、とても役に立つと感じました。

Terraformを扱う上で気になるポイントが説明してあって助かる

Terraformを扱う上で注意するポイントとしては、例えばtfstateやモジュール、パスワードなどの秘匿情報の扱いなど、いくつかあると思います。なので、本格的にTerraformを扱う前に注意するポイントを知れるのはありがたく、導入後の試行錯誤の手間も少なくできるのでは、と思いました。Terraformが成熟してきたこともあり、「こういう時はこうする」というBest Practiceとも言えそうなパターンができていると思うのですが、そういった情報をここで確認できると感じました。

また、こういったよくある注意ポイントは、個別にはブログなどで紹介されていますが、1冊の書籍の中でまとめて載せてあるものを(ほかの書籍にもあるのかもしれませんが)私は把握しておらず、とりあえずこれ1冊でポイントを押さえられるのは、情報収集の点でもありがたいと感じました。

実践向けにやるべきことを整理しているのが助かる

これは後半の8~10章が該当するのですが、Terraformを自分のプロジェクトで利用するときに何を気にするべきかまとまっているのはありがたいと感じました。

例えば8章の 本番レベルのTerraformコード では、実際のプロジェクトで利用する場合にどのようにモジュールを作成すべきか、9章では作成したTerraformをどのようにテストするか、そして10章ではチームでTerraformを利用するため、CI/CDを利用したワークフローの整備など、本番向けにTerraformを扱う上で重要なポイントがまとめられています。なので、これから導入を検討している人、すでに導入しているがもっと改善したい人など、幅広い方に役立つ内容ではないかと思います。

なお、8章には「本番レベルのインフラを構築するのは時間がかかる」という観点から話が始まっており、なぜ時間がかかるのか?設計時にどういった観点が必要か?という情報も紹介されています。こちらはTerraformに限らず役立つ内容かと思うので、Terraformを使う予定のない方も、ここだけは眺めてみてもよいかもしれません。

読書時のメモ

以下に読んだ時のメモを残しています。長文なので展開注意です。

1章 なぜTerraformを使うか

IaCツールは以下の5種類のカテゴリが存在する。

  • アドホックスクリプトBash / Runby等の言語で主導実行するタスクを定義する。汎用的なプログラミング言語で自由にコードを書ける一方、自由に書けることで複数人でメンテナンスすることは困難となりやすい。小規模な1回限りのタスクには最適。
  • 設定管理ツール:Chef / Ansibleなどのツールをサーバにインストールし、管理する。アドホックにはない特徴として、コード規約の導入、冪等性の担保、配布のしやすさなどが挙げられる。
  • サーバテンプレーティングツール:Docker / Packerなどのツールは、サーバのスナップショットをあらかじめ定義し、定義通りのサーバを提供する。イミュータブルインフラ(サーバの定義コードを変更すると新しいサーバを作成する)へのシフトの鍵になる。
  • オーケストレーションツール:Kubernetesなどのツールは、サーバテンプレーティングツールによって作成されたサーバ・コンテナを管理する。
  • プロビジョニングツール:Terraformなどのツールは、これまでの「各サーバで動くコードを定義する」のとは異なり、「サーバ自身を作成する」役割を持つ。

IaCは、コードを使うことで得られる多くの取り組みを与え、それがソフトウェアのデリバリプロセスを劇的に改善する。多くの取り組みとは、セルフサービス・スピードと安全性・ドキュメント化・バージョン管理・バリデーション・再利用性などがある。

IaCのどのツールを選択するかは、トレードオフと優先順位から決定される技術的判断である。ミュータブルかイミュータブルか、手続き型言語か宣言型言語か、有償か無償か、といった複数のトレードオフを考慮し、最適なツールを選択すべきである。また現実的には複数のツールを組み合わせて使うことになるだろう。代表的なパターンは、プロビジョニング+設定管理、プロビジョニング+サーバテンプレーティング、プロビジョニング+サーバテンプレーティング+オーケストレーション、の3つである。

このうちTerraformの特徴とは、以下のようなものである。

  • オープンソースであるがオプションとして有償サービスも利用できる
  • 複数のクラウドに対応している
  • イミュータブル
  • 宣言型言語
  • ドメイン特化の言語
  • マスタ、エージェントは不要
  • 巨大なコミュニティ
  • 成熟度は他と比べて中程度

3章 Terraformステートを管理する

Terraformを実行するたび、管理するインフラの情報をTerraform stateファイル (デフォルトは terraform.tfstate ) に記録する。tfstateにはTerraformのファイルに書かれたリソースと実際のリソースとのマッピング情報を記録する。

tfstateファイルはTerraformが内部で使う、プライベートAPIである。そのためtfstateファイルを手動で修正したりするべきではない。どうしても必要な場合は terraform import / terraform state コマンドを使う。

複数人でTerraformを扱う場合、tfstateに関連する以下のような問題が発生する

  • tfstateの共有ストレージ:Terraformを扱うチームメンバー全員がアクセスできる共有ストレージにtfstateファイルを配置しなければならない。
  • tfstateのロック:2人以上のメンバーが同時に更新するのに備え、ロックの仕組みが必要である。
  • tfstateの分離:例えば環境ごとにtfstateを分けることで、テスト環境と誤って本番環境を変更することのないようにしたい。

リモートバックエンドの利用

通常、複数メンバーでファイルを共有するにはバージョン管理システムで管理するが、Terraformの場合はいくつかの点からそれを推奨しない。そうではなくTerraformの持つリモートバックエンド機能を使い、Amazon S3 / Azure Blob Storage / Google Cloud Storageなどで管理することが推奨される。

例えばAWSの場合、Amazon S3でtfstateを管理し、ロックのためにDynamoDBを利用できる。

バックエンドの制限は主に2つある。

  • Terraformを管理するリソースをTerraformで作成する場合の作業順:最初のステップで必要なリソースを作成し、tfstateはローカルに保存する。次のステップでTerraformバックエンドの設定を変更し、ローカルからリモートにtfstateファイルをコピーする。削除する場合は逆の手順で行う。
  • backendブロックが変数や参照を使用できない:すべてのTerraformコードで似たような記述を書かなければならず、しかもkeyの値はそれぞれ一意でなければならない。これを避けるため、backend.hclのようなhclファイルに共通設定を記載し、-backend-config引数でファイル名を渡すことができる。またTerragruntを使うと、複数のTerraformのバックエンドの設定を1つのファイルで定義できる。

ステートファイルの分離

ステートファイルの分離は2通りある。

ワークスペース

terraform workspaceコマンドを利用すると、ワークスペースと呼ばれる空間を分離し、それぞれ異なるtfstateファイルを扱えるようになる。ただしこちらは以下の理由から、環境を分離するのには適切でない。

  • ワークスペースのtfstateは同じバックエンドに保存するため、全環境に対し同じアクセス権限を適用することになる。
  • terraform workspaceコマンドを実行しないとどのワークスペースにいるか確認できない
  • 上記2点から、ワークスペースの利用は操作ミスを引き起こす可能性が高まる。
ファイルレイアウト

tfstateを完全に分離するには、環境ごとにフォルダを分け、かつバックエンドも別々にする必要がある。また環境以外にも、例えばリソース間のライフサイクルの違いなどから、コンポーネントレベルで分けたい場合もあるだろう。Terraformの一般的なフォルダ構成が紹介されている。

このファイルレイアウトにより、コードと環境のレイアウトが明確になり、分離も程よいものとなる。一方でこの構成による欠点(1回のコマンドですべてのリソースをを更新できない、コピペが多くなる、依存関係の扱いが難しくなる)もあるが、Terragruntやterraform_remote_stateなどの利用で改善できる。

terraform_remote_stateは、そのリソースを管理するのと別のtfstateファイルを呼び出すことができる。そのため、tfstateに書かれたoutputsの設定を呼び出し、変数として利用できる。

IaCファイルに問題があったときの影響範囲はアプリと比べて大きくなる傾向にあるので、通常より多くの安全装置を設定することが推奨される。ロック、分離、ステートはその代表である。

4章 モジュールで再利用可能なインフラを作る

同じ構成を複数の環境で利用する場合、Terraformモジュールを利用することで、同じコードをコピペする必要がなくなる。

モジュールは入力変数を設定できるが、設定可能な入力として公開したくない場合はlocalsブロックを利用できる。localsブロックはそのモジュール内でしか参照できず、またモジュールの外から上書きすることもできない。

モジュールの注意点

  • ファイルパス:Terraformはデフォルトの場合、モジュール内部で相対パスを使用する場合、現在のディレクトリに対する相対パスと判断する。この仕様の都合が悪い場合は、path.というパス参照を利用する。例えばpath.moduleを使用すると、定義があるモジュールが存在するファイルシステムパスを返す。
  • インラインブロック:インラインブロックは、リソースのフォーマットの中で引数として設定するものである。例えばAWS Security Groupリソースはインラインブロックでingress / egressのセキュリティグループルールを設定できる。しかし、インラインブロックはモジュールの中にしか記述できず、モジュールを使用するユーザーはモジュールの外からインラインブロック内のリソースを追加することができない。そのため、モジュールの柔軟性を持たせるには、モジュールを使うときは常に別のリソースで定義する(インラインブロックを使わない)ことが推奨される。
  • モジュールのバージョン管理:異なる環境で同じモジュールを参照すると、モジュールに対する変更が次回のデプロイ時にすべて反映される。これを避けるには、各環境から参照するモジュールのバージョンを管理する。最も簡単なのはモジュールをGitで管理し、タグ等でバージョンを管理することである。Terraformはモジュールを参照するのにGit URLも利用できるので、URLの中でタグやrefを使用し、モジュールのバージョンを指定できる。

5章 Terraformを使うときのヒントとコツ

ループ

宣言型言語には一般的にfor文がない。Terraformにはいくつかの異なるループの仕組みがある

  • countパラメータ:リソースに対してループする。同じ種類の異なる名前のリソースを複数作成する場合、配列の検索文法やlength関数と組み合わせることで実現する。ただしcountはインラインブロック内では使用できない。またリストを扱った場合、リストの途中の値を変更すると、以降の要素をすべて削除して新しいリソースを作成しようと動く。
  • for_each式:リソースやリソースのインラインブロックに対してループする。for_eachでループの対象で指定するのはマップや集合であり、集合を扱うことでcountにあった課題を解決できる。
  • for式:リストやマップに対してループする。Pythonのリスト内包表記と似たような式で表現できる。
  • for文字列命令:文字列内のリストやマップに対してループする。

条件分岐

  • countパラメータ:条件付きリソースに使う。例えば ? <TRUE_VAL> : <FALSE_VAL> というフォーマットの条件式とcountを組み合わせ、ConditionがTrueならリソースを1つ作成する、と表現できる。
  • for_eachとfor式:条件付きのリソース、あるいはリソース内のインラインブロックに使う。countより複雑になるのでcountが推奨。
  • if文字列ディレクティブ:文字列内の分岐に使う。

ゼロダウンタイムデプロイ

ゼロダウンタイムを実現するには、先に変更後のリソースを作成し、その後に変更前のリソースを削除するよう設定する。TerraformのLifecycleパラメータで create_before_destroyをtrueにするとこの動きを実現できる。

つまづきポイント

  • countとfor_eachの制限事項:リソース作成の前にcountとfor_eachを計算できる必要がある。そのためハードコードされた値などは参照できるが、何かの計算の結果を参照することはできない。
  • ゼロダウンタイムの制限事項:すでに多くのクラウドサービスではネイティブにゼロタイムダウンタイムをサポートしており、可能ならそちらを利用すべきである。そうすることでTerraformのコード側で複雑性を持たせることなく、クラウドサービス側で担保できる。
  • 有効なプランも失敗することがある:tarreform planはtfstateファイルだけを見るため、Terraformの管理外の事象は検知できない。そのため、①Terraformを使い始めたらTerraformだけを使う、②既存インフラがあるならimportコマンドを使う、の2点が重要となる。
  • リファクタリングは難しい:変数や名前を変えるだけでリソースの削除・作成が発生することがあり、システム停止につながることがある。そのため①常にplanを実行する、②削除する前に作成する、③イミュータブルなパラメータもある、という点を気にするべきである。またリファクタリングにはステートの変更が必要な場合もある。その際はtfstateを手で修正するのでなく、terraform state mvやmovedブロックの追加で対応する。

※リソースのタグを組織内でルールづけている場合、provider aws内でdefault_tagsを設定すると、モジュール内のあらゆるAWSリソースに指定のタグを付与できる。

6章 シークレットを管理する

シークレット管理は「プレーンテキストで保存しない」ことを絶対に守る。プレーンテキストに保存してバージョン管理システムにコミットすると、以下のような問題がある。

  • VCSにアクセス権があればだれでもシークレット情報にアクセスできる
  • VCSにアクセス権のあるあらゆるコンピュータがシークレットをコピーして保存できる
  • 実行するあらゆるソフトウェアがシークレットにアクセスできる
  • シークレットへのアクセスを監査したり権限をはく奪する方法がない

そのためHashiCorp Vault / AWS Secrets Managerといったシークレット管理ツールと組み合わせることが必要となる。

プロバイダへの認証

人が認証情報を使う場合、例えばローカルの環境変数にシークレット情報を格納し、terraform実行時にその情報を渡すことができる。この時にシークレット管理ツールにシークレット情報を保存し、CLIからそれを呼び出すことでより安全に運用できる。

またマシン上で認証情報を使う場合、例えばCI/CDの実行基盤として使用する場合、利用するソフトウェア自体が提供するシークレット管理の仕組みを利用する、クラウドプロバイダ側が提供するIAMロールなどの仕組みを利用する、またはOIDCを使用することが考えられる。

リソースとデータソース:例えばDBへのアクセス情報

プロバイダへの認証と同様、環境変数を使うことが考えられる。ただしTerraform外で管理するため、管理の標準化が難しく、また設定漏れの可能性もある。

次にシークレット情報を暗号化し、暗号文をファイルに保存してバージョン管理システムで管理することも考えられる。暗号化キーの管理にはKMSやPGPキーを使うことで対応できる。ただし、一度プレーンテキストから暗号化したファイルを用意するという手間があり、誤ってプレーンテキストをバージョン管理システムにアップロードしないよう注意が必要となる。

最後にシークレットストアを利用する方法が考えられる。シークレットをシークレットストアに管理しTerraformから呼び出すことで、シークレット自体の管理を楽にする。ただしシークレットの更新漏れの可能性、金銭的なコストの発生、外部に管理することでの自動テストとの統合の難しさ、などが課題となる。

tfstate、planファイル

tfstateファイルにはシークレット情報もプレーンテキストとして保存される。現状では以下の対策が必要となる。

  • 暗号化をサポートするバックエンドにtfstateを保存する
  • Terraformバックエンドへのアクセス権を厳しくする

terraform planの実行結果をファイルに保存できるが、こちらにもシークレット情報がプレーンテキストで保存される。そのため、このファイルを暗号化する、アクセス権限を厳しくする、などの対応が必要である。

7章 複数のプロバイダを使う

1つのプロバイダを利用する場合

複数のリージョンを使う場合、各プロバイダにエイリアスを付けることで区別できる。

provider “aws” {
  region = “us-east-2”
  alias  = “region_1”
}

provider “aws” {
  region = “us-west-1”
  alias  = “region_2”
}

これを使って別のリージョンにデプロイする場合は以下のようになる

resource “aws_instance” “region_1” {
  provider = aws.region_1
  ami = …
  instance_type = …
}

resource “aws_instance” “region_2” {
  provider = aws.region_2
  …
}

ただし、エイリアスを使って複数リージョンに同じTerraformモジュールで管理すると、あるリージョンがダウンするとterraform plan/applyが失敗し、障害発生中のコード変更ができなくなる。またエイリアスを間違えて実行する可能性も高くなる。そのため、本来は各リージョンを別々のモジュールで管理すべきである。

複数リージョンを扱うエイリアスを使うべき場面としては、複数のプロバイダにまたがるインフラが完全に結合しており、一括でデプロイしたい場合がある。ただし一般的にエイリアスを使うことが多いのは、違うAWSアカウントに対して別々に認証するなど、異なる方法で認証を行う複数のプロバイダを使うときである。

1つのプロバイダで複数アカウントを扱うとき

クラウドプロバイダー上にシステムを作る場合、複数のアカウントに別々の環境を用意することが推奨される。例えばAWS Organizationsを使って複数アカウントを管理し、Terraformのproviderでどのアカウントを使うか指定し、構築・運用することができる。

provider “aws” {
  region = “us-east-2”
  alias = “parent”
}
provider “aws” {
  region = “us-east-2”
  alias = “child”
  assume_role {
    role_arn = “arn:aws:iam::<AWS Account ID>:role/<role name>}
}

ただし、通常複数アカウントを利用するのは、アカウントを分離して影響範囲を限定するためである。複数アカウントにまたがってデプロイを実行するモジュールを利用するのは、この方針に逆らっているため、何らかの理由で複数アカウントにまたがるリソースを結合させ、同時にデプロイしたいときだけ利用する。

複数プロバイダを扱う

複数のクラウドプロバイダを同じモジュール内で扱うのは稀であり、本来は別々のモジュールで管理すべきである。また、例えばAWSKubernetesという2つのモジュールを同時に管理する、という例も考えられる。ただしこれも一般的には別々のモジュールで管理することが推奨されるため、使いどころは考えなければならない。

8章 本番レベルのTerraformコード

本番レベルのインフラを構築するのは時間がかかる。それには3つの理由がある。

  • DevOpsという分野がまだまだ成熟しておらず急速に進化しているため。ツールが十分に成熟しきっていないためうまくいかない点もあれば、それを使う人間も深い経験を持っていないことが多い。
  • あるタスクを実行するために別の大量のタスクを行う必要がある (Yak shaving)。DevOpsは特にYak shavingに陥りやすい傾向にある。
  • そもそも本番用のインフラを準備するための準備リストが膨大である。このリストの項目を知らずに見積もりをすると、想定より大幅に時間がかかってしまう。

本番レベルのモジュールは、いくつかのポイントがある。

  • 小さくて組み合わせ可能なモジュールにする
  • テスト可能なものにする。各モジュールにはexampleフォルダを用意し、モジュールをデプロイするためのテスト用ルートモジュールを用意する。
  • バージョン管理する。各Terraformリソースのバージョンを固定することで、作成後も繰り返し再現可能なコードを提供できる。
  • testフォルダを用意して各サンプルコードに自動テストを書く。

9章 Terrraformのコードをテストする

手動テスト

手動テストを実行するには、Terraformで定義したリソースを実際に作成し、作成したリソースに対して何等かのテストを実行するしかない。そのため、開発者が自由に操作・作成できるサンドボックス環境の用意が推奨される。ただしサンドボックス環境を管理しないとテスト用のリソースが残って余計なコストが発生するので、定期的に掃除が必要となる。

自動テスト

ユニットテスト

Terraformにおけるユニットテストとは、用意したコードを使って実際の環境にデプロイすることを指す。具体的には、1つの独立したモジュールに対してデプロイ用のサンプルコードを作成し、実際の環境にデプロイしたあとで動作確認を行う。このような、手動テストで行うようなことをコードで実現することが自動テストになる。

Terraformの場合はTerratestというGo言語のパッケージを利用すると、terraform init / apply / destroy、作成後のテストなどを比較的簡単に表現できる。

統合テスト

Terraformにおける統合テストは、複数モジュールをデプロイして動作確認をすることが該当する。また統合テストでは、テスト中のステップをステージに分解し、必要に応じて一部ステージをスキップ可能な実装をすることでイテレーションを加速できる。さらにFlaky testに対応するためリトライを実行できるようにすることも推奨される。

E2Eテスト

TerraformにおけるE2Eテストは、本番環境に似せた環境に対してすべてをデプロイし、エンドユーザーの視点でそれをテストすることを指す。ただしすべてのリソースを毎回ゼロから作成しなおすのは速度と安定性の面から推奨されない。

一般的には事前に本番環境に近いテスト環境を構築し、その環境を起動状態にしておく。何かしらの変更がコードに入ったらその変更をテスト環境に適用し、テスト環境に対する動作確認を実行する。この方式だとデプロイプロセスも合わせてチェックできるという利点もある。

その他

静的解析、terraform planの出力に対するテスト、Serverspecなどを使用したサーバテストもある。すべてをカバーする万能な単一のテストは存在せず、すべてのテスト方法を組み合わせることが必要となる。

10章 チームでTerraformを使う

Terraformを扱うときはチームで利用することが多いだろう。まだTerraformを導入していない場合は上司への説得を含めた活動が必要だが、それを乗り越えた後でもまだやるべきことがいくつかある。

デプロイワークフローを整備する

インフラコードに対するデプロイワークフローは、アプリケーションに対するそれと同じような流れとなる。ただしその中身には大きな違いがある

バージョン管理する

Terraformでは、モジュール用のリポジトリ (moduleリポジトリ) とそれを利用した稼働中のリソース管理をするリポジトリ (liveリポジトリ) の2種類を利用する。

またTerraformで管理するうえでは、liveリポジトリのmainブランチが、本番環境にデプロイされたリソースに対し、1:1に対応していなければ、管理できているとは言えない状態である。これを守ることが重要である。

さらにTerraformと複数のブランチを組み合わせることの相性がよくないため、どんな共有環境でもデプロイは常に1つのブランチからのみ行うようにすべきである。

コードをローカルで実行する

Terraformを利用する場合localhostは利用できないので、専用のサンドボックス環境を用意すべきである。

コードに変更を加える

変更を加えるたびにterraform applyを実行し、サンドボックス環境に変更を適用する。インフラは更新完了まで時間がかかりがちなので、テストステージ等を利用してより高速にフィードバックを得るよう工夫すべきである。

変更に対するレビューを依頼する

変更が問題なく動くことを確認したらそれをmainブランチに提供するためレビューを依頼する。レビューをする上で、各種ドキュメントや自動テストの整備などを進めておく必要がある。

自動テストを実行する

変更をコミットするたびにCIサーバで自動テストを実行するよう整備すべきである。

変更をマージしてリリースする
デプロイする

デプロイ環境を用意するには、適切なデプロイツールの選定やデプロイ戦略、環境間の昇格などを考慮する必要がある。

IaCの継続的テストを検証する: Amazon EC2に対するテスト

背景

前回AWS CloudFormationで管理するAmazon S3に対し、CI/CDによる継続的なテストと更新を検証しましたが、今回はAmazon EC2を対象に検証してみました。

Amazon EC2で気を付けた点

まず、Amazon EC2に対してテストを行う前に、以前のAmazon S3と比べて異なる点をいくつか記載しておきます。今回は扱うリソースが異なるのは当然ですが、それによって前回は気にしなくてもよかった部分も少し工夫が必要となります。

OSレイヤーの管理が必要

Amazon EC2AWS上で仮想サーバーを利用できるサービスです。EC2インスタンスは多くの場合インスタンスを作成するだけでなく、インスタンス内部にソフトウェアや実行ファイルなどを配置し、何らかの機能を提供するよう設定します。

そのため、EC2インスタンスを管理するには、AWS CloudFormationだけでは完結できず、別の方法が必要になります。例えば Ansible を使えばNginxのインストールやファイルの配置などの操作を宣言的に定義することが可能です。

リソース内部の状態が正常であることの確認が必要

Amazon EC2を作成すると、インスタンス自体は起動して Running の状態であるにもかかわらず、起動直後は内部プロセスの初期化が完了していないため、インスタンスに接続できないことがあります。これは更新後のテストにも影響するため、インスタンス内部の状態が安定してからテストを行うよう制御したいです。

Amazon EC2DescribeInstanceStatus というAPIを提供しています。このAPIではインスタンスが起動しているかだけでなく、インスタンス内部の状態がどうなっているかも提供します。具体的には instanseStatusインスタンス内部に関する障害を、systemStatusインスタンスをサポートするシステムに関連した障害を、それぞれチェックします。

またこれだけではなく、インスタンス内部で起動するサービス・プロセスが正常に起動しているかも確認が必要となります。

インスタンスへの接続が必要

前項の DescribeInstanceStatus APIなどは、AWS APIを通じてリソースにアクセスすることで情報を取得できます。しかし、EC2の設定変更、特にインスタンスへのファイルの配置やプロセスの再起動などを行う場合、SSHなどのプロトコルを用いて直接インスタンスにアクセスしなければなりません (AWSでは AWS Systems Manager Session ManagerEC2 Instance Connect など別の接続方法も提供されていますが、ここでは割愛します) 。

SSH接続の場合、デフォルトでは22番ポートを開放する必要がありますが、セキュリティの観点から必要以上のポートを開放するのは避けたいところです。そのため、インスタンスにアクセスして設定変更を行う場合だけ、一時的にポートを開放するような処理を追加しました。

テストの観点

続いて今回実施するテストの観点を整理します。ただし前回実施した内容は継続して行うため、今回新しく導入したテストについてのみ触れます。

利用するリソースの大部分をコードで管理する

前回記事の検証では、一連の処理で扱うリソースの大部分をコードで管理するようにしました。Amazon S3の場合はAWS CloudFormationでリソースの定義を完結できますが、上述の通りAmazon EC2はCloudFormationだけでリソースを管理することはできません。

以前紹介した書籍のPractice に従うのであれば、本来は Ansible などOSレイヤーも宣言的に管理できるIaCツールを利用すべきです。ただし今回はEC2で扱うリソース・プロセスを最小限にし、scp コマンドなどを利用してファイルを配置する形としました。理由は、「サーバーに対して継続的テストを行う」ことに集中するため、CloudFormation以外のIaCツールを使わないようにしたかったからです。

こう考えていたのですが結果的にはかなり複雑な処理になってしまいました。。。

オンラインテストを導入し、起動後のサーバーの状態を確認する

前回は構文チェックなど静的解析を中心にしましたが、今回はインスタンス起動後の動的解析も実施しています。

まず、前回実施した cfn-lint trivy による静的解析は、AWSの幅広いリソースに適用可能なため、今回もテストに組み込んでいます。

一方、サーバーに対して継続的テストを行う場合、サーバー上のリソースやプロセスに対して更新を適用した後は、該当のファイルやプロセスなどが正常か、確認する必要があります。今回は、以下の点を確認しています。

  • ファイルの同一性: サーバー上に配置したファイルの内容は、GitHub上のファイルと一致すること。md5sum コマンドで確認。
  • Nginxプロセスの正常性: 更新後にNginxのプロセスが正常に起動していること。 systemctl list-units コマンドで確認。
  • Webページへの疎通性: 更新後にサーバーにアクセスし、Webページがアクセス可能であること。 curl コマンドで確認。

※以前紹介した書籍の中では Verification: Making Assertions About Infrastructure Resources の箇所が該当します。

検証

ここから検証した内容を紹介します。前回に引き続き GitHub Actionsを使ってCI/CDとAWS CloudFormationとの組み合わせになります。

環境

今回の検証環境は以下の通りです。

  • 環境: AWS
  • IaCツール: AWS CloudFormation
  • CI/CDツール: GitHub Actions
  • テストツール: cfn-lint / trivy
  • その他: あらかじめEC2インスタンスNginx のインストールと、テスト用のファイル ( index.html ) を配置したAMIを用意しておきます。

構成は以下の通りです。

処理の流れ

処理の流れは以下の通りです。今回はCloudFormation定義ファイルと index.html という2つのファイルを更新対象としており、それぞれのファイルで更新があれば別の処理を起動するようにしています。また同時に2つのファイルが更新される場合も想定し、追加の処理を行います。

  • (更新対象のCloudFormation Stackはあらかじめ作成しておく)
  • Amazon S3を定義するIaCファイルを更新し、GitHub上でPRを作成する
  • PRの作成をトリガーに、GitHub ActionsがCIを実行する
    • コード解析
    • ドリフトのチェック
    • Change setの作成
  • CIの結果を確認し、問題なければマージする
  • マージをトリガーにGitHub ActionsがCDを実行する
    • CIと同様の処理
    • EC2インスタンスの状態確認
    • CloudFormationファイルを更新した場合
      • Change setの適用
    • index.htmlファイルを更新した場合
      • Securty Groupの設定変更によるSSHポート開放
      • インスタンスへのログインとindex.htmlファイルの配置
      • コピー元とコピー先のファイル比較
      • Nginxプロセスの確認
      • Webページへのアクセス確認
      • Security Groupの設定変更によるポート閉鎖
    • 両方を一度に更新した場合
      • CloudFormationと同じ処理
      • インスタンスの状態確認
      • index.htmlと同じ処理

今回使用したコード、およびGitHubリポジトリ上のディレクトリ構成は以下の通りです。

.
├── .github
│   └── workflows
│       ├── ec2-github-actions-check.yaml
│       └── ec2-github-actions-apply.yaml
├── README.md
├── ec2-github-actions-check
│   ├── ec2.yaml
│   └── src
│       └── index.html
└── iam.yaml

ec2-github-actions-check.yaml

name: EC2 check at Pull Request

on:
  pull_request:
    types: 
      - opened
      - synchronize
    paths:
      - ec2-github-actions-check/**

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE: ${{ secrets.AWS_IAM_ROLE }}
  AWS_STACK_NAME: ec2-github-actions-check

jobs:
  pr:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Testing with CFN Lint Command
        uses: scottbrenner/cfn-lint-action@v2
        with:
          command: cfn-lint -t ./ec2-github-actions-check/*.yaml
      - name: Testing with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          exit-code: '1'
          severity: 'CRITICAL'
      - name: Drift Detection
        run: |
          STACK_DRIFT_DETECT_ID=`aws cloudformation detect-stack-drift --stack-name ${{ env.AWS_STACK_NAME }} --query 'StackDriftDetectionId' --output text`
          sleep 10s
          DRIFT_STATUS=`aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $STACK_DRIFT_DETECT_ID --query 'StackDriftStatus' --output text`
          echo "DRIFT_STATUS=${DRIFT_STATUS}" >> $GITHUB_ENV
      - name: Stop workflow when drift detected
        if: contains(env.DRIFT_STATUS, 'IN_SYNC') == false
        run: |
          aws cloudformation describe-stack-resource-drifts --stack-name ${{ env.AWS_STACK_NAME }}
          exit 1
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v39
        with:
          files: |
            ec2-github-actions-check/**
      - name: Create changeset
        if: contains(steps.changed-files.outputs.all_changed_files, 'ec2-github-actions-check/ec2.yaml')
        run: |
          CHANGE_SET_NAME="changeset-$(date "+%Y%m%d-%H%M%S")"
          echo "CHANGE_SET_NAME=${CHANGE_SET_NAME}" >> $GITHUB_ENV
          aws cloudformation create-change-set --template-body file://ec2-github-actions-check/ec2.yaml --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME --capabilities CAPABILITY_NAMED_IAM
          aws cloudformation wait change-set-create-complete --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation describe-change-set --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME

ec2-github-actions-apply.yaml

name: EC2 check & apply at Pull Request Merge

on:
  pull_request:
    types: 
      - closed
    paths:
      - ec2-github-actions-check/**

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE: ${{ secrets.AWS_IAM_ROLE }}
  AWS_STACK_NAME: ec2-github-actions-check
  INSTANCE_NAME: ec2-github-actions-check-ec2

jobs:
  merge:
    runs-on: ubuntu-22.04
    if: github.event.pull_request.merged == true
    outputs:
      changed-files: ${{ steps.changed-files.outputs.all_changed_files}}
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Testing with CFN Lint Command
        uses: scottbrenner/cfn-lint-action@v2
        with:
          command: cfn-lint -t ./ec2-github-actions-check/*.yaml
      - name: Testing with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          exit-code: '1'
          severity: 'CRITICAL'
      - name: Drift Detection
        run: |
          STACK_DRIFT_DETECT_ID=`aws cloudformation detect-stack-drift --stack-name ${{ env.AWS_STACK_NAME }} --query 'StackDriftDetectionId' --output text`
          sleep 10s
          DRIFT_STATUS=`aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $STACK_DRIFT_DETECT_ID --query 'StackDriftStatus' --output text`
          echo "DRIFT_STATUS=${DRIFT_STATUS}" >> $GITHUB_ENV
      - name: Stop workflow when drift detected
        if: contains(env.DRIFT_STATUS, 'IN_SYNC') == false
        run: |
          aws cloudformation describe-stack-resource-drifts --stack-name ${{ env.AWS_STACK_NAME }}
          exit 1
      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v39
        with:
          files: |
            ec2-github-actions-check/**
      - name: Check EC2 instance status
        run: |
          INSTANCE_ID=`aws ec2 describe-instances --filter "Name=tag:Name,Values=${{ env.INSTANCE_NAME }}" --query 'Reservations[].Instances[].InstanceId' --output text`
          INSTANCE_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceStatus.Status' --output text`
          SYSTEM_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].SystemStatus.Status' --output text`
          INSTANCE_STATE=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceState.Name' --output text`
          echo "INSTANCE_STATUS=${INSTANCE_STATUS}" >> $GITHUB_ENV
          echo "SYSTEM_STATUS=${SYSTEM_STATUS}" >> $GITHUB_ENV
          echo "INSTANCE_STATE=${INSTANCE_STATE}" >> $GITHUB_ENV
      - name: Stop workflow if EC2 instance have problems
        if: >-
          contains(env.INSTANCE_STATUS, 'ok') == false ||
          contains(env.SYSTEM_STATUS, 'ok') == false ||
          contains(env.INSTANCE_STATE, 'running') == false
        run: |
          echo "INSTANCE_STATUS = ${{ env.INSTANCE_STATUS }}"
          echo "SYSTEM_STATUS = ${{ env.SYSTEM_STATUS }}"
          echo "INSTANCE_STATE is ${{ env.INSTANCE_STATE }}"
          exit 1
  cfn-apply:
    runs-on: ubuntu-22.04
    needs: merge
    if: contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      # CloudFormation change
      - name: Create changeset
        run: |
          CHANGE_SET_NAME="changeset-$(date "+%Y%m%d-%H%M%S")"
          echo "CHANGE_SET_NAME=${CHANGE_SET_NAME}" >> $GITHUB_ENV
          aws cloudformation create-change-set --template-body file://ec2-github-actions-check/ec2.yaml --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME --capabilities CAPABILITY_NAMED_IAM
          aws cloudformation wait change-set-create-complete --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation describe-change-set --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
      - name: Execute changeset
        run: |
          aws cloudformation execute-change-set --change-set-name ${{ env.CHANGE_SET_NAME }} --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation wait stack-update-complete --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation describe-stacks --stack-name ${{ env.AWS_STACK_NAME }} --query 'Stacks[*].StackStatus' --output text
  os-file-apply:
    runs-on: ubuntu-22.04
    needs: merge
    if: >-
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/src/index.html') &&
      !contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      # index.html change
      - name: Get public ip
        id: ip
        uses: haythem/public-ip@v1.3
      - name: Open security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
      - name: Update index.html
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > private_key
          chmod 400 private_key
          scp -oStrictHostKeyChecking=no -i private_key ec2-github-actions-check/src/index.html ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }}:/tmp/index.html
          ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo cp /tmp/index.html /usr/share/nginx/html/sample/index.html"
      - name: Check whether index.html is correctly updated
        run: |
          ORIGINAL_FILE_HASH=`md5sum ec2-github-actions-check/src/index.html | awk '{ print $1 }'`
          COPIED_FILE_HASH=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo md5sum /usr/share/nginx/html/sample/index.html" | awk '{ print $1 }'`
          echo "ORIGINAL_FILE_HASH=${ORIGINAL_FILE_HASH}" >> $GITHUB_ENV
          echo "COPIED_FILE_HASH=${COPIED_FILE_HASH}" >> $GITHUB_ENV
      - name: Stop workflow if index.html file is not matched
        if: env.ORIGINAL_FILE_HASH != env.COPIED_FILE_HASH
        run: |
          echo "ORIGINAL_FILE_HASH = ${{ env.ORIGINAL_FILE_HASH }}"
          echo "COPIED_FILE_HASH = ${{ env.COPIED_FILE_HASH }}"
          exit 1
      - name: Check OS status after update index.html
        run: |
          NGINX_PROCESS_STATUS=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo systemctl list-units -t service | grep nginx"`
          CURL_STATUS_CODE=`curl ${{ secrets.EC2_HOST_NAME }}/sample/ -o /dev/null -w '%{http_code}\n' -s`
          echo "NGINX_PROCESS_STATUS=${NGINX_PROCESS_STATUS}" >> $GITHUB_ENV
          echo "CURL_STATUS_CODE=${CURL_STATUS_CODE}" >> $GITHUB_ENV
      - name: Stop workflow if OS status has problem
        if: >-
          contains(env.NGINX_PROCESS_STATUS, 'nginx.service') == false ||
          contains(env.CURL_STATUS_CODE, '200') == false
        run: |
          echo "NGINX_PROCESS_STATUS = ${{ env.NGINX_PROCESS_STATUS }}"
          echo "CURL_STATUS_CODE = ${{ env.CURL_STATUS_CODE }}"
          exit 1
      - name: close security group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
  os-file-apply-with-cfn:
    runs-on: ubuntu-22.04
    needs: [merge, cfn-apply]
    if: >-
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/src/index.html') &&
      contains(needs.merge.outputs.changed-files, 'ec2-github-actions-check/ec2.yaml')
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Wait if CloudFormation file is modified
        run: |
          INSTANCE_ID=`aws ec2 describe-instances --filter "Name=tag:Name,Values=${{ env.INSTANCE_NAME }}" --query 'Reservations[].Instances[].InstanceId' --output text`
          while :
          do
            INSTANCE_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].InstanceStatus.Status' --output text`
            SYSTEM_STATUS=`aws ec2 describe-instance-status --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].SystemStatus.Status' --output text`
            if [ "$INSTANCE_STATUS" = "ok" ] && [ "$SYSTEM_STATUS" = "ok" ]; then
              echo "Instance is running successfully"
              break
            elif [ "$INSTANCE_STATUS" = "initializing" ] || [ "$SYSTEM_STATUS" = "initializing" ]; then
              echo "Instance is initializing..."
              sleep 10
            else
              echo "INSTANCE_STATUS = $INSTANCE_STATUS"
              echo "SYSTEM_STATUS = $SYSTEM_STATUS"
              exit 1
            fi
          done
      # index.html change
      - name: Get public ip
        id: ip
        uses: haythem/public-ip@v1.3
      - name: Open security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32
      - name: Update index.html
        run: |
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > private_key
          chmod 400 private_key
          scp -oStrictHostKeyChecking=no -i private_key ec2-github-actions-check/src/index.html ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }}:/tmp/index.html
          ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo cp /tmp/index.html /usr/share/nginx/html/sample/index.html"
      - name: Check OS status after update index.html
        run: |
          NGINX_PROCESS_STATUS=`ssh -oStrictHostKeyChecking=no -i private_key ${{ secrets.EC2_USER_NAME }}@${{ secrets.EC2_HOST_NAME }} "sudo systemctl list-units -t service | grep nginx"`
          CURL_STATUS_CODE=`curl ${{ secrets.EC2_HOST_NAME }}/sample/ -o /dev/null -w '%{http_code}\n' -s`
          echo "NGINX_PROCESS_STATUS=${NGINX_PROCESS_STATUS}" >> $GITHUB_ENV
          echo "CURL_STATUS_CODE=${CURL_STATUS_CODE}" >> $GITHUB_ENV
      - name: Stop workflow if OS status has problem
        if: >-
          contains(env.NGINX_PROCESS_STATUS, 'nginx.service') == false ||
          contains(env.CURL_STATUS_CODE, '200') == false
        run: |
          echo "NGINX_PROCESS_STATUS = ${{ env.NGINX_PROCESS_STATUS }}"
          echo "CURL_STATUS_CODE = ${{ env.CURL_STATUS_CODE }}"
          exit 1
      - name: close security group
        if: always()
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} --protocol tcp --port 80 --cidr ${{ steps.ip.outputs.ipv4 }}/32

ec2.yaml

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  EnvName:
    Type: String
    Default: ec2-github-actions-check
  EC2InstanceType:
    Type: String
    Default: t3.micro
  SubnetId:
    Type: String
    Default: <Subnet IDを指定>
  EC2ImageId:
    Type: AWS::EC2::Image::Id
    Default: <AMI IDを指定>
  EC2SecurityGroup:
    Type: String
    Default: <Security Group IDを指定>
  KeyPair:
    Type: String
    Default: <Key名を指定>

Resources:
  ElasticIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref EC2InstanceType
      SubnetId: !Ref SubnetId
      ImageId: !Ref EC2ImageId
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      IamInstanceProfile: !Ref EC2InstanceProfile
      KeyName: !Ref KeyPair
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-ec2
  EIPAssociationToEC2:
    Type: AWS::EC2::EIPAssociation
    Properties:
      InstanceId: !Ref EC2Instance
      AllocationId: !GetAtt ElasticIP.AllocationId
  EC2IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${EnvName}-SSM-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - Ref: EC2IAMRole
      InstanceProfileName: !Sub ${EnvName}-EC2InstanceProfile

検証結果

ここから実際の検証した様子を紹介します。

CloudFormationファイルを更新した場合

まずはCloudFormationファイルを更新した場合です。この場合は前回のS3バケットの更新とほぼ同じ流れとなります。

index.htmlファイルを更新した場合

続いて index.html ファイルを更新した場合です。この場合は今までのCloudFormationを扱う場合と大きくプロセスが異なります。

まず、GitHub Actions runnerからインスタンスにアクセスできるよう、Security Groupの変更を行います。今回はGitHub Actions GitHub-hosted runnerを利用しており、毎回アクセス元のサーバーのIPが更新されます。そのため、haythem/public-ip というActionを利用し、GitHub Actions runnerのIPアドレスを取得します。

次に index.html ファイルの配置ですが、SSHでアクセスができればいくつか方法はあります。今回は宛先のディレクトリに直接SCPで配置できなかったため、SCPとSSHを組み合わせて実現しています。

ファイルの配置を完了したら、ファイルが正常に配置できていること、Nginxのプロセスが起動していること、Webページに正常にアクセスできることを確認し、最後にSecurity Groupを閉じます。なおSecurity Groupを開放後にCDプロセスが途中で失敗しても、Security GroupからSSHアクセス許可設定を削除するよう、Stepの条件に always() を指定しています。

処理が正常に完了した場合は以下のようになります。またWebブラウザなどからインスタンスにアクセスしても、 index.html への更新内容が反映されていることを確認しています。

両方のファイルを更新した場合

最後に両方のファイルを更新した場合です。前述の2つの処理は、GitHub Actions上でjobを別々に定義すれば、それぞれ並行で実施されます。ただしこの場合、 index.html ファイルの配置とインスタンスの更新のタイミングがバッティングすると、処理が失敗する可能性があります。そこで今回、両方のファイルが更新された場合は先にCloudFormationの更新を行い、その後 index.html ファイルの配置を行うよう、Jobの実行順を制限しています。

CloudFormationファイル更新後、 DescribeInstanceStatus APIからインスタンスの状態をチェックし、インスタンスの初期化が完了するのを待ってから後続の処理を実行するようにしました。

処理が正常に完了した場合は以下のようになります。CloudFormationファイルの更新Jobの完了後に index.html の処理が行われていることが視覚的にも確認できました。

参考リンク

IaCの継続的テストを検証する: Amazon S3に対するテスト

背景

以前IaCの書籍をいくつか取り上げ、IaCのテストをどう実行すべきか調査したことがありました。書籍の中には具体的なツールや例を取り上げて記載されている箇所もありますが、やはり実際に動かしてみることで見えることもあると思い、今回簡単に検証してみました。

なお、今回はなるべく特定の種類のリソースに制限せず、幅広いリソースで適用できるようなテストを選択しました。

テストの観点

今回は、書籍の内容をベースに、以下の観点から検証内容を選定しています。

利用するリソースの大部分をコードで管理する

大前提として、テスト対象のインフラリソースはIaCで管理します。またインフラリソースだけでなく、テスト等で利用するリソースなどもできる限りコードで管理することが推奨されます。

今回は以下のリソースをコードで管理しています。

  • テスト対象のAWSリソース: Amazon S3バケット
  • CI/CDジョブ: GitHub Actionsのworkflow定義ファイル
  • その他CI/CDに必要なリソース: IAMなど

ただしGitHubリポジトリの作成、GitHubリポジトリの設定、および更新対象のCloudFormation Stackの新規作成は手動で行っています。

継続的なテストとデプロイを自動化プロセスの中で実行する

インフラリソースをより安全に変更するには、IaCコードでリソースを管理し、変更はすべてIaCを介して実行することが推奨されます。またIaCファイルでの管理だけでなく、適用前のテストの実行とデプロイ、そしてそれらをすべて自動化プロセスの中で実現することが必要です。

今回は、まずIaCコードをGItHub上で管理し、IaCコードへのテスト・デプロイをGitHub ActionsによるCI/CDで実行しました。またCI/CDの起動するトリガーには、GitHub上でのPull Requestの作成とマージを選択しました。

なお、ブランチ戦略は特に考慮していませんが、GitHub Flowのようなシンプルな形にしています。

テスト内容は静的解析中心とする

IaCのテストの実行は、いくつかの課題があることが指摘されています。今回はそれらをできる限り回避するため、静的解析 (オフラインテスト) を中心に実施しています。

まず、CI/CDで実施するテストは、短時間で済むことが理想です。これに対しIaCのテストは、一度環境にリソースを作成する必要があるため、長く時間がかかりがちになります。そのためIaCへのテストでは、短時間で完了する静的解析の重要性はより高いと考えています。また静的解析は扱うリソースの違いが影響することは少ないため、今回は静的解析を中心にテストを実行しています。

さらに静的解析に加え、IaCファイルをより安全に適用するため、リソース変更ドリフトのチェックを追加しました。IaCで管理したリソース特有の問題として、IaCの記載と実際のリソースの設定が異なった際のエラー・予期せぬ変更による障害発生などがあります。そのため、IaCを変更した際のチェック項目に、更新対象のリソース・Stackがドリフトを起こしていないかチェックするポイントを追加しています。

具体的なテスト項目は大きく3つになります。

  • コード解析: IaCファイルの構文チェック、セキュリティチェック
  • 更新内容のプレビュー: 変更したIaCファイルを適用したときの更新内容の確認
  • ドリフトのチェック: ドリフトがあればWorkflowを停止

検証

ここから実際にIaC + CI/CDの例として検証したものを紹介します。今回はAmazon S3をターゲットに、GitHub Actionsを使ってCI/CDを設定し、IaCの継続的なテストとデプロイをどう実践するか、検証しました。

環境

今回の検証環境は以下の通りです。

  • 環境: AWS
  • IaCツール: AWS CloudFormation
  • CI/CDツール: GitHub Actions
  • テストツール: cfn-lint trivy

構成は以下の通り非常にシンプルです。

処理の流れ

処理の流れは以下の通りです。

  • (更新対象のCloudFormation Stackはあらかじめ作成しておく)
  • Amazon S3を定義するIaCファイルを更新し、GitHub上でPRを作成する
  • PRの作成をトリガーに、GitHub ActionsがCIを実行する
    • コード解析
    • ドリフトのチェック
    • Change setの作成
  • CIの結果を確認し、問題なければマージする
  • マージをトリガーにGitHub ActionsがCDを実行する
    • CIと同様の処理
    • Change setの適用

使用したIaC等のコードは以下に載せておきます。なおGitHubリポジトリ上のディレクトリ構成は以下の通りです。

.
├── .github
│   └── workflows
│       └── s3-github-actions-check.yaml
├── README.md
├── iam.yaml
└── s3-github-actions-check
    └── s3.yaml

s3-github-actions-check.yaml

name: IaC check & apply at Pull Request

on:
  pull_request:
    types: 
      - opened
      - synchronize
      - closed
    paths:
      - s3-github-actions-check/**

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE: ${{ secrets.AWS_IAM_ROLE }}
  AWS_STACK_NAME: s3-github-actions-check

jobs:
  pr:
    runs-on: ubuntu-22.04
    if: >-
      github.event.pull_request.state == 'open' || 
      github.event.pull_request.merged == true
    steps:
      - uses: actions/checkout@v3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Testing with CFN Lint Command
        uses: scottbrenner/cfn-lint-action@v2
        with:
          command: cfn-lint -t ./s3-github-actions-check/s3.yaml
      - name: Testing with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          exit-code: '1'
          severity: 'CRITICAL'
      - name: Drift Detection
        run: |
          STACK_DRIFT_DETECT_ID=`aws cloudformation detect-stack-drift --stack-name ${{ env.AWS_STACK_NAME }} --query 'StackDriftDetectionId' --output text`
          sleep 10s
          DRIFT_STATUS=`aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id $STACK_DRIFT_DETECT_ID --query 'StackDriftStatus' --output text`
          echo "DRIFT_STATUS=${DRIFT_STATUS}" >> $GITHUB_ENV
      - name: Stop workflow when drift detected
        if: contains(env.DRIFT_STATUS, 'IN_SYNC') == false
        run: |
          aws cloudformation describe-stack-resource-drifts --stack-name ${{ env.AWS_STACK_NAME }}
          exit 1
      - name: Create changeset
        run: |
          CHANGE_SET_NAME="changeset-$(date "+%Y%m%d-%H%M%S")"
          echo "CHANGE_SET_NAME=${CHANGE_SET_NAME}" >> $GITHUB_ENV
          aws cloudformation create-change-set --template-body file://s3-github-actions-check/s3.yaml --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation wait change-set-create-complete --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
          aws cloudformation describe-change-set --stack-name ${{ env.AWS_STACK_NAME }} --change-set-name $CHANGE_SET_NAME
      - name: Execute changeset
        if: github.event.pull_request.merged == true
        run: |
          aws cloudformation execute-change-set --change-set-name ${{ env.CHANGE_SET_NAME }} --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation wait stack-update-complete --stack-name ${{ env.AWS_STACK_NAME }}
          aws cloudformation describe-stacks --stack-name ${{ env.AWS_STACK_NAME }} --query 'Stacks[*].StackStatus' --output text

iam.yaml

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  EnvName:
    Type: String
    Default: "iac-cicd-examples"

Resources:
  # OIDC Providerは作成済みのためコメントアウト
  #OIDCProvider:
  #  Type: AWS::IAM::OIDCProvider
  #  Properties:
  #    Url: https://token.actions.githubusercontent.com
  #    ClientIdList:
  #      - sts.amazonaws.com
  #    ThumbprintList:
  #      - ffffffffffffffffffffffffffffffffffffffff
  IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${EnvName}-role
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Action: sts:AssumeRoleWithWebIdentity
            Principal:
              Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
              StringLike:
                token.actions.githubusercontent.com:sub: repo:<GitHubユーザー名>/<GitHubリポジトリ>:*
      Policies:
        - PolicyName: !Sub ${EnvName}-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - cloudformation:*
                  - s3:*
                Resource: '*'

検証

上記仕組みを検証するため、いくつかのパターンを試しました。

S3に設定を追加

まずは通常通りS3のIaCファイルを修正し、変更内容をAWSに適用します。今回はオブジェクトのバージョニングを有効にするよう以下のような定義を追加しています。

    Properties:
      BucketName: !Ref BucketName
      VersioningConfiguration:
        Status: Enabled

上記変更後にPRを作成すると、GitHub Actionsが起動し、問題なく完了することを確認できます。この時はドリフトもない状態でWorkflowを実行したため、該当の箇所をスキップしてChange setの作成を行っています。

なおこの時のChange setの内容を一部取り出すと、想定通りバージョニングの設定が追加されることも確認できます。

{
    "Changes": [
        {
            "Type": "Resource",
            "ResourceChange": {
                "Action": "Modify",
                "LogicalResourceId": "S3",
                "PhysicalResourceId": "s3-github-actions-check",
                "ResourceType": "AWS::S3::Bucket",
                "Replacement": "False",
                "Scope": [
                    "Properties",
                    "Tags"
                ],
                "Details": [
                    {
                        "Target": {
                            "Attribute": "Properties",
                            "Name": "VersioningConfiguration",
                            "RequiresRecreation": "Never"
                        },
                        "Evaluation": "Static",
                        "ChangeSource": "DirectModification"
                    }
ドリフト発生時の挙動

次に、AWS上のS3バケットの設定を手動で変更し、CloudFormation Stackと差分を作成した状態でCIを実行しました。ここでは先ほど追加したオブジェクトバージョニングの設定を事前に修正した状態でCIを実行します。

この時はCloudFormation Stackがドリフト状態になっているため、CIの Stop workflow when drift detected のstepでworkflowを停止しています。

ドリフトの内容も想定通り出力されていました。

リソースを置き換える種類の変更

最後に、既存のリソースを更新するのでなく、新規にリソースを作成する種類の変更を試しました。AWSに限らずですが、IaCの定義ファイルは、修正箇所によって更新方法が変わります。特にリソースを新規に作成する Replacement タイプの修正は、変更時の影響も大きいため、CIの時点で検知したいところです。

ここではS3バケット名を修正することで、リソースを作り直すようにしました。

ここではCIは問題なく成功しますが、実行結果を確認すると以下のような出力があり、この修正がリソースの再作成を伴うことがわかります。

参考リンク

rainを使ってCloudFormationのデプロイを楽にする

今回はCloudFormationのデプロイをより便利にする rain コマンドを試しました。

rainとは

rainは複数のコマンドが用意されており、それぞれの特徴は公式リポジトリ に記載されています。特によく使いそうなものをいくつか取り上げます。

  • rain deployによるインタラクティブなデプロイ: rain deploy コマンドは aws cloudformation deploy コマンドのように、CloudFormationテンプレートの定義に従ってAWS環境にリソースを作成・更新するコマンドです。 rain deploy コマンドの場合、未指定のパラメータの入力の案内や変更内容の提示、実行後の進行状況の表示など、インタラクティブな要素が特徴となります。また、初回のStackデプロイに失敗しても前回分のStackの削除を実行することなく、deployコマンドを続けて実行することが可能です ( 先日の CloudFormationへのアップデートにより、 DeletionPolicyRetainExceptOnCreate を使うことで同様の効果を得ることはできるようになりました) 。
  • rain fmtによるテンプレートのフォーマット: rain fmt コマンドはCloudFormationテンプレートのフォーマットやJSON/YAML間の変換を行います。AWS CLIでは aws cloudformation validate-template などでテンプレートの検証はできましたが、テンプレートのフォーマットを行うことはできませんでした。
  • rain logによるログの表示: rain log コマンドはStackで発生したログを表示します。表示するログは、指定したStackに加えてそれに関連付けられたNested Stackからのログも結合します。これまではStackのログを見るためにAWSマネジメントコンソールに移動する必要がありました。
  • rain buildコマンドによるテンプレートの構築: rain buildコマンドは指定したAWSリソースのCloudFormationテンプレートを表示します。これによりCloudFormationテンプレートを作り始めるときのinitialテンプレートが簡単に手に入ります。

rainを試す

ここからrainコマンドを試してみます。実行した環境はAWS Cloud9になります。

インストール

インストール方法はいくつかありますが、ここではリポジトリで提供されたバイナリをダウンロードして使います。rainはGo言語製なのでバイナリを配置すれば使えます。

$ wget https://github.com/aws-cloudformation/rain/releases/download/v1.4.3/rain-v1.4.3_linux-amd64.zip

$ unzip rain-v1.4.3_linux-amd64.zip 
Archive:  rain-v1.4.3_linux-amd64.zip
   creating: rain-v1.4.3_linux-amd64/
  inflating: rain-v1.4.3_linux-amd64/README.md  
  inflating: rain-v1.4.3_linux-amd64/LICENSE  
  inflating: rain-v1.4.3_linux-amd64/rain  

$ sudo mv rain-v1.4.3_linux-amd64/rain /usr/local/bin/

# インストール後の確認
$ rain --version
Rain v1.4.2 linux/amd64

rain build / deployによるリソースの作成

初回のStack作成成功時

まずは基本的な操作として、テンプレートの生成とデプロイを行います。今回はAmazon SQSをターゲットにリソースを作成します。

まずは rain build コマンドでinitialテンプレートを生成します。

$ rain build AWS::SQS::Queue > sqs-test.yaml

$ cat sqs-test.yaml
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      ContentBasedDeduplication: false # Optional
      DeduplicationScope: CHANGEME # Optional
      DelaySeconds: 0 # Optional
      FifoQueue: false # Optional
      FifoThroughputLimit: CHANGEME # Optional
      KmsDataKeyReusePeriodSeconds: 0 # Optional
      KmsMasterKeyId: CHANGEME # Optional
      MaximumMessageSize: 0 # Optional
      MessageRetentionPeriod: 0 # Optional
      QueueName: CHANGEME # Optional
      ReceiveMessageWaitTimeSeconds: 0 # Optional
      RedriveAllowPolicy: '{"JSON": "CHANGEME"}' # Optional
      RedrivePolicy: '{"JSON": "CHANGEME"}' # Optional
      SqsManagedSseEnabled: false # Optional
      Tags:
        - Key: CHANGEME
          Value: CHANGEME
      VisibilityTimeout: 0 # Optional

Outputs:
  MyQueueArn:
    Value: !GetAtt MyQueue.Arn

  MyQueueQueueName:
    Value: !GetAtt MyQueue.QueueName

  MyQueueQueueUrl:
    Value: !GetAtt MyQueue.QueueUrl

コマンドを実行して # Optional と書かれた箇所は必須でないパラメータになります。ここではQueneName とタグだけを指定しました。

AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      Tags:
        - Key: test
          Value: rain

次にテンプレートを指定してQueueリソースを作成しますが、 rain ls コマンドを実行すると、新規Stackを作成する前に既存のStackの状態を知ることができます(例えば新規に作成するStack名を検討するときなど)。

$ rain ls
CloudFormation stacks in ap-northeast-1:
  aws-cloud9-test01-1fd0ca9e39c449608d7d6484a29f4069: CREATE_COMPLETE

(以降割愛)

次にrain deploy コマンドを実行し、Stackを作成します。なお、特にオプションで指定しない場合、テンプレートのファイル名がStack名に使われます。

$ rain deploy sqs-test.yaml 
Rain needs to create an S3 bucket called 'rain-artifacts-123456789012-ap-northeast-1'. Continue? (Y/n) Y # 1. S3バケットの作成
CloudFormation will make the following changes: # 2. 変更予定内容の表示
Stack sqs-test:
  + AWS::SQS::Queue MyQueue
Do you wish to continue? (Y/n) Y
Deploying template 'sqs-test.yaml' as stack 'sqs-test' in ap-northeast-1.
Stack sqs-test: CREATE_COMPLETE # 3. 進行状況の表示
Successfully deployed sqs-test # 4. 完了メッセージ

コマンド実行時の流れを簡単に記載します。

  1. rain deploy コマンドの初回実行時は、テンプレートを配置するためのS3バケットの作成が必要となります。
  2. 変更予定内容を表示します。ここでは新規リソースの追加が表示されています
  3. ここはデプロイ後の進行状況が表示されます。テキストだけではイメージが湧かないと思うので、公式リポジトリのデモの様子を見たり、実際に使ってもらえればと思います。
  4. デプロイが完了するとその旨が表示されます。

もう一度 rain ls コマンドを実行すると、先ほどはなかった sqs-testというStackが追加されています。

$ rain ls
CloudFormation stacks in ap-northeast-1:
  aws-cloud9-test01-1fd0ca9e39c449608d7d6484a29f4069: CREATE_COMPLETE

(一部抜粋)

  sqs-test: CREATE_COMPLETE

ここで rain log コマンドを実行すると、先ほどのStack作成で発生したログが表示されます。ただしデフォルトではエラーなどの重要なログしか表示されないため、ここでは --all オプションを追加します。

$ rain logs sqs-test
No interesting log messages to display. To see everything, use the --all flag

$ rain logs sqs-test --all
Aug  5 07:29:41 sqs-test/sqs-test (AWS::CloudFormation::Stack) CREATE_COMPLETE
Aug  5 07:29:40 sqs-test/MyQueue (AWS::SQS::Queue) CREATE_COMPLETE
Aug  5 07:28:29 sqs-test/MyQueue (AWS::SQS::Queue) CREATE_IN_PROGRESS "Resource creation Initiated"
Aug  5 07:28:28 sqs-test/MyQueue (AWS::SQS::Queue) CREATE_IN_PROGRESS
Aug  5 07:28:25 sqs-test/sqs-test (AWS::CloudFormation::Stack) CREATE_IN_PROGRESS "User Initiated"
Aug  5 07:28:11 sqs-test/sqs-test (AWS::CloudFormation::Stack) REVIEW_IN_PROGRESS "User Initiated"

また rain cat コマンドを実行すると、指定したStackのテンプレート情報を取得できます。テンプレートファイルの記載内容と差分がないかを見たりするのに使えそうです。

$ rain cat sqs-test
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      Tags:
        - Key: test
          Value: rain

なお、テンプレートを修正してStackに再デプロイする時も、先ほどと同じく rain deploy コマンドを使えば実行されます。

# テンプレートを一部修正
$ cat sqs-test.yaml 
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      DelaySeconds: 60  # 追加
      Tags:
        - Key: test
          Value: rain


# 修正内容の反映
$ rain deploy sqs-test.yaml 
CloudFormation will make the following changes:
Stack sqs-test:
  > AWS::SQS::Queue MyQueue
Do you wish to continue? (Y/n) 
Deploying template 'sqs-test.yaml' as stack 'sqs-test' in ap-northeast-1.
Stack sqs-test: UPDATE_COMPLETE
Successfully updated sqs-test
初回のStack作成失敗時

次に初回のStack作成に失敗した場合を見てみます。ここでは先ほど作成したのと同じ QueueName を持つSQSリソースを指定した、別のテンプレートを用意します。

このテンプレートを指定して rain deploy コマンドを実行すると、以下のように作成に失敗します。エラーメッセージも表示されるので、簡単なケースならエラー原因をすぐに特定できます。

$ rain deploy fmt-sqs-test.yaml 
CloudFormation will make the following changes:
Stack fmt-sqs-test:
  + AWS::SQS::Queue MyQueue
Do you wish to continue? (Y/n) 
Deploying template 'fmt-sqs-test.yaml' as stack 'fmt-sqs-test' in ap-northeast-1.
Stack fmt-sqs-test: ROLLBACK_COMPLETE
Messages:
  - MyQueue: Resource handler returned message: "Resource of type 'AWS::SQS::Queue' with identifier 'test-sqs' already exists." (RequestToken: dae75161-d420-a85a-8610-f8af52a9f060, HandlerErrorCode: AlreadyExists)
failed deploying stack 'fmt-sqs-test'

この後に rain ls コマンドを実行すると、先ほど作成に失敗したStack名が確認できます。

$ rain ls
CloudFormation stacks in ap-northeast-1:
  aws-cloud9-test01-1fd0ca9e39c449608d7d6484a29f4069: CREATE_COMPLETE

(一部抜粋)

  fmt-sqs-test: ROLLBACK_COMPLETE

ここでQueneName を別名に修正し、再度 rain deploy コマンドを実行します。すると、Stackの作成前に既存のStackを削除している様子が確認できます。

$ rain deploy fmt-sqs-test.yaml 
Deleted existing, empty stack. # Stackを削除している
CloudFormation will make the following changes:
Stack fmt-sqs-test:
  + AWS::SQS::Queue MyQueue
Do you wish to continue? (Y/n) n # ここではデプロイは実施していません
user cancelled deployment

なお、Stackの初回作成以外でのエラー(例えば UPDATE_ROLLBACK_FAILEDなど)が発生している状態では rain deploy コマンドを実行してもエラーが返されるため、実行前にエラーを解消する必要があります。

※参考: AWS re:Post

$ rain deploy sqs-test.yaml 
stack 'sqs-test' could not be updated: UPDATE_ROLLBACK_FAILED

rain fmtによるテンプレートの修正

次に rain fmt コマンドを使ってみます。まずは以下のような、ハイフンの位置をどうするか迷うTagsの部分を少しいじったテンプレートを使ってみます。

AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      Tags:
      - Key: test  # ハイフンの位置を変更
        Value: rain

上記テンプレートにfmtコマンドを適用すると、以下のように修正されます。

$ rain fmt sqs-test.yaml 
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      Tags:
        - Key: test  # 修正されている
          Value: rain  # 修正されている

ただし、 rain fmt は極端にフォーマットが崩れたテンプレートに対しては効果は発揮されないかもしれません。例えば以下のように、各行をすべて先頭列に持ってきたテンプレートに対してfmtを実行します。

AWSTemplateFormatVersion: "2010-09-09"
Description: Template generated by rain
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: test-sqs
FakeParam: test-value
Tags:
  - Key: test

すると、以下のように想定通りにはフォーマットされない結果となりました。

$ rain fmt fmt-sqs-test-01.yaml 
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:

MyQueue:

Type: AWS::SQS::Queue

Properties:

QueueName: test-sqs

FakeParam: test-value

Tags:
  - Key: test

また、以下のように存在しないパラメータを追加したテンプレートに対してもfmtを実行してみます。

AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      FakeParam: test-value  # 存在しないパラメータ
      Tags:
        - Key: test
          Value: rain

ここでは特にエラーも発生せず正常終了してしまいました。

$ rain fmt fake-param-sqs-test.yaml 
AWSTemplateFormatVersion: "2010-09-09"

Description: Template generated by rain

Resources:
  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      FakeParam: test-value
      Tags:
        - Key: test
          Value: rain

そもそも rain build などでテンプレートを生成してから作業することを想定しているかもしれませんが、fmtコマンドはあまり過信せず、cfn-lint などで構文チェックをしてから rain fmt を実行するのがよさそうです。

その他コマンド

rain forecast

rain foecast コマンドは実験的なコマンドとして用意されており、AWSアカウント内の制約やテンプレートの依存関係の設定ミスなど、デプロイに失敗する可能性のある要素がある場合に警告するものです。forecastのhelpオプションによると、コマンド実行ユーザーの権限やクォータ、ドリフトの問題などを検知してくれるようです。

$ rain forecast --help
Outputs warnings about potential deployment failures due to constraints in 
the account or misconfigurations in the template related to dependencies in 
the account.

NOTE: This is an experimental feature!

To use this command, add --experimental or -x as an argument.

This command is not a linter! Use cfn-lint for that. The forecast command 
is concerned with things that could go wrong during deployment, after the 
template has been checked to make sure it has a valid syntax.

This command checks for some common issues across all resources:

- The resource already exists
- You do not have permissions to create/update/delete the resource
- (More to come.. service quotas, drift issues)

Resource-specific checks:

- S3 bucket is not empty
- S3 bucket policy has an invalid principal
- (Many more to come...)

Usage:
  rain forecast --experimental <template> [stackName]

Flags:
  -c, --config string     YAML or JSON file to set tags and parameters
      --debug             Output debugging information
  -x, --experimental      Acknowledge that this is an experimental feature
  -h, --help              help for forecast
      --no-colour         Disable colour output
      --params strings    set parameter values; use the format key1=value1,key2=value2
  -p, --profile string    AWS profile name; read from the AWS CLI configuration file
  -r, --region string     AWS region to use
      --role-arn string   An optional execution role arn to use for predicting IAM failures
      --skip-iam          Skip permissions checks, which can take a long time
      --tags strings      add tags to the stack; use the format key1=value1,key2=value2
      --type string       Optional resource type to limit checks to only that type

なおこちらのコマンドはlinterとして利用することはできないことが明記されており、試しに fmtコマンドで使った不要なパラメータを含むテンプレートを指定してもエラーは発生しませんでした。

$ rain forecast --experimental fake-param-sqs-test.yaml sqs-test
Clear skies!   All 1 checks passed.

テンプレートの構文チェックをしたい場合はcfn-lintなど別のツールを使いましょう。

rain merge

rain merge コマンドは指定したテンプレートの内容を結合した結果を出力します。正直利用するケースがあまり思い浮かばなかったのですが、例えば以下のように、別のテンプレートで同じリソース名(ここでは MyQueue)を使っている場合はエラーが発生します。

$ rain merge sqs-test.yaml fmt-sqs-test.yaml 
templates have clashing Resources: MyQueue

成功した場合は以下のようになり、Descriptionの内容も結合している様子などが見えます。

$ rain merge sqs-test.yaml fmt-sqs-test.yaml 
AWSTemplateFormatVersion: "2010-09-09"

Description: |-
  Template generated by rain

  Template generated by rain

Resources:
  FmtMyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: fmt-test-sqs
      Tags:
        - Key: test
          Value: rain

  MyQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: test-sqs
      Tags:
        - Key: test
          Value: rain
rain rm

rain rm コマンドは既存のStackを削除します。ここでは先ほど rain deploy コマンドで作成したStackを削除します。なお、デフォルトでは削除実行可否の選択が Noになっているため、誤って削除する危険性は少し軽減されています。

$ rain rm sqs-test
Stack sqs-test: UPDATE_COMPLETE
Are you sure you want to delete this stack? (y/N) y
Successfully deleted stack 'sqs-test'

Manning Publication IaC本を読んでIaCのテストを学ぶ

今回はManning Publicationsから2022年に出版された Infrastructure as Code Practices and Patterns の中から、IaCのテストに関連する部分を取り上げます。

www.manning.com

前提

本書ではテストに限らずIaCの広範なテーマを取り上げており、その中で実践的なテクニックやIaCのパターンなどを紹介しています。今回はIaCのテストを取り上げる第6章を中心に、いくつかのトピックを取り上げています。

また、本書では Python / TerraformをIaCツールとして利用し、Google Cloud上にリソースを提供することを想定しています。

以前取り上げた O'Reilly社のIaC本 では具体的なツールの例を意図的に省いていましたが、本書ではPythonなどのサンプルコードも多く紹介されています。

テストのサイクル

本書ではまず、IaC定義ファイルに修正を加えた後、テストを実行するサイクルについて、以下のように想定しています。

  1. IaC定義ファイルを修正し、インフラ設定を更新する
  2. インフラの変更を適用する前に、インフラの設定に対する静的解析を行う
  3. 実際のインフラに変更を適用する
  4. インフラへ変更を適用後、インフラの機能に対する動的解析を行う

ここでは2種類のテストを紹介しており、ほぼすべてのテストは2種類のいずれかに分類することができます。

静的解析

実際のリソースに適用する前にインフラの設定をチェックすることで、想定する正しい値やリソースが作成・更新されるかをチェックします。静的解析を行うには起動中のリソースは不要のため、テストの実行と完了までの時間が短く、フィードバックを高速に得られます。

筆者は実際に行うテストの例として、インフラの命名規約や依存関係のチェックを挙げています。このテストを行うことで、企業やプロジェクトの規約に従わない名称や設定のリソースがないか素早くチェックできます。

動的解析

変更内容を反映したリソースに対し動的解析を行うことで、システムの機能が想定通り実現できているかをチェックします。本番環境に変更を適用する前にテスト環境で動的解析を行うことで、本番環境で問題が発覚するより前にバグを検知・修正する機会を得られます。

またテスト環境については、可能であれば以下の要件を満たすよう構築・管理することが推奨されます。

  • 本番環境とできる限り同じ設定であること: 本番環境との差分を少なくすることで、より本番環境と近いテストの挙動を得られます
  • アプリケーションの開発環境とは別の環境であること: アプリケーション占有環境に対してテスト前の変更をいきなり適用し、アプリケーション開発に影響することを防ぐため。
  • テストのたびに構築・削除をしないpersistentな環境であること: 起動中のインフラに変更を適用することで、より実際の本番環境への影響を確認できる

ただし3つ目の要求は、環境を維持するためのコストがかかることを課題として挙げており、それについては第12章で軽減策を提示しています。

※3つ目の要求は前回のIaC本でも紹介されておりましたが、あちらではコストに加え、変更蓄積による想定外の障害リスクがある、という課題を指摘しています。

テストの種類

IaCのテストは静的解析・動的解析の2つに分けられますが、本書ではそれらに含まれるいくつかのテストを紹介しています。

  • 静的解析
    • unit test
    • contract test
  • 動的解析
    • integration test
    • end-to-end test
    • (continuous test)
    • (regression test)

unit test

unit testはインフラの設定や状態をチェックするテストです。例えばネットワークリソースを定義するTerraformの定義ファイルに対し、想定通りのリソース名とIPアドレスが指定されているかを確認したり、インフラ設定ファイルを生成するpythonコードが想定通りの設定情報が生成されるか、test fixtureとpytestを使って検証する、などが該当します。

unit testを用意すべき条件として、本書では3つのケースを取り上げています。

  • インフラ設定ファイルを生成するロジックをチェックする時: 設定ファイルの生成にIaCツール内のロジック (例えばCloudFormationのConditionsなど) やPythonを利用している場合、想定通りのファイルが生成されるかをチェックするべきです。特にloop/if-elseに対してはバグも混入しやすいためテストするべきでしょう。

  • 誤った、もしくは問題のある設定が含まれないかの確認: 実際にデプロイする前の時点で、インフラ設定に不適切なものが含まれているかチェックすることができます。例えばセキュリティ的に問題のある設定がないか、ほかのリソースで既に利用しているIPアドレスを指定してないか、などが該当します。なお、IaCをセキュアに利用する方針として、本書では「アクセスとSecretを管理すること」「リソースの監査・管理のためにタグ付けすること」「Policy as Codeを利用すること」を挙げています。

  • チームの基準に従っているかのチェック: ひとつ前に近いですが、組織やチームで定めたルールに従っているかをチェックすることにも有用です。チームが定めた命名規約に沿ったリソース名やタグになっているかなどをチェックします。

またここではDomain-Specific language (DSL) をテストする場合のpracticeとして、dry-runの実行結果に対してunit testを実行することも紹介しています。これが有効な場合は、DSLなツールの設定ファイルに、テストしたい全ての設定情報がファイルに含まれないケースが挙げられます。

contract test

contract testはモジュールのinput/outputを比較し、想定通りの値やフォーマットになっているかをチェックするテストです。例えば、あるモジュールAのoutput情報を別のモジュールBが利用している場合、何かの拍子にモジュールAのoutputが別物に置き換わればモジュールBは機能しなくなります。こういった不具合は扱うパラメータとモジュールが増えるほど増加しやすく、また人の目ではチェックしきれなくなるため、contract testを作成するのが有効になります。

またここではcontract testが特に有効なモジュールのパターンを3つ紹介しています。

  • factoryパターン : input情報のセットを受け取って、input情報+デフォルト値をもとにしたリソース群を作成するモジュール
  • prototypeパターン : 他のモジュールに利用される固定値を生成するモジュール
  • builderパターン : 複数のリソース定義を含むが、それらを有効化するか無効化するか選べるモジュール

integration test

integration testは、テスト環境などに変更を適用したのち、moduleや設定に行った変更が正しく機能していることを確認するため、起動中のインフラに対して実行します。

本書ではintegration testを行う2つのケースを取り上げています。

  • モジュール: モジュールのテストは、特定の設定を与えたリソースを作成しテストを行うことで実施します。そのため既存のテスト用リソースに影響しないよう、可能ならモジュールのテスト専用の環境を用意して行います。

  • インフラ設定: インフラの設定に対するテストは、特に簡単には削除できないようなリソースを扱うこともあるため、既存のテスト環境に更新内容を適用後、テストを実行するという手順が案内されています。

またintegration testを実施するうえで2つのチャレンジングな面も紹介しています。

  • どの設定パラメータをテストするか: IaCファイルが巨大化するにしたがって扱うパラメータも増加しやすくなり、どのパラメータをテストするべきか判断に迷うケースも出てきます。筆者はここで、すべてのパラメータをテストする必要はない、としています。個別のリソースの作成や更新・削除といった挙動は、IaCツール開発時の受入テストで確認されており、わざわざそこを繰り返し確認する必要はありません。むしろ、モジュール等や自前の自動化スクリプトを使って複数のリソースを同時に扱う場合に、その設定や依存関係が正しいかをテストすることに価値があるとしています。

  • テストのたびにリソースを削除するか、それとも維持するか: 多くのリソースを維持することでコストも発生するため、必要なリソースだけを維持することを検討します。一般に依存性が少なく、作成や削除に時間がかからないモジュールや設定変更は、毎回作成と削除をすることが推奨されます。これに加えて筆者は具体的な例として、ネットワークやDNSなどのリソースは維持することを推奨し、Application Deployment / SaaSなどは毎回の作成と削除を推奨しています。

end-to-end test

end-to-end testはリソースやシステムの機能性に着目し、IaCファイルの変更によりリソースの果たすべき機能が損なわれていないかをチェックします。特に、ある変更がupstreamな機能を損なう結果になっていないかを確認するうえでは必須なテストとなります。

具体的なテストの例ですが、筆者の場合、end-to-end testのほとんどは、ネットワークおよびコンピューティングリソースに対して書くと紹介しています。またもう一つのケースとして、(おそらくKubernetesなどの) Workload Orchestratorに対してJobを投げ、その処理が完了することを確認することも挙げています。

なお、end-to-end testはこれまで上げたテストの中で最も高価なテストなため、どの環境で実施をするかは検討が必要となります。

continuous test

continuous testは短い周期で頻繁に繰り返し行うテストで、例えばある環境のメトリクスの値が想定通りのものかチェックする、というテストを行います。continuous testは監視システムのメトリクスとセキュリティイベントを含んでおり、何か問題があればアラートを挙げる形で通知することが多いです。

regression test

regression testはある程度の長い期間ごとに実行するテストで、インフラリソースの設定や状態が想定通りかを確認します。これにより、構成ドリフトがないかをチェックし、手動での変更などによるドリフトがあればそれを検知することができます。

テスト戦略

ここまで複数のテストが出てきましたが、これらのテストをいつどのように使えばよいか考える必要があります。本書では大きく2つのケースに分けて、テスト戦略を紹介します。

テストピラミッド

筆者はまずIaCに対する各テストをピラミッド状に配置し(テストピラミッド)、どのテストをどの程度の量実施すべきか、大まかに示しています。通常のソフトウェアテストの原則と同様、Unit testほど多く、End-to-end testほど少ない量テストすることを推奨します。

また、メンテナンスが困難になるようなコードを使ってまでテストを自動化しようとせず、必要に応じて手動テストも行うよう設計することも重要です。

モジュールに対するテスト戦略

モジュールに対しては、unit test / contract test / integration testを実施することで十分に動作を保証できると記載しています。

テストを実施する大きな流れは以下の通りです。

  1. モジュールへの変更をソースコードリポジトリにコミットする
  2. unit testを実行し、フォーマットや設定をチェックする
  3. contract testを実行し、モジュールへのinput/outputが想定通りになることをチェックする
  4. テスト用の環境にリソースを作成・更新する
  5. 起動中のリソースにintegration testを実行し、モジュールへの変更が想定通りに機能することをチェックする
  6. 以降は必要に応じてタグ付けやパッケージング、ドキュメントの更新などを行う

インフラ設定に対するテスト戦略

インフラの設定に対しては、unit test / integration testに加え、end-to-end testを実行することで、機能レベルまで含めて設定に問題がないことを確認できます。また品質を担保するため、テスト環境と本番環境で同じテストを繰り返し実施することを推奨していますが、環境間で差分がある場合は一部テストをスキップすることも可能です。

テスト実施の大きな流れは以下の通りです。本書に記載されたものと若干順番を並べ替えています。

  1. インフラの設定への変更をソースコードリポジトリにコミットする
  2. unit testを実行し、フォーマットや設定をチェックする
  3. テスト用の環境に変更を加えたリソースを作成・更新する
  4. テスト環境のリソースにintegration testを実行し、変更を加えたリソースが問題なく動作することをチェックする
  5. テスト環境のリソースにend-to-end testを実行し、リソースが想定通りの機能を果たしていることをチェックする
  6. テスト環境と同じ流れを本番環境にも適用し、各テストを実行する

【メモ】AWS CloudFormation Guardを試す

CloudFormation Guardとは

AWS CloudFormation Guard (以降cfn-guard) は、汎用的なPolicy as Codeを実現するOpen Sourceツールの一つです。cfn-guardはDomain-Specific Language (DSL) でルールを記載し、テスト対象のコードがルールに従うかをチェックします。チェック時はDSLでルールを記載したファイルとテスト対象のテンプレートを指定し、 cfn-guard コマンドから実行します。なお名称にCloudFormationとありますが、TerraformやKubernetesのファイルに対しても利用できます。

なお今回は検証しませんでしたが、cfn-guardは、作成したルールファイルが想定通り動作するかをテストしたり、AWS Config で、AWS CloudFormation Guardを使用したカスタム AWS Config ルールを作成することもできます。

※参考:

cfn-guardを検証する

ここから検証をします。今回は AWS Cloud9 環境を利用しました。

インストール

cfn-guardのインストールは複数オプション用意されていますが、今回は以下のコマンドを実行してインストールします。インストール後はPATHの追加も忘れずに行います。

# インストール
$ curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/aws-cloudformation/cloudformation-guard/main/install-guard.sh | sh

cfn-guard 2.1.3

(以降割愛)

Remember to SET PATH include PATH=${PATH}:~/.guard/bin

# PATHの追加
$ export PATH=${PATH}:~/.guard/bin

# コマンド確認
$ cfn-guard -V
cfn-guard 2.1.3

rulegen

まずはcfn-guardの動作を確認するため、 rulegen というコマンドを使用します。これは既存のテンプレートからcfn-guardルールファイルを生成するコマンドで、ここでは以下のテンプレートを使用しています。

AWSTemplateFormatVersion: '2010-09-09'
Description: test for cfn-guard

Resources:
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Scheme: "internet-facing"
      Name: "test-alb"
      IpAddressType: ipv4
      Subnets: 
        - "subnet-1234abcd1234abcd0"
        - "subnet-5678efgh5678efgh0"
      SecurityGroups: 
        - "sg-9012ijkl9012ijkl0"

上記テンプレートを使って rulegen コマンドを実行します。

$ cfn-guard rulegen -t test-alb.yaml -o test-elb.guard
$ 

上記コマンドを実行すると、test-elb.guard というファイルが生成されました。

let aws_elasticloadbalancingv2_loadbalancer_resources = Resources.*[ Type == 'AWS::ElasticLoadBalancingV2::LoadBalancer' ]
rule aws_elasticloadbalancingv2_loadbalancer when %aws_elasticloadbalancingv2_loadbalancer_resources !empty {
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.IpAddressType == "ipv4"
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.Subnets == ["subnet-1234abcd1234abcd0","subnet-5678efgh5678efgh0"]
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.Scheme == "internet-facing"
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.SecurityGroups == ["sg-9012ijkl9012ijkl0"]
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.Type == "application"
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.Name == "test-alb"
}

上記ルールファイルの読み方を簡単に残しておきます。

  • let ~: ここでは aws_elasticloadbalancingv2_loadbalancer_resources という変数に、テスト対象のAWSリソースを定義しています。
  • rule ~ when ~ !empty: aws_elasticloadbalancingv2_loadbalancer という名称のルールを定義します。前述した aws_elasticloadbalancingv2_loadbalancer_resources に該当するリソースが存在すれば、以降のルールを適用します。
  • %aws_elasticloadbalancingv2_loadbalancer_resources.Properties ~: AWS ELBの各パラメータが一致するかをチェックします。

cfn-guardDSLの記法、詳細は公式ドキュメントに記載されています。

validate

続いて、用意したルールファイルを使ってテスト対象のテンプレートを評価する validate コマンドを実行します。

まず前項で生成したルールファイルをそのまま使うと、ルールファイルとテンプレートの内容が一致しているため、エラーは検出されずパスします。

$ cfn-guard validate -d test-alb.yaml -r test-elb.guard
$ 

ここでエラーを返すよう、テンプレートファイルの一部パラメータを変更します。

Resources:
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Scheme: "internet-facing"
      Name: "test-alb-2" # Nameを変更

テンプレートを変更後に改めて validate コマンドを実行すると、今度は以下のようなエラーが検出されます。エラーの内容も、修正した Properties.Name を比較して値が異なる旨が表示されています。

$ cfn-guard validate -d test-alb.yaml -r test-elb.guard 
test-alb.yaml Status = FAIL
FAILED rules
test-elb.guard/aws_elasticloadbalancingv2_loadbalancer    FAIL
---
Evaluating data test-alb.yaml against rules test-elb.guard
Number of non-compliant resources 1
Resource = ALB/Properties {
  Type      = application
  Rule = aws_elasticloadbalancingv2_loadbalancer {
    ALL {
      Check =  %aws_elasticloadbalancingv2_loadbalancer_resources[*].Properties.Name EQUALS  "test-alb" {
        ComparisonError {
          Error            = Check was not compliant as property value [Path=/Resources/ALB/Properties/Name[L:9,C:12] Value="test-alb-2"] not equal to value [Path=[L:0,C:0] Value="test-alb"].
          PropertyPath    = /Resources/ALB/Properties/Name[L:9,C:12]
          Operator        = EQUAL
          Value           = "test-alb-2"
          ComparedWith    = "test-alb"
          Code:
                7.    Properties:
                8.      Type: "application"
                9.      Scheme: "internet-facing"
               10.      Name: "test-alb-2"
               11.      IpAddressType: ipv4
               12.      Subnets: 

        }
      }
    }
  }
}

AWS ELBのログ有効化をテストする

今回はルールファイルの書き方をもう少し知るために、AWS ELBに対する追加のテストを作成しました。

AWS ELBはログ出力を有効にするか否か、 LoadBalancerAttributesaccess_logs.s3.enabled というパラメータで制御をします。今回はこれを有効にしているかチェックするルールを作成してみます。

以下のようなテンプレートを用意し、これに対して有効なルールを設定します。

AWSTemplateFormatVersion: '2010-09-09'
Description: test for cfn-guard

Resources:
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Scheme: "internet-facing"
      Name: "test-alb"
      IpAddressType: ipv4
      Subnets: 
        - "subnet-1234abcd1234abcd0"
        - "subnet-5678efgh5678efgh0"
      SecurityGroups: 
        - "sg-9012ijkl9012ijkl0"
      LoadBalancerAttributes:
        - Key: access_logs.s3.enabled
          Value: "true"

上記テンプレートに対して access_logs.s3.enabled というパラメータを評価する際、cfn-guard はKey:Value形式のデータに対してもチェックが可能なため、以下のようなルールを利用できます。

let aws_elasticloadbalancingv2_loadbalancer_resources = Resources.*[ Type == 'AWS::ElasticLoadBalancingV2::LoadBalancer' ]
rule aws_elasticloadbalancingv2_loadbalancer when %aws_elasticloadbalancingv2_loadbalancer_resources !empty {
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.LoadBalancerAttributes == [{"Key":"access_logs.s3.enabled","Value":"true"}]
}

ただ上記ルールではいくつか問題があり、実用的とは言えない状態です。

  • access_logs.s3.enabled 以外のパラメータが存在する時エラー判定になる
  • LoadBalancerAttributes に2つ以上のパラメータが存在する時エラー判定になる

まず access_logs.s3.enabled 以外のパラメータが LoadBalancerAttributes に設定されていると、このルールはエラーと判定してしまいます。LoadBalancerAttributes はELBのログの出力先バケットの指定やログ以外の設定などもできるため、上記ルールは使える場面がかなり限定されます。

これに対しては正規表現を使うことで解消することもできます。 cfn-guard正規表現にも対応しており、以下のような書き方をすると access_logs.s3.enabled 以外は何であれパスすることもできます。

let aws_elasticloadbalancingv2_loadbalancer_resources = Resources.*[ Type == 'AWS::ElasticLoadBalancingV2::LoadBalancer' ]
rule aws_elasticloadbalancingv2_loadbalancer when %aws_elasticloadbalancingv2_loadbalancer_resources !empty {
  %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.LoadBalancerAttributes == [{"Key":"access_logs.s3.enabled","Value":"true"},{"Key":/.*/,"Value":/.*/}]
}

ただしこの書き方にも問題があり、LoadBalancerAttributes にパラメータが3つ以上あるとエラーになります。

$ cfn-guard validate -d test-alb.yaml -r test-alb.guard  -S
test-alb.yaml Status = FAIL
FAILED rules
test-alb.guard/aws_elasticloadbalancingv2_loadbalancer    FAIL
---
Evaluating data test-alb.yaml against rules test-alb.guard
Number of non-compliant resources 1
Resource = ALB/Properties {
  Type      = application
  Rule = aws_elasticloadbalancingv2_loadbalancer {
    ALL {
      Check =  %aws_elasticloadbalancingv2_loadbalancer_resources[*].Properties.LoadBalancerAttributes EQUALS  [{"Key":"access_logs.s3.enabled","Value":"true"},{"Key":"/.*/","Value":"/.*/"}] {
        ComparisonError {
          Error            = Check was not compliant as property value [Path=/Resources/ALB/Properties/LoadBalancerAttributes[L:17,C:8] Value=[{"Key":"access_logs.s3.enabled","Value":"false"},{"Key":"access_logs.s3.bucket","Value":"test-bucket-20230529"},{"Key":"access_logs.s3.prefix","Value":"test-"}]] not equal to value [Path=[L:0,C:0] Value=[{"Key":"access_logs.s3.enabled","Value":"true"},{"Key":"/.*/","Value":"/.*/"}]].
          PropertyPath    = /Resources/ALB/Properties/LoadBalancerAttributes[L:17,C:8]
          Operator        = EQUAL
          Value           = [{"Key":"access_logs.s3.enabled","Value":"false"},{"Key":"access_logs.s3.bucket","Value":"test-bucket-20230529"},{"Key":"access_logs.s3.prefix","Value":"test-"}]
          ComparedWith    = [{"Key":"access_logs.s3.enabled","Value":"true"},{"Key":"/.*/","Value":"/.*/"}]
          Code:
               15.      SecurityGroups: 
               16.        - "sg-9012ijkl9012ijkl0"
               17.      LoadBalancerAttributes:
               18.        - Key: access_logs.s3.enabled
               19.          Value: "false"
               20.        - Key: access_logs.s3.bucket

        }
      }
    }
  }
}

この問題を解消するため、ここでは some を使ってみます。 cfn-guardsome を使うと、該当するものが含まれていればパスする、という書き方ができます。

let aws_elasticloadbalancingv2_loadbalancer_resources = Resources.*[ Type == 'AWS::ElasticLoadBalancingV2::LoadBalancer' ]
rule aws_elasticloadbalancingv2_loadbalancer when %aws_elasticloadbalancingv2_loadbalancer_resources !empty {
  some %aws_elasticloadbalancingv2_loadbalancer_resources.Properties.LoadBalancerAttributes[*] == {"Key":"access_logs.s3.enabled","Value":"true"}
}

このルールの場合は 他のパラメータが複数あっても access_logs.s3.enabledtrue であればパスされます。

また access_logs.s3.enabledValuefalse に変更すると、以下の通りエラーを返すことも確認できます。

$ cfn-guard validate -d test-alb.yaml -r test-alb-2.guard  -S

test-alb.yaml Status = FAIL
FAILED rules
test-alb-2.guard/aws_elasticloadbalancingv2_loadbalancer    FAIL
---
Evaluating data test-alb.yaml against rules test-alb-2.guard
Number of non-compliant resources 1
Resource = ALB/Properties {
  Type      = application
  Rule = aws_elasticloadbalancingv2_loadbalancer {
    ALL {
      Check =  %aws_elasticloadbalancingv2_loadbalancer_resources[*].Properties.LoadBalancerAttributes[*] EQUALS  {"Key":"access_logs.s3.enabled","Value":"true"} {
        ComparisonError {
          Error            = Check was not compliant as property value [Path=/Resources/ALB/Properties/LoadBalancerAttributes/0[L:17,C:10] Value={"Key":"access_logs.s3.enabled","Value":"false"}] not equal to value [Path=[L:0,C:0] Value={"Key":"access_logs.s3.enabled","Value":"true"}].
          PropertyPath    = /Resources/ALB/Properties/LoadBalancerAttributes/0[L:17,C:10]
          Operator        = EQUAL
          Value           = {"Key":"access_logs.s3.enabled","Value":"false"}
          ComparedWith    = {"Key":"access_logs.s3.enabled","Value":"true"}
          Code:
               15.      SecurityGroups: 
               16.        - "sg-9012ijkl9012ijkl0"
               17.      LoadBalancerAttributes:
               18.        - Key: access_logs.s3.enabled
               19.          Value: "false"
               20.        - Key: access_logs.s3.bucket

        }
      }

(以降割愛)

このように some を利用することで、 access_logs.s3.enabled が含まれない場合、あるいは有効化されていない場合はエラーを返すルールが作成できました。

参考リンク