この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
どうも!西村祐二@大阪です。
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の設定ファイルになります。
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に本番環境の設定をいれておくとデプロイ時に切り替えることができます。
詳しくは下記ブログを参照ください。
今回はインスタンス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
デプロイが完了したら下記のような確認メールがきているはずです。
「Confirm subscription」をクリックしておきます。
cliかマネージメントコンソールまたはスケジュール実行から
Lambda関数の「ec2-change-instance-type」を実行し
インスタンスタイプが変更され、メールが飛んでくれば成功です。
さいごに
いかがだったでしょうか。
Serverless Framework + Lambda(Python3.6)を使って
サーバーレスなEC2インスタンスのタイプを定期的に変更する仕組みを作ってみました。
Serverless Frameworkは複数のLambda関数をデプロイ・管理できてとても便利です。
また、今まで作成したCloudformationのテンプレートもそのまま利用できるので
過去の遺産も有効に活用することができます。
ほぼ個人的な備忘録となってしまいましたが、
誰かの参考になれば幸いです。