15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FusicAdvent Calendar 2019

Day 19

究極のCloudFormationをたずねて三千里

Last updated at Posted at 2019-12-18

AWSの猛者たちよ…完全解をください…!

この記事は、Fusic Advent Calendar 2019 19日目の記事です。

究極の機械

こういう機械を見たことはありますか?(画像クリックでYoutubeへ)
Claude Shannon Ultimate Machine

結構昔に流行った気もしますが、スイッチを入れると自分で自分のスイッチを切る機械。
これは「役に立たない機械」の一種で、「究極の機械」(Ultimate Machine)と呼ばれるものらしいです(落差が激しい)。

一方、情報理論において有名になった「役に立たない機械」は、マサチューセッツ工科大学(MIT)の教授で人工知能研究の草分けであるマーヴィン・ミンスキーが発明したといわれる装置のことである。箱についている地味なスイッチを「オン」にすると、箱の中から手かレバーが現れ、スイッチを「オフ」にしてまた箱のなかに消える[4]。この装置をミンスキーが考えついたのは、ベル研究所の大学院生であった1952年だとされる[1]。彼は自分の発明品を「究極の機械」と名付けたが、この表現そのものはあまり有名にはならなかった[1]。この装置は「ほっといてよボックス」("Leave Me Alone Box")とも呼ばれている[5]。

究極のCloudFormation

ということで、究極のCloudFormationをSAMで作ってみようとアレコレやってみました。
デプロイすると自分で自分のStackを削除するCloudFormationです。
最初はすぐできると思っていたのですが、こだわりだすとなかなか難しいんです、これが。

スクリーンショット 2019-12-03 7.11.53.png

CloudWatchEventsを使う作戦

とりあえず作ってみました。CloudWatchEventsを使う作戦です。
1分毎に発火するCloudWatchEventを作成し、そこからStackを削除するLambdaを実行します。

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Ultimate CloudFormation
Parameters:
  LambdaFunctionName:
    Type: String
    Default: UltimateLambda

Resources:
  UltimateLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      FunctionName: !Ref LambdaFunctionName
      Timeout: 300
      Role: !GetAtt UltimateLambdaRole.Arn
      Environment:
        Variables:
          'FUNCTION_NAME': !Ref LambdaFunctionName

  UltimateLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: UltimateLambdaRole
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: UltimateLambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'cloudformation:DescribeStackResources'
                  - 'cloudformation:DeleteStack'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'lambda:RemovePermission'
                  - 'lambda:DeleteFunction'
                  - 'events:RemoveTargets'
                  - 'events:DeleteRule'
                  - 'iam:DeleteRolePolicy'
                  - 'iam:DeleteRole'
                Resource: '*'

  UltimateLambdaEvent:
    Type: AWS::Events::Rule
    Properties:
      Name: ultimate_lambda_event
      ScheduleExpression: 'rate(1 minute)'
      State: ENABLED
      Targets:
        - Arn: !GetAtt UltimateLambda.Arn
          Id: ultimate_lambda

  UltimateLambdaEventPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: 'lambda:InvokeFunction'
      FunctionName: !GetAtt UltimateLambda.Arn
      Principal: 'events.amazonaws.com'
      SourceArn: !GetAtt UltimateLambdaEvent.Arn
app.rb
require 'json'
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  # 関数名からスタック名取得
  cfn = Aws::CloudFormation::Client.new
  resp = cfn.describe_stack_resources({
    physical_resource_id: ENV['FUNCTION_NAME']
  })
  stack_name = resp.stack_resources.first.stack_name

  # スタックを削除
  cfn.delete_stack(stack_name: stack_name)
end

ちゃんと作成後に自動で削除されています。
スクリーンショット 2019-12-03 6.12.38.png

ポイントとしては2点。

一つが、Lambdaに与えるPermissionについて。
LambdaからStackの削除を行うので、削除処理内で行う操作の権限を持たせる必要がありました。
言われてみれば確かにそうですが、エラーを見て気づきました。盲点でした。

この部分です(いろいろ混在させてお行儀悪いですが)。

template.yaml
              - Effect: 'Allow'
                Action:
                  - 'lambda:RemovePermission'
                  - 'lambda:DeleteFunction'
                  - 'events:RemoveTargets'
                  - 'events:DeleteRule'
                  - 'iam:DeleteRolePolicy'
                  - 'iam:DeleteRole'
                Resource: '*'

もう一つが、Lambda内でのStack名の取得方法。
自身の関数名を環境変数に持たせ、その論理IDを持っているStackを取得しています。
なんか間接的な感じでイマイチ。。。

app.rb
  # 関数名からスタック名取得
  cfn = Aws::CloudFormation::Client.new
  resp = cfn.describe_stack_resources({
    physical_resource_id: ENV['FUNCTION_NAME']
  })
  stack_name = resp.stack_resources.first.stack_name

エレガントにStack名を取得する

そもそも自分が所属するStackの名前を取ることはできないのかと考えていたら、よさげなのがありました。
スクリーンショット_2019-12-03_6_07_10.png

なるほど、タグを取ってくればいいわけですね。
とはいえ、Lambdaのタグを取得するにはLambdaのARNが必要。
Lambdaが自身のARNを取得することはできるのか?できなさそうだな。。ということで環境変数に持たせてみました。

template.yaml
  UltimateLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      FunctionName: UltimateLambda
      Timeout: 300
      Role: !GetAtt UltimateLambdaRole.Arn
      Environment:
        Variables:
          'FUNCTION_ARN': !GetAtt UltimateLambda.Arn

デプロイしようとするとエラーが。

Error: Failed to create changeset for the stack: ultimate-cfn, ex: Waiter ChangeSetCreateComplete failed: Waiter encountered a terminal failure state Status: FAILED. Reason: Circular dependency between resources: [UltimateLambdaEventPermission, UltimateLambda, UltimateLambdaEvent]

そうですよね、関数ができる前から自身のARNなんて取得できないですよね。

ということで、CloudWatchEventsの方から渡してもらうようにしてみました。
固定jsonを渡すような設定にしています。

template.yaml
  UltimateLambdaEvent:
    Type: AWS::Events::Rule
    Properties:
      Name: ultimate_lambda_event
      ScheduleExpression: 'rate(1 minute)'
      State: ENABLED
      Targets:
        - Arn: !GetAtt UltimateLambda.Arn
          Id: ultimate_lambda
          Input: !Join
            - ''
            - - "{\"lambda_arn\": \""
              - !GetAtt UltimateLambda.Arn
              - "\"}"
app.rb
require 'json'
require 'aws-sdk-lambda'
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  # スタック名を取得
  lambda_client = Aws::Lambda::Client.new
  resp = lambda_client.list_tags({resource: event["lambda_arn"]})
  stack_name = resp.tags["aws:cloudformation:stack-name"]

  # スタックを削除
  cfn_client = Aws::CloudFormation::Client.new
  cfn_client.delete_stack(stack_name: stack_name)
end

もっとエレガントにStack名を取得する

もうちょっと何かないかと調べていたら、疑似パラメーターにあるじゃないですか!
これを使えば、Lambdaの環境変数に渡しておくだけでOKに。スッキリ!

template.yaml
  UltimateLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      FunctionName: UltimateLambda
      Timeout: 300
      Role: !GetAtt UltimateLambdaRole.Arn
      Environment:
        Variables:
          STACK_NAME: !Ref AWS::StackName
app.rb
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  cfn_client = Aws::CloudFormation::Client.new
  cfn_client.delete_stack(stack_name: ENV['STACK_NAME'])
end

Stackの作成が完了するまで待つ

スタック名を取得する部分は改善されましたが、何度か試しているとCloudWatchEventsが先走ってStackの作成が完了する前に削除が始まることがありました。
スクリーンショット 2019-12-03 6.10.36.png

これは単純に作成完了まで待つようにしましょう。

app.rb
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  stack_name = ENV['STACK_NAME']
  cfn_client = Aws::CloudFormation::Client.new
  resp = cfn_client.describe_stacks(stack_name: stack_name)
  if resp.stacks.first.stack_status == "CREATE_COMPLETE"
    cfn_client.delete_stack(stack_name: stack_name)
  end
end

Stackができた瞬間に削除したい

今まではCloudWatchEventsを使ってLambdaを実行してきましたが、
どうせならもっと直接的に、Stackができた瞬間に削除したい。ということで考えていると、
Stack情報の中に「通知オプション」なるものが。
StackのアクションをSNSに通知してくれるみたいです。
スクリーンショット 2019-12-03 6.38.04.png

ただ、これはtemplate.yamlに書くのではなく、デプロイ時に引数として渡すもの。
これから作ろうとしているSNSのtopicを指定できるのか…?

試しに存在しないtopicを指定してみると…。

$ sam deploy --notification-arns arn:aws:sns:ap-northeast-1:`aws sts get-caller-identity --query 'Account' --output text`:test
Error: Failed to create changeset for the stack: ultimate-cfn, An error occurred (ValidationError) when calling the CreateChangeSet operation: Topic does not exist (Service: AmazonSNS; Status Code: 404; Error Code: NotFound; Request ID: d3180880-7dcb-5978-a450-5ede3fdd2a7a)

やっぱりか…。
ということは、SNSを先に作らなければ。スタックをネストさせれば、子スタックには通知オプションを指定できるようで、それでどうにかなるか…?

Stackをネストさせる

親スタックでSNS Topicを作成し、子スタックの通知オプションに入れてみました。
SNSのサブスクリプションは子スタックで設定しています。

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Ultimate CloudFormation

Parameters:
  LambdaFunctionName:
    Type: String
    Default: UltimateLambda

Resources:
  StackEventSns:
    Type: AWS::SNS::Topic
    Properties:
      DisplayName: StackEventSns
      TopicName: StackEventSns

  ChildStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: template_child.yaml
      Parameters:
        StackEventSnsArn: !Ref StackEventSns
        StackName: !Ref AWS::StackName
      NotificationARNs:
        - !Ref StackEventSns
template_child.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Ultimate CloudFormation

Parameters:
  StackEventSnsArn:
    Type: String

  StackName:
    Type: String

Resources:
  UltimateLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      FunctionName: UltimateLambda
      Timeout: 300
      Role: !GetAtt UltimateLambdaRole.Arn
      Environment:
        Variables:
          PARENT_STACK_NAME: !Ref StackName
          CHILD_STACK_NAME: !Ref AWS::StackName

  UltimateLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: UltimateLambdaRole
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: UltimateLambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'cloudformation:DescribeStacks'
                  - 'cloudformation:DeleteStack'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'lambda:RemovePermission'
                  - 'lambda:DeleteFunction'
                  - 'iam:DeleteRolePolicy'
                  - 'iam:DeleteRole'
                  - 'sns:Unsubscribe'
                  - 'sns:DeleteTopic'
                  - 'sns:GetTopicAttributes'
                Resource: '*'

  UltimateLambdaPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: 'lambda:InvokeFunction'
      FunctionName: !GetAtt UltimateLambda.Arn
      Principal: 'sns.amazonaws.com'
      SourceArn: !Ref StackEventSnsArn

  StackEventSnsSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint: !GetAtt UltimateLambda.Arn
      Protocol: lambda
      TopicArn: !Ref StackEventSnsArn
app.rb
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  msg =  event['Records'].first.dig('Sns', 'Message')
  if msg.include?("ResourceStatus='CREATE_COMPLETE'") &&
      msg.include?("LogicalResourceId='#{ENV['CHILD_STACK_NAME']}'")
    cfn_client = Aws::CloudFormation::Client.new
    cfn_client.delete_stack(stack_name: ENV['PARENT_STACK_NAME'])
  end
end

でもやっぱり、子スタックの構築完了時にLambdaが呼ばれてしまうので、
親スタックからしてみれば構築完了前に削除されたことになるみたいです。
スクリーンショット 2019-12-07 19.27.53.png

親スタックの作成完了まで待つ

親スタックのステータスを見て、完了してから削除するようにしました。

app.rb
require 'aws-sdk-cloudformation'

def lambda_handler(event:, context:)
  msg =  event['Records'].first.dig('Sns', 'Message')
  if msg.include?("ResourceStatus='CREATE_COMPLETE'") &&
      msg.include?("LogicalResourceId='#{ENV['CHILD_STACK_NAME']}'")
    cfn_client = Aws::CloudFormation::Client.new
    while true
      sleep(1)
      stack_name = ENV['PARENT_STACK_NAME']
      stacks = cfn_client.describe_stacks(stack_name: stack_name)
      next unless stacks[:stacks].first[:stack_status] == 'CREATE_COMPLETE'
      cfn_client.delete_stack(stack_name: stack_name)
    end
  end
end

まぁ期待通りには動くんですが、これもやっぱり間接的な感じ。
Lambdaの中で親スタックのステータスを監視するのであれば、何がトリガーでも変わらないし。。
せっかくネストさせましたが、これじゃあCloudWatchEventsとあんまり変わらないですね。

なんとかして親スタックに通知オプションを設定したい…!

カスタムリソースを使う

かくなる上はカスタムリソース!
CloudFormationで提供されていない処理を自分で作れるやつです。
「AWS Lambda-backed カスタムリソース」を使えばスタック構築時にLambdaが呼び出せるので、基本的に何でもできます。
そのLambdaでスタックの通知オプションが設定できれば…!

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Ultimate CloudFormation

Resources:
  UltimateLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: ruby2.5
      FunctionName: UltimateLambda
      Timeout: 300
      Role: !GetAtt UltimateLambdaRole.Arn
      Environment:
        Variables:
          STACK_NAME: !Ref AWS::StackName

  UltimateLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: UltimateLambdaRole
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: UltimateLambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'cloudformation:UpdateStack'
                  - 'cloudformation:DescribeStacks'
                  - 'cloudformation:DeleteStack'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'lambda:RemovePermission'
                  - 'lambda:DeleteFunction'
                  - 'iam:DeleteRolePolicy'
                  - 'iam:DeleteRole'
                  - 'sns:Unsubscribe'
                  - 'sns:DeleteTopic'
                  - 'sns:GetTopicAttributes'
                Resource: '*'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  UltimateLambdaPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: 'lambda:InvokeFunction'
      FunctionName: !GetAtt UltimateLambda.Arn
      Principal: 'sns.amazonaws.com'
      SourceArn: !Ref StackEventSns

  NotificationAttachToStackLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import cfnresponse
          import boto3
          def handler(event, context):
              try:
                  if event['RequestType'] == 'Create':
                      stack_name = event['ResourceProperties']['StackName']
                      topic_arn = event['ResourceProperties']['TopicArn']
                      client = boto3.client('cloudformation')
                      client.update_stack(StackName=stack_name, UsePreviousTemplate=True, NotificationARNs=[topic_arn])
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              except Exception as e:
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'error': e.args})

      Handler: index.handler
      Role: !GetAtt NotificationAttachToStackLambdaRole.Arn
      Runtime: python3.7

  NotificationAttachToStackLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: NotificationAttachToStackLambdaRole
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: NotificationAttachToStackLambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'cloudformation:UpdateStack'
                Resource: '*'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  CustomResourceNotificationAttachToStack:
    Type: Custom::NotificationAttachToStackLambda
    Version: 1.0
    Properties:
      ServiceToken: !GetAtt NotificationAttachToStackLambda.Arn
      StackName: !Ref AWS::StackName
      TopicArn: !Ref StackEventSns

  StackEventSns:
    Type: AWS::SNS::Topic
    Properties:
      DisplayName: StackEventSns
      TopicName: StackEventSns
      Subscription:
        - Endpoint: !GetAtt UltimateLambda.Arn
          Protocol: lambda

これで、CustomResourceNotificationAttachToStackの作成、更新、削除時にNotificationAttachToStackLambdaが呼ばれます。

で、意気揚々とLambdaのコードを書こうと思ったら、スタックの通知オプション単体を変更するような関数がない。
ということで、スタックの更新をかけてみました。スタックの作成中なのにそんなことができるのか…!?

client.update_stack(StackName=stack_name, UsePreviousTemplate=True, NotificationARNs=[topic_arn])

結果…

An error occurred (ValidationError) when calling the UpdateStack operation: Stack:arn:aws:cloudformation:ap-northeast-1:945554759482:stack/ultimate-cfn/200eca90-1980-11ea-bf5b-06ef10f7e39e is in CREATE_IN_PROGRESS state and can not be updated.

無理でした\(^o^)/

まとめ

軽い気持ちで始めたら、思いがけず壮大な旅になってしまいました。
でもまだ理想的な究極のCloudFormationは見つかっていません。。
俺たちの戦いはこれからだ!
(よりよい解法をお待ちしております。)

理想形は以下のとおり。

  • CloudFormationで作成したリソースをすべて削除する
  • StackができたタイミングでのトリガーでLambdaが発火する(Lambda内でループ待機しない)

参考までにコードはこちらに置いています(コミットログを見ればココに書いた紆余曲折も)。
https://github.com/Kta-M/ultimate_cloudformation

15
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?