home

Blog


Creating a Lambda deployment script


You've got a Lambda running in production and a shiny new image sitting in ECR. The lazy move is to update the image URI, walk away, and hope for the best. The problem is that this swaps the code for everyone, instantly, with no safety net. If the new version is broken, congratulations, it's broken for 100% of your traffic before you even finish your coffee. What you might be looking for is to publish an immutable version, then shift traffic onto it in a controlled way, all from a single script and preferably in an automated manner.

The script

#!/usr/bin/env bash

set -euo pipefail

aws lambda wait function-updated-v2 --function-name "$1"
aws lambda update-function-code --function-name "$1" --image-uri "$3"
aws lambda wait function-updated-v2 --function-name "$1"

PUBLISHED_VERSION=$(aws lambda publish-version --function-name "$1" | jq -r '.Version')

aws lambda wait published-version-active --function-name "$1" --qualifier "$PUBLISHED_VERSION"

VERSIONS=$(aws lambda list-versions-by-function --function-name "$1")
CURRENT_VERSION="$(aws lambda get-alias --function-name "$1" --name LIVE | jq -r '.FunctionVersion')"
TARGET_VERSION=$(echo "$VERSIONS" | jq -r '.Versions[-1].Version')
APPSPEC=$(echo '{"version":1,"Resources":[{"'"$1"'":{"Type":"AWS::Lambda::Function","Properties":{"Name":"'"$1"'","Alias":"LIVE","CurrentVersion":'"$CURRENT_VERSION"',"TargetVersion":'"$TARGET_VERSION"'}}}]}' | jq -R)
REVISION='{"revisionType":"AppSpecContent","appSpecContent":{"content":'"$APPSPEC"'}}'
DEPLOYMENT_ID=$(aws deploy create-deployment --application-name "$2" --deployment-group-name Main --revision "$REVISION" | jq -r '.deploymentId')

aws deploy wait deployment-successful --deployment-id "$DEPLOYMENT_ID"

It takes three arguments: $1 is the Lambda function name, $2 is the CodeDeploy application name, and $3 is the URI of the new image. Everything else is figured out along the way.

The tools doing the heavy lifting

  • The Lambda version, which is an immutable snapshot of your code and config, and its alias (LIVE), which is the stable pointer to a version you've published that your triggers actually talk to;
  • CodeDeploy, which is the thing that moves the alias from the old version to the new one, gradually if you want, and rolls it back if something starts screaming;
  • The good old AWS CLI, the tool calling all of the above;
  • Lastly, jq, a tool for digging values out of the JSON the CLI hands back, and for building the deployment payload.

Failing loud and early

set -euo pipefail

This line is the unsung hero here, making the script exit on the first error, treat unset variables as errors, and fail if any command in a pipeline fails. In a deployment script this is exactly what you want: if a step goes sideways, you stop right there instead of merrily shifting traffic to a half-baked version. On a side note, this might not be needed in certain environments, for example in CircleCI, where the default shell already runs with -eo pipefail.

Shipping the new image

aws lambda wait function-updated-v2 --function-name "$1"
aws lambda update-function-code --function-name "$1" --image-uri "$3"
aws lambda wait function-updated-v2 --function-name "$1"

The first wait function-updated-v2 makes sure the function isn't already mid-update from something else (Lambda won't let you touch it while it's settling). Then update-function-code points the function at the new image, and the second wait blocks until that change has fully propagated. Sandwiching the update between two waits is what keeps the rest of the script from racing ahead of AWS.

Publishing a new version

PUBLISHED_VERSION=$(aws lambda publish-version --function-name "$1" | jq -r '.Version')

aws lambda wait published-version-active --function-name "$1" --qualifier "$PUBLISHED_VERSION"

Calling publish-version takes the current code and config and freezes them into a numbered, immutable version. We pull that number out of the response with .Version and hang on to it. Then we wait for published-version-active, because a freshly published version needs a moment before it's ready to serve traffic. No point deploying something that isn't awake yet.

Working out where traffic is and where it's headed

VERSIONS=$(aws lambda list-versions-by-function --function-name "$1")
CURRENT_VERSION="$(aws lambda get-alias --function-name "$1" --name LIVE | jq -r '.FunctionVersion')"
TARGET_VERSION=$(echo "$VERSIONS" | jq -r '.Versions[-1].Version')

CodeDeploy needs two numbers: where traffic is now, and where it should end up. Doing get-alias on the LIVE alias gives us the current one via .FunctionVersion. For the target, we list every version and grab the last one in the list with .Versions[-1].Version, which is the one we just published. So we go from "whatever's live now" to "the brand-new version".

Describing the shift with an AppSpec

APPSPEC=$(echo '{"version":1,"Resources":[{"'"$1"'":{"Type":"AWS::Lambda::Function","Properties":{"Name":"'"$1"'","Alias":"LIVE","CurrentVersion":'"$CURRENT_VERSION"',"TargetVersion":'"$TARGET_VERSION"'}}}]}' | jq -R)
REVISION='{"revisionType":"AppSpecContent","appSpecContent":{"content":'"$APPSPEC"'}}'

CodeDeploy is told what to do through an AppSpec. For Lambda it's tiny and it just names the function, the alias, and the two versions to move between.

{
  "version": 1,
  "Resources": [
    {
      "your-function-name": {
        "Type": "AWS::Lambda::Function",
        "Properties": {
          "Name": "your-function-name",
          "Alias": "LIVE",
          "CurrentVersion": 41,
          "TargetVersion": 42
        }
      }
    }
  ]
}

The slightly weird bit is jq -R. CodeDeploy wants that whole AppSpec passed as a string, not as nested JSON, so -R takes the raw content and encodes it as one escaped string. That string then gets dropped into the REVISION object under appSpecContent, which is exactly the shape the deployment call expects.

Handing it over to CodeDeploy

DEPLOYMENT_ID=$(aws deploy create-deployment --application-name "$2" --deployment-group-name Main --revision "$REVISION" | jq -r '.deploymentId')

aws deploy wait deployment-successful --deployment-id "$DEPLOYMENT_ID"

The create-deployment call kicks the whole thing off against your application and the Main deployment group, and we just grab the returned .deploymentId from the response. From here CodeDeploy owns the traffic shift. The final wait deployment-successful just blocks until it's done, so your CI job stays busy until the deployment genuinely finishes (or fails). No fire-and-forget.

Worth knowing before you use it

  • How fast traffic actually shifts (all-at-once, canary, linear) lives in the deployment configuration of the group, not in this script. The script just says "go from A to B";
  • Automatic rollback lives on the deployment group too - wire up CloudWatch alarms there and a bad version can undo itself while you watch;
  • This assumes the LIVE alias and the Main deployment group already exist. get-alias will blow up on a function that's never had the alias, so your very first deploy needs a bit of bootstrapping;
  • Thanks to set -e and all those waiters, any stuck or failed step aborts the run before CodeDeploy is ever involved, which is precisely the behavior you want.

Wrap-up

A pretty simple shell script you can drop into a CI/CD pipeline that publishes a version, hands the traffic shift over to CodeDeploy, and blocks until it's truly done. No more crossing your fingers right after an update-function-code and doing all this by yourself. It may require a few tweaks to suit your use case, but it's a starting point which can be improved from there on.

Powered by Simple Blog API