Serverless Framework + Lambda(Python3.6)を使って<br>EC2インスタンスのタイプを定期的に変更してみる #サーバーレス
どうも!西村祐二@大阪です。
EC2の利用頻度が低くリソースが余剰であるときインスタンスタイプを変更し、
コスト最適化をしたいと思うことがあると思います。
土曜日、日曜日にリソースが余剰になるなど時期がわかっている場合は
自動化して運用負荷を減らすことが理想的です。
今回、Lambdaを使ってサーバーレスでその仕組みを作ってみたいと思います。
※注意
プログラムをのせておりますが、
あくまでサンプルコードとして捉えてください。
本番環境などで利用する際は必ず事前に検証したのちに利用ください。
やりたいこと
・インスタンスタイプを変更
・インスタンスタイプ変更に伴う停止、起動が正常に完了したか確認を行う
・正常に処理が完了、または異常終了したときにメールを送る
考慮点
インスタンスが正常に起動したかは
インスタンスを起動した際に行われるステータスチェックを基準に考えます。
ステータスチェックで行われる
・システムステータスのチェック
・インスタンスステータスのチェック
両方のチェックに合格した時を正常に起動したと定義します。
環境
Python: 3.6
Serverless Framework: 1.23.0
構成図
インスタンス起動時のステータスチェックは多少時間がかかるため、
ある一定時間経過した後にまだステータスチェックが完了していない場合は
別のLambda関数を起動し、その起動された関数で引き続き
インスタンスのステータスチェックを行います。
やってみた
今回は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の設定ファイルになります。
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に本番環境の設定をいれておくとデプロイ時に切り替えることができます。
詳しくは下記ブログを参照ください。
今回はインスタンスIDとインスタンスタイプを記載しています。
# 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のプラグインを使えばもっと手軽に利用できたかもしれません。
--- 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
下記がインスタンスの停止、起動、インスタンスタイプを変更するプログラムです。
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の内容をログに書き込んでくれます。
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
デプロイが完了したら下記のような確認メールがきているはずです。
「Confirm subscription」をクリックしておきます。
cliかマネージメントコンソールまたはスケジュール実行から
Lambda関数の「ec2-change-instance-type」を実行し
インスタンスタイプが変更され、メールが飛んでくれば成功です。
さいごに
いかがだったでしょうか。
Serverless Framework + Lambda(Python3.6)を使って
サーバーレスなEC2インスタンスのタイプを定期的に変更する仕組みを作ってみました。
Serverless Frameworkは複数のLambda関数をデプロイ・管理できてとても便利です。
また、今まで作成したCloudformationのテンプレートもそのまま利用できるので
過去の遺産も有効に活用することができます。
ほぼ個人的な備忘録となってしまいましたが、
誰かの参考になれば幸いです。