Module 9 left behind a reproducible, governed infrastructure spread across five accounts, and an uncomfortable sentence at the end: MercadoFresco's shop still lives on EC2 instances that have to be patched, on top of an AMI that has to be rebuilt every time a dependency changes, and that take two minutes to start. At 19:00 on a Friday, with 900 orders an hour, those two minutes are exactly the time customers spend waiting while the ASG does its job.
This lesson attacks the problem at the root: replacing the machine with the container. You will see what a container is and how it really differs from a virtual machine, how the shop's image is built with a serious Dockerfile, where that image is stored (Amazon ECR) and who runs it (Amazon ECS). By the end you will have the shop and the order-queue workers containerised, with their task definition, their service behind the ALB that already exists and their auto scaling.
Cost warning. Amazon ECS costs nothing by itself: it is a free control plane, and you pay only for the compute that runs the tasks — the EC2 instances in this lesson, Fargate in 10-02. Amazon ECR charges 0.10 USD per GB per month of storage plus data transfer out of the region; the free tier includes 500 MB for 12 months. Basic scanning is free; enhanced scanning with Amazon Inspector is charged per image scanned. Work through every exercise with correct tags and delete whatever you create at the end. All data, accounts and identifiers are fictitious.
Contents
- The three problems module 9 left behind
- What a container is and how it differs from a virtual machine
- Image, layer, registry and running container
- The Dockerfile for MercadoFresco's shop
.dockerignore, size and layer caching- Amazon ECR: the registry that replaces the AMI
- Tagging, immutability and image lifecycle
- Vulnerability scanning: basic and enhanced
- Replication, encryption and repository policy
- Amazon ECS: cluster, task definition, task and service
- Launch types, the ECS agent and capacity providers
- The shop's task definition, annotated
- Task execution role versus task role
- The
awsvpcnetwork mode and what it implies - The service: ALB, health checks and placement
- Deployments: rolling, percentages and circuit breaker
- Service auto scaling and discovery with Cloud Map
- Observability: Container Insights, logs and X-Ray
- MercadoFresco's migration
- Cost and cleanup
- Common mistakes and tips
- Exercises
- Conclusion
The three problems module 9 left behind
It is worth naming them precisely before solving them, because each one has a different cause and the container solves them by different routes.
| Problem | What happens in MercadoFresco today | Real cost |
|---|---|---|
| Patching | Every ASG instance carries a complete operating system that receives CVEs for OpenSSL, the kernel and glibc |
One maintenance window a month, with staggered reboots |
| AMI rebuilds | Adding a Python library means launching an instance, installing it, creating the AMI and updating the launch template | 40-60 minutes per dependency change |
| Two-minute start-up | Start EC2, run cloud-init, start the application, pass the target group's health checks |
The Friday peak arrives before the capacity does |
The container does not remove the operating system from the planet — there is still a kernel underneath — but it changes where the boundary between your responsibility and AWS's sits. Patching the base operating system becomes a line in the Dockerfile and a thirty-second rebuild, not a maintenance window; with Fargate (10-02), the host stops existing as far as you are concerned. The AMI disappears as an artefact and its place is taken by the image, which is built in CodeBuild in two minutes, versioned per commit and stored in a registry. And start-up stops including the start-up of a machine: an ECS task on an existing instance starts in 5-15 seconds, and on Fargate in 30-45 including the ENI. Against today's 120 seconds, that is a different category of response.
What a container is and how it differs from a virtual machine
A container is a process on the host operating system that the kernel has lied to about the world: it sees its own file system, its own process table, its own network and its own CPU and memory limits. That lie is built by two mechanisms in the Linux kernel: namespaces (isolating what is visible) and cgroups (limiting what is consumed).
A virtual machine is something else: a hypervisor emulates complete hardware and inside it a kernel of its own boots, with its own boot system, its own services and its own file system.
| Aspect | Virtual machine (EC2) | Container |
|---|---|---|
| Isolation | Virtualised hardware; own kernel. Very strong boundary | Namespaces and cgroups; shared kernel. Strong boundary, but weaker |
| Start-up | 30 s - 2 min (BIOS, kernel, systemd, cloud-init) |
0.1 - 2 s (it is launching a process) |
| Size | AMI of 2-8 GB | Image of 80-400 MB when well built |
| Density | A handful per physical server | Dozens or hundreds per server |
| Overhead | A complete operating system per instance | Only the process and its libraries |
| Portability | AMI tied to the region and the provider | The image runs the same on a laptop, on ECS, on EKS or at another provider |
| Immutability | Possible, but costly: the AMI has to be rebuilt | Natural: the image is not modified, it is replaced |
| State | Persists across reboots (EBS) | Ephemeral by definition; state lives outside |
The practical difference that matters most to MercadoFresco is in two rows: start-up and portability. Start-up solves the Friday peak. Portability solves the sentence Luis has been repeating since module 8: "it works on my machine". With an image, on his machine exactly the same thing works as in production, byte for byte, because it is the same artefact.
And an honest warning about isolation, because the marketing usually leaves it out: containers share the host's kernel. A container-escape vulnerability allows, in theory, jumping from one container to another on the same machine. That is why AWS does not run containers belonging to different customers on the same kernel: Fargate (10-02) gives each task its own lightweight virtualisation boundary. For MercadoFresco, where all the containers are its own, the risk is acceptable; for a multi-tenant platform, it would not be.
graph TB
subgraph VM["Virtual machine model"]
H1[Hardware] --> HV[Hypervisor]
HV --> V1["VM 1<br/>full OS<br/>+ app"]
HV --> V2["VM 2<br/>full OS<br/>+ app"]
end
subgraph CT["Container model"]
H2[Hardware] --> SO["Host OS<br/>+ shared kernel"]
SO --> RT[Container runtime]
RT --> C1["Container 1<br/>app + libraries"]
RT --> C2["Container 2<br/>app + libraries"]
RT --> C3["Container 3<br/>app + libraries"]
end
Image, layer, registry and running container
Four words that get confused all the time and are worth pinning down:
- Image: a read-only artefact containing the file system and the metadata needed to start the process. It is immutable. It is identified by a digest (
sha256:...) and, optionally, by one or more tags. - Layer: an image is not a monolithic block but a stack of layers, each one holding the differences introduced by one Dockerfile instruction. Layers are shared between images: if ten images use
python:3.12-slim, that layer is stored and downloaded only once. - Registry: the image store. Amazon ECR is the AWS managed image registry; Docker Hub is the best-known public registry.
- Container: an image that is running, with an ephemeral writable layer on top. When the container stops, that layer disappears.
The operational consequence of layers is enormous and it dictates how a Dockerfile is written: if a layer changes, every layer below it is invalidated. Putting COPY . . before pip install means that any change to any file in the project forces every dependency to be reinstalled. Putting requirements.txt first and the code afterwards means a code change reuses the dependency layer and the build drops from three minutes to twenty seconds.
The Dockerfile for MercadoFresco's shop
The shop is a Python application with Gunicorn behind the ALB. This is its complete Dockerfile, with a multi-stage build, an unprivileged user, pinned versions and a HEALTHCHECK consistent with the /salud path that tg-mercadofresco-tienda has used since module 3.
# ===== Stage 1: build. The compiler lives here; it never reaches production. =====
FROM python:3.12.4-slim-bookworm AS constructor
# Tools needed by psycopg2 and some native wheels: they stay in this stage.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential=12.9 libpq-dev=15.* \
&& rm -rf /var/lib/apt/lists/*
# Its own virtual environment: copied whole into the final stage in a single COPY.
RUN python -m venv /opt/entorno
ENV PATH="/opt/entorno/bin:$PATH"
WORKDIR /construccion
# --- Dependency layer, FIRST and on its own: it is reused as long as the
# dependencies do not change, even if all of the shop code changes. ---
COPY requirements.txt requirements-bloqueo.txt ./
RUN pip install --no-cache-dir --require-hashes -r requirements-bloqueo.txt
# ===== Stage 2: runtime. Minimal, no compilers, no root. =====
FROM python:3.12.4-slim-bookworm AS ejecucion
LABEL org.opencontainers.image.title="mercadofresco-tienda" \
org.opencontainers.image.vendor="MercadoFresco" \
org.opencontainers.image.source="https://github.com/mercadofresco/mercadofresco-tienda"
# Only the PostgreSQL client library, not the -dev package with the headers.
RUN apt-get update && apt-get install -y --no-install-recommends libpq5=15.* curl=7.88.* \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 tienda \
&& useradd --uid 10001 --gid tienda --no-create-home --shell /usr/sbin/nologin tienda
# The complete virtual environment arrives from the previous stage: a single layer.
COPY --from=constructor /opt/entorno /opt/entorno
ENV PATH="/opt/entorno/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# The code, last: it is what changes on every commit.
COPY --chown=tienda:tienda ./tienda ./tienda
COPY --chown=tienda:tienda ./gunicorn.conf.py ./
USER 10001
EXPOSE 8080
# Consistent with the target group health check: same path, same port.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:8080/salud || exit 1
ENTRYPOINT ["gunicorn"]
CMD ["--config", "gunicorn.conf.py", "tienda.wsgi:aplicacion"]Every decision in that file answers a concrete problem:
python:3.12.4-slim-bookwormwith the full version, notpython:3.12orpython:latest. A floating tag turns a reproducible build into a lottery: the same command, run two weeks later, produces a different image. It is the same discipline as pinningaws-cdk-libin 09-02. Andsliminstead of the full image brings it down from ~1.0 GB to ~130 MB: the difference is documentation and utilities that in production are attack surface, not functionality.- Multi-stage.
build-essentialandlibpq-devweigh more than 300 MB and are only needed to compile. With two stages they stay in the first one. A production image with a compiler is an image in which an attacker can compile. --require-hasheswith a lock file. It pins not just the version but the hash of every wheel downloaded. It protects against package substitution attacks in the index.USER 10001with an explicit numeric UID. ECS and Kubernetes can enforce "do not run as root"; a numeric UID lets the rule be verified without resolving names, and--no-create-home --shell /usr/sbin/nologinmakes sure that user is good for nothing else.HEALTHCHECKwith--start-period. The 20 seconds of grace stop the container being marked unhealthy while Gunicorn brings up its child processes. In ECS it is complementary to the target group's: the container's decides whether ECS restarts the task; the ALB's decides whether it sends it traffic.ENTRYPOINTandCMDkept separate. TheENTRYPOINTfixes the executable and theCMDthe default arguments, so the task definition can override only the arguments (command) without changing the binary.
.dockerignore, size and layer caching
The .dockerignore is the file most people forget and the one that causes the most problems. Without it, COPY . . puts the whole .git directory into the image — which can weigh hundreds of megabytes and contains the history, including deleted secrets — along with the local virtual environment, the test cache and the .env files with development credentials.
.git .gitignore .github .venv venv __pycache__ *.pyc *.pyo .pytest_cache .mypy_cache .coverage htmlcov .env .env.* *.log infra-cdk cdk.out docs README.md Dockerfile .dockerignore
In the real file there is one entry per line; here they are grouped by family. Size and cache rules, applied in this order:
| Rule | Effect | Application in MercadoFresco |
|---|---|---|
| Minimal base | -80 % size | slim instead of the full image; distroless if curl were not needed |
| Multi-stage | Removes compilers | The constructor stage with build-essential |
| Order from stable to volatile | Maximum cache reuse | requirements.txt before the code |
Group RUN with && |
Fewer layers, no leftovers | apt-get update && install && rm -rf /var/lib/apt/lists/* |
| Clean up in the same layer | What is deleted in another layer still takes up space | The rm -rf sits right next to the apt-get, not in a separate RUN |
--no-cache-dir in pip |
-50-150 MB | In the build stage |
A complete .dockerignore |
Avoids a huge context and leaks | The one above |
The point that surprises people most is "clean up in the same layer". Since each layer stores differences, deleting in layer 7 a file created in layer 5 does not reduce the size of the image: the file is still in layer 5 and is still downloaded. It only disappears from view. A RUN apt-get install ... followed by a separate RUN rm -rf /var/lib/apt/lists/* saves nothing at all.
In CodeBuild (08-02), the cache is exploited with docker pull "$REPO:cache" || true followed by docker build --cache-from "$REPO:cache" --build-arg BUILDKIT_INLINE_CACHE=1 ..., pointing at the previous image in the registry. With this pattern, the shop's build drops from around 3 minutes to 35-50 seconds when only the code changes, which is the usual case.
Amazon ECR: the registry that replaces the AMI
Amazon Elastic Container Registry is the AWS managed image registry: private by default, integrated with IAM, with encryption at rest, vulnerability scanning, replication and lifecycle policies. It occupies exactly the place the AMI occupied in the previous architecture, with two differences: it is regional but replicable, and the unit of version is the digest, not an opaque identifier. MercadoFresco creates two repositories in the tooling account 555566667777, which is where the pipeline has lived since 09-04:
for REPO in mercadofresco/tienda mercadofresco/trabajadores; do
aws ecr create-repository --repository-name "$REPO" --region eu-west-1 \
--image-tag-mutability IMMUTABLE \
--image-scanning-configuration scanOnPush=true \
--encryption-configuration '{"encryptionType":"KMS","kmsKey":"alias/mercadofresco-datos"}' \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=compartido \
Key=Componente,Value=registro-imagenes Key=Propietario,Value=plataforma \
Key=CentroCoste,Value=tecnologia
done
# Authentication does not use stored passwords: a temporary IAM token handed to Docker.
aws ecr get-login-password --region eu-west-1 \
| docker login --username AWS --password-stdin 555566667777.dkr.ecr.eu-west-1.amazonaws.comThe token lasts 12 hours and is tied to the IAM principal that made the call. In CodeBuild, the line above is literally the first one in the pre_build phase of the buildspec.yml, and the build project's role needs ecr:GetAuthorizationToken (which is account-level, with Resource: "*") plus write permissions on the specific repository.
version: 0.2
env:
variables: { REGION: eu-west-1, TOOLING_ACCOUNT: "555566667777", REPOSITORY: mercadofresco/tienda }
phases:
pre_build:
commands:
- REGISTRY="${TOOLING_ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com"; URI="${REGISTRY}/${REPOSITORY}"
- SHORT_SHA=$(echo "$CODEBUILD_RESOLVED_SOURCE_VERSION" | cut -c1-7)
- TAG="${SEMANTIC_VERSION}-${SHORT_SHA}" # e.g. v1.6.0-a3f9c21
- aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$REGISTRY"
build:
commands:
- docker build --cache-from "${URI}:cache" --build-arg BUILDKIT_INLINE_CACHE=1 -t "${URI}:${TAG}" -t "${URI}:cache" .
- docker run --rm "${URI}:${TAG}" python -c "import tienda; print(tienda.__version__)"
post_build:
commands:
- docker push "${URI}:${TAG}" && docker push "${URI}:cache"
# The digest is what actually gets deployed; it is passed on to the next phase.
- DIGEST=$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$TAG" --query 'imageDetails[0].imageDigest' --output text)
- printf '{"image":"%s@%s","tag":"%s"}' "$URI" "$DIGEST" "$TAG" > image.json
artifacts:
files: [image.json]Tagging, immutability and image lifecycle
latest is a tag like any other: it does not mean "the most recent one", it only means "the one somebody tagged that way last". And as a deployment tag it is downright dangerous:
Problem with latest |
Concrete consequence |
|---|---|
| Not reproducible | "We deployed latest" says nothing about what code is in production |
| Cannot be rolled back | Going back requires knowing which one was previous, and latest no longer is |
| Breaks restarts | A task that restarts pulls a different image from its siblings |
| Mixed coexistence | With an aggressive imagePullPolicy, some tasks run one version and others another |
| Auditing impossible | CloudTrail records a deployment, but not what was deployed |
MercadoFresco uses the scheme module 8 already introduced: semantic version plus the commit's short SHA, for example v1.6.0-a3f9c21. A human reads the semantic version; the SHA ties it to an exact commit in the mercadofresco-tienda repository. And in the task definition that gets deployed, the tag is not used — the digest is:
555566667777.dkr.ecr.eu-west-1.amazonaws.com/mercadofresco/tienda@sha256:9c1e...f4a2
The reason is subtle but decisive: a tag is a mutable pointer — even if tag immutability protects it inside the repository, a misconfiguration or a badly done replication can knock it out of alignment — whereas the digest is the content. Deploying by digest guarantees that what starts in production today is exactly what passed the tests in pre-production yesterday, bit for bit. The tag is there so a human can find the image; the digest, so the machine can deploy it.
With --image-tag-mutability IMMUTABLE, ECR rejects any attempt to reuse an existing tag. That turns a silent accident — overwriting v1.6.0-a3f9c21 with different content — into a visible build error.
The other indispensable control is the lifecycle policy. Without it, the repository grows indefinitely: MercadoFresco deploys 4.8 times a week, with images of around 220 MB, and adding the feature-branch ones the registry puts on about 6 GB a year per repository. It is not money, but it is noise and it does complicate audits.
{"rules": [
{"rulePriority": 1, "description": "Keep the last 10 released versions", "action": {"type": "expire"},
"selection": {"tagStatus": "tagged", "tagPrefixList": ["v"], "countType": "imageCountMoreThan", "countNumber": 10}},
{"rulePriority": 2, "description": "Feature branch images: 14 days", "action": {"type": "expire"},
"selection": {"tagStatus": "tagged", "tagPrefixList": ["rama-"], "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 14}},
{"rulePriority": 3, "description": "Orphan untagged layers: 1 day", "action": {"type": "expire"},
"selection": {"tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 1}}
]}Three warnings about these policies, which are the number one source of frights with ECR:
- Rules are evaluated by priority and an image is only affected by the first one that selects it. A broad rule with priority 1 cancels out everything behind it.
- Expiry does not check whether the image is in use. ECR will delete the image production is running if the rule selects it. That is why rule 1 keeps ten released versions and not two: the worst rollback case has to be covered.
- Test it dry first. The console offers a preview of the policy against the real contents of the repository; running that before applying is mandatory.
Vulnerability scanning: basic and enhanced
ECR offers two scanning modes, and the difference between them is bigger than the name suggests.
| Aspect | Basic scanning | Enhanced scanning (Amazon Inspector) |
|---|---|---|
| Engine | Operating system CVE database | Amazon Inspector, continuous |
| Scope | Operating system packages | Operating system and application dependencies (Python, npm, Java, Go) |
| When | On image push (or manually) | On push and continuously as new CVEs appear |
| Account scope | Per repository | Registry level, enabled for the whole account |
| Integration | Console and API | Security Hub, EventBridge, the Inspector dashboard |
| Cost | Free | Charged per image scanned and per rescan |
For MercadoFresco the "scope" row is what matters: most of the shop's real vulnerabilities are in Python dependencies — an old version of a serialisation library, for instance — and basic scanning does not see them. Marta enables enhanced scanning in the tooling account and wires the result into the pipeline's quality gate:
aws ecr put-registry-scanning-configuration --scan-type ENHANCED --region eu-west-1 \
--rules '[{"scanFrequency": "CONTINUOUS_SCAN",
"repositoryFilters": [{"filter": "mercadofresco/*", "filterType": "WILDCARD"}]}]'
# The blocking rule run by the mercadofresco-puerta-calidad Lambda (module 8):
aws ecr describe-image-scan-findings --repository-name mercadofresco/tienda \
--image-id imageTag=v1.6.0-a3f9c21 \
--query 'imageScanFindingsSummary.findingSeverityCounts'
# {"HIGH": 0, "MEDIUM": 3, "LOW": 12} -> passes: the rule blocks CRITICAL and HIGHThe agreed policy is pragmatic and that is why it is followed: CRITICAL always blocks; HIGH blocks unless there is a documented exception with an expiry date; MEDIUM and LOW raise a ticket. A policy that blocks on MEDIUM does not survive two weeks: somebody switches it off and then there is no policy at all. Continuous scanning also has a property the basic mode cannot offer: the image that is clean today and is not tomorrow. When a new CVE appears that affects an already deployed image, Inspector emits an event to EventBridge and bus-mercadofresco (07-03) routes it to alertas-mercadofresco. That alert is the direct replacement for the instance patching cycle.
Replication, encryption and repository policy
The image is built in the tooling account 555566667777 and runs in production 111122223333, pre-production 222233334444 and development 333344445555. There are two ways to resolve that:
| Option | How it works | When it suits |
|---|---|---|
| Shared central repository | A single repository in tooling with a policy authorising the workload accounts | Fewer copies, one source of truth, dependency on one account |
| Cross-account replication | ECR copies the image into a repository in each target account | Isolates failures, allows different policies, duplicates storage |
MercadoFresco uses a shared central repository for development and pre-production, and replication into production, because it wants an incident in the tooling account not to stop production scaling on a Friday afternoon.
aws ecr put-replication-configuration --region eu-west-1 --replication-configuration '{
"rules": [{"destinations": [{"region": "eu-west-1", "registryId": "111122223333"},
{"region": "eu-central-1", "registryId": "111122223333"}],
"repositoryFilters": [{"filter": "mercadofresco/", "filterType": "PREFIX_MATCH"}]}]}'The second destination line also replicates to eu-central-1, which is where MercadoFresco would have its recovery plan if the main region failed. Replication is asynchronous and takes from seconds to a few minutes depending on size; the pipeline must wait for the image to exist in the destination before deploying, with an explicit wait.
The repository policy authorises the organisation's accounts to pull, without authorising anyone to push:
{"Version": "2012-10-17", "Statement": [
{"Sid": "DescargaDesdeLaOrganizacion", "Effect": "Allow", "Principal": "*",
"Action": ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability"],
"Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-a1b2c3d4e5"}}},
{"Sid": "SoloElPipelinePublica", "Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::555566667777:role/rol-build-mercadofresco-tienda"},
"Action": ["ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload"]}
]}It is the same pattern as 09-04: aws:PrincipalOrgID instead of an account list that would have to be maintained by hand. And the separation between who reads and who writes is what makes the pipeline the only route into production. Encryption with alias/mercadofresco-datos (04-02) has one surprising consequence: if the key belongs to the tooling account and production has to pull the image, the KMS key policy must authorise production's principals to use kms:Decrypt. It is the classic mistake with customer managed keys across accounts: the repository policy is fine and the pull fails anyway, with a message that talks about access denied and never mentions KMS.
Amazon ECS: cluster, task definition, task and service
Amazon Elastic Container Service is AWS's own container orchestrator. Its model has four objects and they must never be confused:
| Object | What it is | Analogy with what you have seen |
|---|---|---|
| Cluster | A logical grouping where tasks run | Similar to an ASG plus its environment |
| Task definition | The immutable, versioned template: which images, how much CPU and memory, which network, which roles | The launch template lt-mercadofresco-tienda |
| Task | A running instance of a task definition; one or more containers that live and die together | An EC2 instance in the ASG |
| Service | Keeps N tasks running, registers them with the ALB, replaces them and deploys them | The ASG asg-mercadofresco-tienda |
The correspondence with what MercadoFresco already has is almost one to one, and it is the best way to understand ECS: the service does what the ASG did, the task definition does what the launch template plus the AMI did, and the task replaces the instance. Task definitions are versioned and immutable: each register-task-definition creates a new revision (mercadofresco-tienda:23) and the previous ones continue to exist. Rolling back a deployment means pointing the service at the previous revision, and that is a one-field change.
graph LR TD["Task definition<br/>mercadofresco-tienda:23"] --> SRV["Service<br/>svc-mercadofresco-tienda<br/>desiredCount = 4"] SRV --> T1["Task 1<br/>AZ a"] SRV --> T2["Task 2<br/>AZ a"] SRV --> T3["Task 3<br/>AZ b"] SRV --> T4["Task 4<br/>AZ b"] ALB["alb-mercadofresco-tienda"] --> TG["tg-mercadofresco-tienda<br/>target type: ip"] TG --> T1 TG --> T2 TG --> T3 TG --> T4 ECR["ECR<br/>mercadofresco/tienda"] -.image.-> TD CL["Cluster<br/>ecs-mercadofresco"] --- SRV
Launch types, the ECS agent and capacity providers
ECS runs tasks in two ways, and the choice changes day-to-day life far more than it changes the architecture.
| Aspect | EC2 launch type | Fargate launch type (10-02) |
|---|---|---|
| Who manages the machines | You: AMI, patches, cluster scaling | AWS: you never see a machine |
| Billing unit | The EC2 instance, whether full or empty | vCPU-hour and GB-hour of the task |
| Density | You can pack many tasks per instance | A task is its own unit |
| Task start-up | 5-15 s if there is room; 2 min if an instance has to be added | 30-45 s always |
| GPU, special instances | Yes | No (or with limitations) |
| Network modes | awsvpc, bridge, host, none |
awsvpc only |
| Host access | SSH, DaemonSet, host volumes | No: ECS Exec for debugging |
| Cost with high, steady usage | Cheaper if the packing is good | Dearer per unit, with no idle cost |
With the EC2 launch type, each instance runs the ECS agent (amazon-ecs-agent), a container that registers with the cluster, reports the available resources and receives orders from the control plane; the correct way to create those instances is with the ECS-optimised AMI, which already carries the agent and the runtime. Capacity providers are the abstraction that saves you from managing cluster scaling by hand. An EC2 capacity provider is associated with an ASG and enables managed scaling: ECS works out how many instances the pending tasks need and adjusts the ASG's desired capacity by itself.
aws ecs create-capacity-provider --name cp-mercadofresco-ec2 --auto-scaling-group-provider '{
"autoScalingGroupArn": "arn:aws:autoscaling:eu-west-1:111122223333:autoScalingGroup:...:autoScalingGroupName/asg-mercadofresco-tienda",
"managedScaling": { "status": "ENABLED", "targetCapacity": 90, "minimumScalingStepSize": 1, "maximumScalingStepSize": 4 },
"managedTerminationProtection": "ENABLED" }'targetCapacity: 90 means "keep the cluster at 90 % occupancy": it leaves 10 % of headroom to absorb a surge without waiting for a new instance. And managedTerminationProtection stops the ASG terminating an instance that is still running tasks, the most irritating failure in the EC2 model. Even so, that 2 min in the table is still there in the worst case: if there is no room, an instance is needed, and an instance takes as long as it takes. That is exactly why MercadoFresco will not stop here and why 10-02 exists.
The shop's task definition, annotated
This is the complete definition of mercadofresco-tienda, with injected secrets, logs going to CloudWatch and the awsvpc network mode.
{
"family": "mercadofresco-tienda",
"networkMode": "awsvpc",
"requiresCompatibilities": ["EC2", "FARGATE"],
"cpu": "1024", "memory": "2048",
"executionRoleArn": "arn:aws:iam::111122223333:role/rol-ejecucion-mercadofresco-tienda",
"taskRoleArn": "arn:aws:iam::111122223333:role/rol-tarea-mercadofresco-tienda",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"containerDefinitions": [
{
"name": "tienda",
"image": "555566667777.dkr.ecr.eu-west-1.amazonaws.com/mercadofresco/tienda@sha256:9c1e...f4a2",
"essential": true, "cpu": 896, "memoryReservation": 1536, "memory": 1792,
"portMappings": [{ "name": "http", "containerPort": 8080, "protocol": "tcp", "appProtocol": "http" }],
"environment": [
{ "name": "ENTORNO", "value": "produccion" },
{ "name": "COLA_PEDIDOS", "value": "cola-mercadofresco-pedidos" },
{ "name": "TABLA_CARRITOS", "value": "mercadofresco-carritos" },
{ "name": "AWS_XRAY_DAEMON_ADDRESS", "value": "127.0.0.1:2000" }
],
"secrets": [
{ "name": "BD_CONTRASENA",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:mercadofresco/produccion/rds/mfadmin:password::" },
{ "name": "BD_USUARIO",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:mercadofresco/produccion/rds/mfadmin:username::" },
{ "name": "ENDPOINT_CACHE",
"valueFrom": "arn:aws:ssm:eu-west-1:111122223333:parameter/mercadofresco/produccion/cache/endpoint" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": { "awslogs-group": "/ecs/mercadofresco-tienda", "awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "tienda", "awslogs-create-group": "true",
"mode": "non-blocking", "max-buffer-size": "4m" }
},
"healthCheck": {
"command": ["CMD-SHELL", "curl -f -s http://127.0.0.1:8080/salud || exit 1"],
"interval": 15, "timeout": 3, "retries": 3, "startPeriod": 30
},
"readonlyRootFilesystem": true,
"linuxParameters": { "initProcessEnabled": true },
"mountPoints": [{ "sourceVolume": "temporal", "containerPath": "/tmp", "readOnly": false }],
"stopTimeout": 30
},
{
"name": "xray",
"image": "public.ecr.aws/xray/aws-xray-daemon:3.x",
"essential": false, "cpu": 32, "memoryReservation": 256,
"portMappings": [{ "containerPort": 2000, "protocol": "udp" }],
"logConfiguration": { "logDriver": "awslogs", "options": {
"awslogs-group": "/ecs/mercadofresco-tienda", "awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "xray" } }
}
],
"volumes": [{ "name": "temporal" }],
"tags": [ { "key": "Proyecto", "value": "mercadofresco" }, { "key": "Entorno", "value": "produccion" },
{ "key": "Componente", "value": "tienda" }, { "key": "Propietario", "value": "plataforma" },
{ "key": "CentroCoste", "value": "tecnologia" } ]
}The points to understand in that JSON:
- CPU and memory at two levels. The task's
cpuandmemoryare the total reserved; the per-container ones divide that total up. With Fargate, the task values are mandatory and only specific combinations are allowed (10-02); with EC2 they can be omitted, in which case the reservation is made per container only. memoryReservationversusmemory.memoryReservationis the guaranteed minimum ECS uses to place the task;memoryis the hard limit: if the container goes over it, the kernel kills it with OOM. Setting onlymemorywastes capacity; setting onlymemoryReservationlets a leaking container eat the instance. You set both.essential: trueon the shop andfalseon the X-Ray sidecar. If an essential container dies, ECS kills the whole task and replaces it. The X-Ray daemon must not bring the shop down if it falls over.secretsinstead ofenvironmentfor anything sensitive. The ECS agent resolves the value at start-up using the execution role and injects it as an environment variable. It never appears in the task definition, nor in the console, nor in CloudTrail. The:password::syntax picks a specific key out of the secret's JSON (04-03).mode: non-blockingfor the logs. This is the option that avoids the nastiest failure of theawslogsdriver: if CloudWatch Logs is slow, in blocking mode the application stops waiting to write its log. Withnon-blockingyou lose lines in the worst case, which is infinitely better than losing orders.readonlyRootFilesystem: truewith a volume for/tmp. The read-only file system stops an attacker writing binaries; the ephemeral volume provides the/tmpthat Python needs.stopTimeout: 30. The seconds ECS waits betweenSIGTERMandSIGKILL. It is the margin Gunicorn has to finish in-flight requests during a deployment.
Task execution role versus task role
This is the number one confusion for people starting with ECS, and it produces errors that seem to make no sense: the task does not start but the application's permissions are fine, or the task starts and the application cannot read a queue.
Task execution role (executionRoleArn) |
Task role (taskRoleArn) |
|
|---|---|---|
| Who assumes it | The ECS agent, before starting the container | Your application code, inside the container |
| What it is for | Pulling the image from ECR, writing to CloudWatch Logs, resolving the secrets |
Calling the AWS APIs the application needs |
| When it is used | During the provisioning phase | Throughout the life of the task |
| Symptom if missing or broken | The task never starts: CannotPullContainerError, ResourceInitializationError |
The application starts and fails with AccessDenied when calling AWS |
| Is it mandatory | Yes on Fargate and whenever there are secrets or awslogs |
No, but without it the application cannot call anything |
{"Version": "2012-10-17", "Statement": [
{"Sid": "DescargarImagenDeECR", "Effect": "Allow", "Resource": "*",
"Action": ["ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"]},
{"Sid": "DescifrarLaImagen", "Effect": "Allow", "Action": ["kms:Decrypt"],
"Resource": "arn:aws:kms:eu-west-1:555566667777:key/*",
"Condition": {"StringEquals": {"kms:ViaService": "ecr.eu-west-1.amazonaws.com"}}},
{"Sid": "EscribirRegistros", "Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents", "logs:CreateLogGroup"],
"Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/ecs/mercadofresco-*:*"},
{"Sid": "ResolverSecretos", "Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "ssm:GetParameters"],
"Resource": ["arn:aws:secretsmanager:eu-west-1:111122223333:secret:mercadofresco/produccion/*",
"arn:aws:ssm:eu-west-1:111122223333:parameter/mercadofresco/produccion/*"]}
]}And the task role, which is the one the code uses and therefore the one that must be minimal:
{"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": ["sqs:SendMessage"],
"Resource": "arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-pedidos"},
{"Effect": "Allow", "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/mercadofresco-carritos"},
{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/*"},
{"Effect": "Allow", "Action": ["xray:PutTraceSegments", "xray:PutTelemetryRecords"], "Resource": "*"}
]}The mnemonic that prevents 90 % of the errors: the execution role works for AWS, the task role works for your code. If the failure happens before you see a single application log, it is the execution role. If the failure shows up in the application logs, it is the task role.
The awsvpc network mode and what it implies
With networkMode: awsvpc, each task gets its own elastic network interface (ENI) with its own private IP inside the VPC. The consequences are concrete and all of them matter to MercadoFresco:
- Security groups per task.
sg-mercadofresco-tiendanow applies to the task, not to the instance. Two different services on the same instance can have different rules, which is impossible withbridge. - No port conflicts. With
bridgeyou had to usehostPort: 0for dynamic assignment and the target group registeredinstance:port. Withawsvpc, each task listens on its own 8080 and the target group is of typeip. - Each task consumes a subnet IP. With
snet-mercadofresco-app-aand-bbeing /20 there are addresses to spare, but in small subnets it is a real limit that arrives sooner than expected. - ENI limit per instance. With the EC2 launch type, each instance supports a limited number of ENIs depending on its size. Without the
awsvpcTrunkingsetting enabled (aws ecs put-account-setting-default --name awsvpcTrunking --value enabled), anm5.largecan only run two or three tasks withawsvpc, no matter how much CPU it has spare. It is one of the most expensive traps in the EC2 model. - No access to the host's
localhost. Containers within the same task do see each other over127.0.0.1— which is why the X-Ray sidecar works withAWS_XRAY_DAEMON_ADDRESS=127.0.0.1:2000— but they do not see containers belonging to other tasks.
| Network mode | Isolation | SG per task | Ports | Available on Fargate |
|---|---|---|---|---|
awsvpc |
Its own ENI per task | Yes | No conflicts | Yes (the only one) |
bridge |
Host virtual network | No (they belong to the instance) | Needs dynamic mapping | No |
host |
Shares the host's network stack | No | Direct conflicts | No |
none |
No network | — | — | No |
The service: ALB, health checks and placement
The service is what turns a task into something you can call production: it keeps the desired count, replaces whatever dies, registers with the ALB and manages deployments.
aws ecs create-service --cluster ecs-mercadofresco --region eu-west-1 \
--service-name svc-mercadofresco-tienda --task-definition mercadofresco-tienda:23 \
--desired-count 4 \
--capacity-provider-strategy capacityProvider=cp-mercadofresco-ec2,weight=1,base=2 \
--network-configuration 'awsvpcConfiguration={subnets=[snet-mercadofresco-app-a,snet-mercadofresco-app-b],
securityGroups=[sg-mercadofresco-tienda],assignPublicIp=DISABLED}' \
--load-balancers 'targetGroupArn=arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/tg-mercadofresco-tienda/abc123,containerName=tienda,containerPort=8080' \
--health-check-grace-period-seconds 60 \
--deployment-configuration '{"minimumHealthyPercent": 100, "maximumPercent": 200,
"deploymentCircuitBreaker": { "enable": true, "rollback": true }}' \
--placement-strategy 'type=spread,field=attribute:ecs.availability-zone' 'type=spread,field=instanceId' \
--enable-execute-command --propagate-tags SERVICEThree details that matter:
--health-check-grace-period-seconds 60. This is the period during which the service ignores the target group's verdict after starting a task. Without it, an application that takes 40 seconds to warm its cache enters an infinite loop: the ALB marks it unhealthy, ECS kills it, starts another one, and so on indefinitely. It is the most frustrating error of all because it gives no clue whatsoever.- A target group of type
ip. Mandatory withawsvpc. Iftg-mercadofresco-tiendawas created in module 3 with typeinstance, you have to create a new target group: the type cannot be changed. - Placement strategies (EC2 launch type only, they do not apply on Fargate):
| Strategy | What it does | When to use it |
|---|---|---|
spread by AZ |
Spreads across availability zones | Always, first: this is availability |
spread by instanceId |
Spreads across instances | Second: avoids losing several tasks with one instance |
binpack by memory or CPU |
Fills one instance before using the next | Optimising cost with tolerant workloads |
random |
At random | Almost never |
For the shop, spread by AZ and then by instance. For the queue workers, which are fault-tolerant, binpack by memory saves instances.
Deployments: rolling, percentages and circuit breaker
The default ECS deployment is the rolling update (ECS rolling update). The service starts tasks with the new revision, waits for them to be healthy in the target group, drains and stops the old ones, and repeats. The two parameters that govern it are:
minimumHealthyPercent: the percentage of thedesiredCountthat must stay healthy during the deployment.maximumPercent: the maximum percentage that can be running at once.
| Configuration | Behaviour with desiredCount = 4 |
Use |
|---|---|---|
100 / 200 |
Starts 4 new, then stops 4 old. Never fewer than 4 healthy | Production: no loss of capacity, temporary double cost |
50 / 100 |
Stops 2, starts 2, repeats. Never more than 4 | No spare capacity; degrades during the deployment |
100 / 150 |
An intermediate window, batch by batch | A reasonable compromise |
0 / 100 |
Stops everything and starts everything | Development only: there is an outage |
MercadoFresco uses 100 / 200 in production and 50 / 100 in development, where cost matters more than availability. With 100 / 200 and a task start-up of 15 seconds, a complete deployment of four tasks finishes in a little over a minute, against the eight or nine of the ASG with instances.
The deployment circuit breaker is the safety net. With enable: true, ECS counts consecutive task start-up failures; when the threshold is exceeded — calculated from the desiredCount, with a minimum of 10 — it declares the deployment failed. With rollback: true, it also automatically reverts to the last revision that did work.
In the most common case this replaces the manual alarm: if the new image has a configuration error that stops it starting, the deployment halts and rolls back on its own. What the circuit breaker does not detect is an image that starts fine and responds badly: for that you need CloudWatch alarms attached to the deployment, with alarmNames in the configuration, and blue/green. Blue/green deployment with CodeDeploy (08-03) is the other option: you switch the deployment controller to CODE_DEPLOY, and CodeDeploy brings up the complete green set in tg-mercadofresco-verde, lets you test it on a test port and shifts traffic all at once or as a canary. That is what MercadoFresco wires into the pipeline in 10-02, once the service is already on Fargate.
Service auto scaling and discovery with Cloud Map
ECS auto scaling is provided by Application Auto Scaling, the same service that scales Aurora Serverless or DynamoDB. You register the service as a scalable target and attach a policy to it.
TARGET="--service-namespace ecs --scalable-dimension ecs:service:DesiredCount \
--resource-id service/ecs-mercadofresco/svc-mercadofresco-tienda"
aws application-autoscaling register-scalable-target $TARGET --min-capacity 4 --max-capacity 20
aws application-autoscaling put-scaling-policy $TARGET --policy-type TargetTrackingScaling \
--policy-name seguimiento-peticiones-por-destino \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 120.0, "ScaleInCooldown": 300, "ScaleOutCooldown": 30,
"PredefinedMetricSpecification": { "PredefinedMetricType": "ALBRequestCountPerTarget",
"ResourceLabel": "app/alb-mercadofresco-tienda/abc123/targetgroup/tg-mercadofresco-tienda/def456" } }'The choice of metric is not a matter of indifference:
| Metric | What it measures | When it is the right one |
|---|---|---|
ECSServiceAverageCPUUtilization |
The service's average CPU | The load is proportional to CPU |
ECSServiceAverageMemoryUtilization |
Average memory | Rarely: memory does not fall when the load falls |
ALBRequestCountPerTarget |
Requests per target | The shop: it reacts sooner than CPU |
A custom metric (ApproximateNumberOfMessagesVisible) |
Queue depth | The workers on cola-mercadofresco-pedidos |
For the shop, ALBRequestCountPerTarget with a target of 120 is better than CPU for a reason of timing: traffic rises before CPU does, so scaling on requests brings the reaction forward by 30 to 60 seconds. And the cooldowns are asymmetric on purpose: 30 seconds to scale out, 300 to scale in. Growing late costs orders; shrinking early costs a bit of oscillation. For the workers, the right metric is queue depth divided by the number of tasks — the "backlog per task" of 07-01 — computed as a CloudWatch metric math expression and used in a custom target tracking policy.
Service discovery with Cloud Map
When one service needs to call another without going through the ALB, the question is how it finds its IP, which changes with every task. AWS Cloud Map solves it with DNS: ECS automatically registers and deregisters every task in a private namespace (aws servicediscovery create-private-dns-namespace --name interno.mercadofresco --vpc vpc-mercadofresco). With the service configured with serviceRegistries, the shop reaches inventory at inventario.interno.mercadofresco and DNS returns the IPs of the healthy tasks. The alternatives are:
| Mechanism | Advantage | Drawback |
|---|---|---|
| Internal ALB | Health checks, TLS, path-based routing | Fixed cost and one extra latency hop |
| Cloud Map (DNS) | No load balancer cost, direct | The client must handle retries and DNS caching |
| ECS Service Connect | A managed proxy with retries and per-service metrics | Adds a sidecar and some complexity |
MercadoFresco does not need this yet because the shop is a monolith behind the ALB and everything else goes through queues (module 7). When the shop is split up, ECS Service Connect will be the preferable option: it gives per-call metrics between services without instrumenting the code.
Observability: Container Insights, logs and X-Ray
Everything from module 5 still holds, with three adjustments:
-
Container Insights is enabled at cluster level (
aws ecs update-cluster-settings --cluster ecs-mercadofresco --settings name=containerInsights,value=enhanced) and publishes CPU, memory, network and disk metrics per service and per task in theECS/ContainerInsightsnamespace. Without it, CloudWatch only gives youCPUUtilizationandMemoryUtilizationat service level, which is not enough to diagnose which container is doing the consuming. -
Logs go to the
/ecs/mercadofresco-tiendagroup with one stream per task. The practical consequence: because tasks are ephemeral and are constantly replaced, the only reasonable way to search is CloudWatch Logs Insights (05-01), not opening streams by hand. And retention has to be set explicitly: withawslogs-create-group: true, the group is created with no expiry and keeps everything for ever. -
X-Ray works with the sidecar in the example. The application SDK sends segments over UDP to
127.0.0.1:2000, the daemon batches them and uploads them. The modern alternative is the AWS Distro for OpenTelemetry as a sidecar, which collects metrics as well as traces. In both cases, the task role — not the execution role — needsxray:PutTraceSegments.
The new metrics Marta adds to the mercadofresco-produccion dashboard:
| Metric | Alarm threshold | What it indicates |
|---|---|---|
RunningTaskCount versus DesiredTaskCount |
Difference > 0 for 5 min | Tasks that cannot start |
CpuUtilized / CpuReserved per service |
> 85 % sustained | Badly sized reservation |
MemoryUtilized / MemoryReserved |
> 90 % | Risk of OOM |
TaskStopped with exitCode: 137 |
Any | The kernel killed the container for memory |
DeploymentCount |
> 1 for 15 min | Stuck deployment |
exitCode: 137 deserves a note: it is 128 + 9, that is, SIGKILL. In ECS it almost always means OOM: the container went over its memory and the kernel killed it. The symptom is a task that reappears every few minutes without the application logs saying anything, because there was no time to write anything.
MercadoFresco's migration
Marta's plan separates what will be containerised now, what will be containerised later and what is never touched.
| Component | Decision | Reason |
|---|---|---|
The shop (asg-mercadofresco-tienda) |
Containerise now | It is the one that suffers the Friday peak and the one with the two-minute start-up |
Workers on cola-mercadofresco-pedidos (asg-mercadofresco-trabajadores) |
Containerise now | Same AMI, same patching problem, and they scale on the queue |
Lambdas (-cobrar-pago, -reservar-stock, …) |
Leave alone | They have no server already; containerising them would be a step backwards |
| Aurora, DynamoDB, Redshift, ElastiCache | Leave alone | They are managed services; a container adds nothing |
| ALB, CloudFront, Route 53, WAF | Leave alone | Only the target group's target type changes, to ip |
| Buckets and queues | Leave alone | The interface is the same from inside a container |
The steps, in the order that reduces risk:
- Write the Dockerfile and run it locally. Before touching AWS, the image must start and return
200on/saludon Luis's laptop. - Create the ECR repositories with immutability, enhanced scanning and lifecycle policies, and add the image build phase to the
build-mercadofresco-tiendaproject. - Create the
ecs-mercadofrescocluster in development, with a capacity provider over a small ASG of instances running the ECS-optimised AMI. - Register the task definition and launch a single task with
run-task. This is where the execution role, secrets and security group errors show up, and this is where they should show up. - Create the
ip-type target group and the service in development, and validate it with the smoke tests from thebuild-mercadofresco-humoproject (08-02). - Repeat in pre-production with the synthetic Friday load, measuring task start-up time and comparing it against today's 120 seconds.
- In production, coexistence: the same ALB with two target groups and a weighted split — 90 % to the ASG, 10 % to the ECS service — raising the weight over two weeks.
- Retire the ASG and the AMI once the service has spent two weeks at 100 % without incident, and delete the launch template from the CDK in a PR that leaves a record.
Step 7 is what makes this a migration and not a leap of faith. With the ALB's weighted split (03-03), a problem in the ECS service affects 10 % of requests for however long it takes to set the weight to zero, which is seconds.
Cost and cleanup
ECS costs nothing. The control plane, the task definitions, the services and the deployments are free. What you pay for with the EC2 launch type is exactly what you were already paying for: the instances, their EBS volumes and data transfer.
The honest comparison for MercadoFresco, with the shop containerised on the same instances:
| Item | Before (ASG with AMI) | Now (ECS on EC2) |
|---|---|---|
| Instances during normal hours | 2 × m5.large |
2 × m5.large (denser: the workers fit too) |
| Instances at the peak | Up to 4 | Up to 4, with better packing |
| ECR | — | ~2 GB × 0.10 = 0.20 USD/month |
| Enhanced scanning | — | ~4 USD/month with 4.8 images a week |
| Real saving | — | Consolidating the shop and the workers in the same cluster removes 2 dedicated instances |
The saving in this lesson does not come from ECS but from density: the workers had their own ASG with their own instances, often idle, and in a shared cluster they are packed alongside the shop. The big saving — removing idle capacity altogether — arrives with Fargate in 10-02, and the fine-grained cost analysis in module 11. Cleanup, in this strict order:
# 1. The service to zero before deleting it, or the deletion fails
aws ecs update-service --cluster ecs-mercadofresco --service svc-mercadofresco-tienda --desired-count 0
aws ecs delete-service --cluster ecs-mercadofresco --service svc-mercadofresco-tienda --force
# 2. Capacity provider and cluster
aws ecs delete-capacity-provider --capacity-provider cp-mercadofresco-ec2
aws ecs delete-cluster --cluster ecs-mercadofresco
# 3. The ASG of cluster instances: THIS is what costs money
aws autoscaling delete-auto-scaling-group --auto-scaling-group-name asg-cluster-mercadofresco --force-delete
# 4. Images and repository; 5. the log group, which otherwise keeps data for ever
aws ecr delete-repository --repository-name mercadofresco/tienda --force
aws logs delete-log-group --log-group-name /ecs/mercadofresco-tiendaStep 4 is the only one that is indispensable from a financial point of view: deleting the ECS cluster does not delete the EC2 instances, which carry on billing merrily with nothing to run. It is the most expensive leftover in this lesson.
Common Mistakes and Tips
Mistake: using latest in the task definition. It produces non-reproducible deployments and impossible rollbacks. Tip: tag with v1.6.0-a3f9c21 and deploy by digest; let the pipeline be the only thing that decides which digest goes to each environment.
Mistake: confusing the execution role with the task role. Half an hour lost per incident. Tip: if the task never starts, look at the execution role; if the application fails calling AWS, the task role. The messages CannotPullContainerError and ResourceInitializationError are always the execution role.
Mistake: not setting --health-check-grace-period-seconds. The application takes a while to warm up, the ALB kills it, ECS replaces it, infinite loop. Tip: measure the real time to the first 200 on /salud and set double that.
Mistake: COPY . . without a .dockerignore. It puts the whole .git and the .env files into the image. Tip: the .dockerignore is written before the Dockerfile, not afterwards.
Mistake: layers in the wrong order. COPY . . before pip install invalidates the cache on every commit. Tip: from what changes least to what changes most: base, system, dependencies, code.
Mistake: a container running as root with a writable file system. It is unnecessary and it turns any application flaw into binaries being written. Tip: a numeric USER, readonlyRootFilesystem: true and an ephemeral volume for /tmp.
Mistake: forgetting the ENI limit with awsvpc. An m5.large runs two tasks and the cluster scales "for no reason". Tip: enable awsvpcTrunking at account level before sizing anything.
Mistake: awslogs in blocking mode. A CloudWatch Logs delay slows the application down. Tip: mode: non-blocking with an explicit max-buffer-size on every service that carries user traffic.
Mistake: an aggressive lifecycle policy. It deletes the image you needed to roll back to. Tip: keep at least ten released versions and test the policy dry before applying it.
Tip: enable the circuit breaker with rollback on every service from day one. It costs nothing and it turns a broken deployment into a three-minute incident that resolves itself.
Tip: set retention on the ECS log groups. With awslogs-create-group the group is born with no expiry, and with ephemeral tasks the log volume grows faster than anyone expects.
Tip: tag your tasks with propagateTags: SERVICE. It is the only way for the five mandatory tags to reach the task and for module 11 to be able to allocate costs.
Tip: keep the task definition in the CDK. The ecs.FargateTaskDefinition or ecs.Ec2TaskDefinition from 09-02 generates least-privilege roles automatically through the grant* methods, including the KMS ones almost nobody remembers.
Exercises
Exercise 1: the workers' Dockerfile
Write the complete Dockerfile for MercadoFresco's worker component, which consumes cola-mercadofresco-pedidos, writes to Aurora and publishes to the mercadofresco-pedido-confirmado topic. It is a Python application with no HTTP server: it exposes no ports and cannot use a curl-based HEALTHCHECK. Work out (a) how you do the health check without HTTP and why ECS needs it anyway; (b) what changes relative to the shop's Dockerfile in terms of stages, user and .dockerignore; (c) how you handle graceful shutdown when ECS sends SIGTERM in the middle of processing a message; and (d) what stopTimeout you set and how it relates to the queue's visibility timeout.
Exercise 2: the task that will not start
Luis registers the shop's task definition and launches the service in development. No task ever reaches RUNNING. The console shows, across different attempts, these three stopped reasons:
CannotPullContainerError: pull access denied for 555566667777.dkr.ecr.eu-west-1.amazonaws.com/mercadofresco/tienda, repository does not exist or may require 'docker login'ResourceInitializationError: unable to pull secrets or registry auth: execution resource retrieval failed: unable to retrieve secret from asm: AccessDeniedException- The task reaches
RUNNING, but the service kills it after 90 seconds and starts another, indefinitely.
For each one: diagnose the exact cause, say where you would check it, and give the concrete fix. For the third one, also explain why the desired count never stabilises and which two different configurations could be causing it.
Exercise 3: deciding on tagging and lifecycle
MercadoFresco deploys 4.8 times a week to production, keeps between 6 and 10 feature branches alive at once and needs to be able to roll back up to four versions in production. Compliance requires being able to demonstrate, for any day in the last 12 months, exactly which image was in production. Design (a) the complete image tagging scheme, including which tags a branch image carries and which a production one does; (b) the complete lifecycle policy in JSON, with its priorities; (c) how you satisfy the 12-month compliance requirement without keeping 250 images; and (d) what concrete risk there would be in giving the untagged-images rule priority 1.
Solutions
Solution 1
# The "constructor" stage is identical to the shop's (venv in /opt/entorno with
# --require-hashes); only the runtime stage changes:
FROM python:3.12.4-slim-bookworm AS ejecucion
RUN apt-get update && apt-get install -y --no-install-recommends libpq5=15.* \
&& rm -rf /var/lib/apt/lists/* && groupadd --gid 10002 trabajador \
&& useradd --uid 10002 --gid trabajador --no-create-home --shell /usr/sbin/nologin trabajador
COPY --from=constructor /opt/entorno /opt/entorno
ENV PATH="/opt/entorno/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --chown=trabajador:trabajador ./trabajadores ./trabajadores
USER 10002
# (a) No HTTP and no curl: the loop writes a timestamp after each polling
# iteration and this command fails if that mark is more than 90 seconds old.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -m trabajadores.salud || exit 1
ENTRYPOINT ["python", "-m", "trabajadores.principal"](a) The check without HTTP. The polling loop writes /tmp/latido with a timestamp after each ReceiveMessage cycle, and trabajadores.salud returns an error if the file is more than 90 seconds old. ECS needs it anyway because a hung worker does not die: it sits waiting on a socket, stops consuming the queue and the service never notices. Without a health check, that zombie worker counts as healthy capacity and the queue grows with four "live" tasks. With the HEALTHCHECK, ECS kills it and replaces it. Since readonlyRootFilesystem will be enabled, /tmp has to be mounted as an ephemeral volume.
(b) What changes relative to the shop. The multi-stage structure is identical — it is the pattern, not the application. There is no EXPOSE and no portMappings because nobody calls the worker. curl is not needed, which allows an even smaller image and a smaller attack surface: the HEALTHCHECK uses Python itself. The UID is different (10002) out of hygiene, so processes can be told apart on the host. And the .dockerignore is the same, with one important addition: the test data directory with sample messages, which may contain data that looks real and must not travel inside an image.
(c) Graceful shutdown. The process registers a SIGTERM handler that sets a flag; the main loop checks it before asking for the next message, not in the middle of processing. The message in flight is finished and deleted from the queue with DeleteMessage; the process then exits with code 0. If the message cannot be finished in time, it is not deleted: when the visibility timeout expires it will return to the queue and another worker will process it, which is why idempotency with mercadofresco-idempotencia (07-05) matters. The mistake to avoid is deleting the message on receipt: then the SIGKILL loses it for ever.
(d) stopTimeout and visibility. Processing an order takes at most about 20 seconds, so stopTimeout: 30 gives enough margin. The relationship with the queue's visibility timeout is the key point: the visibility timeout must be greater than stopTimeout plus the processing time, so that an interrupted message does not reappear while the old worker is still finishing it and a second worker processes it in parallel. With 20 s of processing and a stopTimeout of 30 s, a visibility timeout of 180 s is generous and correct. The ECS maximum for stopTimeout is 120 seconds, which also forces no individual piece of processing to last longer than that.
Solution 2
Reason 1: CannotPullContainerError — execution role permissions or the network. There are three possible causes and they are quick to tell apart. The first, that the execution role does not have the ECR permissions (GetAuthorizationToken, BatchGetImage, GetDownloadUrlForLayer); check it in CloudTrail (05-03) looking for AccessDenied on ecr: with the role's principal. The second, that the repository policy in the tooling account does not authorise the development account: it is cross-account and both policies are needed, the role's and the repository's. The third, and the one that produces this exact misleading message, is a network problem: the tasks are in snet-mercadofresco-app-a with no route to ECR, because the NAT is down or because there are no VPC endpoints. You tell it apart by whether the message arrives after a long timeout (network) or immediately (permissions). Fix: the permissions in the execution policy plus the ecr.api, ecr.dkr and S3 endpoints — the layers are downloaded from S3, and that is the one everybody forgets.
Reason 2: ResourceInitializationError ... unable to retrieve secret from asm. This one is unambiguous: the execution role cannot read mercadofresco/produccion/rds/mfadmin. Two causes, and both have to be checked: secretsmanager:GetSecretValue on the secret's ARN is missing, or kms:Decrypt on alias/mercadofresco-datos is missing, because the secret is encrypted with a customer managed key (04-02) and reading the secret requires decrypting it. The second is the one most often forgotten, and the error message never mentions KMS. Check it in CloudTrail with the failed GetSecretValue event. Fix: add both permissions to the execution role — not the task role, which is the next mistake Luis will make — and verify that the KMS key policy includes the role as a user.
Reason 3: the infinite loop of starting and dying. The task reaches RUNNING, then dies. Two configurations can cause it:
- The health check grace period. If the application takes 60 seconds to return
200on/salud— connecting to Aurora, preloading the catalogue from ElastiCache — and the target group declares it unhealthy before that, the service deregisters and kills it. ECS replaces it, and the cycle repeats. Fix:--health-check-grace-period-secondsat double the measured time, and review the target group's thresholds (HealthyThresholdCount,Interval,Timeout). - The security group. If
sg-mercadofresco-tiendadoes not allow inbound traffic fromsg-mercadofresco-albon port 8080 — and not on 80, which is what was configured when the target was the instance — the ALB's check never passes. Withawsvpc, the SG applies to the task and the port is the container's, not a dynamic host port.
Why the desired count never stabilises: because the service does not distinguish between "the task died" and "the task never became healthy". It replaces indefinitely, consuming execution quota and filling up the event log. And this is exactly the scenario the deployment circuit breaker exists for: with enable: true and rollback: true, after the consecutive-failure threshold the deployment is declared failed and rolled back, instead of spinning in the void for hours. If Luis had enabled it, the diagnosis would have arrived as a failed deployment event instead of a dashboard that never settles.
Solution 3
(a) Tagging scheme. Each image carries several tags, because a tag is a pointer and you can point several of them at the same digest:
| Origin | Tags | Example |
|---|---|---|
| Feature branch | rama-<name>-<sha7> |
rama-cesta-rapida-b71c4e0 |
main not yet released |
main-<sha7> |
main-a3f9c21 |
| Released version | v<semver>-<sha7> and v<semver> |
v1.6.0-a3f9c21 and v1.6.0 |
| Build cache | cache (mutable, separate repository) |
cache |
The cache tag cannot coexist with IMMUTABLE, so it goes in a different repository — mercadofresco/tienda-cache — with immutability disabled and a three-day lifecycle. It is the subtlety you discover when you enable immutability and break the build.
(b) Lifecycle policy.
{"rules": [
{"rulePriority": 10, "description": "Released versions: keep 12", "action": {"type": "expire"},
"selection": {"tagStatus": "tagged", "tagPrefixList": ["v"], "countType": "imageCountMoreThan", "countNumber": 12}},
{"rulePriority": 20, "description": "main not yet released: 30 days", "action": {"type": "expire"},
"selection": {"tagStatus": "tagged", "tagPrefixList": ["main-"], "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 30}},
{"rulePriority": 30, "description": "Feature branches: 14 days", "action": {"type": "expire"},
"selection": {"tagStatus": "tagged", "tagPrefixList": ["rama-"], "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 14}},
{"rulePriority": 90, "description": "Untagged: 1 day", "action": {"type": "expire"},
"selection": {"tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 1}}
]}The priorities go up in tens so that rules can be slotted in later without renumbering everything, which is the same discipline as the WAF rules (04-05). Twelve released versions comfortably cover the four rollbacks required and about two and a half weeks of deployments.
(c) The 12-month compliance requirement. It is not solved by keeping images, it is solved by keeping evidence. Compliance does not need to be able to run the image from eleven months ago; it needs to be able to prove which image was deployed. Three sources give that with no storage cost:
- CloudTrail (05-03) records every
RegisterTaskDefinitionandUpdateServicewith the exact digest and who did it; with the organisation trail in the security account and S3 with Object Lock, the evidence is immutable and queryable with Athena. - The Git repository
mercadofresco-tiendakeeps the commit and theimage.jsonartefact from each pipeline run. - AWS Config (05-04) keeps the configuration history of the ECS service.
If it were also necessary to be able to rebuild an old image, the answer is to rebuild it from the commit with the reproducible Dockerfile — pinned versions and hashes — not to keep it.
(d) The risk of giving the untagged-images rule priority 1. It would be catastrophic because of how ECR evaluates rules: an image is only affected by the first rule that selects it, and an untagged rule with priority 1 is evaluated before all the others. When a released image loses its tag — because another image reuses it, or during a replication, or when a v1.6.0 tag is removed by hand — it becomes untagged and rule 1 deletes it within 24 hours, even if it is the one production is running. untagged rules always go at the end, with the highest priority number, and only after the specific rules have had their chance to retain what matters.
Conclusion
MercadoFresco no longer deploys machines: it deploys images. This lesson has replaced the three artefacts that hurt — the AMI, the patching cycle and the two-minute start-up — with a forty-line Dockerfile, a registry and an orchestrator.
You are clear on what a container really is: namespaces and cgroups over a shared kernel, not a small virtual machine, with everything that implies for start-up (seconds instead of minutes), size (megabytes instead of gigabytes), density and portability — the same artefact on Luis's laptop and in production, byte for byte — and with the honest warning about isolation that the marketing usually leaves out. You have the shop's Dockerfile with a multi-stage build that leaves the compilers outside, unprivileged user 10001, versions pinned with hashes, a HEALTHCHECK consistent with /salud and the layer ordering that turns a three-minute build into a forty-second one. And the .dockerignore that is written before the Dockerfile, not after .git has already gone into an image.
You have Amazon ECR with mercadofresco/tienda and mercadofresco/trabajadores: authentication by temporary token, tag immutability, the v1.6.0-a3f9c21 scheme that ties each image to a commit, and the rule to internalise — the tag is there so a human can find the image; the digest, so the machine can deploy it. Along with the lifecycle policies and their three traps, Inspector's enhanced scanning that does see Python dependencies and warns when an already deployed image stops being clean, replication from the tooling account 555566667777 into production, and encryption with alias/mercadofresco-datos that requires remembering the key policy.
And you have Amazon ECS: the ecs-mercadofresco cluster, the mercadofresco-tienda task definition with secrets injected from Secrets Manager and Parameter Store, non-blocking logs, an X-Ray sidecar and awsvpc with a security group per task; the svc-mercadofresco-tienda service behind tg-mercadofresco-tienda with a grace period, 100/200 deployments and a circuit breaker with automatic rollback; auto scaling on ALBRequestCountPerTarget with asymmetric cooldowns; and the distinction that saves half an hour in every incident: the execution role works for AWS, the task role works for your code.
But a residue remains, and you can see it in the capacity provider table. Underneath the cluster there are still EC2 instances: you have to choose their size, maintain their ECS-optimised AMI, watch that managed scaling does not fall short and accept that, when there is no room for a new task, a new instance is needed and an instance takes two minutes to start. It is the same number we were running away from. We have hidden it behind a capacity provider, we have not removed it. And there is still idle capacity being paid for: two instances running at four in the morning on a Tuesday.
In 10-02, "AWS Fargate", the instances disappear. No AMI to maintain, no cluster to scale, no packing of tasks onto machines, no idle capacity: you declare how much CPU and how much memory each task needs and AWS provides the rest. You will see the valid resource combinations, the real saving from Graviton, how to debug without SSH using ECS Exec, the VPC endpoints that avoid paying for the NAT, scheduled scaling for 17:00 on Friday, and Fargate Spot for the queue workers. Plus the honest comparison between Fargate, EC2 and Lambda, with MercadoFresco's reasoned decision for each component.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
