slackからIPアドレスを指定してEC2の作成、削除を実施
前提
- 起動したいEC2のAMIの作成
- セキュリティグループで許可するIPアドレスの指定
やりたいこと
- SlackコマンドでEC2の作成から起動、削除
- 作成されたEC2のIPアドレスをSlackへ送信
構成図
詳細
指定したSlackコマンドからAPI Gateway経由でLambdaにリクエストを送り、CloudFormationの作成・削除を行います。使用されるセキュリティグループは、Slackコマンド内に記載されたIPアドレスで新規作成されます。CloudFormation完了時に、起動したEC2のパブリックIPアドレスをSlackのWebhookに通知します。
作成時に面倒な点
- Slackコマンドのレスポンスのタイムアウト(3秒)
- リクエストから3秒以内にレスポンスが返ってこないとタイムアウトになる
- Chatbotを使用した場合、JSON形式での送信ができない
- SlackのWebhookへの送信メッセージがパブリックIPがJSON形式であるため、Chatbotが使えない
使用するAWSリソース
- S3: CloudFormationテンプレートの保存先
- SNS: Lambda間の連携
- IAM: 各Lambdaで使用するポリシーとロール
- API Gateway: Lambdaのエンドポイント
- Lambda: EC2起動、削除、Slack通知を行う
- CloudFormation: インフラの設定
Slack側の設定
下記設定のみを紹介します。Slack自体の作成などはご自身で行なってください。
- Slackコマンドの設定
- webhookの設定
リソース内容
S3
S3は下記紹介しているCloudFormationのテンプレートの保存先です。
テンプレートの保管のみであるため、省きます。
SNS
SNSは下記で作成しているLambda②と④を連携するために使用しています。
作成だけしかしていないため、省きます。
API Gateway
新規でAPIを作成し、リソースを作成します。
-
統合リクエスト
総合タイプ: Lambda関数
Lambda プロキシ統合: False
入力パススルー: リクエストの content-type ヘッダーに一致するテンプレートがない場合
Lambda関数: Lambda①を指定
マッピングテンプレート: application/x-www-form-urlencoded
テンプレートを生成: 空白
テンプレート本文: 下記## See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html ## This template will pass through all parameters including path, querystring, header, stage variables, and context through to the integration endpoint via the body/payload #set($allParams = $input.params()) { "body-json" : $input.json('$'), "params" : { #foreach($type in $allParams.keySet()) #set($params = $allParams.get($type)) "$type" : { #foreach($paramName in $params.keySet()) "$paramName" : "$util.escapeJavaScript($params.get($paramName))" #if($foreach.hasNext),#end #end } #if($foreach.hasNext),#end #end }, "stage-variables" : { #foreach($key in $stageVariables.keySet()) "$key" : "$util.escapeJavaScript($stageVariables.get($key))" #if($foreach.hasNext),#end #end }, "context" : { "account-id" : "$context.identity.accountId", "api-id" : "$context.apiId", "api-key" : "$context.identity.apiKey", "authorizer-principal-id" : "$context.authorizer.principalId", "caller" : "$context.identity.caller", "cognito-authentication-provider" : "$context.identity.cognitoAuthenticationProvider", "cognito-authentication-type" : "$context.identity.cognitoAuthenticationType", "cognito-identity-id" : "$context.identity.cognitoIdentityId", "cognito-identity-pool-id" : "$context.identity.cognitoIdentityPoolId", "http-method" : "$context.httpMethod", "stage" : "$context.stage", "source-ip" : "$context.identity.sourceIp", "user" : "$context.identity.user", "user-agent" : "$context.identity.userAgent", "user-arn" : "$context.identity.userArn", "request-id" : "$context.requestId", "resource-id" : "$context.resourceId", "resource-path" : "$context.resourcePath" } }
- 統合レスポンス
Content handling: パススルー
マッピングテンプレート: application/json
設定後、APIをデプロイします
- 統合レスポンス
IAM
- 全てのLambdaで使用する信頼されたエンティティ
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
-
Lambda①で使用するポリシー
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "*" }, { "Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": [ "Lambda②のarn", "Lambda③のarn" ] } ] }
-
Lambda②③で使用するポリシー
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:CreateSecurityGroup", "ec2:DescribeSecurityGroups", "ec2:DescribeVpcs", "ec2:RunInstances", "ec2:DescribeInstances", "ec2:TerminateInstances", "ec2:CreateTags", "ec2:DescribeSubnets", "ec2:DescribeNetworkInterfaces", "ec2:AttachNetworkInterface", "ec2:DeleteSecurityGroup" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "*" }, { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "EC2へアタッチするIAMのarn" }, { "Effect": "Allow", "Action": [ "cloudformation:CreateStack", "cloudformation:DescribeStacks", "cloudformation:DeleteStack", "cloudformation:DescribeStackEvents", "cloudformation:DescribeStackResources", "cloudformation:GetTemplateSummary" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": [ "s3のarn", "s3のarn/*" ] }, { "Effect": "Allow", "Action": "sns:Publish", "Resource": "snsのarn" } ] }
-
Lambda④で使用するポリシー
snsのarnは作成したsnsのarnを指定してください。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "snsのarn"
}
]
}
Lambda
- Lambda①: API Gatewayからのリクエストを受け取る
Slackコマンドの内容によって後続のEC2起動用または削除用のLambdaを
非同期で呼び出しを実行している。
非同期で呼び出している理由はSlackがタイムアウトしないようにです。
import json
import logging
import boto3
client = boto3.client('lambda')
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
# Slackからのリクエストボディをログに出力
logger.info('Event: %s', json.dumps(event))
try:
# リクエストパラメータを取得
command = event.get('command', None)
text = event.get('text', '')
if not command or not text:
return {
'statusCode': 400,
'body': 'Missing parameter: command or text'
}
command_text = command + ' ' + text
words = text.split(' ')
logger.info("Command executed: " + command_text)
if len(words) < 1:
return {
'statusCode': 400,
'body': 'コマンドを指定してください。'
}
keyword = words[0]
ip_address = words[1] if len(words) > 1 else None
if keyword == '起動':
if not ip_address:
return {
'statusCode': 400,
'body': 'IPアドレスを指定してください。'
}
target_function_name = 'boot-cloudformation-proxy'
payload = {
'message': 'ip_addressを送ります',
'ip_address': ip_address
}
response = client.invoke(
FunctionName=target_function_name,
InvocationType='Event', # 非同期呼び出し
Payload=json.dumps(payload)
)
return {
'statusCode': 200,
'body': '環境を作成しています。'
}
if keyword == '削除':
target_function_name = 'stop-cloudformation-proxy'
payload = {
'message': '環境を削除します。'
}
response = client.invoke(
FunctionName=target_function_name,
InvocationType='Event', # 非同期呼び出し
Payload=json.dumps(payload)
)
return {
'statusCode': 200,
'body': '環境を削除します。'
}
return {
'statusCode': 400,
'body': f'無効なキーワード: {keyword}'
}
except KeyError as e:
return {
'statusCode': 400,
'body': f'Missing parameter: {str(e)}'
}
except Exception as e:
return {
'statusCode': 500,
'body': f'Error: {str(e)}'
}
-
Lambda②: CloudFormationスタック作成
①のLambdaより呼び出され、CloudFormationのスタックの作成をする
スタック作成後にSNSへ連携しています。
このSNSの連携を受けて④のLambdaを起動させております。Lambda環境変数には下記を設定しています。
キー 値 SnsTopicArn ④のsnsのarn StackName proxy-ec2 TemplateURL ⑦S3のflex VpcId ⑧EC2を設置するVPCID
import json
import boto3
import os
import time
cloudformation = boto3.client('cloudformation')
sns = boto3.client('sns')
ec2 = boto3.client('ec2')
def send_sns_message(message):
sns_topic_arn = os.environ.get('SnsTopicArn')
if not sns_topic_arn:
raise ValueError("SnsTopicArn environment variable is not set")
response = sns.publish(
TopicArn=sns_topic_arn,
Message=message,
Subject='CloudFormation Stack Notification'
)
return response
def wait_for_stack_creation(stack_name):
while True:
response = cloudformation.describe_stacks(StackName=stack_name)
stack_status = response['Stacks'][0]['StackStatus']
if stack_status == 'CREATE_COMPLETE':
return response['Stacks'][0]
elif 'FAILED' in stack_status or 'ROLLBACK' in stack_status:
raise Exception(f"Stack creation failed with status: {stack_status}")
time.sleep(30) # 30秒ごとにステータスをチェック
def get_public_ip_from_stack(stack):
outputs = stack.get('Outputs', [])
instance_id = None
for output in outputs:
if (output.get('OutputKey') == 'InstanceId'):
instance_id = output.get('OutputValue')
break
if not instance_id:
raise ValueError("InstanceId not found in stack outputs")
reservations = ec2.describe_instances(InstanceIds=[instance_id])['Reservations']
if not reservations:
raise ValueError(f"No reservations found for instance {instance_id}")
instances = reservations[0]['Instances']
if not instances:
raise ValueError(f"No instances found in reservation for instance {instance_id}")
public_ip = instances[0].get('PublicIpAddress')
if not public_ip:
raise ValueError(f"No public IP address found for instance {instance_id}")
return public_ip
def lambda_handler(event, context):
try:
ip_address = event.get('ip_address')
message = event.get('message')
vpc_id = os.environ.get('VpcId')
stack_name = os.environ.get('StackName')
template_url = os.environ.get('TemplateURL')
if not vpc_id:
raise ValueError("VpcId environment variable is not set")
if not ip_address:
raise ValueError("ip_address is not provided in the event")
if not stack_name:
raise ValueError("StackName environment variable is not set")
if not template_url:
raise ValueError("TemplateURL environment variable is not set")
cidr_ip_address = f"{ip_address}/32"
response = cloudformation.create_stack(
StackName=stack_name,
TemplateURL=template_url,
Parameters=[
{
'ParameterKey': 'IpAddress',
'ParameterValue': cidr_ip_address
},
{
'ParameterKey': 'VpcId',
'ParameterValue': vpc_id
}
],
Capabilities=['CAPABILITY_NAMED_IAM']
)
stack_id = response['StackId']
message = f"Stack {stack_name} creation initiated. StackId: {stack_id}"
# スタックの作成完了を待つ
stack = wait_for_stack_creation(stack_name)
# メッセージを更新してSNSメッセージを送信
message = f"Stack {stack_name} created successfully."
send_sns_message(message)
return {
'statusCode': 200,
'body': json.dumps({
'message': message,
})
}
except Exception as e:
error_message = f"Error creating stack: {str(e)}"
print(error_message)
# エラーメッセージをSNSトピックに送信
send_sns_message(error_message)
return {
'statusCode': 500,
'body': json.dumps({
'message': 'Error creating stack',
'error': str(e)
})
}
- Lambda③: CloudFormationスタック削除
①のLambdaより呼び出され、②で作成したCloudFormationのスタックの削除します。
変数stack_nameに指定されているスタックを削除します。
Lambda環境変数には下記を設定しています。キー 値 stack_name proxy-ec2
import json
import boto3
import os
cloudformation = boto3.client('cloudformation')
def lambda_handler(event, context):
try:
# イベントからスタック名を取得
stack_name = os.environ.get('stack_name')
# デバッグのためのログ出力
print(f"Received event: {json.dumps(event)}")
print(f"StackName: {stack_name}")
if not stack_name:
raise ValueError("stack_name is not provided in the event")
# CloudFormationスタックを削除
response = cloudformation.delete_stack(
StackName=stack_name
)
print(f"Deleting stack: {stack_name}")
# スタック削除のレスポンスを返す
return {
'statusCode': 200,
'body': json.dumps({
'message': f'Stack {stack_name} deletion initiated.'
})
}
except Exception as e:
print(f"Error: {str(e)}")
return {
'statusCode': 500,
'body': json.dumps({
'message': 'Error deleting stack',
'error': str(e)
})
}
- Lambda④: Slackへの通知
スタック作成時に連携されているSNSにより起動します。
起動することによってSlackのwebhookへIPアドレスの通知を行っています。
requestsがLambdaコンソールからだとインポートできないので、ローカルでLambdaレイヤーを作成して、
zipファイルでアップロードします。
仮想環境を作成したいディレクトリを作成します。
作成したディレクトリにLambdaをコピーします。
ローカルで仮想環境を作成します。
python3 -m venv vnev
パッケージのインストール
source venv/bin/activate
pip install requests
スクリプトとライブラリのパッケージ化
deactivate
mkdir package
現状ファイル配置
ls -la
lambda_function.py
package
venv
cd package
cp -r ../venv/lib/python3.*/site-packages/* .
cp ../venv/lib64/python3.*/site-packages/* .
cp ../lambda_function.py .
zip -r ../my_lambda_function.zip .
Lambda環境変数には下記を設定しています。
キー | 値 |
---|---|
SLACK_WEBHOOK_URL | webhookのURL |
import boto3
import logging
import os
import requests
slack_webhook_url = os.environ['SLACK_WEBHOOK_URL']
# ログ設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
ec2 = boto3.client('ec2', region_name='ap-northeast-1')
message = ''
try:
instances = ec2.describe_instances()
public_ips = []
target_tag_key = 'Name' # ターゲットとするタグのキー
target_tag_value = 'proxy-ec2'
for reservation in instances['Reservations']:
for instance in reservation['Instances']:
tags = {tag['Key']: tag['Value'] for tag in instance.get('Tags', [])}
if tags.get(target_tag_key) == target_tag_value:
public_ip = instance.get('PublicIpAddress')
if public_ip:
public_ips.append(public_ip)
logger.info(f'Found public IP: {public_ip}')
if public_ips:
message = '作成されたプロキシサーバーのパブリックIPアドレスは ' + ', '.join(public_ips) + ' になります。'
else:
message = '指定されたタグを持つインスタンスは見つかりませんでした。'
# Slack通知を送信
payload = {
'text': message
}
response = requests.post(slack_webhook_url, json=payload) # Slack WebhookにPOSTリクエストを送信
if response.status_code != 200:
logger.error(f'Slack通知の送信に失敗しました: {response.text}')
except Exception as e:
logger.error(f'Error retrieving instances or publishing to Slack: {str(e)}')
error_message = 'エラーが発生しました: ' + str(e)
payload = {
'text': error_message
}
try:
response = requests.post(slack_webhook_url, json=payload) # エラーメッセージをSlackに送信
if response.status_code != 200:
logger.error(f'Slack通知の送信に失敗しました: {response.text}')
except Exception as slack_error:
logger.error(f'Slack通知の送信中にエラーが発生しました: {str(slack_error)}')
return {
'statusCode': 200,
'body': message
}
CloudFormation
- CloudFormation⑤: 親スタック
②スタック作成用Lambdaより、IPアドレスと起動先VPCIDを受け取ってパラメータの値に設定しています。
ネスト先は下記リソースを作成しています。
EC2の作成用のCloudFormation・・・⑥
セキュリティグループ作成用のCloudFormation・・・⑦
AWSTemplateFormatVersion: '2010-09-09'
Description: Parent template to create EC2 instance with a security group
Parameters:
VpcId:
Type: String
Description: The VPC ID
IpAddress:
Type: String
Description: The IP address to allow in the security group
Resources:
SecurityGroupStack:
Type: 'AWS::CloudFormation::Stack'
Properties:
TemplateURL: 's3保存先'
Parameters:
VpcId: !Ref VpcId
IpAddress: !Ref IpAddress
EC2InstanceStack:
Type: 'AWS::CloudFormation::Stack'
Properties:
TemplateURL: 's3保存先'
Parameters:
SecurityGroupId: !GetAtt SecurityGroupStack.Outputs.MySecurityGroupId
Outputs:
EC2InstanceId:
Value: !GetAtt EC2InstanceStack.Outputs.MyEC2InstanceId
Description: The EC2 Instance ID
- CloudFormation⑥: EC2作成用ネスト先
ResourcesにはAMIなどは自分で好きに設定してください。
AWSTemplateFormatVersion: '2010-09-09'
Description: Create a EC2 instance for proxy with EBS, network interface, and public IP
Parameters:
SecurityGroupId:
Type: String
Description: The Security Group ID
Resources:
MyEC2Instance:
Type: 'AWS::EC2::Instance'
Properties:
InstanceType: t2.micro
KeyName: 'キー名'
ImageId: 'AMI'
IamInstanceProfile: 'ロール名'
NetworkInterfaces:
- AssociatePublicIpAddress: true
DeviceIndex: 0
SubnetId: 'サブネットID'
GroupSet:
- !Ref SecurityGroupId
Description: Primary network interface
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 20
VolumeType: gp2
DeleteOnTermination: true
Tags:
- Key: Name
Value: Name
- Key: project
Value: project
Outputs:
MyEC2InstanceId:
Value: !Ref MyEC2Instance
Description: The EC2 Instance ID
- CloudFormation⑦: セキュリティグループ作成用ネスト先
親スタックよりIPアドレス、VPCIDを受け取りセキュリティグループの作成をしています。
AWSTemplateFormatVersion: '2010-09-09'
Description: Create a security group with an IP address passed from Lambda
Parameters:
IpAddress:
Type: String
Description: The IP address to allow in the security group
VpcId:
Type: AWS::EC2::VPC::Id
Description: The ID of the VPC where the security group will be created
Resources:
MySecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow access from specific IP
VpcId: !Ref VpcId
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: !Ref IpAddress
Outputs:
MySecurityGroupId:
Value: !Ref MySecurityGroup
Description: The Security Group ID
Slackコマンドの設定
Slack app の、Create New AppよりSlack コマンドを作成します。
Slack api
Create an app
From an app manifestを選択
Pick a workspace to develop your app inでSlackコマンドを実装したいワークスペースを選択して「Next]
Enter app manifest below
JSONの方にして「Next」
Review summary & create your app
「Create」を押すとAPPが追加される
作成されたAPPの設定
Slash Commandsに移動して「Create New Command」でSlackコマンドを作る
New Command
設定をしたら「Save」を押す
- Command コマンド名を入れる
例)/proxy
- Request URL API GatewayのデプロイしたURL
API Gatewayのステージの「URL を呼び出す」のURL
- Short Description
このコマンドの説明とかを入れとく
- Usage Hint
[コマンド][IPアドレス]
起動のコマンドを実施する際はIPアドレスが必要のため
Slackコマンド作成後に、Basic Informationの「Install your app」を押すことにより、ワークスペースにSlackコマンドが追加されます。
webhookの設定
Slackコマンドの設定のようにAPPを作成します。
Incoming Webhooksページに移動して、トグルボタンをONにする。
ページ下部の「Add New Webhook to Workspace」を押してwebhookでの投稿先のワークスペースを選択して許可します。
作成されたWebhook URLをLambda④の環境変数に設定する。
これでLambdaのメッセージがwebhookへ投稿されます。
確認
EC2の起動
Slackコマンドを追加したワークスペースで、下記投稿をします。
/Slackコマンド名 起動 IPアドレス
少し待つとCloudFormationでEC2が作成されます。
そうすると、aws側の通知用のLambdaよりEC2のIPアドレスの通知が投稿されます。
通知されたIPアドレスにアクセスするとサイトが表示されます。
EC2の削除
Slackコマンドを追加したワークスペースで、下記投稿をします。
/Slackコマンド名 削除
EC2が削除されます。
Discussion