CloudFormationスタックから外れたサブネットをスタック管理下に引き戻したい

TECH記事

こんにちは。ダントーです。

CloudFormationスタックで管理していたサブネットが、スタックの更新・削除により意図せずスタック管理下から外れてしまって焦っている方向けの記事です。

他リソースが依存しているサブネットをスタックから削除しようとすると、スタックから消えないだけでなくスタック管理下から外れるといった現象に出くわしたので、その際の対応を備忘録として残します。

想定検索ワード:CloudFormation スタック サブネット 外れる

TL; DR;

  1. CloudFormationの既存スタックへのリソースインポート機能を使う
    • スタック管理下から外れたサブネットを既存スタックにインポートします
  2. 本来デプロイするはずのテンプレートを使用して既存スタックを再度更新します
    • 消失したリソースがある場合はこの手順も踏んだほうが良いです

では、いきましょう。

構成図とCloudFormationテンプレート

今回想定している構成です。

ポイントはサブネットの存在に依存するリソースが存在していること、つまりサブネット上にVPC Lambdaがデプロイされている点になります。

また、ネットワーク復旧確認用にVPC EndpointとS3を用意しています。

本題

まずは、今回使用するCloudFormationテンプレートを確認します。

テンプレートはNetwork.yamlとLambda.yamlの2種類です。

Network.yamlでは

  • VPC
  • ルートテーブル
  • サブネット
  • VPCエンドポイント(Gateway)
  • パラメータストア(VPCID)      ← Lambda.yamlにパラメータを渡すために作成
  • パラメータストア(サブネットID)   ← Lambda.yamlにパラメータを渡すために作成

これらを作成しています。

また、Lambda.yamlでは、

  • IAMロール
  • セキュリティグループ
  • Lambda関数
  • S3バケット             ←ネットワーク疎通確認用

これらを作成しています。

ネットワークの疎通が取れているかを確認するために、Lambda関数内ではS3バケットにファイルをはき出すロジックを入れています。

①正常時、②サブネットをスタックに引き戻したとき

これら2つのケースでネットワークに問題がないことを確認します。

詳細は以下のテンプレートを確認してください。

AWSTemplateFormatVersion: "2010-09-09"
Description: Network

Parameters:
  Prefix:
    Description: Enter Prefix.
    Type: String
    Default: sample

Resources:
#---------------------------------------------------#
# VPC
#---------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-vpc

#---------------------------------------------------#
# RouteTable
#---------------------------------------------------#
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-private-rt

#---------------------------------------------------#
# Subnet1A and RouteTableAssociation
#---------------------------------------------------#
  PrivateSubnet1A:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.16.0/20
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref AWS::Region
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-private-subnet-1a

  PrivateSubnet1ARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet1A

#---------------------------------------------------#
# S3 VPCEndpoint
#---------------------------------------------------#
  RecoveryS3VPCEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      VpcEndpointType: Gateway
      VpcId: !Ref VPC
      RouteTableIds:
        - !Ref PrivateRouteTable

#---------------------------------------------------#
# Parameters Section
#---------------------------------------------------#
  ParamRecoveryVPCId:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /Network/VPCId
      Type: String
      Value: !Ref VPC

  ParamPrivateSubnet1AId:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /Network/PrivateSubnet1AId
      Type: String
      Value: !Ref PrivateSubnet1A

AWSTemplateFormatVersion: "2010-09-09"
Description: Lambda

Parameters:
  Prefix:
    Description: Enter Prefix.
    Type: String
    Default: sample

  ParamVPCId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /Network/VPCId

  ParamPrivate1ASubnetId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /Network/PrivateSubnet1AId

Resources:
#---------------------------------------------------#
# IAM Role for Lambda Function
#---------------------------------------------------#
  LambdaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${Prefix}-lambda-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
        - arn:aws:iam::aws:policy/AmazonS3FullAccess

#---------------------------------------------------#
# SecurityGroup for Lambda Function
#---------------------------------------------------#
  LambdaSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for lambda
      GroupName: !Sub ${Prefix}-lambda-sg
      VpcId: !Ref ParamVPCId

  LambdaSGEgressToANY:
    Type: AWS::EC2::SecurityGroupEgress
    Properties:
         IpProtocol: -1
         CidrIp: 0.0.0.0/0
         GroupId: !Ref LambdaSG

#---------------------------------------------------#
# Lambda Function
#---------------------------------------------------#
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${Prefix}-function
      Role: !GetAtt LambdaFunctionRole.Arn
      Runtime: python3.10
      Handler: index.lambda_handler
      Code:
        ZipFile: |
          import json
          import boto3
          from datetime import datetime
          
          def lambda_handler(event, context):
              s3 = boto3.client('s3')
              bucket_name = 'sample-check-network-status-bucket'
              current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
              file_name = f'trial_{current_time}.txt'
              file_content = 'Hello World!'
              
              s3.put_object(Bucket=bucket_name, Key=file_name, Body=file_content)
              return {
                  'statusCode': 200,
                  'body': json.dumps('Hello from Lambda!')
              }
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSG
        SubnetIds:
          - !Ref ParamPrivate1ASubnetId
      Timeout: 10

#---------------------------------------------------#
# S3 Bucket for checking the network status
#---------------------------------------------------#
  AssetsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${Prefix}-check-network-status-bucket
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      VersioningConfiguration:
        Status: Suspended

状況を再現(スタックを作成する)

ここでは、CloudShellからスタック作成を実施します。

(破壊した環境にもよりますが、おそらく通常では開発環境等で復旧テスト→当該環境で復旧作業となると想定しています。その際に余計な環境差を出さないためにもCloudShellからの実行としています。)

2つのスタックには依存関係があるので、Network.yaml → Lambda.yamlの順でデプロイしていきます。

ファイルをCloudShellにアップロードした後、以下のコマンドを実行します。

# 1. ネットワークスタックを作成
aws cloudformation deploy \
    --template-file Network.yaml \
    --stack-name  sample-network-stack

# 2. Lambdaスタックを作成
aws cloudformation deploy \
    --template-file Lambda.yaml \
    --stack-name  sample-lambda-stack \
    --capabilities CAPABILITY_NAMED_IAM

# 3. ネットワークスタックの管理下にあるリソースを確認
aws cloudformation describe-stack-resources \
    --stack-name sample-network-stack \
    --query "join(', ', StackResources[*].LogicalResourceId)" --output text \
    --output text

3の実行結果は以下となります。

また、Lambda関数を実行してS3バケットにファイルが出力できることも確認しておきます。

# 1. S3バケット内のオブジェクト数を確認
#    → 「Total Objects: 0」と表示される
aws s3 ls s3://sample-check-network-status-bucket \
    --recursive \
    --summarize

# 2. Lambda関数を実行
aws lambda invoke \
    --function-name sample-function \
    outputfile.txt

# 3. S3バケット内のオブジェクト数を確認
#    → 「Total Objects: 1」と表示される
aws s3 ls s3://sample-check-network-status-bucket\
    --recursive \
    --summarize

ここまででスタックの作成はOKです。

状況を再現(スタック管理下からサブネットを外す)

では、ここからはサブネットをスタック管理下から外していきます。

Network.yaml内の記述を修正したDestructive-Network.yamlを用意して、既存のスタックを上書きしましょう。

Destructive-Network.yamlで作成予定のリソースは以下です。

  • VPC
  • ルートテーブル

サブネットを削除しているのがポイントです。

AWSTemplateFormatVersion: "2010-09-09"
Description: Network

Parameters:
  Prefix:
    Description: Enter Prefix.
    Type: String
    Default: sample

Resources:
#---------------------------------------------------#
# VPC
#---------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-vpc

#---------------------------------------------------#
# RouteTable
#---------------------------------------------------#
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-private-rt

ではこのテンプレートをCloudShellにアップロードして、デプロイしちゃいます。

aws cloudformation deploy \
    --template-file Destructive-Network.yaml \
    --stack-name  sample-network-stack

1時間程度経過した後にコンソール画面を確認すると、サブネットの依存関係の問題で数回DELETE_FAILEDとなり、最終的にエラー文は出力されていはいるもののUPDATE_COMPLETEステータスとなっています。

# DELETE_FAILED時のエラー文
Resource handler returned message: "The subnet 'subnet-0dxxxxxxxxxxxxx25' has dependencies and cannot be deleted.

# UPDATE_COMPLETE時の文
Update successful. One or more resources could not be deleted.

ここで、ネットワークスタック管理下にあったサブネットの状況を確認します。

# 1. ネットワークスタックの管理下にあるリソースを確認
aws cloudformation describe-stack-resources \
    --stack-name sample-network-stack \
    --query "join(', ', StackResources[*].LogicalResourceId)" --output text \
    --output text

# 2. Nameを指定して、既存のサブネットから対象のサブネットの情報を表示
aws ec2 describe-subnets \
    --query "Subnets[?Tags[?Key=='Name' && Value=='sample-private-subnet-1a']]"

1の実行結果は以下です。

サブネットはスタック管理下にはいないことが伺えます。

また一方で、2の結果からはサブネットは削除されず環境上に存在しており、たんにスタックから外れているだけだと推測できます。

この状況ですと、今後の運用でNetwork.yamlテンプレート内でサブネットのID等を利用したい場合には値をべた書きすることになります。仮にこのテンプレートを1環境だけで利用する場合はこれでも構いませんが、複数環境で利用している場合には!Ref等で値を渡せないのはかなり厳しいです。

そのため、再度サブネットをスタック管理下に引き戻し、元の状態へ戻す必要がでてくるはずです。

本題:サブネットをスタック管理下に引き戻す

サブネットをスタック管理下に引き戻すためには、既存スタック(sample-network-stack)に対象のサブネットをインポートします。

既存スタックへのインポート機能の詳細については、以下の公式ドキュメントを参照ください。

スタックへの既存リソースのインポート – AWS CloudFormation (amazon.com)

では、順を追ってインポートを実行していきましょう。

1. インポート時の設定ファイルを作成する

インポートコマンド実行時に必要になる設定ファイルを組み立てていきます。

[
    {
        "ResourceType": "AWS::EC2::Subnet",
        "LogicalResourceId": "PrivateSubnet1A",
        "ResourceIdentifier": {
            "SubnetId":"subnet-0dxxxxxxxxxxxxx25"
        }
    }
]

各種キーについて簡単に説明します。

  • ResourceType
    • インポート対象のリソースタイプを指定する
  • LogicalResourceId
    • インポート時に読み込ませるCloudFormationテンプレート上で、インポート対象のリソースに割り当てる論理IDを指定する
    • この記事では最終的に元のNetwork.yamlで上書きするため、そこで使用していた論理IDを使っています
  • ResourceIdentifier
    • インポート対象リソース識別子を指定する

また、それぞれのキーに何を指定すればよいか(ここではSubnetId)は、get-tempate-summeryコマンドで大まかに調査可能です。

aws cloudformation get-template-summary \
    --template-body file://./Network.yaml \
    --query 'ResourceIdentifierSummaries'

2. インポート用のCloudFormationテンプレートを作成する

ここでは、現在デプロイされているCloudFormationテンプレートにインポート対象とするサブネットを追記したものを作成します。

次の2点に注意してください。

  • 既存リソースの設定値は変更しない
  • インポート対象のリソースには、DeletionPolicyとUpdateReplacePolicyを追記する

※その他の削除済みリソースは後ほど再作成します。

AWSTemplateFormatVersion: "2010-09-09"
Description: Network

Parameters:
  Prefix:
    Description: Enter Prefix.
    Type: String
    Default: sample

Resources:
#---------------------------------------------------#
# VPC
#---------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-vpc

#---------------------------------------------------#
# RouteTable
#---------------------------------------------------#
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-private-rt

#---------------------------------------------------#
# Subnet1A
#---------------------------------------------------#
  PrivateSubnet1A:
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.16.0/20
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref AWS::Region
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-private-subnet-1a

3. インポート実行

インポートは次の3ステップで実行していきます。

  1. 変更セットの作成 (CLIコマンド:create-change-set)
  2. 変更セットの確認 (CLIコマンド:describe-change-set)
  3. 変更セットの実行 (CLIコマンド:execute-change-set)

Import.jsonとImport-Network.yamlをCloudShellにアップロードし、コマンドを実行します。

# 1. 変更セットの作成
aws cloudformation create-change-set \
    --stack-name sample-network-stack --change-set-name sample-network-change-set \
    --change-set-type IMPORT \
    --resources-to-import file://Import.json \
    --template-body file://Import-Network.yaml

# 2. 変更セットの確認
aws cloudformation describe-change-set \
    --stack-name sample-network-stack \
    --change-set-name sample-network-change-set

# 3. 変更セットの実行
aws cloudformation execute-change-set \
    --stack-name sample-network-stack \
    --change-set-name sample-network-change-set

サブネットが再びスタック管理下となったことを確認します。

# ネットワークスタックの管理下にあるリソースを確認
aws cloudformation describe-stack-resources \
    --stack-name sample-network-stack \
    --query "join(', ', StackResources[*].LogicalResourceId)" --output text \
    --output text

無事に成功しました。

4. 削除してしまったその他のリソースを再作成する

ここまでくればあと一息です。

最初のスタックではVPCエンドポイントなども管理していましたが、Destructive-Network.yamlで上書きした際にこれらのリソースは削除されてしまいました。

ですので、最後の手順として元のNetwork.yamlでデプロイして元の状態に戻しましょう。

# 1. ネットワークスタックを作成
aws cloudformation deploy \
    --template-file Network.yaml \
    --stack-name  sample-network-stack

# 2. ネットワークスタックの管理下にあるリソースを確認
aws cloudformation describe-stack-resources \
    --stack-name sample-network-stack \
    --query "join(', ', StackResources[*].LogicalResourceId)" --output text \
    --output text

5. 復旧を確認

最後にLambda関数を実行して、ネットワークの疎通に問題がないことを確認して終わりとします。

# 1. Lambda関数を実行
aws lambda invoke \
    --function-name sample-function \
    outputfile.txt

# 2. S3バケット内のオブジェクト数を確認
#    → 「Total Objects: 2」と表示される
aws s3 ls s3://sample-check-network-status-bucket\
    --recursive \
    --summarize

おまけ + 参考文献

おまけ

VPC Lambdaを含むスタックを削除しようとすると、ENIが引っかかって削除に時間がかかることがあります。(私の環境では25分ほどかかりました。)

そんなときのTipsとして、先にCLI操作によってLambdaの設定からVPC設定を外してしまいましょう。

# 1. S3バケットを空にする
aws s3 rm s3://sample-check-network-status-bucket --recursive

# 2. Lambda関数からVPC設定を外す
aws lambda update-function-configuration \
    --function-name sample-function \
    --vpc-config SubnetIds=[],SecurityGroupIds=[]

# 3. Lambdaスタックを削除
aws cloudformation delete-stack \
    --stack-name sample-lambda-stack

# 4. ネットワークスタックを削除(※依存関係があるため、Lambdaスタックが削除されたことを確認してから実行)
aws cloudformation delete-stack \
    --stack-name sample-network-stack

以上です。

参考文献

コメント

タイトルとURLをコピーしました