aws2-6 Exercise 3: The Pattern Construct, Its Bill, and Validating a Task Definition Offline ======================================================================================= Run with Docker 28.0.4 (Docker Desktop engine, Linux containers, x86-64 Windows 11 host); python:3.13-slim image; Node 24.18.0 with aws-cdk 2.1145.0, aws-cdk-lib 2.273.0, constructs 10.8.1, typescript 7.0.2, jest 30.5.2 (the chapter 5 install, reached from a junction); cfn-lint 1.57.2 and PyYAML from the chapter 4 environment; boto3/botocore 1.43.112 from the chapter 3 environment (offline, stubbed client). Scratch folder C:/lwaws/c6, nothing installed globally. VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS credentials, nothing pushed or deployed). Outputs below are saved from the real runs of 2026-10-13. --- bin/pattern.ts (cdk synth -q -o cdk.out.pattern --app "node dist/bin/pattern.js") --- import * as cdk from "aws-cdk-lib"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as patterns from "aws-cdk-lib/aws-ecs-patterns"; // The L3 pattern with almost no arguments: what does "a few lines" create? const app = new cdk.App(); const stack = new cdk.Stack(app, "Pattern"); new patterns.ApplicationLoadBalancedFargateService(stack, "Web", { taskImageOptions: { image: ecs.ContainerImage.fromRegistry("public.ecr.aws/docker/library/nginx:alpine"), containerPort: 80 }, }); --- resources in the synthesised pattern (saved output of the real run) --- resources: 38 1 AWS::CDK::Metadata 2 AWS::EC2::EIP 1 AWS::EC2::InternetGateway 2 AWS::EC2::NatGateway 4 AWS::EC2::Route 4 AWS::EC2::RouteTable 2 AWS::EC2::SecurityGroup 1 AWS::EC2::SecurityGroupEgress 1 AWS::EC2::SecurityGroupIngress 4 AWS::EC2::Subnet 4 AWS::EC2::SubnetRouteTableAssociation 1 AWS::EC2::VPC 1 AWS::EC2::VPCGatewayAttachment 1 AWS::ECS::Cluster 1 AWS::ECS::Service 1 AWS::ECS::TaskDefinition 1 AWS::ElasticLoadBalancingV2::Listener 1 AWS::ElasticLoadBalancingV2::LoadBalancer 1 AWS::ElasticLoadBalancingV2::TargetGroup 1 AWS::IAM::Policy 2 AWS::IAM::Role 1 AWS::Logs::LogGroup NAT gateways: 2 | elastic IPs: 2 task size: cpu 256 memory 512 | read-only root fs: None | user: None service: desired None | deployment config: {"Alarms": {"AlarmNames": [], "Enable": false, "Rollback": false}, "MaximumPercent": 200, "MinimumHealthyPercent": 50} | public IP: DISABLED target group health check: {} | deregistration delay attrs: [{'Key': 'stickiness.enabled', 'Value': 'false'}] listener port 80 HTTP --- cost_sketch.py --- """Arithmetic only, from prices read on 2026-10-13 through a page-fetch tool (it summarised the pages and converted the per-second Fargate prices to hours itself, so I use the per-second figures it quoted). Fargate Linux/X86, US East (N. Virginia): $0.000011244 per vCPU-second, $0.000001235 per GB-second. Linux/ARM: $0.0000089944 and $0.0000009889. NAT gateway: only US East (Ohio) was listed: $0.045/hour (plus $0.045/GB processed). Public IPv4: $0.005/hour. NOT read: ALB, CloudWatch Logs, ECR storage, data transfer. Prices change; check the pricing pages before relying on any of this.""" H = 730 # hours in an average month (365*24/12) SEC = H * 3600 def fargate(vcpu, gb, tasks, arm=False): c, m = (0.0000089944, 0.0000009889) if arm else (0.000011244, 0.000001235) return tasks * SEC * (vcpu * c + gb * m) print("Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, X86 : $%.2f" % fargate(0.25, 0.5, 2)) print("Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, ARM : $%.2f" % fargate(0.25, 0.5, 2, arm=True)) print("Fargate, 2 tasks of 1 vCPU + 2 GB, one month, X86 : $%.2f" % fargate(1, 2, 2)) nat = 2 * H * 0.045 ip = 2 * H * 0.005 print("2 NAT gateways, one month, hourly charge only (Ohio rate): $%.2f" % nat) print("2 public IPv4 addresses on those NAT gateways, one month: $%.2f" % ip) print("so the two NAT gateways plus their addresses cost about %.0fx the two small tasks (hourly charges only)" % ((nat + ip) / fargate(0.25, 0.5, 2))) print("the default L3 pattern keeps them running whether or not any traffic flows") --- py cost_sketch.py (saved output of the real run) --- Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, X86 : $18.02 Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, ARM : $14.42 Fargate, 2 tasks of 1 vCPU + 2 GB, one month, X86 : $72.08 2 NAT gateways, one month, hourly charge only (Ohio rate): $65.70 2 public IPv4 addresses on those NAT gateways, one month: $7.30 so the two NAT gateways plus their addresses cost about 4x the two small tasks (hourly charges only) the default L3 pattern keeps them running whether or not any traffic flows --- taskdef.json --- { "family": "web", "requiresCompatibilities": ["FARGATE"], "networkMode": "awsvpc", "cpu": "256", "memory": "512", "runtimePlatform": {"cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX"}, "taskRoleArn": "arn:aws:iam::111122223333:role/web-task", "executionRoleArn": "arn:aws:iam::111122223333:role/web-exec", "containerDefinitions": [{ "name": "web", "image": "111122223333.dkr.ecr.eu-west-1.amazonaws.com/web:1.0.0", "essential": true, "user": "10001", "readonlyRootFilesystem": true, "stopTimeout": 20, "linuxParameters": {"initProcessEnabled": true}, "portMappings": [{"containerPort": 8080, "protocol": "tcp"}], "healthCheck": {"command": ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/health')\" || exit 1"], "interval": 15, "timeout": 5, "retries": 3, "startPeriod": 10}, "logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/ecs/web", "awslogs-region": "eu-west-1", "awslogs-stream-prefix": "web"}} }] } --- taskdef_check.py (run with the chapter 3 environment's python) --- """Offline check of a task definition JSON with botocore's parameter validation (no network, no credentials: a fake client and a Stubber). Then the same file is run through two rules from the ECS docs (Fargate size table, healthCheck/stopTimeout ranges) so the three layers can be compared.""" import copy, json, sys import boto3 from botocore.stub import Stubber from botocore.exceptions import ParamValidationError from fargate_model import valid_fargate_size base = json.load(open("taskdef.json")) client = boto3.client("ecs", region_name="eu-west-1", aws_access_key_id="x", aws_secret_access_key="x") def botocore_says(td): with Stubber(client) as st: st.add_response("register_task_definition", {"taskDefinition": {"family": td.get("family", "x")}}) try: client.register_task_definition(**td); return "accepted" except ParamValidationError as e: return "rejected: " + str(e).split("\n")[1].strip()[:110] def docs_rules(td): p = [] if "FARGATE" in td.get("requiresCompatibilities", []) and not valid_fargate_size(int(td["cpu"]), int(td["memory"])): p.append("size not in the Fargate table") for c in td["containerDefinitions"]: if not 0 <= c.get("stopTimeout", 30) <= 120: p.append("stopTimeout outside 0-120") # 120 is the documented maximum; the minimum is not stated by the page I read h = c.get("healthCheck", {}) if h and not 5 <= h.get("interval", 30) <= 300: p.append("interval outside 5-300") if h and not 2 <= h.get("timeout", 5) <= 60: p.append("timeout outside 2-60") if h and not 1 <= h.get("retries", 3) <= 10: p.append("retries outside 1-10") if h and not 0 <= h.get("startPeriod", 0) <= 300: p.append("startPeriod outside 0-300") return "; ".join(p) or "no problem found" def mut(f): t = copy.deepcopy(base); f(t); return t cases = [ ("the file as written", base), ("cpu as a number instead of a string", mut(lambda t: t.update(cpu=256))), ("size 256 / 4096", mut(lambda t: t.update(memory="4096"))), ("stopTimeout 300", mut(lambda t: t["containerDefinitions"][0].update(stopTimeout=300))), ("health check interval 1", mut(lambda t: t["containerDefinitions"][0]["healthCheck"].update(interval=1))), ("health check without command", mut(lambda t: t["containerDefinitions"][0]["healthCheck"].pop("command"))), ("containerPort as a string", mut(lambda t: t["containerDefinitions"][0]["portMappings"][0].update(containerPort="8080"))), ("no family", mut(lambda t: t.pop("family"))), ("misspelt key readOnlyRootFilesystem", mut(lambda t: t["containerDefinitions"][0].update(readOnlyRootFilesystem=t["containerDefinitions"][0].pop("readonlyRootFilesystem")))), ] print("%-40s | %-48s | %s" % ("case", "botocore (offline)", "documented rules")) for name, td in cases: print("%-40s | %-48s | %s" % (name, botocore_says(td), docs_rules(td) if "cpu" in td and isinstance(td["cpu"], str) and "family" in td else "(skipped)")) --- python taskdef_check.py (saved output of the real run) --- case | botocore (offline) | documented rules the file as written | accepted | no problem found cpu as a number instead of a string | rejected: Invalid type for parameter cpu, value: 256, type: , valid types: | (skipped) size 256 / 4096 | accepted | size not in the Fargate table stopTimeout 300 | accepted | stopTimeout outside 0-120 health check interval 1 | accepted | interval outside 5-300 health check without command | rejected: Missing required parameter in containerDefinitions[0].healthCheck: "command" | no problem found containerPort as a string | rejected: Invalid type for parameter containerDefinitions[0].portMappings[0].containerPort, value: 8080, type: may_stop=2 (documented 2) ok 75% min, 2 desired: can't stop any (result is two) -> may_stop=0 (documented 0) ok 200% max, 4 desired: can start four -> may_start=4 (documented 4) ok 125% max, 3 desired: can't start any (result is three) -> may_start=0 (documented 0) --- the stuck case (100/100) against 100/200 with two tasks (saved output of the real run) --- desired 2, min 100, max 100: {'may_stop': 0, 'may_start': 0, 'min_running': 2, 'max_running': 2} desired 2, min 100, max 200: {'may_stop': 0, 'may_start': 2, 'min_running': 2, 'max_running': 4} WHY THIS WORKS AS AN ANSWER --------------------------- The pattern with an image and a port created 38 resources: two NAT gateways with two Elastic IPs, an internet-facing load balancer with an HTTP-only listener, a task with no read-only root file system and no non-root user, and MinimumHealthyPercent 50. The cost sketch shows the two NAT gateways' hourly charge plus their two public IPv4 addresses is about four times the Fargate cost of two small tasks (the gateways alone about 3.6 times), with the caveat that the NAT price I could read was for US East (Ohio) and the Fargate prices were N. Virginia. botocore's offline validation accepts a wrong size, a stop timeout of 300 and a health interval of 1, and rejects wrong types, missing required fields and misspelt keys; the documented-range check has the opposite blind spots. Rolling-update arithmetic matches the four examples on the documentation page. Not priced or read: the load balancer, CloudWatch Logs, ECR storage, data transfer, the target group defaults, and any real registration with ECS.