🦔

slackからIPアドレスを指定してEC2の作成、削除を実施

2024/06/30に公開

前提

  • 起動したい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