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

  1. The three problems module 9 left behind
  2. What a container is and how it differs from a virtual machine
  3. Image, layer, registry and running container
  4. The Dockerfile for MercadoFresco's shop
  5. .dockerignore, size and layer caching
  6. Amazon ECR: the registry that replaces the AMI
  7. Tagging, immutability and image lifecycle
  8. Vulnerability scanning: basic and enhanced
  9. Replication, encryption and repository policy
  10. Amazon ECS: cluster, task definition, task and service
  11. Launch types, the ECS agent and capacity providers
  12. The shop's task definition, annotated
  13. Task execution role versus task role
  14. The awsvpc network mode and what it implies
  15. The service: ALB, health checks and placement
  16. Deployments: rolling, percentages and circuit breaker
  17. Service auto scaling and discovery with Cloud Map
  18. Observability: Container Insights, logs and X-Ray
  19. MercadoFresco's migration
  20. Cost and cleanup
  21. Common mistakes and tips
  22. Exercises
  23. 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-bookworm with the full version, not python:3.12 or python: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 pinning aws-cdk-lib in 09-02. And slim instead 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-essential and libpq-dev weigh 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-hashes with 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 10001 with 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/nologin makes sure that user is good for nothing else.
  • HEALTHCHECK with --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.
  • ENTRYPOINT and CMD kept separate. The ENTRYPOINT fixes the executable and the CMD the 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.com

The 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:

  1. 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.
  2. 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.
  3. 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 HIGH

The 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 cpu and memory are 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.
  • memoryReservation versus memory. memoryReservation is the guaranteed minimum ECS uses to place the task; memory is the hard limit: if the container goes over it, the kernel kills it with OOM. Setting only memory wastes capacity; setting only memoryReservation lets a leaking container eat the instance. You set both.
  • essential: true on the shop and false on 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.
  • secrets instead of environment for 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-blocking for the logs. This is the option that avoids the nastiest failure of the awslogs driver: if CloudWatch Logs is slow, in blocking mode the application stops waiting to write its log. With non-blocking you lose lines in the worst case, which is infinitely better than losing orders.
  • readonlyRootFilesystem: true with a volume for /tmp. The read-only file system stops an attacker writing binaries; the ephemeral volume provides the /tmp that Python needs.
  • stopTimeout: 30. The seconds ECS waits between SIGTERM and SIGKILL. 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-tienda now applies to the task, not to the instance. Two different services on the same instance can have different rules, which is impossible with bridge.
  • No port conflicts. With bridge you had to use hostPort: 0 for dynamic assignment and the target group registered instance:port. With awsvpc, each task listens on its own 8080 and the target group is of type ip.
  • Each task consumes a subnet IP. With snet-mercadofresco-app-a and -b being /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 awsvpcTrunking setting enabled (aws ecs put-account-setting-default --name awsvpcTrunking --value enabled), an m5.large can only run two or three tasks with awsvpc, 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 over 127.0.0.1 — which is why the X-Ray sidecar works with AWS_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 SERVICE

Three 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 with awsvpc. If tg-mercadofresco-tienda was created in module 3 with type instance, 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 the desiredCount that 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 the ECS/ContainerInsights namespace. Without it, CloudWatch only gives you CPUUtilization and MemoryUtilization at service level, which is not enough to diagnose which container is doing the consuming.

  • Logs go to the /ecs/mercadofresco-tienda group 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: with awslogs-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 — needs xray: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:

  1. Write the Dockerfile and run it locally. Before touching AWS, the image must start and return 200 on /salud on Luis's laptop.
  2. Create the ECR repositories with immutability, enhanced scanning and lifecycle policies, and add the image build phase to the build-mercadofresco-tienda project.
  3. Create the ecs-mercadofresco cluster in development, with a capacity provider over a small ASG of instances running the ECS-optimised AMI.
  4. 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.
  5. Create the ip-type target group and the service in development, and validate it with the smoke tests from the build-mercadofresco-humo project (08-02).
  6. Repeat in pre-production with the synthetic Friday load, measuring task start-up time and comparing it against today's 120 seconds.
  7. 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.
  8. 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-tienda

Step 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:

  1. CannotPullContainerError: pull access denied for 555566667777.dkr.ecr.eu-west-1.amazonaws.com/mercadofresco/tienda, repository does not exist or may require 'docker login'
  2. ResourceInitializationError: unable to pull secrets or registry auth: execution resource retrieval failed: unable to retrieve secret from asm: AccessDeniedException
  3. 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 200 on /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-seconds at double the measured time, and review the target group's thresholds (HealthyThresholdCount, Interval, Timeout).
  • The security group. If sg-mercadofresco-tienda does not allow inbound traffic from sg-mercadofresco-alb on port 8080 — and not on 80, which is what was configured when the target was the instance — the ALB's check never passes. With awsvpc, 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 RegisterTaskDefinition and UpdateService with 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-tienda keeps the commit and the image.json artefact 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.

© Copyright 2026. All rights reserved