在AWS上创建、监控和测试cron作业

624 阅读14分钟

Cron作业无处不在--从运行你的数据管道的脚本到自动清理你的开发机器,从清理云中未使用的资源到发送电子邮件通知。这些任务往往在后台不知不觉地发生。而在任何企业中,一定会有许多任务可以成为cron job,但却是手动运行的进程或作为不相关的应用程序的一部分。

许多公司希望控制他们的cron作业:管理成本,确保作业是可维护的,运行它们的基础设施是最新的,并分享关于作业如何运行的知识。对于那些已经把其他基础设施带到亚马逊公共云的人来说,在AWS中运行cron工作是一个明显的选择。

如果你是一个使用AWS的开发者,并且你想把你的cron作业带到AWS,有两个主要的选择:使用EC2机器--旋转一个虚拟机并配置cron作业在上面运行;或者使用AWS Lambda--无服务器计算服务,抽象出机器管理并为任务自动化提供一个简单的界面。

表面上看,EC2似乎是运行cron作业的正确选择,但随着时间的推移,你会发现自己开始遇到以下问题。

  1. 大多数cron作业不需要每秒钟运行,甚至不需要每小时运行。这意味着为cron作业预留的EC2机器至少有90%的时间是闲置的,更不用说它的资源没有得到有效利用。
  2. 运行cron作业的机器当然需要定期更新,必须有一个机制来处理这个问题,无论是Terraform对实例的描述还是Chef烹饪书。
  3. 昨晚的cron作业运行了吗?过去几周的平均运行时间有变化吗?回答这些问题和其他问题需要向你的cron job添加更多的代码,如果你的cron job是一个简单的Bash脚本,这就很难做到。

AWS Lambda解决了所有这些问题。通过其按使用付费的模式,你只需为你的Lambda应用程序使用的计算时间付费。对于短命的任务,这可以产生巨大的节省。当使用无服务器框架部署Lambda时,该功能所连接的所有基础设施的描述与应用程序代码驻留在同一个仓库中。此外,你还可以获得指标、异常检测和易于使用的秘密管理,这些都是开箱即用的。

在这篇文章中,我们将指导你如何使用AWS Lambda和无服务器框架在AWS上创建一个cron job,如何获得正确的警报和安全措施,以及如何根据需要扩展你的cron job。如果你想跟着学习,可以看看我们在GitHub上为这篇文章准备的例子库。让我们开始吧!

使用AWS Lambda创建一个cron job

在这个例子中,我们将介绍一个执行数据库翻转的cron job。我们的用例:我们想把过去一周的数据从生产数据库中归档,以保持数据库的小规模,同时仍然保持其数据的可访问性。我们首先在版本库根部的serverless.yml 文件中定义了我们的cron job应用程序的所有细节。

    # serverless.yml
    service: week-cron

    provider:
      name: aws
      runtime: nodejs8.10
      region: 'us-east-1'
      frameworkVersion: ">=1.43.0"
      timeout: 900 # in seconds
    ...

我们的函数需要连接到我们的生产数据库,所以我们通过环境变量提供我们需要的数据库的秘密。

    # serverless.yml
    ...
      environment:
        DB_HOST: ${file(./secrets.json):DB_HOST}
        DB_USER: ${file(./secrets.json):DB_USER}
        DB_PASS: ${file(./secrets.json):DB_PASS}
        DB_NAME: ${file(./secrets.json):DB_NAME}
    ...

然后我们添加我们的函数的描述。我们希望它有一个名为transfer 的单一函数来执行数据库翻转。我们希望transfer 函数每周在我们的应用程序负载最低的时候自动运行,比如周一凌晨3点左右。

    # serverless.yml
        ...
    functions:
        transfer:
        handler: handler.transfer
        events:
            # every Monday at 03:15 AM
            - schedule: cron(15 3 ? * MON *)

计划表达式的语法

在我们上面的例子中,transfer 处理程序在events 块中指定的时间表上运行,在这里是通过schedule 事件。schedule 事件的语法可以有两种类型。

  • rate - 使用这种语法,你可以指定你的函数应该被触发的速度。

使用rate 语法的schedule 事件必须指定速率为rate(value unit) 。支持的单位有:minute/minutes,hour/hoursday/days 。如果数值是1,那么应该使用单位的单数形式,否则你需要使用复数形式。例如。

          - schedule: rate(15 minutes)
          - schedule: rate(1 hour)
          - schedule: rate(2 days)
  • cron - 这个选项用于指定一个使用Linux 语法的更复杂的时间表。crontab

cron 计划事件使用的语法是cron(minute hour day-of-month month day-of-week year) 。 你可以为每个单元指定多个值,用逗号隔开,并且有一些通配符可用。关于支持的通配符的完整列表,请参见AWS日程表表达式文档页面,关于同时使用多个通配符的限制。

这些是有效的schedule 事件。

    # the example from our serverless.yml that runs every Monday at 03:15AM UTC
    - schedule: cron(15 3 ? * MON *)
    # run in 10-minute increments from the start of the hour on all working days
    - schedule: cron(1/10 * ? * W *)
    # run every day at 6:00PM UTC
    - schedule: cron(0 18 * * ? *)

你可以为每个函数指定多个时间表事件,以备你想合并时间表。也可以在同一个函数上组合ratecron 事件。

传输数据库记录的业务逻辑

到此为止,函数的描述已经完成。下一步是为transfer 处理程序添加代码。定义处理程序的handler.js 文件是相当短的。

    // handler.js
    exports.transfer = require("./service/transfer_data").func;

实际的应用逻辑存在于service/transfer_data.js 文件中。 接下来让我们看一下这个文件。

我们希望我们的应用程序完成的任务是一个数据库的翻转。当它运行时,应用程序要经过三个步骤。

  1. 确保所有必要的数据库表被创建。
  2. 将数据从生产表转移到一个 "月度 "表。
  3. 清理从生产表转移来的数据。

我们假设现在不能添加有过去日期的记录,并且在生产数据库上创造额外的负载是没有问题的。

我们首先为上面定义的三个任务分别引用辅助函数,并初始化日期操作和数据库访问的公用程序。

    // service/transfer_data.js
    var monthTable = require('../database/create_month_table')
    var transferData = require('../database/transfer_data')
    var cleanupData = require('../database/cleanup_data')
    var dateUtil = require('../utils/date')
    const Client = require('serverless-mysql')

该函数的代码非常简单:确保表的存在,传输数据,删除数据,并记录所有正在发生的动作。下面是简化版。

    // service/transfer_data.js
    exports.func = async () => {
        var client = Client({
            config: {
              ...
            }
        })
        var weeknumber = dateUtil.getWeekNumber(new Date())
        var currentWeek = weeknumber[1];
        var currentYear = weeknumber[0];
        try {
            await monthTable.create(client, currentWeek, currentYear)
            await transferData.transfer(client, currentWeek, currentYear)
            await cleanupData.cleanup(client, currentWeek, currentYear)
            } catch (error) {
            if (error.sqlMessage) {
                // handle SQL errors
            } else {
                // handle other errors
            }
        }
        client.quit()
        return "success";
    }

你可以在我们的GitHub仓库中找到该文件的完整版本。

创建月度表的辅助函数导出了一个单一的create ,该函数基本上由一个SQL查询组成。

    // database/create_month_table.js
    exports.create = async (client, week, year) => {
        await client.query(`
        CREATE TABLE IF NOT EXISTS weather_${year}_${week}
        (
            id MEDIUMINT UNSIGNED not null AUTO_INCREMENT, 
            date TIMESTAMP,
            city varchar(100) not null, 
            temperature int not null, 
            PRIMARY KEY (id)
        );  
        `)
    }

transfer_data 辅助函数在结构上与之类似,有自己的SQL查询。

    // database/transfer_data.js
    exports.transfer = async (client, week, year) => {
        var anyId = await client.query(`select id from weather where YEAR(date)=? and WEEK(date)=?`, [year, week])
        if (anyId.length == 0) {
            console.log(`records does not exists for year = ${year} and week = ${week}`)
            return
        }
        await client.query(`
        INSERT INTO weather_${year}_${week}
        (date, city, temperature)
        select date, city, temperature 
        from weather
        where YEAR(date)=? and WEEK(date)=? 
        `, [year, week])
    }

最后,cleanup 帮助器中的数据清理看起来是这样的。

    // database/cleanup_data.js
    exports.cleanup = async (client, week, year) => {
        var anyId = await client.query(`select id from weather where YEAR(date)=? and WEEK(date)=?`, [year, week])
        if (anyId.length == 0) {
            console.log(`cleanup did't needed, because does not exists records for year = ${year} and week = ${week}`)
            return
        }
        anyId = await client.query(`select id from weather_${year}_${week} limit 1`, [year, week])
        if (anyId.length == 0) {
            throw Error(`cleanup can't finished, because records are not transferred for year = ${year} and week = ${week} in`)
        }
        await client.query(`
        delete
        from weather 
        where YEAR(date)=? and WEEK(date)=? 
        `, [year, week])
    }

这样,核心业务逻辑就完成了。我们还为业务逻辑添加了一些单元测试,可以我们的Repo中的[test](https://github.com/chief-wizard/serverless-cron-job-example/blob/master/test/database_transfer_test.js) 目录中找到。

下一步是部署我们的cron job。

将我们的cron工作部署到AWS上

应用程序代码和serverless.yml 文件现在都已经设置好了。部署我们的cron job的剩余步骤如下。

  • 安装无服务器框架。
  • 安装所需的依赖项。
  • 运行部署步骤。

为了安装无服务器框架,我们运行。

    $ npm install -g serverless

为了安装我们应用程序的依赖项,我们运行

    $ npm install

在项目目录中。

对于如何运行部署步骤,我们现在有两个选择。一种是在本地机器上设置AWS凭证,另一种是在Serverless Dashboard中设置AWS凭证,而不要让本地机器直接访问AWS。

选项1: 在开发机器上使用AWS凭证 如果你只有一个人在部署样本cron job,或者你团队中的开发者已经可以访问相关的AWS生产账户,那么这个选项就很好用。对于较大的团队和生产应用,我们不推荐这个选项。遵循这些步骤。

  1. 确保AWS CLI已经安装在本地。尝试运行aws --version ,如果CLI还没有安装,请运行pip install awscli
  2. 通过运行aws configure ,为AWS CLI配置AWS凭据。
  3. 一旦凭证设置完毕,运行serverless deploy 来部署cron job。

选项2: 使用Serverless Dashboard为每次部署生成单一用途的AWS凭证 我们建议有多个开发人员的团队使用这一选项。通过这种设置,你可以授予Serverless Dashboard一组AWS权限,对于每次部署,Serverless框架都会生成一个具有有限权限的一次性凭证来部署你的cron job。

在部署之前,如果你还没有一个账户,请注册Serverless Dashboard。一旦你的账户设置完毕,就使用添加按钮创建一个新的应用程序。

serverless.yml 为了确保Serverless Framework知道哪个应用与我们的cron job相关联,我们在根层的tenantapp 文件中添加了属性。你需要用你的Serverless账户中的值替换这里显示的值。

    # serverless.yml

    # our Serverless Dashboard account name
    tenant: chiefwizard
    # our Serverless Dashboard application name
    app: cron-database-rollover

之后,部署的步骤是。

  1. 在Dashboard中,导航到Profiles → Create or choose a profile → AWS credential access role。

  2. 选择个人AWS账户,并指定你想用于部署的IAM角色。如果该角色还不存在,单击创建角色链接以创建它。

  3. 点击保存并退出。

  4. 在本地机器的控制台中运行serverless login ,并使用你的Serverless Dashboard凭证进行登录。

  5. 运行serverless deploy ,不要在你的机器上配置生产型AWS账户。

完成了!cron工作已经部署完毕,将按照我们在serverless.yml 文件中配置的时间表运行。

请看这个YouTube视频,我们现场演示了部署过程。

为你的cron工作设置监控

当你通过Serverless Dashboard进行部署时(我们推荐的方法),一旦cron job被部署,监控就已经设置好了。在应用程序页面上,我们点击进入我们刚刚运行的部署。

在部署页面上,当我们进入概览部分时,我们看到了警报列表以及函数调用和错误的图表。

它目前是空的,但随着cron job开始被调用,更多的信息会出现。在 "警报 "选项卡上,任何与你的cron job相关的警报都会被显示出来。这就是了!不需要额外的工作来设置监控和警报。

通过Serverless Dashboard进行部署,你还可以使用Dashboard来查看你的函数最近的调用情况(在Invocations Explorer标签中找到),列出你的服务的所有部署情况,等等。

为你的cron job编写和运行测试

为了增加对cron job代码的信心,我们将创建一些单元测试来覆盖我们在test/database_transfer_test.js 文件中的数据库翻转逻辑的主要部分。

我们首先要求所有的辅助文件和设置测试数据。

    // test/database_transfer_test.js
    var assert = require('assert');
    var fs = require("fs")
    var dateUtil = require('../utils/date')
    var monthTable = require('../database/create_month_table')
    var transferData = require('../database/transfer_data')
    var cleanupData = require('../database/cleanup_data')
    var init = require('../database/init_data')
    const Client = require('serverless-mysql')
    const secrets = JSON.parse(fs.readFileSync("secrets.json"));

    describe('Transfer test', function () {
        // initialize the database client
        var client = Client({
            config: {
              ...
            }
        })
        // set up the test vars
        ...

describe 块中,我们为我们的业务逻辑添加单个测试。例如,在这个片段中,我们测试monthTable.create 辅助函数。

    // test/database_transfer_test.js
    ...
        describe('#monthTable.create(client, week, year)', function () {
            it('exists new table for week = 33 and year = 2018', async function () {
                await client.query(`drop table if exists weather_${year}_${week}`)
                await monthTable.create(client, week, year)
                var anyId = await client.query(`SELECT table_schema db,table_name tb  FROM information_schema.TABLES
                where table_name='weather_${year}_${week}'`)
                assert.equal(anyId.length, 1);
            });
        });
    ...

我们继续这样做,直到我们的cron job的所有关键部分都被单元测试所覆盖(或者,如果你愿意,集成测试)。在我们的例子库中可以看到所有的测试。

为了运行这些测试,我们需要确保我们有一个本地运行的MySQL实例。如果你需要安装MySQL,请从MySQL社区下载页面选择一个适合你的方法。

在我们的Mac上,我们已经用Homebrew安装了MySQL,为了启动它,我们运行。

    $ brew services start mysql
    ==> Successfully started `mysql` (label: homebrew.mxcl.mysql)

为了创建测试数据库,我们通过CLI连接到MySQL。

    $ mysql -uroot
    Welcome to the MySQL monitor.  Commands end with ; or \g.
    Your MySQL connection id is 3
    Server version: 5.7.16 Homebrew

    Copyright (c) 2000, 2016, Oracle and/or its affiliates. All rights reserved.

    Oracle is a registered trademark of Oracle Corporation and/or its
    affiliates. Other names may be trademarks of their respective
    owners.

    Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

    mysql> create database testdb;
    Query OK, 1 row affected (0.00 sec)

    mysql> ^DBye

在我们的secrets.json 文件中,我们为MySQL数据库设置了本地凭证。

    # secrets.json
    {
      "DB_HOST": "127.0.0.1",
      "DB_USER": "root",
      "DB_PASS": "",
      "DB_NAME": "testdb"
    }

**注意:**默认情况下,MySQL根密码是空的。请考虑将密码改为更安全的密码,并确保你不会将数据库暴露在你的本地开发环境之外。

配置好凭证后,我们现在可以运行测试了。

    npm test

    > cron-aws@1.0.0 test /Users/alexey/wizard/serverless-cron-job-example
    > mocha

      Transfer test
        #init(client, year, month, day, city)
          ✓ exists record for 2018/08/21 (167ms)
        #monthTable.create(client, week, year)
          ✓ exists new table for week = 33 and year = 2018
        #transferData.transfer(client, week, year)
          ✓ exists record in new and old table for week = 33 and year = 2018
        #cleanupData.cleanup(client, week, year)
          ✓ exists record in new table and not exists in old table for week = 33 and year = 2018

      Date utils test
        #getWeekNumber(d)
          ✓ should return week = 33 and year = 2018 for 2018/08/21


      5 passing (205ms)

很好!我们的cron job已经可以运行了。

迭代cron工作

为了迭代和更新cron job的代码,只需在你做完修改后运行serverless deploy ,以部署最新的版本。我们建议设置一个CI/CD管道,在每次推送修改内容到GitHub时,持续验证和部署你的cron job。

我们刚才浏览的应用程序的完整例子可以在我们的GitHub repo中找到。

总结

在这篇文章中,我们介绍了使用无服务器框架在AWS上创建和部署一个cron job。与AWS EC2相比,使用AWS Lambda可能更适合你的cron作业,因为在Lambda中,你只需为你使用的东西付费,而且已经有良好的基础设施来部署、监控和保护你创建的cron作业。

直接与AWS Lambda合作,对开发者的体验来说是一种挑战。通过使用无服务器框架,你可以获得更简单的部署和迭代流程,还可以从内置的AWS凭证管理、对cron作业的零设置监控等方面获益。如果你决定从AWS迁移出去,使用无服务器框架还可以帮助你避免锁定供应商。

虽然我们认为使用AWS Lambda和Serverless Framework对大多数类型的cron作业来说是一个很好的解决方案,但Lambda确实有一些限制。例如,如果你的作业需要运行超过15分钟,或者你的函数需要访问特殊的硬件(例如GPU),那么使用EC2可能更合适。此外,当你有非常多的cron作业同时运行时,从长远来看,使用EC2很可能更有成本效益。

你可以在我们的GitHub repo中找到我们所走过的完整例子。

在Serverless网站上查看Serverless框架的细节。Serverless的AWS文档以及Serverless Dashboard的参考资料可能会有所帮助。

在我们的例子页面上找到更多的无服务器应用的例子