はじめに

こんにちは、エンジニアの遠藤です。

serverless framework でカスタムプラグインを作成し、 Fargate の Scheduled Task をデプロイできるようにしました。
今回はその作成手順を簡単にまとめていきたいと思います。

バージョン情報など

以下のバージョンで動作確認をしています。
また、実際に試す際は、各種公式ドキュメントに必ず目を通してください。

serverless framework

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 入門(いまさら!?)みたいな記事も書いていきたいなと思っています。