Canary from a pipeline
Promotion and abort criteria, written down first.
Part 1: The problem
QuickCart started releasing new versions of the menu service to 10% of customers first, as a canary. It worked — in theory.
On Friday evening, Ana started a canary and watched the dashboard. The error graph wobbled. Was that the new version, or the usual Friday rush? She messaged Kofi. Kofi thought it looked “probably fine”. They waited twenty minutes to be sure, then promoted to 100%. The wobble had been the new version, and now everyone had it.
The canary had done its job and found the problem. The people reading it hadn’t known what they were looking for.
Part 2: The idea
A canary sends a small share of real traffic to a new version, watches it, and either widens the share step by step or pulls it back. Ways to release explained the idea. This lesson puts it in the pipeline, so a machine makes the call using rules people agreed on calmly, in advance.
Abort criteria, written first
Before the release, the team writes down what “failing” means. Each criterion is a number the pipeline can check.
| Criterion | QuickCart’s rule | Why it’s there |
|---|---|---|
| Error rate | The new version’s server errors (5xx) stay under 2% of its requests | Failed requests are what customers notice first |
| Latency | The new version’s p99 response time stays under 800 ms | A version can be correct but too slow |
| Minimum sample | At least 20 requests reach the new version in the step, or the step doesn’t count | Too little data is not a pass |
| Bake time | Each step lasts at least 5 minutes | Some failures only show up after caches expire or a job runs |
How the traffic moves
| Where the service runs | How the pipeline shifts traffic | How it rolls back |
|---|---|---|
| ECS or EC2 behind an ALB | The listener rule forwards to two target groups with weights, such as 90 and 10 | Set the canary target group’s weight to 0 |
| ECS with CodeDeploy | A deployment configuration such as CodeDeployDefault.ECSCanary10Percent5Minutes, with CloudWatch alarms attached | CodeDeploy rolls back itself when an attached alarm fires |
| Lambda | A weighted alias: the alias points at the stable version and sends a share to the new one | Remove the extra weight |
| Kubernetes | A service mesh, or an ingress controller that supports weights | Set the canary’s weight to 0 |
Newer ECS releases also have canary options built into ECS deployments. Check what your platform supports before building your own.
Where the numbers come from
The pipeline reads the same metrics a person would, but per version, so the canary is judged only on its own requests.
| Platform | Error metric | Split per version by |
|---|---|---|
| ALB | HTTPCode_Target_5XX_Count and RequestCount in CloudWatch | The TargetGroup dimension: one target group per version |
| ALB | TargetResponseTime, at the p99 statistic | TargetGroup |
| Lambda | Errors and Invocations in CloudWatch | The ExecutedVersion dimension, for calls through a weighted alias |
The pipeline’s shape
| Stage | What it does |
|---|---|
| Deploy the new version next to the old one | No customer reaches it yet |
| Shift a small share, then wait and judge | For example 10%, for 5 minutes |
| Shift more, then wait and judge | For example 50%, for 5 minutes |
| Promote | 100% to the new version. Keep the old one briefly for a fast rollback |
post → failure | Put all traffic back on the old version. This runs however the build fails: a broken criterion, an error, or a timeout |
Putting the rollback in post { failure } means it happens on any failure, not just the ones you thought of.
Part 3: See it
Below, a pipeline releases a new version of QuickCart’s menu service in steps. First release a Healthy version. Then choose Raises errors and start again. Finally try Quiet night, where very little traffic arrives.
All traffic goes to the stable version.
The steps
- 10% to the new version waiting
- 50% to the new version waiting
- 100% to the new version waiting
Abort criteria, agreed first
- Canary requests this step
- 0 need at least 20
- Canary error rate
- 0.0% abort above 2%
Pipeline log
Waiting to start.
What to notice: nobody decides anything during the canary. The failing version is stopped at the first step, and only that step’s share of customers ever saw it. On a quiet night, the pipeline refuses to call a handful of successful requests a pass; it waits until it has enough to judge.
Part 4: Do it
Pennies — a few hundred calls to a tiny Lambda function: well under a cent, and inside the free allowance on most accounts.
You’ll give Jenkins a canary to run. The service is the quickcart-menu Lambda function from Ways to release, with a live alias that can send a share of calls to a second version. The pipeline shifts 25% and then 50% to the new version. At each step it calls the alias until enough calls reach the new version, then compares that version’s error rate with the limit. About 40 minutes.
You need the Jenkins on your computer from The Jenkinsfile, started with AWS_PROFILE=quickcart as in Continuous delivery.
Create the function and version 1
If you still have
quickcart-menufrom Ways to release, delete it first (Lambda → Functions →quickcart-menu→ Actions → Delete function), so the version numbers below match.In the AWS console: LambdaFunctionsCreate function
Field Enter Create function Author from scratch Function name quickcart-menuRuntime The newest Node.js version offered Architecture arm64 Choose Create function. In the Code tab, replace the contents of
index.mjswith version 1, then choose Deploy:index.mjsexport const handler = async () => { return { version: 1, dishes: ["Margherita", "Pad thai", "Falafel wrap"] }; };Publish it as a version:
In the AWS console: LambdaFunctionsquickcart-menuActionsPublish new version
Field Enter Version description stableThen create the alias that customers call:
In the AWS console: LambdaFunctionsquickcart-menuAliasesCreate alias
Field Enter Name liveVersion 1You should seeThe alias
livepointing at version1, with no weighted version.Publish a version that fails
Replace
index.mjswith version 2. It fails on about 4 calls in 10. Choose Deploy:index.mjsexport const handler = async () => { if (Math.random() < 0.4) { throw new Error("menu lookup failed"); } return { version: 2, dishes: ["Margherita", "Pad thai", "Falafel wrap", "Ramen"] }; };Publish it: Actions → Publish new version, description
adds ramen, then Publish.You should seeVersions lists
1and2. Thelivealias still sends every call to version1: publishing a version changes nothing for customers.Create the canary pipeline
In Jenkins, choose New Item:
Field Enter Enter an item name quickcart-canaryType Pipeline Choose OK, and paste this into Pipeline → Script:
Jenkinsfilepipeline { agent any parameters { string(name: 'STABLE', defaultValue: '1', description: 'The version customers get today') string(name: 'CANARY', defaultValue: '2', description: 'The new version to try') } options { timeout(time: 20, unit: 'MINUTES') disableConcurrentBuilds() timestamps() } environment { AWS_REGION = 'us-east-1' AWS_PAGER = '' FUNCTION = 'quickcart-menu' ALIAS = 'live' } stages { stage('Check the versions') { steps { script { if (params.STABLE == params.CANARY) { error('STABLE and CANARY must be different versions') } } sh 'aws lambda get-function --function-name "$FUNCTION" --qualifier "$CANARY" --query Configuration.Version --output text' } } stage('Canary at 25%') { steps { script { shift(0.25) judge() } } } stage('Canary at 50%') { steps { script { shift(0.5) judge() } } } stage('Promote') { steps { sh 'aws lambda update-alias --function-name "$FUNCTION" --name "$ALIAS" --function-version "$CANARY" --routing-config \'{"AdditionalVersionWeights":{}}\' --query FunctionVersion --output text' echo "Version ${params.CANARY} now takes all traffic. Version ${params.STABLE} is still there for a quick rollback." } } } post { failure { echo "Rolling back: all traffic to version ${params.STABLE}" sh 'aws lambda update-alias --function-name "$FUNCTION" --name "$ALIAS" --function-version "$STABLE" --routing-config \'{"AdditionalVersionWeights":{}}\' --query FunctionVersion --output text' } } } def shift(double share) { def weights = '{"AdditionalVersionWeights":{"' + params.CANARY + '":' + share + '}}' echo "Sending ${share * 100}% of calls to version ${params.CANARY}" sh "aws lambda update-alias --function-name \"\$FUNCTION\" --name \"\$ALIAS\" --function-version \"\$STABLE\" --routing-config '${weights}' --query RoutingConfig --output json" } def judge() { int minCalls = 10 int maxTries = 150 double maxErrorPercent = 2.0 int canaryCalls = 0 int canaryErrors = 0 int tries = 0 while (canaryCalls < minCalls && tries < maxTries) { def result = sh( script: 'aws lambda invoke --function-name "$FUNCTION" --qualifier "$ALIAS" --query "[ExecutedVersion, FunctionError]" --output text /dev/null', returnStdout: true ).trim().split(/\s+/) tries++ if (result[0] == params.CANARY) { canaryCalls++ if (result.length > 1 && result[1] != 'None') { canaryErrors++ } } } if (canaryCalls < minCalls) { error("Only ${canaryCalls} of ${tries} calls reached version ${params.CANARY}. Too few to judge, so not promoting.") } double errorPercent = canaryErrors * 100.0 / canaryCalls echo "Version ${params.CANARY}: ${canaryCalls} calls, ${canaryErrors} errors (${Math.round(errorPercent)}%). Limit: ${maxErrorPercent}%." if (errorPercent > maxErrorPercent) { error("Abort: version ${params.CANARY} failed ${Math.round(errorPercent)}% of its calls, above the ${maxErrorPercent}% limit") } }Line What it does If you leave it out parameters→STABLE,CANARYWhich version customers get now, and which one to try. Jenkins also sets them as environment variables You’d edit the pipeline for every release timeout(time: 20, …)Ends the build, and so triggers the rollback, if it runs too long A hung call would leave the canary running at 25% indefinitely disableConcurrentBuilds()One canary at a time Two builds would fight over the same alias Check the versionsStops at once if both versions are the same, or the canary version doesn’t exist The canary step would fail halfway, after moving traffic get-function … --qualifier "$CANARY"Fails the build if that version doesn’t exist — shift(0.25)Sets the alias to version STABLE, sending 25% of calls toCANARY— --routing-config '{"AdditionalVersionWeights":{"2":0.25}}'The weighted part: which extra version gets calls, and what share, from 0 to 1 All calls stay on the stable version judge()Calls the alias, counts only the calls that reached the new version, and fails the build if too few arrived or too many failed Traffic would widen with nobody checking aws lambda invoke … --qualifier "$ALIAS"Calls the function through live, exactly as a customer wouldCalling a version directly would bypass the weights --query "[ExecutedVersion, FunctionError]" --output textPrints which version answered, and Unhandledif it threw an error, orNoneif notYou couldn’t tell the versions apart /dev/nullWhere invokewrites the function’s reply. The pipeline doesn’t need itRequired: invokealways takes an output fileminCalls = 10,maxTries = 150Needs 10 calls to the new version, and gives up after 150 tries Without a minimum, four lucky calls could pass a broken version maxErrorPercent = 2.0The abort criterion, written down before the release — PromotePoints liveat the new version with no weights: 100% of callsThe canary would stay at 50% post→failurePoints liveback atSTABLEwith no weights, whatever made the build failA failed canary would leave customers on the bad version Choose Save.
Release the failing version
Choose Build Now (Jenkins uses the defaults,
1and2, the first time). Open Console Output. Within a minute or two:The console output, shortenedSending 25.0% of calls to version 2 Version 2: 10 calls, 4 errors (40%). Limit: 2.0%. ERROR: Abort: version 2 failed 40% of its calls, above the 2.0% limit Rolling back: all traffic to version 1 1 Finished: FAILUREYour error count will differ, because version 2 fails at random. It is very unlikely to stay under the limit.
You should seeIn the console, Lambda → quickcart-menu → Aliases →
livepoints at version1with no weighted version. The stage view showsCanary at 25%failed andCanary at 50%andPromotenever ran. Nobody decided anything; the rule did.Fix it, and release again
Replace
index.mjswith a fixed version, choose Deploy, and publish it as version3with the descriptionadds ramen, fixed:index.mjsexport const handler = async () => { return { version: 3, dishes: ["Margherita", "Pad thai", "Falafel wrap", "Ramen"] }; };In Jenkins, choose Build with Parameters, keep STABLE
1, and set CANARY to3.The console output, shortenedSending 25.0% of calls to version 3 Version 3: 10 calls, 0 errors (0%). Limit: 2.0%. Sending 50.0% of calls to version 3 Version 3: 10 calls, 0 errors (0%). Limit: 2.0%. 3 Version 3 now takes all traffic. Version 1 is still there for a quick rollback. Finished: SUCCESSYou should seeThe alias
livepoints at version3with no weights. Every customer now gets the fixed version, and it got there without anyone watching a graph.Clean-up step: Clean up
In the AWS console: LambdaFunctionsquickcart-menuActionsDelete function
Type
confirmif asked, and choose Delete. Deleting the function deletes its versions and thelivealias too.Lambda also made a log group, which keeps logs until you delete it:
In the AWS console: CloudWatchLog groups/aws/lambda/quickcart-menuActionsDelete log group(s)
The IAM role Lambda created, named like
quickcart-menu-role-followed by random letters, costs nothing. You can delete it in IAM → Roles to keep things tidy.In Jenkins, open
quickcart-canaryand choose Delete Pipeline. If you’ve finished the Automation module, stop Jenkins with Ctrl+C in its terminal.You should seeNo
quickcart-menufunction or log group remains. Nothing from this lab is left costing money.
Part 5: Check yourself
When should a team decide what counts as a failed canary?
The canary step saw 4 requests to the new version, with 0 errors. What should the pipeline do?
A canary at 10% crosses the error limit. What should happen, with nobody touching anything?
Why is an ALB's weighted target group a good way to run a canary for an ECS service?
Which signal is the best abort criterion for QuickCart's checkout?
Part 6: Cheat sheet
| Idea or code | In one line |
|---|---|
| Canary | A small share of real traffic tries the new version first |
| Abort criteria | Error rate, latency, minimum sample and bake time, written down before the release |
| Too little data | Not a pass. Wait for more, or fail the step |
| Judge per version | ALB: one target group per version. Lambda: the ExecutedVersion dimension |
| ALB weighted forward | Two target groups with weights. Roll back by setting the canary’s weight to 0 |
| CodeDeploy | CodeDeployDefault.ECSCanary10Percent5Minutes, with alarms for automatic rollback |
| Lambda alias | --routing-config '{"AdditionalVersionWeights":{"2":0.1}}' |
post { failure { … } } | The rollback runs whatever made the build fail |
| Promote | 100% to the new version. Keep the old one briefly |