aws2-11 Exercise 2: Deployment Arithmetic and a SAM Canary Template ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with actionlint-py 1.7.12 (actionlint 1.7.12), check-jsonschema 0.38.2, cfn-lint 1.57.2, aws-sam-translator 1.114.0, moto 5.2.4 (in-process, mock_aws), boto3 1.43.112, PyYAML 6.0.3. Scratch folder C:/lwaws/c11, nothing installed globally, Docker not used. VERIFICATION LEVEL: 1 (no AWS account, no GitHub repository wired to AWS, nothing deployed or run in a real pipeline); statements about the real services come from the documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. Set AWS_DEFAULT_REGION=eu-west-1 and AWS_EC2_METADATA_DISABLED=true before the SAM translator scripts. --- deploy_model.py --- """Deployment arithmetic from the pages read on 2026-10-13. READ: CodeDeploy predefined configurations (Lambda canary/linear, ECS canary/linear: names and 'shifts 10 percent every N minutes'), ECS rolling update (minimumHealthyPercent rounds UP, maximumPercent rounds DOWN, examples 50/4, 75/2, 200/4, 125/3), SAM DeploymentPreference page. MY ASSUMPTIONS (not on the pages): a linear configuration shifts its first 10 percent at minute 0 and reaches 100 percent at 9 x interval; the alarm needs DETECT_MIN minutes of errors before it stops the deployment; requests are spread evenly; an error is a request to the new version that fails.""" import math, random import re CONFIGS = [ "LambdaCanary10Percent5Minutes", "LambdaCanary10Percent10Minutes", "LambdaCanary10Percent15Minutes", "LambdaCanary10Percent30Minutes", "LambdaLinear10PercentEvery1Minute", "LambdaLinear10PercentEvery2Minutes", "LambdaLinear10PercentEvery3Minutes", "LambdaLinear10PercentEvery10Minutes", "LambdaAllAtOnce", "ECSLinear10PercentEvery1Minutes", "ECSLinear10PercentEvery3Minutes", "ECSCanary10Percent5Minutes", "ECSCanary10Percent15Minutes", "ECSAllAtOnce", ] def schedule(name): """list of (minute, percent of traffic on the new version after this step)""" m = re.fullmatch(r"(?:Lambda|ECS)Canary(\d+)Percent(\d+)Minutes?", name) if m: p, n = int(m[1]), int(m[2]); return [(0, p), (n, 100)] m = re.fullmatch(r"(?:Lambda|ECS)Linear(\d+)PercentEvery(\d+)Minutes?", name) if m: p, n = int(m[1]), int(m[2]); return [(i * n, min(100, (i + 1) * p)) for i in range(math.ceil(100 / p))] if name.endswith("AllAtOnce"): return [(0, 100)] raise ValueError(name) def share_at(sched, t): s = 0 for minute, pct in sched: if t >= minute: s = pct return s def failed_requests(sched, rps, detect_min, bug_rate=1.0): """requests failed on the new version before the deployment is stopped: integrate share x rate until detect_min after the first traffic""" total = 0.0 for t in range(detect_min * 60): total += rps * share_at(sched, t / 60) / 100 * bug_rate return total def minutes_to_full(sched): return sched[-1][0] # ---- ECS rolling update arithmetic ---- def rolling(desired, min_pct, max_pct): min_healthy = math.ceil(min_pct * desired / 100) # rounded up (docs) max_total = math.floor(max_pct * desired / 100) # rounded down (docs) can_stop = desired - min_healthy can_start = max_total - desired return min_healthy, max_total, can_stop, can_start def waves(desired, min_pct, max_pct): """number of replace waves to swap every task (None if stuck); each wave starts what it can, then stops what it can. My simple model: new tasks start first up to max_total, old tasks are stopped down to min_healthy once the new ones are healthy.""" mh, mt, stop, start = rolling(desired, min_pct, max_pct) if stop == 0 and start == 0: return None old, new, w = desired, 0, 0 while old > 0: w += 1 s = min(desired - new, mt - (old + new)) # start new tasks within the cap new += max(s, 0) # stop old tasks while keeping healthy (old + new) >= min_healthy k = min(old, max(old + new - mh, 0)) old -= k if w > 50: return None return w --- deploy_tests.py --- from deploy_model import * print("== schedules (my assumption: linear starts at minute 0 and ends at 9 x interval) ==") for c in CONFIGS: s = schedule(c) steps = " ".join(f"{m}m:{p}%" for m, p in s[:4]) + (" ..." if len(s) > 4 else "") print(f"{c:38s} steps {len(s):2d} full after {minutes_to_full(s):3d} min {steps}") print("\n== checks against the page's wording ==") ok = True def chk(label, cond): global ok print(("ok " if cond else "DIFF ") + label); ok &= cond chk("Canary10Percent5Minutes: 10% first, the rest 5 minutes later", schedule("LambdaCanary10Percent5Minutes") == [(0, 10), (5, 100)]) chk("Linear10PercentEvery3Minutes: 10 equal steps of 10%", [p for _, p in schedule("LambdaLinear10PercentEvery3Minutes")] == list(range(10, 101, 10))) chk("AllAtOnce: one step of 100%", schedule("ECSAllAtOnce") == [(0, 100)]) chk("ECS rolling 50% / desired 4: scheduler can stop 2", rolling(4, 50, 200)[2] == 2) chk("ECS rolling 75% / desired 2: cannot stop any (minimum healthy rounds up to 2)", rolling(2, 75, 200)[2] == 0) chk("ECS rolling max 200% / desired 4: can start 4", rolling(4, 100, 200)[3] == 4) chk("ECS rolling max 125% / desired 3: cannot start any (limit rounds down to 3)", rolling(3, 100, 125)[3] == 0) print("\n== failed requests before a stop (100 rps, a bug that fails every request on the new version, alarm needs 2 minutes) ==") for c in ["LambdaAllAtOnce", "LambdaCanary10Percent5Minutes", "LambdaCanary10Percent30Minutes", "LambdaLinear10PercentEvery1Minute", "LambdaLinear10PercentEvery10Minutes"]: s = schedule(c) print(f"{c:38s} {failed_requests(s, 100, 2):9,.0f} failed requests of {100*120:,} in the window") print("\nsame, but the alarm needs 10 minutes (a slow metric):") for c in ["LambdaAllAtOnce", "LambdaCanary10Percent5Minutes", "LambdaCanary10Percent30Minutes", "LambdaLinear10PercentEvery1Minute", "LambdaLinear10PercentEvery10Minutes"]: s = schedule(c) print(f"{c:38s} {failed_requests(s, 100, 10):9,.0f} failed requests of {100*600:,} in the window") print("\n== can a canary see the bug at all? (bug fails 1 request in 1,000 on the new version) ==") print("expected failures on the new version during the first canary step (10% of traffic):") for rps in (1, 10, 100): for N in (5, 15): e = rps * 60 * N * 0.10 * 0.001 p0 = math.exp(-e) print(f" {rps:3d} rps, {N:2d}-minute canary: expect {e:6.2f} failures, chance of seeing none = {p0:6.1%}") print("(Poisson arithmetic; the real alarm's threshold and evaluation period decide what counts as 'seen'.)") print("\n== weighted alias sampling (random, seed 7): does 10% mean 10%? ==") rnd = random.Random(7) for n in (100, 1_000, 100_000): k = sum(rnd.random() < 0.10 for _ in range(n)) print(f" {n:7,d} requests -> {k:6,d} to the new version ({k/n:5.1%})") print("\n== ECS rolling update: waves and stuck settings (my simple model of the scheduler, not the real one) ==") print(f"{'desired':>7s} {'min%':>5s} {'max%':>5s} {'min healthy':>11s} {'max total':>9s} {'can stop':>8s} {'can start':>9s} waves") for d, mn, mx in [(4, 100, 200), (4, 50, 100), (4, 100, 100), (4, 0, 100), (2, 75, 200), (3, 100, 125), (1, 100, 200), (1, 0, 100), (10, 90, 110), (10, 100, 110)]: a, b, c, e = rolling(d, mn, mx) w = waves(d, mn, mx) print(f"{d:7d} {mn:5d} {mx:5d} {a:11d} {b:9d} {c:8d} {e:9d} {'STUCK (cannot stop or start)' if w is None else w}") print("\nall page checks agree" if ok else "\nSOME PAGE CHECKS DIFFER") --- py deploy_tests.py (saved output of the real run) --- == schedules (my assumption: linear starts at minute 0 and ends at 9 x interval) == LambdaCanary10Percent5Minutes steps 2 full after 5 min 0m:10% 5m:100% LambdaCanary10Percent10Minutes steps 2 full after 10 min 0m:10% 10m:100% LambdaCanary10Percent15Minutes steps 2 full after 15 min 0m:10% 15m:100% LambdaCanary10Percent30Minutes steps 2 full after 30 min 0m:10% 30m:100% LambdaLinear10PercentEvery1Minute steps 10 full after 9 min 0m:10% 1m:20% 2m:30% 3m:40% ... LambdaLinear10PercentEvery2Minutes steps 10 full after 18 min 0m:10% 2m:20% 4m:30% 6m:40% ... LambdaLinear10PercentEvery3Minutes steps 10 full after 27 min 0m:10% 3m:20% 6m:30% 9m:40% ... LambdaLinear10PercentEvery10Minutes steps 10 full after 90 min 0m:10% 10m:20% 20m:30% 30m:40% ... LambdaAllAtOnce steps 1 full after 0 min 0m:100% ECSLinear10PercentEvery1Minutes steps 10 full after 9 min 0m:10% 1m:20% 2m:30% 3m:40% ... ECSLinear10PercentEvery3Minutes steps 10 full after 27 min 0m:10% 3m:20% 6m:30% 9m:40% ... ECSCanary10Percent5Minutes steps 2 full after 5 min 0m:10% 5m:100% ECSCanary10Percent15Minutes steps 2 full after 15 min 0m:10% 15m:100% ECSAllAtOnce steps 1 full after 0 min 0m:100% == checks against the page's wording == ok Canary10Percent5Minutes: 10% first, the rest 5 minutes later ok Linear10PercentEvery3Minutes: 10 equal steps of 10% ok AllAtOnce: one step of 100% ok ECS rolling 50% / desired 4: scheduler can stop 2 ok ECS rolling 75% / desired 2: cannot stop any (minimum healthy rounds up to 2) ok ECS rolling max 200% / desired 4: can start 4 ok ECS rolling max 125% / desired 3: cannot start any (limit rounds down to 3) == failed requests before a stop (100 rps, a bug that fails every request on the new version, alarm needs 2 minutes) == LambdaAllAtOnce 12,000 failed requests of 12,000 in the window LambdaCanary10Percent5Minutes 1,200 failed requests of 12,000 in the window LambdaCanary10Percent30Minutes 1,200 failed requests of 12,000 in the window LambdaLinear10PercentEvery1Minute 1,800 failed requests of 12,000 in the window LambdaLinear10PercentEvery10Minutes 1,200 failed requests of 12,000 in the window same, but the alarm needs 10 minutes (a slow metric): LambdaAllAtOnce 60,000 failed requests of 60,000 in the window LambdaCanary10Percent5Minutes 33,000 failed requests of 60,000 in the window LambdaCanary10Percent30Minutes 6,000 failed requests of 60,000 in the window LambdaLinear10PercentEvery1Minute 33,000 failed requests of 60,000 in the window LambdaLinear10PercentEvery10Minutes 6,000 failed requests of 60,000 in the window == can a canary see the bug at all? (bug fails 1 request in 1,000 on the new version) == expected failures on the new version during the first canary step (10% of traffic): 1 rps, 5-minute canary: expect 0.03 failures, chance of seeing none = 97.0% 1 rps, 15-minute canary: expect 0.09 failures, chance of seeing none = 91.4% 10 rps, 5-minute canary: expect 0.30 failures, chance of seeing none = 74.1% 10 rps, 15-minute canary: expect 0.90 failures, chance of seeing none = 40.7% 100 rps, 5-minute canary: expect 3.00 failures, chance of seeing none = 5.0% 100 rps, 15-minute canary: expect 9.00 failures, chance of seeing none = 0.0% (Poisson arithmetic; the real alarm's threshold and evaluation period decide what counts as 'seen'.) == weighted alias sampling (random, seed 7): does 10% mean 10%? == 100 requests -> 13 to the new version (13.0%) 1,000 requests -> 104 to the new version (10.4%) 100,000 requests -> 9,950 to the new version (10.0%) == ECS rolling update: waves and stuck settings (my simple model of the scheduler, not the real one) == desired min% max% min healthy max total can stop can start waves 4 100 200 4 8 0 4 1 4 50 100 2 4 2 0 2 4 100 100 4 4 0 0 STUCK (cannot stop or start) 4 0 100 0 4 4 0 1 2 75 200 2 4 0 2 1 3 100 125 3 3 0 0 STUCK (cannot stop or start) 1 100 200 1 2 0 1 1 1 0 100 0 1 1 0 1 10 90 110 9 11 1 1 5 10 100 110 10 11 0 1 10 all page checks agree --- sam/template.yaml --- AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: Gradual Lambda deployment with CodeDeploy (chapter 11) Globals: Function: Runtime: python3.13 Timeout: 5 MemorySize: 128 Resources: AppFn: Type: AWS::Serverless::Function Properties: Handler: app.handler CodeUri: s3://example-bucket/code.zip AutoPublishAlias: live DeploymentPreference: Type: Canary10Percent5Minutes Alarms: - !Ref AliasErrorsAlarm Hooks: PreTraffic: !Ref PreTrafficFn PreTrafficFn: Type: AWS::Serverless::Function Properties: FunctionName: CodeDeployHook_pretraffic Handler: hook.handler CodeUri: s3://example-bucket/hook.zip Policies: - Version: "2012-10-17" Statement: - Effect: Allow Action: codedeploy:PutLifecycleEventHookExecutionStatus Resource: "*" DeploymentPreference: Enabled: false AliasErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: Namespace: AWS/Lambda MetricName: Errors Dimensions: - Name: FunctionName Value: !Ref AppFn - Name: Resource Value: !Sub "${AppFn}:live" Statistic: Sum Period: 60 EvaluationPeriods: 1 Threshold: 0 ComparisonOperator: GreaterThanThreshold TreatMissingData: notBreaching --- transform.py --- """Run the SAM translator offline on a template and summarise what it generates.""" import sys, json, collections, yaml from samtranslator.translator.transform import transform from samtranslator.yaml_helper import yaml_parse from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader class FakeIam: # the translator only needs a client for named managed policies; ours use none def get_paginator(self, name): raise RuntimeError("managed-policy lookup attempted") def run(path): with open(path) as f: tpl = yaml_parse(f) for r in tpl["Resources"].values(): # stand-in for what `sam package` does if r["Type"] == "AWS::Serverless::Function" and "CodeUri" not in r["Properties"]: r["Properties"]["CodeUri"] = "s3://example-bucket/code.zip" out = transform(tpl, {}, ManagedPolicyLoader(FakeIam())) return out if __name__ == "__main__": path = sys.argv[1] out = run(path) res = out["Resources"] c = collections.Counter(r["Type"] for r in res.values()) print(f"{path}: {len(res)} resources, {len(c)} types") for t, n in sorted(c.items()): print(f" {n:2d} {t}") if len(sys.argv) > 2: json.dump(out, open(sys.argv[2], "w"), indent=2) --- py transform.py sam/template.yaml (saved output of the real run) --- sam/template.yaml: 10 resources, 7 types 1 AWS::CloudWatch::Alarm 1 AWS::CodeDeploy::Application 1 AWS::CodeDeploy::DeploymentGroup 3 AWS::IAM::Role 1 AWS::Lambda::Alias 2 AWS::Lambda::Function 1 AWS::Lambda::Version --- mutate_sam11.py --- """One change at a time in sam/template.yaml; report the SAM translator (offline, same call as transform.py) and cfn-lint 1.57.2.""" import os, re, subprocess, sys, tempfile os.environ.update(AWS_DEFAULT_REGION="eu-west-1", AWS_EC2_METADATA_DISABLED="true") import transform as T B = "C:/lwaws/c11" good = open(f"{B}/sam/template.yaml", encoding="utf-8").read() M = [ ("Type Canary10Percent5Minutes -> Canary10Percent7Minutes", "Type: Canary10Percent5Minutes", "Type: Canary10Percent7Minutes", True), ("Type -> Linear10PercentEvery1Minute (valid, slower)", "Type: Canary10Percent5Minutes", "Type: Linear10PercentEvery1Minute", False), ("Type -> AllAtOnce (valid, no gradual shift)", "Type: Canary10Percent5Minutes", "Type: AllAtOnce", False), ("remove AutoPublishAlias", " AutoPublishAlias: live\n", "", True), ("Alarms entry names a resource that does not exist", "- !Ref AliasErrorsAlarm", "- !Ref NoSuchAlarm", True), ("PreTraffic hook names a resource that does not exist", "PreTraffic: !Ref PreTrafficFn", "PreTraffic: !Ref NoSuchFn", True), ("hook function name lacks the CodeDeployHook_ prefix", "FunctionName: CodeDeployHook_pretraffic", "FunctionName: pretraffic", True), ("hook role lacks PutLifecycleEventHookExecutionStatus", " Action: codedeploy:PutLifecycleEventHookExecutionStatus", " Action: codedeploy:GetApplication", True), ("alarm watches the function, not the alias (Dimension removed)", " - Name: Resource\n Value: !Sub \"${AppFn}:live\"\n", "", True), ("alarm Threshold 0 -> text", "Threshold: 0", "Threshold: zero", True), ("alarm ComparisonOperator typo", "GreaterThanThreshold", "GreaterThan", True), ("alarm Period 60 -> 45 (not a valid period)", "Period: 60", "Period: 45", True), ("Alarms list empty", " Alarms:\n - !Ref AliasErrorsAlarm\n", " Alarms: []\n", True), ("DeploymentPreference on the hook function too (hook shifts traffic itself)", " DeploymentPreference:\n Enabled: false", " AutoPublishAlias: live\n DeploymentPreference:\n Type: Canary10Percent5Minutes", True), ("Runtime python3.13 -> python3.99", "Runtime: python3.13", "Runtime: python3.99", True), ("Timeout 5 -> 901", "Timeout: 5", "Timeout: 901", True), ("alarm EvaluationPeriods 1 -> 0", "EvaluationPeriods: 1", "EvaluationPeriods: 0", True), ("alarm TreatMissingData breaching (alarm fires on idle alias)", "TreatMissingData: notBreaching", "TreatMissingData: breaching", True), ] def lint(t): f = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8", dir=f"{B}/sam") f.write(t); f.close() p = subprocess.run([f"{B}/venv/Scripts/cfn-lint.exe", f.name], capture_output=True, text=True) os.unlink(f.name) return p.returncode != 0, sorted(set(re.findall(r"^([EW]\d{4})", p.stdout, re.M))) def trans(t): f = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8", dir=f"{B}/sam") f.write(t); f.close() try: T.run(f.name); r = (False, "") except BaseException as e: r = (True, re.sub(r"\s+", " ", str(e))[:60]) os.unlink(f.name) return r print(f"{'change':76s} translator cfn-lint") c = (trans(good)[0], lint(good)[0]) print(f"{'(control: unchanged template)':76s} {int(c[0]):10d} {int(c[1]):9d}") nm = tr = li = both = none = fp = 0 for label, old, new, mist in M: assert old in good, label t = good.replace(old, new, 1) a, msg = trans(t); b, rules = lint(t) print(f"{label[:76]:76s} {int(a):10d} {int(b):9d} {','.join(rules) if b else ''} {msg if a else ''}") if mist: nm += 1; tr += a; li += b; both += (a and b); none += (not a and not b) else: fp += (a or b) print(f"\n{nm} mistakes: translator {tr}, cfn-lint {li}, both {both}, neither {none}; false alarms on {len(M)-nm} valid changes: {fp}") --- py mutate_sam11.py (saved output of the real run) --- change translator cfn-lint (control: unchanged template) 0 0 Type Canary10Percent5Minutes -> Canary10Percent7Minutes 0 0 Type -> Linear10PercentEvery1Minute (valid, slower) 0 0 Type -> AllAtOnce (valid, no gradual shift) 0 0 remove AutoPublishAlias 1 0 [InvalidResourceException('AppFn', "'DeploymentPreference' r Alarms entry names a resource that does not exist 0 0 PreTraffic hook names a resource that does not exist 0 1 E1020,E3030,E3031 hook function name lacks the CodeDeployHook_ prefix 0 0 hook role lacks PutLifecycleEventHookExecutionStatus 0 0 alarm watches the function, not the alias (Dimension removed) 0 0 alarm Threshold 0 -> text 0 1 E3012 alarm ComparisonOperator typo 0 1 E3030 alarm Period 60 -> 45 (not a valid period) 0 1 E3615 Alarms list empty 0 0 DeploymentPreference on the hook function too (hook shifts traffic itself) 0 0 Runtime python3.13 -> python3.99 0 0 Timeout 5 -> 901 0 0 alarm EvaluationPeriods 1 -> 0 0 1 E3034 alarm TreatMissingData breaching (alarm fires on idle alias) 0 0 16 mistakes: translator 1, cfn-lint 5, both 0, neither 10; false alarms on 2 valid changes: 0 -------------------------- Reading the result: the schedules and the ECS percentages agree with the page's wording and examples (7 of 7). Two assumptions are mine, not the pages': a linear configuration sends its first 10 percent at minute 0 and ends at 9 x interval, and the alarm stops the deployment a fixed time after traffic first reaches the new version. The failed-request table shows that a canary shorter than the alarm's time to detect does not limit the damage (5-minute canary, 10-minute alarm: 33,000 failures, the same as the 1-minute linear); the visibility table is Poisson arithmetic. The ECS wave table uses my simple model of the scheduler, not the real one, and the circuit breaker, blue/green and early-success pages were not read. SAM: the translator turned 3 declared resources into 10 (alias with an UpdatePolicy, CodeDeploy application and deployment group, roles, version). Of 16 mistakes the translator caught 1 and cfn-lint 5; 10 got past both, including Canary10Percent7Minutes, which is not in the documented list of four canary options (judged against the docs; I did not see what CodeDeploy does with the name). cfn-lint 1.57.2 also passed Runtime python3.99 and Timeout 901 in this template; I reinstalled the translator at 1.114.0 (the version used in chapter 8) to rule out a version mismatch and the result did not change; the cause was not investigated. Mistake to avoid: a test result that looks like a tool failure may be an environment problem; I changed one variable (the translator version) and repeated before reporting it.