juanjoDiaz / serverless-plugin-warmup

Keep your lambdas warm during winter. ♨
MIT License
1.11k stars 114 forks source link
aws aws-lambda lambda serverless serverless-plugin

Serverless WarmUp Plugin ♨

Serverless npm version npm monthly downloads Node.js CI Coverage Status license

Keep your lambdas warm during winter.

Requirements:

How it works

WarmUp solves cold starts by creating a scheduled lambda (the warmer) that invokes all the selected service's lambdas in a configured time interval (default: 5 minutes) and forcing your containers to stay warm.

Installation

Install via npm in the root of your Serverless service:

npm install --save-dev serverless-plugin-warmup

Add the plugin to the plugins array in your Serverless serverless.yaml:

plugins:
  - serverless-plugin-warmup

Configuration

The warmup plugin supports creating one or more warmer functions. Warmers must be defined under custom.warmup in the serverless.yaml file before they can be used in the functions' configs:

custom:
  warmup:
    officeHoursWarmer:
      enabled: true
      events:
        - schedule: cron(0/5 8-17 ? * MON-FRI *)
      concurrency: 10
      verbose: true
      logRetentionInDays: 14
    outOfOfficeHoursWarmer:
      enabled: true
      events:
        - schedule: cron(0/5 0-7 ? * MON-FRI *)
        - schedule: cron(0/5 18-23 ? * MON-FRI *)
        - schedule: cron(0/5 * ? * SAT-SUN *)
      concurrency: 1
      verbose: false
    testWarmer:
      enabled: false

The options are the same for all the warmers:

There are also some options which can be set under custom.warmup.<yourWarmer> to be applied to all your lambdas or under yourLambda.warmup.<yourWarmer> to overridde the global configuration for that particular lambda. Keep in mind that in order to configure a warmer at the function level, it needed to be previously configured at the custom section or the pluging will error.

custom:
  warmup:
    default:
      enabled: true # Whether to warm up functions by default or not
      folderName: '.warmup' # Name of the folder created for the generated warmup 
      cleanFolder: false
      memorySize: 256
      name: warmer-default
      roleName: my-custom-role
      role: WarmupRole
      tags:
        Project: foo
        Owner: bar 
      vpc: false
      events:
        - schedule: 'cron(0/5 8-17 ? * MON-FRI *)' # Run WarmUp every 5 minutes Mon-Fri between 8:00am and 5:55pm (UTC)
      package:
        individually: true
        patterns:
          - '!../**'
          - '!../../**'
          - ./**
      timeout: 20
      tracing: true
      verbose: false # Disable the logs
      logRetentionInDays: 14
      prewarm: true # Run WarmUp immediately after a deploymentlambda
      clientContext:
        source: my-custom-source
        other: '20'
      payload: 
        source: my-custom-source
        other: 20
      payloadRaw: true # Won't JSON.stringify() the payload, may be necessary for Go/AppSync deployments
      concurrency: 5 # Warm up 5 concurrent instances

functions:
  myColdfunction:
    handler: 'myColdfunction.handler'
    events:
      - http:
          path: my-cold-function
          method: post
    warmup:
      default:
        enabled: false

  myLowConcurrencyFunction:
    handler: 'myLowConcurrencyFunction.handler'
    events:
      - http:
          path: my-low-concurrency-function
          method: post
    warmup:
      default:
        clientContext:
          source: different-source-only-for-this-lambda
        payload:
          source: different-source-only-for-this-lambda
        concurrency: 1

  myProductionOnlyFunction:
    handler: 'myProductionOnlyFunction.handler'
    events:
      - http:
          path: my-production-only-function
          method: post
    warmup:
      default:
        enabled: prod

   myDevAndStagingOnlyFunction:
    handler: 'myDevAndStagingOnlyFunction.handler'
    events:
      - http:
          path: my-dev-and-staging-only-function
          method: post
    warmup:
      default:
        enabled:
          - dev
          - staging

Runtime Configuration

Concurrency can be modified post-deployment at runtime by setting the warmer lambda environment variables.
Two configuration options exist:

Networking

The WarmUp function use normal calls to the AWS SDK in order to keep your lambdas warm. If you set up at the provider level or the warmer confir level that the wamer function should be deployed into into a VPC subnet you need to keep in mind a couple of things:

Since the AWS SDK doesn't provide any timeout by default, this plugin uses a default connection timeout of 1 second. This is to avoid the issue of a lambda constantly timing out and consuming all its allowed duration simply because it can't connect to the AWS API.

Permissions

WarmUp requires permission to be able to invoke your lambdas.

If no role is provided at the custom.warmup level, each warmer function gets a default role with minimal permissions allowing the warmer function to:

The default role for each warmer looks like:

resources:
  Resources:
    WarmupRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: WarmupRole
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: WarmUpLambdaPolicy
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
               # Warmer lambda to send logs to CloudWatch
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                  Resource: 
                    - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${warmer.name}:*
                - Effect: Allow
                  Action:
                    - logs:PutLogEvents
                  Resource: 
                    - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${warmer.name}:*:*
                # Warmer lambda to invoke the functions to be warmed
                - Effect: 'Allow'
                  Action:
                    - lambda:InvokeFunction
                  Resource:
                    - !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${fn1.name}
                    - !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${fn2.name}
                    # and one more row for each function that must be warmed up by the warmer
                # Warmer lambda to manage ENIS (only needed if deploying to VPC, https://docs.aws.amazon.com/lambda/latest/dg/vpc.html)
                - Effect: Allow
                  Action:
                    - ec2:CreateNetworkInterface
                    - ec2:DescribeNetworkInterfaces
                    - ec2:DetachNetworkInterface
                    - ec2:DeleteNetworkInterface
                  Resource: "*"

The permissions can also be added to all lambdas using setting the role to IamRoleLambdaExecution and setting the permissions in iamRoleStatements under provider (see https://serverless.com/framework/docs/providers/aws/guide/functions/#permissions):

provider:
  name: aws
  runtime: nodejs20.x
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - 'lambda:InvokeFunction'
      Resource:
      - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${opt:stage, self:provider.stage}-*
custom:
  warmup:
    default:
      enabled: true
      role: IamRoleLambdaExecution
      architecture: 'arm64'

If setting prewarm to true, the deployment user used by the AWS CLI and the Serverless framework also needs permissions to invoke the warmer.

On the function side

When invoked by WarmUp, your lambdas will have the event source serverless-plugin-warmup (unless otherwise specified using the payload option):

{
  "Event": {
    "source": "serverless-plugin-warmup"
  }
}

To minimize cost and avoid running your lambda unnecessarily, you should add an early return call before your lambda logic when that payload is received.

Javascript

Using the Promise style:

module.exports.lambdaToWarm = async function(event, context) {
  /** Immediate response for WarmUp plugin */
  if (event.source === 'serverless-plugin-warmup') {
    console.log('WarmUp - Lambda is warm!');
    return 'Lambda is warm!';
  }

  // ... function logic
}

Using the Callback style:

module.exports.lambdaToWarm = function(event, context, callback) {
  /** Immediate response for WarmUp plugin */
  if (event.source === 'serverless-plugin-warmup') {
    console.log('WarmUp - Lambda is warm!')
    return callback(null, 'Lambda is warm!')
  }

  // ... function logic
}

Using the context. This could be useful if you are handling the raw input and output streams.

module.exports.lambdaToWarm = async function(event, context) {
  /** Immediate response for WarmUp plugin */
  if (context.custom.source === 'serverless-plugin-warmup') {
    console.log('WarmUp - Lambda is warm!');
    return 'Lambda is warm!';
  }

  // ... function logic
}

If you're using the concurrency option you might want to add a slight delay before returning on warmup calls to ensure that your function doesn't return before all concurrent requests have been started:

module.exports.lambdaToWarm = async (event, context) => {
  if (event.source === 'serverless-plugin-warmup') {
    console.log('WarmUp - Lambda is warm!');
    /** Slightly delayed (25ms) response 
        to ensure concurrent invocation */
    await new Promise(r => setTimeout(r, 25));
    return 'Lambda is warm!';
  }

  // ... add lambda logic after
}

Python

You can handle it in your function:

def lambda_handler(event, context):
    # early return call when the function is called by warmup plugin
    if event.get("source") == "serverless-plugin-warmup":
        print("WarmUp - Lambda is warm!")
        return {}

    # ... function logic

Or you could use a decorator to avoid the redundant logic in all your functions:

def skip_execution_if.warmup_call(func):
    def warmup_wrapper(event, context):
      if event.get("source") == "serverless-plugin-warmup":
        print("WarmUp - Lambda is warm!")
        return {}

      return func(event, context)

    return warmup_wrapper

# ...

@skip_execution_if.warmup_call
def lambda_handler(event, context):
    # ... function logic

Java

You can handle it in your function:

public ApiGatewayResponse handleRequest(Map<String, Object> input, Context context) {
  if ("serverless-plugin-warmup".equals(input.get("source"))) {
    System.out.println("WarmUp - Lambda is warm!");
    return ApiGatewayResponse.builder()
        .setStatusCode(200)
        .build();
  }

  // ... function logic
}

Ruby

You can handle it in your function:

def handle_request(app:, event:, context:, config: {})
  if event['source'] == 'serverless-plugin-warmup'
    puts 'WarmUp - Lambda is warm!'
    return {} 
  end

  # ... function logic
end

Lifecycle hooks

WarmUp plugin uses 3 lifecycles hooks:

Usage

Packaging

WarmUp supports

serverless package

By default, each warmer function is packaged individually and it uses a folder named .warmup/<function_name> to serve as temporary folder during the packaging process. This folder is deleted at the end of the packaging process unless the cleanFolder option is set to false.

If you are doing your own package artifact you can set the cleanFolder option to false and include the .warmup folder in your custom artifact.

Deployment

WarmUp adds package the warmers and add them to your services automatically when you run

serverless deploy

After the deployment, any warmer with prewarm: true is automatically invoked to warm up your functions without delay.

Prewarming

Apart from prewarming automatically after each deployment. You can invokes a warmer after a sucessful deployment to warm up functions using:

serverless warmup prewarm -warmers <warmer_name>

The warmers flag takes a comma-separated list of warmer names. If it's nor provided, all warmers with prewarm set to true are invoked.

Migrations

v5.X to v6.X

Removed include/exclude in favour of patterns

From Serverless 2.32.0 the patterns option is the recommended approach to include/exclude files from packaging. In version 3.X, the include and exclude are removed.

This plugin applies the same philosophy.

What used to be:

custom:
  warmup:
    default:
      enabled: 'prod'
      package:
        individually: true
        exclude: '../**',
        include: 'myFolder'

is the same as

custom:
  warmup:
    default:
      enabled: 'prod'
      package:
        individually: true
        patterns:
          - '!../**',
          - 'myFolder'

v4.X to v5.X

Support multiple warmer

Previous versions of the plugin only support a single warmer which limited use cases like having different concurrentcies in different time periods. From v5, multiple warmers are supported. The warmup field in the custom section or the function section, takes an object where each key represent the name of the warmer and the value the configuration which is exactly as it used to be except for the changes listed below.

custom:
  warmup:
    enabled: true
    events:
      - schedule: rate(5 minutes)

have to be named, for example, to default:

custom:
  warmup:
    default:
      enabled: true
      events:
        - schedule: rate(5 minutes)

Change the default temporary folder to .warmup

Previous versions of the plugin named the temporary folder to create the warmer handler _warmup. It has been renamed to .warmup to better align with the serverless framework and other plugins' behaviours.

Remembe to add .warmup to your git ignore.

Default to Unqualified alias

Previous versions of the plugin used the $LATEST alias as default alias to warm up if no alias was provided. From v5, the unqualified alias is the default. You can still use the $LATEST alias by setting it using the alias option.

custom:
  warmup:
    default:
      alias: $LATEST

Automatically exclude package level includes

Previous versions of the plugin exclude everything in the service folder and include the .warmup folder. This caused that any files that you include to the service level were also included in the plugin specially if you include ancestor folders (like ../**) From v5, all service level include are automatically excluded from the plugin. You still override this behaviour using the package option.

Removed shorthand

Previous versions of the plugin supported replacing the configuration by a boolean, a string representing a stage or an array of strings representing a list of stages. From v5, this is not supported anymore. The enabled option is equivalent.

custom:
  warmup: 'prod'

is the same as

custom:
  warmup:
    default: # Name of the warmer, see above
      enabled: 'prod'

Removed legacy options

The following legacy options have been completely removed:

Automatically creates a role for the lambda

If no role is provided in the custom.warmup config, a default role with minimal permissions is created for each warmer. See "Permissions" section

Support Tracing

If tracing is enabled at the provider level or at the warmer config level, the X-Ray client is automatically installed and X-Ray tracing is enabled.

Add a 1 second connect timeout to the AWS SDK

See the "Networking" section for more details.

Cost

You can check the Lambda pricing and CloudWatch pricing or can use the AWS Lambda Pricing Calculator to estimate the monthly cost

Example

If you have a single warmer and want to warm 10 functions, each with memorySize = 1024 and duration = 10, using the default settings ($0.0000166667 for every GB-second) and ignoring the free tier:

CloudWatch costs are not consdiered in this example.

Contribute

Help us making this plugin better and future-proof.

License

This software is released under the MIT license. See the license file for more details.

Acknowledgements

Thanks to Fidel who initially developed this plugin.