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
LIVEalias and theMaindeployment group already exist.get-aliaswill blow up on a function that's never had the alias, so your very first deploy needs a bit of bootstrapping; -
Thanks to
set -eand 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.