Webサーバーを運用していると、定期的にコマンドを実行させたいことはよくあります。 たとえば、このブログだと予約投稿で決められた日時を過ぎたら公開処理をする、みたいなのですね。
そういったものを簡単に実現する手法として、古くから cron が用いられてきました。
しかしながら、負荷分散のために EC2 AutoScaling や OpsWorks など、同じ構成のインスタンスを複数並列起動する構成では、グループのインスタンスすべてで同じジョブが実行されてしまう問題があります。
1台をグループから外し、その1台だけでジョブを実行するようにする方法もありますが、cron実行が単一障害点となり、複数台構成の恩恵を十分に受けられません。
複数台構成に対応するジョブスケジューラ製品もいろいろありますが、ここではAWSのサービスだけで実現する方法を考えてみます。
CloudWatch Events + Lambda + AWS SSM RunCommand
構成の特徴
冗長化可能
EC2インスタンスが複数あるとき、そのうちのどれかで実行できるので、構成の冗長化が可能です。
サーバーレス
ジョブ管理サーバーを用意する必要が無いため、管理の手間がかからないこと、余分なコストが発生しないことが特徴です。
CloudFormationで構成可能
AWSのサービスのみで構成されていて、CloudFormationで構成を追加できます。 構成をコードで管理可能で、同じものを作り直すのも簡単です。
手順
マネジメントコンソールを使って構成する場合の手順を示します。 通常はCloudFormationを使って構成する方が管理上好ましいです。
0. EC2インスタンスの準備
EC2インスタンスに AWS Systems Manager のための設定が必要です。
1. Step Functions でワークフローを作成
RunCommand をLambdaから実行すれば良いのですが、RunCommand はコマンドを送信するだけで実行結果はとれないので、次の複数の処理からなるワークフローを作成します。
ワークフローの実現には、AWS Step Functions のステートマシンを使うのが簡単です。
Lambda 関数の準備
# Lambda 関数の実行ロールを作成
まず、Lambda関数を実行するときの権限を指定するためのロールを作成しておきます。
今回 Lambda でやりたいことは次の2つです。
- SSMにコマンド送信 ssm の SendCommand API
- コマンド実行完了を待機
- SSMからコマンドを送信したEC2インスタンスを特定 ssm の ListCommandInvocations API
- EC2からコマンドの進行状況を取得 ssm の GetCommandInvocation API
なので、次のようなIAM Role を作成すれば良いです。
- 管理ポリシー
AWSLambdaBasicExecutionRole
(Lambda関数を実行するのに必要)
- 次のインラインポリシーを含む
ssm:SendCommand
ssm:ListCommandInvocations
ssm:GetCommandInvocation
- サービスプリンシパル
- lambda.amazonaws.com
# Lambda 関数を作成
次の2つのLambda関数を作成します。
作成したLambda関数のARNを控えておきます。(ワークフローの定義する際に使用します)
## SendCommand: コマンドを送信
入力 event
に、実行インスタンスを特定する情報と、実行したいコマンドを受け取って、 sendCommand
で送ります。
const AWS = require('aws-sdk'); const ssm = new AWS.SSM({apiVersion: '2014-11-06'}); const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); } exports.handler = async (event, context) => { console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event)); const { TagKey, TagValue, Command } = event; const sendCommandParams = { DocumentName: 'AWS-RunShellScript', Targets: [ { Key: `tag:${TagKey}`, Values: [TagValue] } ], Parameters: { commands: [Command], executionTimeout: ['3600'] }, MaxConcurrency: '1', MaxErrors: '1', TimeoutSeconds: 3600, }; debug("sendCommandParams", sendCommandParams); const sendCommandResult = await ssm.sendCommand(sendCommandParams).promise(); debug("sendCommandResult", sendCommandResult); const results = { sendCommandParams: sendCommandParams, sendCommandResult: sendCommandResult }; debug("results", results); return results; };
## WaitForCommandExecutions: コマンド実行完了を待機
getCommandInvocation
でコマンドの実行状態を確認します。
実行状態として "Success"
または "Cancelled"
"TimedOut"
"Failed"
のいずれかが得られない場合は CommandNotYetCompleteError
例外を送出し、呼び出し元にコマンド実行が終わっていないことを通知します。
const AWS = require('aws-sdk'); const ssm = new AWS.SSM({apiVersion: '2014-11-06'}); const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); } class CommandNotYetCompleteError extends Error { constructor(message) { super(message); this.name = 'CommandNotYetCompleteError'; } } exports.handler = async (event, context) => { console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event)); const { sendCommandParams, sendCommandResult } = event; let commandStatus; const listCommandInvocationsParams = { CommandId: sendCommandResult.Command.CommandId }; debug("listCommandInvocationsParams", listCommandInvocationsParams); const listCommandInvocationsResult = await ssm.listCommandInvocations(listCommandInvocationsParams).promise().catch(e => console.error("CommandInvocations", e)); debug("listCommandInvocationsResult", listCommandInvocationsResult); const getCommandInvocationParams = { CommandId: sendCommandResult.Command.CommandId, InstanceId: listCommandInvocationsResult.CommandInvocations[0].InstanceId, }; debug("getCommandInvocationParams", getCommandInvocationParams); const getCommandInvocationResult = await ssm.getCommandInvocation(getCommandInvocationParams).promise().catch(e => console.error("getCommandInvocation", e)); debug("getCommandInvocationResult", getCommandInvocationResult); if (getCommandInvocationResult) { commandStatus = getCommandInvocationResult.Status; } if (commandStatus !== "Success" && commandStatus !== "Cancelled" && commandStatus !== "TimedOut" && commandStatus !== "Failed") { throw new CommandNotYetCompleteError("Command is not yet complete. Retry"); } const results = { sendCommandParams: sendCommandParams, sendCommandResult: sendCommandResult, getCommandInvocationParams: getCommandInvocationParams, getCommandInvocationResult: getCommandInvocationResult, commandStatus: commandStatus }; debug("results", results); return results; };
SNSトピックを作成
コマンドの成功/失敗を通知するSNSトピックを作成しておきます。 成功時と失敗時で通知先を変えたい場合はそれぞれ作成します。 作成したSNSトピックのARNを控えておきます。(ワークフローの定義する際に使用します)
Step Functions でステート マシン ワークフローの作成
ステートマシンの実行ロールの作成
ステートマシンに必要なアクセス権限を与えるための、実行ロールが必要です。
今回ステートマシン ワークフローでは、次の2つのことを行います。
- Lambda関数の実行:Invoke API
- SNSトピックへのメッセージ送信:Publish API
なので、次のようなIAM Role を作成します。
- 次のインラインポリシーを含む
lambda:InvokeFunction
sns:Publish
- サービスプリンシパル
- states.amazonaws.com
IAM > ロール > ロールの作成へと進み、ユースケースとして Step Functions を選択します。
ステートマシンの作成
Step Functions > ステートマシン > ステートマシンの作成を選びます。
定義に次のJSONを使います。 ARNを指定する箇所は、それぞれ作成したもので置き換えてください。
{ "Comment": "ExecuteScheduleTask", "StartAt": "SendCommand", "States": { "SendCommand": { "Type": "Task", "Resource": "コマンドを送信するLambda関数のARN(arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxxxx:function:WaitForCommandExecutions)", "Retry": [ { "ErrorEquals": [ "States.TaskFailed", "States.Timeout" ], "IntervalSeconds": 10, "MaxAttempts": 6, "BackoffRate": 1.0 } ], "Next": "WaitForCommandExecutions" }, "WaitForCommandExecutions": { "Type": "Task", "Resource": "コマンド実行完了を待機するLambda関数のARN(arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxxxx:function:WaitForCommandExecutions)", "Retry": [ { "ErrorEquals": [ "CommandNotYetCompleteError" ], "IntervalSeconds": 10, "MaxAttempts": 360, "BackoffRate": 1.0 }, { "ErrorEquals": [ "States.TaskFailed", "States.Timeout" ], "IntervalSeconds": 10, "MaxAttempts": 6, "BackoffRate": 1.0 } ], "Next": "ChoiceCommandStatus" }, "ChoiceCommandStatus": { "Type": "Choice", "Choices": [ { "Variable": "$.commandStatus", "StringEquals": "Success", "Next": "NotifySuccess" } ], "Default": "NotifyFail" }, "NotifySuccess": { "Type": "Task", "Resource": "arn:aws:states:::sns:publish", "Parameters": { "Subject": "Step Functions succeeded", "Message.$":"$", "TopicArn": "成功時に通知するSNSトピックのARN(arn:aws:sns:ap-northeast-1:xxxxxxxxxxxxxx:CommandExecutionSucceeded)" }, "End": true }, "NotifyFail": { "Type": "Task", "Resource": "arn:aws:states:::sns:publish", "Parameters": { "Subject": "Step Functions failed", "Message.$":"$", "TopicArn": "失敗時に通知するSNSトピックのARN(arn:aws:sns:ap-northeast-1:xxxxxxxxxxxxxx:CommandExecutionSucceeded)" }, "Next": "Fail" }, "Fail": { "Type": "Fail" } } }
アクセス許可は、作成した実行ロールを選択します。
ステートマシンの実行
インスタンス名 test
に対して適当なコマンドを送信して試してみます。
実行時の入力として次のJSONを与えます。
この内容が最初のステップのLambdaの event
に入ります。
{ "TagKey": "Name", "TagValue": "test", "Command": "date" }
実行すると進捗状況が表示されます。
SNSに通知され、メールが届きました。
2. CloudWatch Events でワークフローを開始
ワークフローができたので、これを cron のように定期実行できるようにします。
これには次の操作が必要です。
- ルールのターゲットのロールの作成
- ルールの作成
ルールのターゲットのロールの作成
スケジュールがトリガーされたとき、ターゲットを呼び出そうとします。
ターゲットの種類はいろいろ設定できますが、今回は Step Functions の ステートマシンを実行したいので、そのために必要なアクセス権限を許可するためのロールを作成します。
ロールの内容は次の通りです。
- 次のインラインポリシーを含む
states:StartExecution
- サービスプリンシパル
- events.amazonaws.com
IAM > ロール > ロールの作成へと進み、ユースケースとして CloudWatch Events を選択します。
ルールの作成
いよいよスケジュールを登録します。
これには CloudWatch > ルール > ルールの作成 に進みます。
イベントソースは、スケジュールを選択します。 一定時間おきか、Cron式での指定が可能です。 Cron式についてはこちらをご覧下さい。
ターゲットは、Step Functions ステートマシン を選択し、先ほど作成したステートマシンにします。
入力の設定は、JSONテキストを選択し、ステートマシン実行の入力を指定します。ここでは {"TagKey": "Name","TagValue": "test","Command": "date > /tmp/date.txt"}
としました。
既存のロールを使用を選び、作成したロールを指定します。
ルールが作成でき、スケジュールがトリガーされると、CloudWatchのメトリクスで確認できるほか、Step Functions の実行ログに追加されていきます。
無事に動きました!
CloudFormationテンプレート
ここまでAWSマネジメントコンソール上での操作方法を説明しましたが、いちいちこれを手作業で行うのは非常に辛いので、CloudFormationテンプレートを作成しての運用をおすすめします。
使用方法
2つのテンプレートで構成しています。
- コマンド実行に必要な各種リソース作成
- 実行スケジュールと実行コマンドを持つ CloudWatch Events Rule リソース作成
この構成では、複数のコマンドを登録したい場合も、1つめのスタックは1つだけで、2つめのスタックで追加していく形になります。
1. コマンド実行に必要な各種リソース作成
まず コマンド実行に必要な各種リソースの作成のテンプレートを使ってスタックを作成します。
スタックの作成が終わったら、出力の StateMachineArn
の値を控えておきます。
2. 実行スケジュールと実行コマンドを持つ CloudWatch Events Rule リソース作成
続いて、実際に定期的にコマンドを実行するルールを追加します。
StateMachineArn には先ほど控えておいた値を入力します。 ScheduleExpression の書き方はこちらをご覧下さい。
スタック作成が終わると、CloudWatch Events のルールからSSMを叩くLambdaまで一式が作成されます。
コード
コマンド実行に必要な各種リソースの作成
Resources: StateMachineExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - states.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: run-statemachine PolicyDocument: Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: - !GetAtt LambdaSendCommand.Arn - !GetAtt LambdaWaitForCommandExecutions.Arn - Effect: Allow Action: sns:Publish Resource: - !Ref TopicCommandExecutionSucceeded - !Ref TopicCommandExecutionFailed LambdaExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: / ManagedPolicyArns: - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" Policies: - PolicyName: run-command PolicyDocument: Statement: - Effect: Allow Action: - ssm:SendCommand - ssm:ListCommandInvocations - ssm:GetCommandInvocation Resource: "*" LambdaSendCommand: Type: AWS::Lambda::Function Properties: Code: ZipFile: |+ const AWS = require('aws-sdk'); const ssm = new AWS.SSM({apiVersion: '2014-11-06'}); const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); } exports.handler = async (event, context) => { console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event)); const { TagKey, TagValue, Command } = event; const sendCommandParams = { DocumentName: 'AWS-RunShellScript', Targets: [ { Key: `tag:${TagKey}`, Values: [TagValue] } ], Parameters: { commands: [Command], executionTimeout: ['3600'] }, MaxConcurrency: '1', MaxErrors: '1', TimeoutSeconds: 3600, }; debug("sendCommandParams", sendCommandParams); const sendCommandResult = await ssm.sendCommand(sendCommandParams).promise(); debug("sendCommandResult", sendCommandResult); const results = { sendCommandParams: sendCommandParams, sendCommandResult: sendCommandResult }; debug("results", results); return results; }; Handler: index.handler Role: !GetAtt LambdaExecutionRole.Arn Runtime: "nodejs12.x" MemorySize: 128 Timeout: 60 LambdaPermissionLambdaSendCommand: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: !GetAtt LambdaSendCommand.Arn Principal: states.amazonaws.com SourceArn: !Ref StateMachine LambdaWaitForCommandExecutions: Type: AWS::Lambda::Function Properties: Code: ZipFile: |+ const AWS = require('aws-sdk'); const ssm = new AWS.SSM({apiVersion: '2014-11-06'}); const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); } class CommandNotYetCompleteError extends Error { constructor(message) { super(message); this.name = 'CommandNotYetCompleteError'; } } exports.handler = async (event, context) => { console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event)); const { sendCommandParams, sendCommandResult } = event; let commandStatus; const listCommandInvocationsParams = { CommandId: sendCommandResult.Command.CommandId }; debug("listCommandInvocationsParams", listCommandInvocationsParams); const listCommandInvocationsResult = await ssm.listCommandInvocations(listCommandInvocationsParams).promise().catch(e => console.error("CommandInvocations", e)); debug("listCommandInvocationsResult", listCommandInvocationsResult); const getCommandInvocationParams = { CommandId: sendCommandResult.Command.CommandId, InstanceId: listCommandInvocationsResult.CommandInvocations[0].InstanceId, }; debug("getCommandInvocationParams", getCommandInvocationParams); const getCommandInvocationResult = await ssm.getCommandInvocation(getCommandInvocationParams).promise().catch(e => console.error("getCommandInvocation", e)); debug("getCommandInvocationResult", getCommandInvocationResult); if (getCommandInvocationResult) { commandStatus = getCommandInvocationResult.Status; } if (commandStatus !== "Success" && commandStatus !== "Cancelled" && commandStatus !== "TimedOut" && commandStatus !== "Failed") { throw new CommandNotYetCompleteError("Command is not yet complete. Retry"); } const results = { sendCommandParams: sendCommandParams, sendCommandResult: sendCommandResult, getCommandInvocationParams: getCommandInvocationParams, getCommandInvocationResult: getCommandInvocationResult, commandStatus: commandStatus }; debug("results", results); return results; }; Handler: index.handler Role: !GetAtt LambdaExecutionRole.Arn Runtime: "nodejs12.x" MemorySize: 128 Timeout: 60 LambdaPermissionWaitForCommandExecutions: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: !GetAtt LambdaWaitForCommandExecutions.Arn Principal: states.amazonaws.com SourceArn: !Ref StateMachine TopicCommandExecutionSucceeded: Type: AWS::SNS::Topic TopicCommandExecutionFailed: Type: AWS::SNS::Topic StateMachine: Type: AWS::StepFunctions::StateMachine Properties: DefinitionString: !Sub - |+ { "Comment": "ExecuteScheduleTask", "StartAt": "SendCommand", "States": { "SendCommand": { "Type": "Task", "Resource": "${lambdaSendCommandArn}", "Retry": [ { "ErrorEquals": [ "States.TaskFailed", "States.Timeout" ], "IntervalSeconds": 10, "MaxAttempts": 6, "BackoffRate": 1.0 } ], "Next": "WaitForCommandExecutions" }, "WaitForCommandExecutions": { "Type": "Task", "Resource": "${lambdaWaitForCommandExecutionsArn}", "Retry": [ { "ErrorEquals": [ "CommandNotYetCompleteError" ], "IntervalSeconds": 10, "MaxAttempts": 360, "BackoffRate": 1.0 }, { "ErrorEquals": [ "States.TaskFailed", "States.Timeout" ], "IntervalSeconds": 10, "MaxAttempts": 6, "BackoffRate": 1.0 } ], "Next": "ChoiceCommandStatus" }, "ChoiceCommandStatus": { "Type": "Choice", "Choices": [ { "Variable": "$.commandStatus", "StringEquals": "Success", "Next": "NotifySuccess" } ], "Default": "NotifyFail" }, "NotifySuccess": { "Type": "Task", "Resource": "arn:aws:states:::sns:publish", "Parameters": { "Subject": "Step Functions succeeded", "Message.$":"$", "TopicArn": "${topicCommandExecutionSucceededArn}" }, "End": true }, "NotifyFail": { "Type": "Task", "Resource": "arn:aws:states:::sns:publish", "Parameters": { "Subject": "Step Functions failed", "Message.$":"$", "TopicArn": "${topicCommandExecutionFailedArn}" }, "Next": "Fail" }, "Fail": { "Type": "Fail" } } } - lambdaSendCommandArn: !GetAtt LambdaSendCommand.Arn lambdaWaitForCommandExecutionsArn: !GetAtt LambdaWaitForCommandExecutions.Arn topicCommandExecutionSucceededArn: !Ref TopicCommandExecutionSucceeded topicCommandExecutionFailedArn: !Ref TopicCommandExecutionFailed RoleArn: !GetAtt StateMachineExecutionRole.Arn Outputs: StateMachineArn: Value: !Ref StateMachine
実行スケジュールと実行コマンドを持つ CloudWatch Events Rule リソース作成
Parameters: RuleName: Type: String ScheduleExpression: Type: String EC2InstanceTagName: Type: String Default: Name EC2InstanceTagValue: Type: String Command: Type: String Default: sar Enabled: Type: String Default: "true" AllowedValues: - "true" - "false" StateMachineArn: Type: String Conditions: isEnabled: !Equals [ !Ref Enabled, "true" ] Resources: StartStateMachineExecution: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - events.amazonaws.com Action: - "sts:AssumeRole" Path: / Policies: - PolicyName: run-statemachine PolicyDocument: Statement: - Effect: Allow Action: states:StartExecution Resource: !Ref StateMachineArn Rule: Type: AWS::Events::Rule Properties: Description: !Sub ${AWS::StackName}-${RuleName} ScheduleExpression: !Ref ScheduleExpression State: !If [isEnabled, "ENABLED", "DISABLED"] Targets: - Id: StateMachine Input: !Sub '{"TagKey": "${EC2InstanceTagName}", "TagValue": "${EC2InstanceTagValue}", "Command": "${Command}"}' Arn: !Ref StateMachineArn RoleArn: !GetAtt StartStateMachineExecution.Arn