CloudFormation管理下にあるリソースをたまに削除したり復活したりさせる方法

# 背景
ただ起動しているだけでジリジリと地味なコストがかかるAWSのリソースは
できることなら夜間に停止したり、遊びたい時間だけ動かすなどして何とか費用を節約したいところです。
EC2やRDSなどの簡単に停止できるものはいいとして、ElastiCacheやNATゲートウェイなどの**停止するには削除するしかないリソース**は困りものです。

とはいえ費用なやっぱり節約したいので**必要な時に新規作成し、いらなくなったら削除する**という操作を毎回やる方法を考えます。
手動で削除するのは大変なので自動化したいところですが、CloudFormationでリソースを構築している場合、CloudFormationの管理下に置きつつ新規作成/削除を行う方法がよくわかっていませんでした。
今回やり方を調べましたので備忘録として記事にしておきます。

# テスト用CloudFormationテンプレート
ポイントは以下の3つです。
- NATゲートウェイとそのセキュリティグループ、NATゲートウェイへのルートを作成するかどうかを`CreateNatGateway`のパラメータで指定し、その値を各リソースの`Condition`に与えることで作成要否を判断する。
- プライベートサブネットにEC2インスタンスを作成し、NATゲートウェイの有無によってインターネットにアクセスできるかが変わるかを確認する。
- このテンプレート単体でテストができるようにVPCエンドポイントなどを作成していますが、参考程度に見ておいてください。

```yaml:natgw.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Temporary Resource Test"

Metadata: 
  AWS::CloudFormation::Interface: 
    ParameterGroups: 
      - 
        Label: 
          default: "Configuration"
        Parameters: 
          - CreateNatGateway
          - ClientKeyName

Parameters:
  CreateNatGateway:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"

  ClientKeyName:
    Type: "AWS::EC2::KeyPair::KeyName"

Conditions:
  CreateNatGatewayCondition:
    !Equals [!Ref CreateNatGateway, "true"]

Resources:
  VPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: "10.1.0.0/16"
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: "default"
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-vpc"

  PublicSubnet1:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: !Sub "${AWS::Region}a"
      CidrBlock: "10.1.0.0/20"
      VpcId: !Ref VPC
      MapPublicIpOnLaunch: false
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-public-subnet01"

  PrivateSubnet1:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: !Sub "${AWS::Region}a"
      CidrBlock: "10.1.64.0/20"
      VpcId: !Ref VPC
      MapPublicIpOnLaunch: false
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-private-subnet01"

  InternetGateway: 
    Type: "AWS::EC2::InternetGateway"
    Properties: 
      Tags: 
        - Key: "Name"
          Value: "cfn-tmp-rc-igw"

  InternetGatewayAttachment: 
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties: 
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC 

  NatGateway:
    Type: "AWS::EC2::NatGateway"
    Condition: CreateNatGatewayCondition
    Properties:
      SubnetId: !Ref PublicSubnet1
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-nat-gw"
      AllocationId: !GetAtt NatGatewayElasticIp.AllocationId

  NatGatewayElasticIp:
    Type: "AWS::EC2::EIP"
    Condition: CreateNatGatewayCondition
    Properties:
      Domain: "vpc"

  PublicRouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-public-rtb"

  PrivateRouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-private-rtb"

  PublicRoute1:
    Type: "AWS::EC2::Route"
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway

  PrivateRoute1:
    Type: "AWS::EC2::Route"
    Condition: CreateNatGatewayCondition
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NatGateway

  SubnetRouteTableAssociation1:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet1

  SubnetRouteTableAssociation2:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet1

  SSMEndpointSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "cfn-tmp-rc-ssm-ep-sg"
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: "cfn-tmp-rc-ssm-ep-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !GetAtt VPC.CidrBlock

  SSMEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssm
      SubnetIds:
        - !Ref PrivateSubnet1
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  SSMEndpointMessages:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages
      SubnetIds:
        - !Ref PrivateSubnet1
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  SSMEndpointEC2:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ec2messages
      SubnetIds:
        - !Ref PrivateSubnet1
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  Client:
    Type: "AWS::EC2::Instance"
    Properties:
      ImageId: "ami-0df2ca8a354185e1e"
      InstanceType: "t2.micro"
      KeyName: !Ref ClientKeyName
      AvailabilityZone: !Sub "${AWS::Region}a"
      Tenancy: "default"
      SubnetId: !Ref PrivateSubnet1
      EbsOptimized: false
      SecurityGroupIds: 
        - !Ref ClientSecurityGroup
      SourceDestCheck: true
      BlockDeviceMappings: 
        - 
          DeviceName: "/dev/xvda"
          Ebs: 
            Encrypted: false
            VolumeSize: 8
            VolumeType: "gp3"
            DeleteOnTermination: true
      IamInstanceProfile: !Ref ClientInstanceProfile
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-tmp-rc-client"
              
  ClientSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: "cfn-tmp-rc-client-sg"
      GroupName: "cfn-tmp-rc-client-sg"
      VpcId: !Ref VPC
      SecurityGroupIngress: 
        - 
          CidrIp: "0.0.0.0/0"
          FromPort: 22
          IpProtocol: "tcp"
          ToPort: 22
      SecurityGroupEgress: 
        - 
          CidrIp: "0.0.0.0/0"
          IpProtocol: "-1"

  ClientRole:
    Type: "AWS::IAM::Role"
    Properties:
      Path: "/"
      RoleName: "cfn-tmp-rc-client-role"
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

  ClientInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Path: "/"
      InstanceProfileName: !Ref ClientRole
      Roles: 
        - !Ref ClientRole
```

# 使い方
ここで想定するのは以下のようなシナリオです。
1. まずは何もない状態から`CreateNatGateway`を`true`にしてNATゲートウェイに関係するリソースを作成する。
1. NATゲートウェイがいらなくなったら`CreateNatGateway`を`false`にした変更セットを作成して適用する。

## NATゲートウェイの作成
1. CloudFormationの「スタックの作成」より上記のテンプレートを選択し、以下のようにパラメータを入力してスタックを作成します。
* `CreateNatGateway`には`true`を指定します。
* `ClientKeyName`には検証用のEC2インスタンスのSSHキーを指定します。
2. EC2にSession Managerなどを使ってログインし、GoogleのDNS(`8.8.8.8`)にPINGを送ってみます。確かにNATゲートウェイ経由でインターネットに出られているようです。
    ![ping-ok.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/d5426fec-8daa-f002-3abc-ce1b7a73dbf2.png)

## NATゲートウェイの削除
1. 作成したスタックに以下のような変更セットを作成して適用します。テンプレート自体は変更せず、`CreateNatGateway`のパラメータのみを`false`にセットします。
    ![change-set.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/f7a85987-ab98-6db7-0d8b-9b01f0c94fc4.png)

1. EC2のPINGの様子を見るとPINGの応答が返ってこなくなることがわかります。ちなみにSession Managerとの通信はVPCエンドポイント経由で行っているのでNATゲートウェイの有無は関係ありません。

# まとめ
- やったことは極めて単純。リソースの作成要否を各リソースの`Condition`属性で指定してやればよいだけ。

# おまけ
やっぱりCloudFormationで変更セットを作るって初心者にはとっつきにくいので、自作アプリやEventBridgeなどからNATゲートウェイの作成/削除を簡単に制御出来たらいいなと思ったので**Step Functions**を作成してみました。

テンプレートに以下の内容を追加してください。

```diff_yaml:natgw.yml
(前略)
  ClientRole:
    Type: "AWS::IAM::Role"
    Properties:
      Path: "/"
      RoleName: "cfn-tmp-rc-client-role"
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

# ここから追加
  ClientInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Path: "/"
      InstanceProfileName: !Ref ClientRole
      Roles: 
        - !Ref ClientRole

  StepFunctionsStateMachine:
    Type: "AWS::StepFunctions::StateMachine"
    Properties:
      StateMachineName: "cfn-tmp-rc-create-delete-function"
      DefinitionString: |
          {
            "StartAt": "Create or Delete",
            "States": {
              "Create or Delete": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.CreateNatGateway",
                    "BooleanEquals": false,
                    "Next": "SetDelete"
                  }
                ],
                "Default": "SetCreate"
              },
              "SetCreate": {
                "Type": "Pass",
                "Result": "create",
                "ResultPath": "$.Operation",
                "Next": "CreateChangeSet"
              },
              "SetDelete": {
                "Type": "Pass",
                "ResultPath": "$.Operation",
                "Result": "delete",
                "Next": "CreateChangeSet"
              },
              "CreateChangeSet": {
                "Type": "Task",
                "Parameters": {
                  "ChangeSetName.$": "States.Format('{}-nat-gw-{}', $.Operation, States.UUID())",
                  "StackName.$": "$.StackName",
                  "Capabilities": [
                    "CAPABILITY_NAMED_IAM"
                  ],
                  "UsePreviousTemplate": true,
                  "Parameters": [
                    {
                      "ParameterKey": "CreateNatGateway",
                      "ParameterValue.$": "States.Format($.CreateNatGateway)"
                    },
                    {
                      "ParameterKey": "ClientKeyName",
                      "UsePreviousValue": true
                    }
                  ]
                },
                "Resource": "arn:aws:states:::aws-sdk:cloudformation:createChangeSet",
                "ResultPath": "$.CreateChangeSetResult",
                "Next": "DescribeChangeSet"
              },
              "DescribeChangeSet": {
                "Type": "Task",
                "Parameters": {
                  "ChangeSetName.$": "$.CreateChangeSetResult.Id"
                },
                "Resource": "arn:aws:states:::aws-sdk:cloudformation:describeChangeSet",
                "ResultPath": "$.DescribeChangeSetResult",
                "Next": "CheckChangeSetStatus"
              },
              "CheckChangeSetStatus": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.DescribeChangeSetResult.Status",
                    "StringEquals": "FAILED",
                    "Next": "Fail"
                  },
                  {
                    "Variable": "$.DescribeChangeSetResult.Status",
                    "StringEquals": "CREATE_COMPLETE",
                    "Next": "ExecuteChangeSet"
                  }
                ],
                "Default": "WaitForCreation"
              },
              "ExecuteChangeSet": {
                "Type": "Task",
                "End": true,
                "Parameters": {
                  "ChangeSetName.$": "$.CreateChangeSetResult.Id"
                },
                "Resource": "arn:aws:states:::aws-sdk:cloudformation:executeChangeSet"
              },
              "Fail": {
                "Type": "Fail",
                "Error": "Failed to Create a Change Set"
              },
              "WaitForCreation": {
                "Type": "Wait",
                "Seconds": 5,
                "Next": "DescribeChangeSet"
              }
            },
            "Comment": "NAT Gateway Create/Destroy Flow"
          }
      RoleArn: !GetAtt StepFunctionExecutionRole.Arn
      StateMachineType: "STANDARD"
      LoggingConfiguration: 
        IncludeExecutionData: false
        Level: "OFF"

  StepFunctionExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      Path: "/"
      RoleName: "cfn-tmp-rc-create-delete-step-function-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - states.amazonaws.com
            Action:
              - sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns: 
      - "arn:aws:iam::aws:policy/AmazonEC2FullAccess"
      - "arn:aws:iam::aws:policy/AWSCloudFormationFullAccess"
```

Step FunctionsからCloudFormationのAPIを呼び出し、変更セットの作成と適用を行っています。
変更セットを作成しても適用可能な状態になるまで少し時間がかかるのでループで作成状態の取得をしています。
![fnc.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/acbc4dda-de6d-7b84-8212-df6e1d6548a0.png)

実行する際は以下のパラメータを渡して実行する必要があります。
- `CreateNatGateway`にNATゲートウェイを作成する場合は`true`、作成しない場合は`false`を指定します。
- `StackName`は上記のテンプレートで作成したスタックのIDを指定します。このIDはCloudFormationのスタックの詳細画面から確認できます。
    
    ![invoke-data.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/08ba4a71-4e70-18a9-7348-587b9990504d.png)

上記の例ではStep Funtionsのテスト用データを示していますが、Step Functionsを呼び出すことができればどのような方法でも構いません。たとえば利用者のPCが起動したタイミングでNATゲートウェイを作るとか、自作の管理用コンソールからAWS CLIなどを使って好きなタイミングで作るとか、自由自在に操作することができるようになります。