はじめに
こんにちは、エンジニアの遠藤です。
serverless framework でカスタムプラグインを作成し、 Fargate の Scheduled Task をデプロイできるようにしました。
今回はその作成手順を簡単にまとめていきたいと思います。
バージョン情報など
以下のバージョンで動作確認をしています。
また、実際に試す際は、各種公式ドキュメントに必ず目を通してください。
serverless
$ sls --version
Framework Core: 2.52.1 (local)
Plugin: 5.4.3
SDK: 4.2.6
Components: 3.14.2
npm
$ npm -v
6.14.8
node
$ node -v
v12.19.1
プラグインの基本
プロジェクト構成
プラグイン自体は、 npm を使って、 node.js プロジェクトを作成するのと同じ手順で作成することができます。
今回は分かりやすく(?)既存の serverless プロジェクトの配下に、子プロジェクトとして作っていきます。
構成としては、以下のようになるイメージです。
また、 sample の親プロジェクトはすでに、 deploy が成功する状態となっている想定で進めていきます。
.
└── sample # 親プロジェクト
├── handler.py
├── package.json
├── serverless.yml
└── fargate_plugin # Fargate用のプラグイン
├── index.js
└── package.json
子プロジェクトの作成
まずは sample プロジェクトの直下に移動します。
$ cd sample
プラグイン用のプロジェクフォルダを作成し、移動します。
$ mkdir fargate_plugin
$ cd fargate_plugin
そこで、新規 node.js プロジェクトを作成します。
細かいところはなんでも良いです。
$ npm init
上記コマンドを叩いたあと、10回くらいエンターを叩くと、以下のような package.json
が作成されます。
{
"name": "fargate_plugin",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
続いて、 index.js
を作成します。
$ touch index.js
index.js の中身は、仮で以下のようなものにしてみましょう。
'use strict';
class ServerlessFargateScheduledTasks {
constructor(serverless, options) {
this.serverless = serverless;
this.hooks = {
'package:compileFunctions': this.compileTasks.bind(this)
};
}
compileTasks() {
const template = this.serverless.service.provider.compiledCloudFormationTemplate;
console.log(template)
}
}
module.exports = ServerlessFargateScheduledTasks;
親プロジェクトへの取り込み
sample/serverless.yml
に、以下の記載を追加します。
plugins:
- ./fargate_plugin
この状態で deploy コマンドを叩いてみると、 template が標準出力に出てくるかと思います。
sls deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
{
AWSTemplateFormatVersion: '2010-09-09',
Description: 'The AWS CloudFormation template for this Serverless application',
Resources: {
ServerlessDeploymentBucket: { Type: 'AWS::S3::Bucket', Properties: [Object] },
ServerlessDeploymentBucketPolicy: { Type: 'AWS::S3::BucketPolicy', Properties: [Object] },
HelloLogGroup: { Type: 'AWS::Logs::LogGroup', Properties: [Object] }
},
HelloLambdaFunction: {
Type: 'AWS::Lambda::Function',
Properties: [Object],
DependsOn: [Array]
}
},
Outputs: {
ServerlessDeploymentBucketName: { Value: [Object] },
HelloLambdaFunctionQualifiedArn: { Description: 'Current Lambda function version', Value: [Object] }
}
}
...
出てくる中身は、 serveless.yml の内容によって、変わってくると思いますが、デプロイをハンドリングできていることが分かるかと思います。
Scheduled Task の定義
Template へ値を追加
カスタムプラグインの index.js
で参照できる template に、 CloudFormation の定義をガシガシ追加していきます。
この処理は、serverless-fargate-tasks のコードを大いに参考にしました。
ありがとうございます。
constructor
constructor で必要な定義を記載しておきます。
constructor(serverless, options) {
this.serverless = serverless;
this.service = serverless.service;
this.provider = serverless.getProvider('aws');
this.options = options || {};
this.debug = this.options.debug || process.env.SLS_DEBUG;
this.hooks = {
'package:compileFunctions': this.compileTasks.bind(this)
};
}
compileTasks の処理
ここからは、 ハンドラである、 compileTasks
の中身を書いていきます。
cluster
const template = this.serverless.service.provider.compiledCloudFormationTemplate;
// add the cluster
template['Resources']['FargateTasksCluster'] = {
"Type" : "AWS::ECS::Cluster",
}
log group の追加
// Create a loggroup for the logs
template['Resources']['FargateTasksLogGroup'] = {
"Type" : "AWS::Logs::LogGroup",
}
env
let environment = []
var target_environment = {"hoge": "hoge"}
Object.keys(target_environment).forEach(function(key,index) {
let value = target_environment[key];
environment.push({"Name": key, "Value": value})
})
container definition
var definitions = {
'Name': 'FargateTestTaskDefinition',
'Image': 'image_url',
'Environment': environment,
'LogConfiguration': {
'LogDriver': 'awslogs',
'Options': {
'awslogs-region':{"Fn::Sub": "${AWS::Region}"},
'awslogs-group': {"Fn::Sub": "${FargateTasksLogGroup}"},
'awslogs-stream-prefix': 'fargate'
},
},
}
task definition
var task = {
'Type': 'AWS::ECS::TaskDefinition',
'Properties': {
'ContainerDefinitions': [definitions],
'Family': 'FargateTestTaskDefinition',
'NetworkMode': 'awsvpc',
'ExecutionRoleArn': {"Fn::Sub": 'arn:aws:iam::${AWS::AccountId}:role/ecsTaskExecutionRole'},
'TaskRoleArn': {"Fn::Sub": '${IamRoleLambdaExecution}'},
'RequiresCompatibilities': ['FARGATE'],
'Memory': "0.5GB",
'Cpu': 256,
}
}
template['Resources']['Task'] = task
Events Rule
var rule = {
'Type': 'AWS::Events::Rule',
'Properties': {
'State': "ENABLED",
'Targets': [
{
"Id": "ScheduledNameHoge",
"RoleArn": {"Fn::GetAtt": ["FargateRole", "Arn"]},
"EcsParameters":{
"TaskDefinitionArn": {"Fn::GetAtt": ["FargateTestTaskDefinition", "TaskDefinitionArn"]},
"TaskCount": 1,
'LaunchType': 'FARGATE',
'NetworkConfiguration': {
'AwsVpcConfiguration': {
'AssignPublicIp': 'DISABLED',
'SecurityGroups': ['sg-xxxxxx'],
'Subnets': ['subnet-xxxxxx'],
},
}
},
"Arn": {"Fn::GetAtt": ["FargateCluster", "Arn"]},
}
],
'ScheduleExpression': "cron(* * * * ? *)"
}
}
template['Resources'][normalizedIdentifier + 'rule'] = rule
以上のコードを書くと、固定の値でデプロイすることができるようになります。
ただ、これでは、 serverless.yml から値を編集することができません。
serverless.yml で定義したいことを決める
例えば、 serverless.yml の custom に以下の定義をしてみます。
custom:
hoge:
fuga: fuga
この値は、プラグイン側で、以下のように取得できます。
compileTasks() {
const hoge = this.serverless.service.custom.hoge;
console.log(hoge)
// { fuga: 'fuga' } が出力される
この定義を使って、便利に設定できるよう構成可能です。
最終的なもの
serverless.yml
serverless.yml には以下のようにカスタム項目を定義できるようにしました。
plugins:
- ./fargate_plugin
custom:
fargate:
vpc:
subnets:
- subnet-xxxxxx
environment:
hoge: fuga
schedules:
my-schedules:
environment:
fuga: hoge
image: xxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/fargate_test:latest
plugin(index.js)
index.js は 以下のようなものを作りました。
'use strict';
class ServerlessFargateScheduledTasks {
constructor(serverless, options) {
this.serverless = serverless;
this.service = serverless.service;
this.provider = serverless.getProvider('aws');
this.options = options || {};
this.debug = this.options.debug || process.env.SLS_DEBUG;
this.hooks = {
'package:compileFunctions': this.compileTasks.bind(this)
};
}
compileTasks() {
const template = this.serverless.service.provider.compiledCloudFormationTemplate;
const options = this.serverless.service.custom.fargate;
// add the cluster
template['Resources']['FargateTasksCluster'] = {
"Type" : "AWS::ECS::Cluster",
}
// Create a loggroup for the logs
template['Resources']['FargateTasksLogGroup'] = {
"Type" : "AWS::Logs::LogGroup",
}
// for each defined task, we create a service and a task, and point it to
// the created cluster
Object.keys(options.schedules).forEach(identifier => {
// get all override values, if they exists
var override = options.schedules[identifier]['override'] || {}
var container_override = override['container'] || {}
var task_override = override['task'] || {}
var service_override = override['service'] || {}
var network_override = override['network'] || {}
var name = options.schedules[identifier]['name'] || identifier
var normalizedIdentifier = this.provider.naming.normalizeNameToAlphaNumericOnly(identifier)
if (!override.hasOwnProperty('role')) {
// check if the default role can be assumed by ecs, if not, make it so
if(template.Resources.IamRoleLambdaExecution.Properties.AssumeRolePolicyDocument.Statement[0].Principal.Service.indexOf('ecs-tasks.amazonaws.com') == -1) {
template.Resources.IamRoleLambdaExecution.Properties.AssumeRolePolicyDocument.Statement[0].Principal.Service.push('ecs-tasks.amazonaws.com')
// check if there already is a ManagedPolicyArns array, if not, create it
if(!template.Resources.IamRoleLambdaExecution.Properties.hasOwnProperty('ManagedPolicyArns')) {
template.Resources.IamRoleLambdaExecution.Properties['ManagedPolicyArns'] = [];
}
template.Resources.IamRoleLambdaExecution.Properties['ManagedPolicyArns'].push('arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy')
}
}
// create a key/value list for the task environment
let environment = []
var target_environment = options['environment'] || {}
target_environment = Object.assign(target_environment, options.schedules[identifier].environment)
if(options.schedules[identifier].hasOwnProperty('environment')) {
// when a global environment is set, we need to extend it
Object.keys(target_environment).forEach(function(key,index) {
let value = target_environment[key];
environment.push({"Name": key, "Value": value})
})
}
// create the container definition
var definitions = Object.assign({
'Name': name,
'Image': options.schedules[identifier]['image'],
'Environment': environment,
'LogConfiguration': {
'LogDriver': 'awslogs',
'Options': {
'awslogs-region':{"Fn::Sub": "${AWS::Region}"},
'awslogs-group': {"Fn::Sub": "${FargateTasksLogGroup}"},
'awslogs-stream-prefix': 'fargate'
},
},
}, container_override)
// create the task definition
var task = {
'Type': 'AWS::ECS::TaskDefinition',
'Properties': Object.assign({
'ContainerDefinitions': [definitions],
'Family': name,
'NetworkMode': 'awsvpc',
'ExecutionRoleArn': options['role'] || {"Fn::Sub": 'arn:aws:iam::${AWS::AccountId}:role/ecsTaskExecutionRole'},
'TaskRoleArn': override['role'] || {"Fn::Sub": '${IamRoleLambdaExecution}'},
'RequiresCompatibilities': ['FARGATE'],
'Memory': options.schedules[identifier]['memory'] || "0.5GB",
'Cpu': options.schedules[identifier]['cpu'] || 256,
}, task_override)
}
template['Resources'][normalizedIdentifier + 'Task'] = task
// create the rule definition
var rule = {
'Type': 'AWS::Events::Rule',
'Properties': Object.assign({
'State': "ENABLED",
'Targets': [
{
"Id": "ScheduledNameHoge",
"RoleArn": {"Fn::GetAtt": ["FargateRole", "Arn"]},
"EcsParameters":{
"TaskDefinitionArn": {"Fn::GetAtt": ["FargateTestTaskDefinition", "TaskDefinitionArn"]},
"TaskCount": 1,
'LaunchType': 'FARGATE',
'NetworkConfiguration': {
'AwsVpcConfiguration': Object.assign({
'AssignPublicIp': options.vpc['public-ip'] || "DISABLED",
'SecurityGroups': options.vpc['security-groups'] || [],
'Subnets': options.vpc['subnets'] || [],
}, network_override),
}
},
"Arn": {"Fn::GetAtt": ["FargateCluster", "Arn"]},
}
],
'ScheduleExpression': "cron(* * * * ? *)"
}, service_override)
}
template['Resources'][normalizedIdentifier + 'rule'] = rule
});
}
}
module.exports = ServerlessFargateScheduledTasks;
感想
[前回の記事]の結びで、
というわけで、 Scheduled Task に関しては、近いうちにカスタムプラグインの作成を試したいなと思っています。
うまく実装できたら、また記事にしていきたいと思います。
と書いてしまったので、上手く動かすことができて一安心しました。
とはいっても、実はまだお試しで作っただけで、実運用は出来ていません。
おそらく実際に運用していく中で、カスタマイズしたい項目も増えてくると思いますし、将来的にはきちんと使える状態にして、 Github などで公開していけたら良いなと思っています。
カスタムプラグインも作れるようになり、 serverless framework もいよいよ、大抵のことはできるな、という印象になりました。
しかし、社内では最近、 AWS CDK なるものが流行の兆しを見せています。
これはとても悔しいので、今後は AWS CDK にも入門していきたいなと思っています。
serverless framework については、色々と書いてきましたが、新規プロジェクトの作り方とか、基本的な部分をあまり書いてこなかったので、 serverless framework 入門(いまさら!?)みたいな記事も書いていきたいなと思っています。