Serverless Framework + Lambda(Python3.6)を使って<br>EC2インスタンスのタイプを定期的に変更してみる #サーバーレス

2017.10.16

どうも!西村祐二@大阪です。

EC2の利用頻度が低くリソースが余剰であるときインスタンスタイプを変更し、
コスト最適化をしたいと思うことがあると思います。
土曜日、日曜日にリソースが余剰になるなど時期がわかっている場合は
自動化して運用負荷を減らすことが理想的です。
今回、Lambdaを使ってサーバーレスでその仕組みを作ってみたいと思います。

※注意
プログラムをのせておりますが、
あくまでサンプルコードとして捉えてください。
本番環境などで利用する際は必ず事前に検証したのちに利用ください。

やりたいこと

・インスタンスタイプを変更
・インスタンスタイプ変更に伴う停止、起動が正常に完了したか確認を行う
・正常に処理が完了、または異常終了したときにメールを送る

考慮点

インスタンスが正常に起動したかは
インスタンスを起動した際に行われるステータスチェックを基準に考えます。
ステータスチェックで行われる
・システムステータスのチェック
・インスタンスステータスのチェック
両方のチェックに合格した時を正常に起動したと定義します。

2017-10-13 16.09.50

環境

Python: 3.6
Serverless Framework: 1.23.0

構成図

インスタンス起動時のステータスチェックは多少時間がかかるため、
ある一定時間経過した後にまだステータスチェックが完了していない場合は
別のLambda関数を起動し、その起動された関数で引き続き
インスタンスのステータスチェックを行います。

instance-type-change

やってみた

今回はServerless Frameworkを使って構築していきます。

まず、プロジェクトを作成します。言語は「python3.6」です。

$ sls create --template aws-python3

今回、主要なファイルのディレクトリ構成は下記のようになります。
Lambda関数のIAMとSNSを使ってメールを送るところは
別ファイルのCloudformationのテンプレートに記載し、それを呼び出します。

また、今回はLambda関数を実行する毎にインスタンスタイプが「t2.small」、「t2.micro」へ交互に変わっていきます。
インスタンスタイプはdev.yml、prd.ymlに記載しており、それを環境変数として読み込んでいるので
違うタイプに変更したいはそちらを変更してください。
詳細は後ほど説明します。

├── cfn
│   └── resources.yml -> Cloudformationのテンプレートファイル(IAMとメール送信)
├── conf
│   ├── dev.yml -> 開発環境用の環境変数
│   └── prd.yml -> 本番環境用の環境変数
├── instance_start_stop.py -> インスタンスタイプを変更するプログラム
├── serverless.yml -> Serverless Framworkの設定ファイル
└── status_check.py -> インスタンスステータスを確認するプログラム

それぞれの設定内容など備忘録のために記載していきます。

serverless.yml

Serverless Frameworkの設定ファイルになります。

serverless.yml

service: ec2-ops # NOTE: update this with your service name
frameworkVersion: ">=1.23.0 <2.0.0"

provider:
  name: aws
  region: ap-northeast-1
  memorySize: 128
  runtime: python3.6
  stage: ${opt:stage, self:custom.defaultStage}
  profile: ${self:custom.profiles.${self:provider.stage}}

custom:
  defaultStage: dev
  profiles:
    dev: sls-dev
    prd: sls-prd
  ec2:
    environment:
      dev: ${file(./conf/dev.yml)}
      prd: ${file(./conf/prd.yml)}
package:
  individually: true
  exclude:
    - "**"

functions:
  instance_start_stop: # file_name, Function
    handler: instance_start_stop.lambda_handler # file_name.functions_name
    name: ec2-change-instance-type
    timeout: 300
    role: myCustRole0
    package:
      include:
        - instance_start_stop.py
    environment:
      INSTANCE_ID: ${self:custom.ec2.environment.${self:provider.stage}.INSTANCE_ID}
      INSTANCE_TYPE_A: ${self:custom.ec2.environment.${self:provider.stage}.INSTANCE_TYPE_A}
      INSTANCE_TYPE_B: ${self:custom.ec2.environment.${self:provider.stage}.INSTANCE_TYPE_B}
    events:
      - schedule: <Schedule setting>

  status_check:
    handler: status_check.lambda_handler
    name: ec2-status-check
    timeout: 300
    role: myCustRole1
    package:
      include:
        - status_check.py
      
resources: ${file(./cfn/resources.yml)}

▼複数のLambda関数をまとめて管理
Serverless Frameworkは複数のLambda関数をfunctionsセクションの中に記載することで
まとめてデプロイ、管理することができます。

functions:
  instance_start_stop:
  .
  .
  .
  status_check:
  .
  .
  .

▼開発環境、本番環境で設定を切替
開発環境、本番環境で読み込む環境変数を変更したい場合は
dev.ymlに開発環境の設定、
prd.ymlに本番環境の設定をいれておくとデプロイ時に切り替えることができます。

詳しくは下記ブログを参照ください。

Serverless Frameworkで環境変数を外部ファイルから読み込み、環境毎に自動で切り替えてみる

今回はインスタンスIDとインスタンスタイプを記載しています。

dev.yml

# development environment
INSTANCE_ID: 'i-xxxxxxxxxxxx'
INSTANCE_TYPE_A: 't2.small'
INSTANCE_TYPE_B: 't2.micro'

▼開発環境、本番環境で利用するプロファイルを指定できます。

  profiles:
    dev: sls-dev
    prd: sls-prd

▼デプロイするパッケージの設定
デプロイするパッケージの設定を下記のpackageセクションで指定できます。
関数の実行に必要ないデータは含めたくないためここでは一旦すべて除外し、
関数の個別設定で必要なファイルだけ指定し含めるようにしています。
また「individually」をTrueとしておくと関数毎に
独立してパッケージを作成しデプロイしてくれます。

package:
  individually: true
  exclude:
    - "**"
  .
  .
  .    
functions:
    .
    .
    .
    package:
      include:
        - status_check.py

また、特定のディレクトリ配下のファイルだけ除外することや
notの意味の「!」も利用することができます。

ex)
- node_modules/**
- '!node_modules/node-fetch/**'

▼Lambda関数のIAMの設定
IAMはproviderセクションで「iamRoleStatements」プロパティを使って
デフォルトのIAMロールの設定することができますが、すべての関数に適用されてします。
複数の関数それぞれに設定したい場合はresourcesセクションに
「AssumeRolePolicy」や関数実行時に出力されるCloudWatchログの設定もすべて記載する必要があります。
関数の設定を行うところでroleプロパティからテンプレート内の名前を指定することで個別にIAMを設定できます。

functions:
  instance_start_stop:
    .
    .
    .
    role: myCustRole0 →Cloudformationのテンプレートに記載した設定を指定


  status_check:
    .
    .
    .
    role: myCustRole1 →Cloudformationのテンプレートに記載した設定を指定

▼トリガー設定
定期的なスケジュール実行をする場合は、
下記のように記載することで簡単に設定できます。
通常のスケジュール設定のようにRate方式とcron方式で記載できます。

    events:
      - schedule: <Schedule setting>

また、他のパラメータの設定も可能です。
下記に例を示しておきます。

    events:
      - schedule:
          rate: cron(0 * * * *) // call once an hour
          trigger: triggerName
          rule: ruleName
          max: 10000 // max invocations, default: 1000, max: 10000
          params: // event params for invocation
            hello: world

resources.yml

呼び出されるCloudformationのテンプレートファイルが下記になります。
ここではLambdaで利用するIAMの設定と
Lambda関数を実行したときに出力されるログからSNSを使ってメールをおくる設定を記載しております。

今回、ログに「Successfully」と「ERROR」が出力されたときメールを送信するように設定しております。
送信したいアドレスを<your address>@<hoge.com>のところに記載しておいてください。
また、デプロイした際に指定したアドレスに確認メールが届きますので承認しておいてください。

書いた後に気づきましたがServerless Frameworkのプラグインを使えばもっと手軽に利用できたかもしれません。

resources.yml

---
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  MailAddress:
    Description: Please specify the E-mail Address
    Type: String
    Default: <your address>@<hoge.com>
  FilterPattern1:
    Description: Please specify FilterPattern
    Type: String
    Default: Successfully
  FilterPattern2:
    Description: Please specify FilterPattern
    Type: String
    Default: ERROR
  Threshold:
    Description: Threshold
    Type: String
    Default: "0"
    
Resources:
  myCustRole0:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      RoleName: EC2InstanceOpsLambdaRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ec2-instance-lambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - 'Fn::Join':
                    - ':'
                    -
                      - 'arn:aws:logs'
                      - Ref: 'AWS::Region'
                      - Ref: 'AWS::AccountId'
                      - 'log-group:/aws/lambda/*:*:*'
              - Effect: Allow
                Action:
                  - "ec2:Start*"
                  - "ec2:Stop*"
                  - "ec2:DescribeInstances*"
                  - "ec2:ModifyInstanceAttribute*"
                  - "lambda:InvokeFunction"
                Resource: "*"

  myCustRole1:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      RoleName: StatusCheckLambdaRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ec2-status-check-lambdaPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - 'Fn::Join':
                    - ':'
                    -
                      - 'arn:aws:logs'
                      - Ref: 'AWS::Region'
                      - Ref: 'AWS::AccountId'
                      - 'log-group:/aws/lambda/*:*:*'
              - Effect: Allow
                Action:
                  - "ec2:DescribeInstances*"
                Resource: "*"


####
#### send mail
####
  MySNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: "LambdaLogMailTopic"
      Subscription:
        - Endpoint:
            Ref: MailAddress
          Protocol: email

  SuccessMetricFilter1:
    Type: "AWS::Logs::MetricFilter"
    Properties:
      LogGroupName:
        Ref: InstanceUnderscorestartUnderscorestopLogGroup
      FilterPattern:
        Ref: FilterPattern1
      MetricTransformations:
        -
          MetricValue: "1"
          MetricNamespace: EC2/change
          MetricName: InstanceChangeSuccess1

  SuccessMetricFilter2:
    Type: "AWS::Logs::MetricFilter"
    Properties:
      LogGroupName:
        Ref: StatusUnderscorecheckLogGroup
      FilterPattern:
        Ref: FilterPattern1
      MetricTransformations:
        -
          MetricValue: "1"
          MetricNamespace: EC2/change
          MetricName: InstanceChangeSuccess2

  ErrorMetricFilter1:
    Type: "AWS::Logs::MetricFilter"
    Properties:
      LogGroupName:
        Ref: InstanceUnderscorestartUnderscorestopLogGroup
      FilterPattern:
        Ref: FilterPattern2
      MetricTransformations:
        -
          MetricValue: "1"
          MetricNamespace: EC2/change
          MetricName: InstanceChangeError1

  ErrorMetricFilter2:
    Type: "AWS::Logs::MetricFilter"
    Properties:
      LogGroupName:
        Ref: StatusUnderscorecheckLogGroup
      FilterPattern:
        Ref: FilterPattern2
      MetricTransformations:
        -
          MetricValue: "1"
          MetricNamespace: EC2/change
          MetricName: InstanceChangeError2

  SuccessLogAlarm1:
    Type: "AWS::CloudWatch::Alarm"
    Properties:
      AlarmActions:
        - Ref: MySNSTopic
      AlarmName: SuccessLogSendMail1
      MetricName: InstanceChangeSuccess1
      Namespace: EC2/change
      ComparisonOperator: "GreaterThanThreshold"
      EvaluationPeriods: '1'
      Period: '60'
      Threshold: '0'
      Statistic: Average
      TreatMissingData: notBreaching


  SuccessLogAlarm2:
    Type: "AWS::CloudWatch::Alarm"
    Properties:
      AlarmActions:
        - Ref: MySNSTopic
      AlarmName: SuccessLogSendMail2
      MetricName: InstanceChangeSuccess2
      Namespace: EC2/change
      ComparisonOperator: "GreaterThanThreshold"
      EvaluationPeriods: '1'
      Period: '60'
      Threshold: '0'
      Statistic: Average
      TreatMissingData: notBreaching


  ErrorLogAlarm1:
    Type: "AWS::CloudWatch::Alarm"
    Properties:
      AlarmActions:
        - Ref: MySNSTopic
      AlarmName: ErrorLogSendMail1
      MetricName: InstanceChangeError1
      Namespace: EC2/change
      ComparisonOperator: "GreaterThanThreshold"
      EvaluationPeriods: '1'
      Period: '60'
      Threshold: '0'
      Statistic: Average
      TreatMissingData: notBreaching


  ErrorLogAlarm2:
    Type: "AWS::CloudWatch::Alarm"
    Properties:
      AlarmActions:
        - Ref: MySNSTopic
      AlarmName: ErrorLogSendMail2
      MetricName: InstanceChangeError2
      Namespace: EC2/change
      ComparisonOperator: "GreaterThanThreshold"
      EvaluationPeriods: '1'
      Period: '60'
      Threshold: '0'
      Statistic: Average
      TreatMissingData: notBreaching

メトリクスフィルターの設定のところで、Lambda関数のロググループを指定するところがあります。
ロググループを直接記載すると「そのようなロググループは存在しません」という内容の
エラーメッセージが出力されてデプロイできませんでした。
ですが、デプロイコマンドのsls deploy -vの実行ログをよく確認すると、

$ sls deploy -v
・
・
・
CloudFormation - CREATE_IN_PROGRESS - AWS::Logs::LogGroup - StatusUnderscorecheckLogGroup
CloudFormation - CREATE_IN_PROGRESS - AWS::Logs::LogGroup - InstanceUnderscorestartUnderscorestopLogGroup
・
・
・

というような出力があり、これを指定することで問題なくデプロイすることができました。

instance_start_stop.py

下記がインスタンスの停止、起動、インスタンスタイプを変更するプログラムです。

instance_start_stop.py

import json
import logging
import os
import boto3
import sys
from time import sleep

logger = logging.getLogger()
for h in logger.handlers:
  logger.removeHandler(h)

h = logging.StreamHandler(sys.stdout)
FORMAT = '%(levelname)s %(asctime)s [%(filename)s:%(funcName)s:%(lineno)d] %(message)s'
h.setFormatter(logging.Formatter(FORMAT))
logger.addHandler(h)
logger.setLevel(logging.INFO)

INSTANCE_ID = os.environ['INSTANCE_ID']
INSTANCE_TYPE_A = os.environ['INSTANCE_TYPE_A']
INSTANCE_TYPE_B = os.environ['INSTANCE_TYPE_B']

ec2 = boto3.resource('ec2')
ec2instance = ec2.Instance(INSTANCE_ID)
ec2client = ec2.meta.client

def stop():
    response = ec2client.describe_instance_status(InstanceIds=[INSTANCE_ID])
    InstanceStatus = response['InstanceStatuses'][0]['InstanceStatus']['Status']
    SystemStatus = response['InstanceStatuses'][0]['SystemStatus']['Status']

    if InstanceStatus == 'initializing' or SystemStatus == 'initializing':
        logger.info('End the process for the InstanceStatus Checking')
        return("InstanceStatus Checking")
    elif ec2instance.state['Name'] == 'running' and InstanceStatus == 'ok' and SystemStatus == 'ok':
        try:
            ec2instance.stop()
            logger.info('Stop InstanceID: {} '.format(INSTANCE_ID))
            ec2client.get_waiter('instance_stopped').wait(
                InstanceIds=[
                    INSTANCE_ID
                ],
                WaiterConfig={
                    'Delay': 5, #Default: 15
                    'MaxAttempts': 30 #Default: 40
                    }
            )
        except Exception as e:
            logger.exception("{}".format(e))
    else:
        pass

def start():
    if ec2instance.state['Name'] == "stopped":
        try:
            ec2instance.start()
            logger.info('Start InstanceID: {} '.format(INSTANCE_ID))
            sleep(150)
            response = ec2client.describe_instance_status(InstanceIds=[INSTANCE_ID])
            InstanceStatus = response['InstanceStatuses'][0]['InstanceStatus']['Status']
            SystemStatus = response['InstanceStatuses'][0]['SystemStatus']['Status']

            if InstanceStatus == 'initializing' or SystemStatus == 'initializing':
                logger.info('Taking time to StatusCheck  InstanceID: {} '.format(INSTANCE_ID))
                payload = { 'instance_id' : INSTANCE_ID }
                boto3.client('lambda').invoke(
                        FunctionName = 'ec2-status-check',
                        InvocationType = 'Event',
                        LogType = 'Tail',
                        Payload = json.dumps(payload))
                logger.info("Next Function StatusCheck Continue")
                return("StatusCheck Continue")
            elif ec2instance.state['Name'] == 'running' and InstanceStatus == 'ok' and SystemStatus == 'ok':
                logger.info('Successfully start InstanceID: {} '.format(INSTANCE_ID))
                return('Successfully start InstanceID: {} '.format(INSTANCE_ID))
        except Exception as e:
            logger.exception("{}".format(e))
    else:
        pass

def change_type(INSTANCE_TYPE):
    try:
        if ec2instance.instance_type != INSTANCE_TYPE:
            try:
                stop()
            except IndexError:
                pass
            ec2client.modify_instance_attribute(
                InstanceId=INSTANCE_ID,
                InstanceType={
                    'Value': INSTANCE_TYPE
                })
            logger.info('Change InstanceType : {} '.format(INSTANCE_TYPE))
            start()
        else:
            logger.info('The current InstanceType : {} '.format(INSTANCE_TYPE))
            return('The current InstanceType : {} '.format(INSTANCE_TYPE))
    except Exception as e:
        logger.exception("{}".format(e))

def lambda_handler(event, context):
    if ec2instance.instance_type == INSTANCE_TYPE_A:
        change_type(INSTANCE_TYPE_B)
    else:
        change_type(INSTANCE_TYPE_A)

▼ログ出力フォーマットを変更
8-16行目はログフォーマット変更に関する記載になります。
ログ出力フォーマットを
ログレベル 時刻,処理時間 [ファイル名:関数名:行目] メッセージ
というように変更しています。

下記に出力例を示します。

ex)
INFO 2017-10-14 11:06:18,188 [instance_start_stop.py:change_type:92] Change InstanceType : t2.micro

ログフォーマットに関する参考ドキュメント

▼stop関数について
はじめに
対象インスタンスの「InstanceStatus」「SystemStatus」の状態を取得しています。
インスタンスが起動状態でないと値が取得できずエラーとなるので注意です。
取得した値がinitializingだったら
起動はしているけどステータスチェック中と判断して、returnを返し処理を終了します。
取得した値がOKかつインスタンスが起動状態の場合のみ
ec2instance.stop()でインスタンスの停止処理を行います。
その後、get_waiter('instance_stopped')で停止が完了するまで待ちます。

▼start関数について
はじめに
対象インスタンスが停止状態か確認します。
停止状態であれば、
ec2instance.start()でインスタンスの起動処理を行います。
そのあと、sleepで150秒まち、インスタンスのステータスの状態を確認します。
initializingだったとき、まだステータスチェックが未完了だと判断し、
ステータスチェック用の別のLambdaを起動します。
別Lambdaを起動する際に
payload = { 'instance_id' : INSTANCE_ID }として対象インスタンスのIDを渡しています。

▼change_type関数について
引数にインスタンスタイプを取り、現在のインスタンスタイプと比較し
同じでない場合に処理を実行します。
インスタンスタイプを変更するためには停止する必要があるので、
stop関数を呼び出しますが、インスタンスが停止中の場合は
インスタンスの状態取得の際にエラーとなるため、そのときはexceptでpassします。
modify_instance_attributeでインスタンスタイプ変更したら、
start関数を呼びインスタンスを起動します。

status_check.py

下記がLambdaから呼び出されインスタンスステータスを行うLambda関数です。
Lambda関数から渡ってきたeventからインスタンスIDを取得し、
get_waiter('instance_status_ok')でステータスがokになるまで待ちます。
処理が完了するとログに「Successfully」を含む文字列が出力されるので、
これをトリガーにメールが送信されます。
また、logger.exceptionとしておくと、エラーが発生した際に
「ERROR」を含むTracebackの内容をログに書き込んでくれます。

status_check.py

import logging
import boto3
import sys

logger = logging.getLogger()
for h in logger.handlers:
  logger.removeHandler(h)
h = logging.StreamHandler(sys.stdout)
FORMAT = '%(levelname)s %(asctime)s [%(filename)s:%(funcName)s:%(lineno)d] %(message)s'
h.setFormatter(logging.Formatter(FORMAT))
logger.addHandler(h)
logger.setLevel(logging.INFO)

ec2 = boto3.resource('ec2')

def lambda_handler(event, context):
    INSTANCE_ID = event['instance_id']
    ec2instance = ec2.Instance(INSTANCE_ID)
    ec2client = ec2.meta.client

    # ec2instance -statements code-
    #0 : pending
    #16 : running
    #32 : shutting-down
    #48 : terminated
    #64 : stopping
    #80 : stopped
    try:
        if ec2instance.state['Name'] == 'running':
            ec2client.get_waiter('instance_status_ok').wait(
                InstanceIds=[
                    INSTANCE_ID
                ],
                WaiterConfig={
                    'Delay': 30, #Default: 15
                    'MaxAttempts': 10 #Default: 40
                }
            )
            logger.info('Instance Status OK')
            logger.info('Successfully start InstanceID: {} '.format(INSTANCE_ID))
        else:
            pass
    except Exception as e:
        logger.exception("{}".format(e))
        return("abnormal end")

動作確認

インスタンスID、スケジュールの設定、メールアドレスを設定した後に
下記コマンドでAWSへデプロイすることができます。

$ sls deploy -v

デプロイが完了したら下記のような確認メールがきているはずです。

2017-10-16_3_51_41

「Confirm subscription」をクリックしておきます。

cliかマネージメントコンソールまたはスケジュール実行から
Lambda関数の「ec2-change-instance-type」を実行し
インスタンスタイプが変更され、メールが飛んでくれば成功です。

さいごに

いかがだったでしょうか。
Serverless Framework + Lambda(Python3.6)を使って
サーバーレスなEC2インスタンスのタイプを定期的に変更する仕組みを作ってみました。
Serverless Frameworkは複数のLambda関数をデプロイ・管理できてとても便利です。
また、今まで作成したCloudformationのテンプレートもそのまま利用できるので
過去の遺産も有効に活用することができます。

ほぼ個人的な備忘録となってしまいましたが、
誰かの参考になれば幸いです。