AWSの猛者たちよ…完全解をください…!
この記事は、Fusic Advent Calendar 2019 19日目の記事です。
究極の機械
こういう機械を見たことはありますか?(画像クリックでYoutubeへ)
結構昔に流行った気もしますが、スイッチを入れると自分で自分のスイッチを切る機械。
これは「役に立たない機械」の一種で、「究極の機械」(Ultimate Machine)と呼ばれるものらしいです(落差が激しい)。
一方、情報理論において有名になった「役に立たない機械」は、マサチューセッツ工科大学(MIT)の教授で人工知能研究の草分けであるマーヴィン・ミンスキーが発明したといわれる装置のことである。箱についている地味なスイッチを「オン」にすると、箱の中から手かレバーが現れ、スイッチを「オフ」にしてまた箱のなかに消える[4]。この装置をミンスキーが考えついたのは、ベル研究所の大学院生であった1952年だとされる[1]。彼は自分の発明品を「究極の機械」と名付けたが、この表現そのものはあまり有名にはならなかった[1]。この装置は「ほっといてよボックス」("Leave Me Alone Box")とも呼ばれている[5]。
究極のCloudFormation
ということで、究極のCloudFormationをSAMで作ってみようとアレコレやってみました。
デプロイすると自分で自分のStackを削除するCloudFormationです。
最初はすぐできると思っていたのですが、こだわりだすとなかなか難しいんです、これが。
CloudWatchEventsを使う作戦
とりあえず作ってみました。CloudWatchEventsを使う作戦です。
1分毎に発火するCloudWatchEventを作成し、そこからStackを削除するLambdaを実行します。
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
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
ポイントとしては2点。
一つが、Lambdaに与えるPermissionについて。
LambdaからStackの削除を行うので、削除処理内で行う操作の権限を持たせる必要がありました。
言われてみれば確かにそうですが、エラーを見て気づきました。盲点でした。
この部分です(いろいろ混在させてお行儀悪いですが)。
- Effect: 'Allow'
Action:
- 'lambda:RemovePermission'
- 'lambda:DeleteFunction'
- 'events:RemoveTargets'
- 'events:DeleteRule'
- 'iam:DeleteRolePolicy'
- 'iam:DeleteRole'
Resource: '*'
もう一つが、Lambda内でのStack名の取得方法。
自身の関数名を環境変数に持たせ、その論理IDを持っているStackを取得しています。
なんか間接的な感じでイマイチ。。。
# 関数名からスタック名取得
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の名前を取ることはできないのかと考えていたら、よさげなのがありました。
なるほど、タグを取ってくればいいわけですね。
とはいえ、Lambdaのタグを取得するにはLambdaのARNが必要。
Lambdaが自身のARNを取得することはできるのか?できなさそうだな。。ということで環境変数に持たせてみました。
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を渡すような設定にしています。
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
- "\"}"
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に。スッキリ!
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
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の作成が完了する前に削除が始まることがありました。
これは単純に作成完了まで待つようにしましょう。
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に通知してくれるみたいです。
ただ、これは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のサブスクリプションは子スタックで設定しています。
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
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
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が呼ばれてしまうので、
親スタックからしてみれば構築完了前に削除されたことになるみたいです。
親スタックの作成完了まで待つ
親スタックのステータスを見て、完了してから削除するようにしました。
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でスタックの通知オプションが設定できれば…!
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