Laptop to Production

Project 3: One service, two runtimes

The same container on ECS and EKS, released with zero failures.

Time
7 to 9 hours, in two sittings. Do the EKS half in one go.
What you buildThe same container on ECS Fargate and on EKS, both behind HTTPS load balancers.
What it provesA new version released under load with zero failed requests, counted by a load-testing script, plus a written ECS-or-EKS recommendation.

QuickCart’s order service is outgrowing its servers. The team is split. Maya wants to move it to ECS, which they already know. Leo says everyone is moving to Kubernetes, so they should too. Nobody has tried both.

In this project you settle it with evidence. You run the same container image on ECS Fargate and on EKS, each behind its own HTTPS load balancer. You release a new version on each while a script keeps ordering, and you count exactly how many requests fail. The target is zero. Then you write a recommendation that the other side can argue with and still not beat.

What you’ll have at the end

Show
What you build in Project 3. Customers reach two HTTPS load balancers. The top one sends traffic to an ECS service with two tasks on Fargate. The bottom one was built by the load balancer controller, and sends traffic through an Ingress and a Service to two pods on two EC2 nodes in an EKS cluster. Both runtimes pull the same order service image from ECR.ECS on FargateLoad balanceryou create itECS servicekeeps 2 tasks runningFargate: no servers for you to seev1taskv1taskECR: one quickcart-orders imageEKSLoad balancerthe controller builds itIngress, ServiceDeployment: 2 pods2 EC2 nodes: yours to size and upgradenode 1node 2v1podv1podCustomersHTTPS only
ReadyOne image, two ways to run it. Choose Release v2 to watch each runtime swap v1 for v2 while customers keep ordering.

What to notice: the image is identical on both sides. On ECS you create the load balancer and there are no servers. On EKS a controller builds the load balancer for you, but you now own two EC2 nodes, a controller to keep up to date, and two extra settings that ECS handles on its own.

PieceECS sideEKS side
The imagequickcart-orders:v1 and v2 in ECRthe same image
Load balancerquickcart-ecs-alb, created by you, HTTPS on 443created by the AWS Load Balancer Controller from an Ingress
What keeps two copies runningan ECS service with 2 tasksa Kubernetes Deployment with 2 pods
Where they runFargate: no servers in your account2 t3.small EC2 nodes in your account
Networkthe default VPC, two zonesthe same default VPC and zones
Certificateone self-signed certificate in ACMthe same certificate

To keep the cost down, this project uses your account’s default VPC with public subnets and no NAT gateway. The containers get public IP addresses only so they can download their image; their security group still lets in nothing but the load balancer. Project 1 built the stricter private-subnet version, and Project 4 rebuilds this project that way, in code.

Before you start

Read these first. The project assumes them and doesn’t explain them again.

ReadWhy you need it here
Images and containers and Writing a DockerfileYou build the image in the first stage
Storing images: ECRImmutable tags and scanning
Security groups and Load balancersThe load balancer to app chain
Domain names and HTTPSWhy the certificate here is self-signed
ECS on Fargate and Releases without downtimeEvery ECS setting you choose
Kubernetes and EKSPods, Deployments, Services and Ingress
ECS or EKS?How to turn measurements into a decision

You also need these on your computer. Set up your computer installs the first three and a safe AWS login.

ToolUsed for
AWS CLIECR login, and connecting kubectl to the cluster
Dockerbuilding and running the image
curl and openssltesting, and making the certificate. Both come with macOS and Ubuntu, including Ubuntu on WSL
kubectltalking to the Kubernetes cluster
helminstalling the load balancer controller into the cluster

Install the last two with Homebrew:

Terminal
brew install kubectl helm
PartWhat it means
brew installinstall programs with Homebrew
kubectlthe command-line tool for any Kubernetes cluster
helminstalls packaged add-ons, called charts, into a cluster

Check your AWS login works in the terminal:

Terminal
aws sts get-caller-identity
PartWhat it means
aws sts get-caller-identityask AWS which identity your terminal is using
You should see

Three lines: UserId, Account and Arn. The Account is your 12-digit account ID. This page shows it as 111122223333; wherever you see that, use yours. The Arn must not end in :root. If it does, stop and follow Who can do what: IAM first.

Cost

WhatPrice in us-east-1 (check the pricing pages; prices change)Per day, roughly
EKS control plane$0.10 an hour, even with nothing running on it$2.40
2 EKS nodes, t3.smallabout $0.021 an hour each$1.00
2 Application Load Balancersabout $0.0225 an hour each, plus a little per unit of traffic$1.10
2 Fargate tasks, 0.25 vCPU and 0.5 GB eachabout $0.012 an hour each$0.60
Public IPv4 addresses on tasks, nodes and load balancers$0.005 an hour each, about eight of them$1.00
Node disks, ECR storage, CloudWatch logsa few centspennies
The imported certificate in ACMfree$0

Build and run the order service

The app is small on purpose, so all your attention goes on running it. You prove it works on your computer before AWS is involved, where problems are far easier to find.

  1. Create the three files

    Make a folder called quickcart-orders and open it in your editor. Create these three files in it.

    app.py
    import os
    import socket
    
    from flask import Flask, jsonify
    
    app = Flask(__name__)
    VERSION = os.environ.get("APP_VERSION", "v1")
    
    ORDERS = [
        {"id": 1, "item": "margherita pizza", "qty": 2},
        {"id": 2, "item": "pad thai", "qty": 1},
        {"id": 3, "item": "mango lassi", "qty": 3},
    ]
    
    
    @app.get("/health")
    def health():
        return jsonify(status="ok", version=VERSION)
    
    
    @app.get("/")
    def home():
        return jsonify(service="orders", version=VERSION, served_by=socket.gethostname())
    
    
    @app.get("/orders")
    def orders():
        return jsonify(version=VERSION, count=len(ORDERS), orders=ORDERS)
    PageWhat it returnsWho uses it
    /healthok and the versionThe load balancer, to decide whether a copy may get traffic
    /the version, and the name of the copy that answeredYou, to see which version and which copy served you
    /ordersthree example ordersYou, to check the app really works
    requirements.txt
    flask==3.1.3
    gunicorn==23.0.0
    LineWhat it means
    flask==3.1.3the web framework, pinned to one exact version so every build is the same
    gunicorn==23.0.0the web server that runs the app in production and stops cleanly when asked
    Dockerfile
    FROM python:3.12-slim
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY app.py .
    RUN useradd --create-home --uid 1001 appuser
    USER 1001
    ARG APP_VERSION=v1
    ENV APP_VERSION=$APP_VERSION
    EXPOSE 8080
    CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "20", "app:app"]
    LineWhat it doesWhy it matters in this project
    FROM python:3.12-slimstart from a small Linux with Pythonsmall images download faster, so new copies start faster
    WORKDIR /appwork in a folder called /appkeeps the app’s files in one place
    COPY requirements.txt . then RUN pip installinstall the libraries before copying the codea code change reuses the cached library layer, so builds take seconds
    COPY app.py .copy the code inthe part that changes most, so it goes last
    RUN useradd and USER 1001create a normal user and run as ita break-in only gets a normal user’s power
    ARG and ENV APP_VERSIONstamp a version into the image when you build itthis is how you tell v1 from v2 during a release
    EXPOSE 8080a note that the app listens on 8080only root may use ports below 1024
    CMD [...] in square bracketsstart gunicorn directly, with no shell in betweengunicorn hears the stop signal, finishes its requests and exits. This is one of the three settings that make zero failures possible
    --graceful-timeout 20give requests in progress up to 20 seconds to finish on stopshorter than the 30-second drain you set later
  2. Build the image

    In a terminal, go into the folder, then build:

    Terminal
    cd quickcart-orders
    PartWhat it means
    cd quickcart-ordersmove into the project folder. Every later command runs here
    Terminal
    docker build --platform linux/amd64 --provenance=false -t quickcart-orders:v1 .
    PartWhat it means
    docker buildmake an image from the Dockerfile
    --platform linux/amd64build for Intel and AMD processors, which is what your Fargate tasks and t3 nodes use
    --provenance=falsedon’t attach a build record, so ECR sees one plain image and can scan it
    -t quickcart-orders:v1name the image quickcart-orders, tag v1
    .use the files in this folder
    You should see

    A list of build steps, ending with a line that names the image quickcart-orders:v1.

  3. Run it on your computer

    Terminal
    docker run -d -p 8080:8080 --name orders quickcart-orders:v1
    PartWhat it means
    docker runstart a container from an image
    -drun it in the background, so you get the terminal back
    -p 8080:8080connect port 8080 on your computer to port 8080 in the container
    --name orderscall the container orders so later commands can use the name
    quickcart-orders:v1the image to start
    You should see

    A long container ID. Docker may also warn that the image’s platform doesn’t match your computer’s. That’s expected on Apple silicon: the image is built for AWS, and Docker Desktop translates it.

    Terminal
    curl localhost:8080/health
    PartWhat it means
    curlfetch a web page and print it
    localhost:8080port 8080 on your own computer, which leads into the container
    /healththe page to fetch
    You should see
    {"status":"ok","version":"v1"}

    Check it isn’t running as root:

    Terminal
    docker exec orders whoami
    PartWhat it means
    docker execrun a command inside a running container
    orderswhich container
    whoamiprint the name of the current user
    You should see
    appuser

    Stop and remove it:

    Terminal
    docker rm -f orders
    PartWhat it means
    docker rmdelete a container
    -fstop it first if it’s running
    orderswhich container
    You should see
    orders
You should see

Checkpoint: you have an image called quickcart-orders:v1 that answers /health with ok and runs as appuser.

Put the image in ECR

ECS and EKS both download your image from ECR, AWS’s private image store.

  1. Create the repository

    In the AWS console: ECRPrivate registryRepositoriesCreate repository

    FieldEnter
    Repository namequickcart-orders
    Image tag mutabilityImmutable
    Scan on pushOn. It may sit under Image scanning settings
    Encryptionleave the default, AES-256

    Choose Create.

    SettingWhy
    Immutableonce v1 is pushed, nothing can replace it. v1 always means the same code, which is what makes going back safe
    Scan on pushevery image is checked for known security problems as it arrives
  2. Log Docker in to ECR

    Terminal
    aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com
    PartWhat it means
    aws ecr get-login-passwordask AWS for a temporary registry password
    --region us-east-1for the registry in this region
    |a pipe: pass the password straight into the next command, so it’s never shown or typed
    docker loginlog Docker in to a registry
    --username AWSthe username for ECR is always the word AWS
    --password-stdinread the password from the pipe
    111122223333.dkr.ecr.us-east-1.amazonaws.comyour registry’s address. Replace the account ID with yours
    You should see
    Login Succeeded

    The login lasts 12 hours.

  3. Tag and push v1

    Docker needs the image named with the registry’s full address. docker tag gives it a second name; it copies nothing.

    Terminal
    docker tag quickcart-orders:v1 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1
    PartWhat it means
    docker taggive an existing image another name
    quickcart-orders:v1the image you built
    the second argumentthe new name: your registry’s address, then /quickcart-orders:v1

    It prints nothing when it works.

    Terminal
    docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1
    PartWhat it means
    docker pushupload an image to a registry
    the addresswhich image, by its full name
    You should see

    Each layer uploads, then a last line starting v1: digest: sha256:.

  4. Read the scan

    In the AWS console: ECRRepositoriesquickcart-ordersv1Vulnerabilities

    Give it a minute after the push.

    You should see

    A count of findings by severity. The slim Python image usually has a few low or medium findings and no critical ones.

You should see

Checkpoint: quickcart-orders in ECR holds one image tagged v1, with a finished scan.

Network, security groups and the certificate

  1. Find two subnets in the default VPC

    In the AWS console: VPCSubnets

    Look for the subnets whose VPC is the one marked Default VPC: Yes. There is one per availability zone. Write down three values; you need them later:

    Write downWhere it is
    The default VPC’s ID, like vpc-0abcthe VPC column
    The subnet ID in us-east-1athe row whose Availability Zone is us-east-1a
    The subnet ID in us-east-1bthe row for us-east-1b
  2. Tag both subnets for EKS

    The EKS load balancer controller only places an internet-facing load balancer in subnets tagged for it. Adding the tag now saves a confusing failure later.

    In the AWS console: VPCSubnetsthe us-east-1a subnetTagsManage tagsAdd new tag

    FieldEnter
    Keykubernetes.io/role/elb
    Value1

    Choose Save, then do the same on the us-east-1b subnet.

  3. Create the load balancer's security group

    In the AWS console: EC2Security GroupsCreate security group

    FieldEnter
    Security group namequickcart-alb-sg
    DescriptionQuickCart load balancer
    VPCthe default VPC
    Inbound rule typePortSource
    HTTPS443Anywhere-IPv4
    HTTP80Anywhere-IPv4

    Leave the outbound rule as it is. Choose Create security group.

  4. Create the app's security group

    In the AWS console: EC2Security GroupsCreate security group

    FieldEnter
    Security group namequickcart-app-sg
    DescriptionQuickCart order service tasks
    VPCthe default VPC
    Inbound rule typePortSource
    Custom TCP8080Custom, then choose quickcart-alb-sg

    Leave the outbound rule allowing everything: the tasks need it to download their image.

  5. Make a self-signed certificate

    A certificate encrypts traffic and proves who the site is. ACM only issues trusted certificates for domains you own. This project doesn’t need a domain, so you make a certificate yourself. It encrypts exactly as well; browsers just warn that nobody trusted vouched for it.

    In the quickcart-orders folder:

    Terminal
    openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj "/CN=orders.quickcart.test"
    PartWhat it means
    openssl req -x509make a certificate that signs itself
    -newkey rsa:2048make a new 2048-bit RSA key to go with it. ACM accepts this type
    -nodesdon’t put a password on the key. ACM needs it without one
    -keyout key.pemsave the private key as key.pem
    -out cert.pemsave the certificate as cert.pem
    -days 30valid for 30 days, plenty for this project
    -subj "/CN=orders.quickcart.test"the name written inside it. Any name works for a self-signed certificate
    You should see

    Two new files in the folder: cert.pem and key.pem.

  6. Import it into Certificate Manager

    Print each file so you can copy it:

    Terminal
    cat cert.pem
    PartWhat it means
    catprint a file
    cert.pemthe certificate
    You should see

    Text starting -----BEGIN CERTIFICATE----- and ending -----END CERTIFICATE-----. Do the same with cat key.pem.

    In the AWS console: Certificate ManagerImport

    FieldEnter
    Certificate bodyall of cert.pem, including the BEGIN and END lines
    Certificate private keyall of key.pem, including the BEGIN and END lines
    Certificate chainleave empty

    Choose Import certificate.

    You should see

    The certificate listed with status Issued and type Imported. Open it and copy its ARN, which starts arn:aws:acm:us-east-1:111122223333:certificate/. EKS needs it later.

You should see

Checkpoint: two tagged subnets, two security groups in a chain (quickcart-alb-sg lets anyone in on 443 and 80; quickcart-app-sg lets only the load balancer in on 8080), and one certificate showing Issued.

Run it on ECS

  1. Create the target group

    In the AWS console: EC2Target GroupsCreate target group

    FieldEnter
    Target typeIP addresses
    Target group namequickcart-ecs-tg
    Protocol : PortHTTP : 8080
    VPCthe default VPC
    Health check path/health
    Healthy threshold (under Advanced health check settings)2
    Unhealthy threshold2
    Interval10 seconds

    Choose Next. Don’t register any targets; ECS adds them itself. Choose Create target group.

    SettingWhy
    IP addressesa Fargate task has no server behind it, just its own IP address
    /healtha page that proves the app works, not just that something answers
    Healthy threshold 2the default of 5 makes each new task wait 50 seconds for traffic; 2 makes it 20
  2. Shorten the drain

    In the AWS console: EC2Target Groupsquickcart-ecs-tgAttributesEdit

    FieldEnter
    Deregistration delay30 seconds

    Choose Save changes. The default of 300 seconds makes every release wait five minutes for nothing. Thirty seconds is plenty for this app’s requests to finish.

  3. Create the load balancer

    In the AWS console: EC2Load BalancersCreate load balancerApplication Load BalancerCreate

    FieldEnter
    Load balancer namequickcart-ecs-alb
    SchemeInternet-facing
    Load balancer IP address typeIPv4
    VPCthe default VPC
    Availability Zonestick us-east-1a and us-east-1b
    Security groupsquickcart-alb-sg only. Remove default

    Under Listeners and routing, change the first listener, then add a second:

    ListenerDefault action
    HTTPS : 443Forward to quickcart-ecs-tg
    HTTP : 80Redirect to URL: HTTPS, port 443, status code 301

    Adding the HTTPS listener shows Secure listener settings:

    FieldEnter
    Security policyleave the recommended default
    Default SSL/TLS server certificateFrom ACM, then choose the certificate you imported

    Choose Create load balancer. It takes a few minutes to become Active.

  4. Create the ECS cluster

    In the AWS console: ECSClustersCreate cluster

    FieldEnter
    Cluster namequickcart-cluster
    InfrastructureFargate only

    Choose Create.

    You should see

    The cluster in the list within seconds. Look in EC2 > Instances: nothing new. On Fargate a cluster is just a name.

  5. Create the task definition

    In the AWS console: ECSTask definitionsCreate new task definition

    FieldEnter
    Task definition familyquickcart-orders
    Launch typeAWS Fargate
    Operating system/ArchitectureLinux/X86_64, to match --platform linux/amd64
    CPU.25 vCPU
    Memory.5 GB
    Task rolenone
    Task execution roleCreate new role, or ecsTaskExecutionRole if it’s already listed
    Container nameapp
    Image URI111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1, with your account ID
    Container port8080, protocol TCP, app protocol HTTP
    Log collectionleave Use log collection ticked

    Choose Create.

    You should see

    quickcart-orders:1: revision 1 of the recipe. Every change you make creates a new revision, and the old ones stay exactly as they were.

    RoleUsed byThis app
    Execution roleECS itself, before your code starts: to pull the image and write logsneeds one
    Task roleyour code while it runs, to call AWS servicesneeds none; the app never calls AWS
  6. Create the service

    In the AWS console: ECSClustersquickcart-clusterServicesCreate

    FieldEnter
    Compute optionsLaunch type, then FARGATE
    Task definition familyquickcart-orders, revision LATEST
    Service nameorders
    Desired tasks2

    Under Deployment options:

    FieldEnter
    Deployment typeRolling update
    Min running tasks %100
    Max running tasks %200
    Deployment failure detectiontick Use the Amazon ECS deployment circuit breaker and Rollback on failures

    Under Networking:

    FieldEnter
    VPCthe default VPC
    Subnetsonly the us-east-1a and us-east-1b ones
    Security groupUse an existing security group: quickcart-app-sg only. Remove default
    Public IPTurned on

    Under Load balancing:

    FieldEnter
    Load balancer typeApplication Load Balancer
    Load balancerUse an existing load balancer: quickcart-ecs-alb
    Containerapp 8080:8080
    ListenerUse an existing listener: 443:HTTPS
    Target groupUse an existing target group: quickcart-ecs-tg
    Health check grace period30 seconds

    Choose Create.

    SettingIn plain words
    Desired tasks 2always keep two copies running, one per zone
    Min 100%during a release, never drop below two working copies
    Max 200%during a release, up to four copies may run at once
    Circuit breaker with rollbackif the new version keeps failing to start, stop and go back by itself
    Public IP onlets each task reach ECR to download its image, because there’s no NAT gateway
    You should see

    After one to three minutes, the service shows 2/2 tasks running. In EC2 > Target Groups > quickcart-ecs-tg > Targets, two IP addresses are Healthy.

  7. Test it over HTTPS

    In the AWS console: EC2Load Balancersquickcart-ecs-alb

    Copy its DNS name. It looks like quickcart-ecs-alb-1234567890.us-east-1.elb.amazonaws.com. Use yours wherever this page shows ECS_ALB.

    Terminal
    curl -k https://ECS_ALB/orders
    PartWhat it means
    curlfetch a web page
    -kaccept the self-signed certificate. Without it, curl refuses to connect
    https://ECS_ALB/ordersthe orders page, through your load balancer, over HTTPS
    You should see

    The three orders, with "version":"v1".

    Now ask six times in a row, to see the load balancer share the work:

    Terminal
    for i in 1 2 3 4 5 6; do echo "request $i"; curl -sk https://ECS_ALB/; echo; done
    PartWhat it means
    for i in 1 2 3 4 5 6; dorepeat what follows six times, with i counting 1 to 6
    echo "request $i"print which request this is
    curl -sk https://ECS_ALB/fetch the home page quietly, accepting the certificate. It says which copy answered
    echoprint a new line, so each answer sits on its own line
    donethe end of the part to repeat
    You should see

    Six answers. served_by switches between two different names: the two tasks.

  8. Watch it heal itself

    In the AWS console: ECSClustersquickcart-clusterServicesordersTasks

    Tick one task and choose Stop, then Stop again to confirm.

    You should see

    The running count drops to 1. A new task appears and the count is back to 2 in about a minute, with no one touching anything.

You should see

Checkpoint: curl -k https://ECS_ALB/orders returns the orders over HTTPS, http:// redirects to https://, and a stopped task is replaced by itself.

Release v2 on ECS under load

This is the heart of the project: release a new version while traffic flows, and count exactly how many requests failed.

  1. Create the load-testing script

    In the quickcart-orders folder, create load.sh:

    load.sh
    #!/usr/bin/env bash
    # Ask one address for a page, about five times a second, and count the failures.
    # Usage: bash load.sh URL SECONDS
    url="$1"
    seconds="${2:-180}"
    if [ -z "$url" ]; then
      echo "usage: bash load.sh URL SECONDS"
      exit 1
    fi
    ok=0
    failed=0
    end=$((SECONDS + seconds))
    while [ "$SECONDS" -lt "$end" ]; do
      code=$(curl --silent --insecure --output /dev/null --write-out "%{http_code}" --max-time 3 "$url")
      if [ "$code" = "200" ]; then
        ok=$((ok + 1))
      else
        failed=$((failed + 1))
        echo "$(date -u +%H:%M:%S) failed with $code"
      fi
      sleep 0.1
    done
    echo "----"
    echo "requests: $((ok + failed))   ok: $ok   failed: $failed"
    LineWhat it does
    url="$1" and seconds="${2:-180}"the first thing you type after the script’s name is the address; the second is how many seconds to run, 180 if you leave it out
    if [ -z "$url" ]stop with a usage message if you forgot the address
    end=$((SECONDS + seconds))SECONDS is bash’s own stopwatch. This works out when to stop
    while [ "$SECONDS" -lt "$end" ]keep going until that time
    code=$(curl ...)fetch the page and keep only the status code, like 200 or 502
    --silent --insecure --output /dev/nullprint nothing, accept the self-signed certificate, and throw the page away
    --write-out "%{http_code}"print just the status code, which $(...) captures
    --max-time 3count anything slower than 3 seconds as a failure. A connection that never answers shows as 000
    if [ "$code" = "200" ]200 is success; everything else is a failure, printed with the time it happened
    sleep 0.1wait a tenth of a second between requests
    the last two echo linesprint the totals: your evidence

    Try it for ten seconds:

    Terminal
    bash load.sh https://ECS_ALB/ 10
    PartWhat it means
    bash load.shrun the script
    https://ECS_ALB/the page to keep asking for. There’s a space before the next part
    10run for 10 seconds
    You should see

    After ten seconds, one line like requests: 48 ok: 48 failed: 0. Your count depends on your connection; anything from 30 to 90 is normal.

  2. Build and push v2

    Terminal
    docker build --platform linux/amd64 --provenance=false --build-arg APP_VERSION=v2 -t quickcart-orders:v2 .
    PartWhat it means
    --build-arg APP_VERSION=v2stamp this image as v2. Nothing else changes
    -t quickcart-orders:v2name it with tag v2
    everything elsethe same as for v1
    Terminal
    docker tag quickcart-orders:v2 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2
    PartWhat it means
    docker taggive the v2 image its ECR name
    Terminal
    docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2
    PartWhat it means
    docker pushupload it
    You should see

    Most layers say Layer already exists. Only what changed is uploaded.

  3. Make revision 2 of the task definition

    In the AWS console: ECSTask definitionsquickcart-ordersquickcart-orders:1Create new revision

    Change one thing: the end of the container’s Image URI, from :v1 to :v2. Choose Create.

    You should see

    quickcart-orders:2. Revision 1 is still there, untouched. That’s what makes going back easy.

  4. Release while traffic runs

    Open two terminal windows, both in the quickcart-orders folder.

    Window 1: the load test, for four minutes.

    Terminal
    bash load.sh https://ECS_ALB/ 240
    PartWhat it means
    240run for four minutes, long enough to cover the whole release

    Window 2: watch which version answers.

    Terminal
    while true; do curl -sk https://ECS_ALB/; echo; sleep 1; done
    PartWhat it means
    while true; dorepeat forever, until you press Ctrl+C
    curl -sk https://ECS_ALB/fetch the home page quietly (-s), accepting the certificate (-k)
    echoa new line after each answer
    sleep 1wait one second between requests
    donethe end of the part to repeat

    Now release:

    In the AWS console: ECSClustersquickcart-clusterServicesordersUpdate service

    FieldEnter
    Revision2

    Choose Update, and watch the service’s Deployments tab.

    You should see

    Window 2 shows v1, then a mix of v1 and v2, then only v2. When window 1 finishes, it prints its totals, like requests: 1150 ok: 1150 failed: 0. Write the numbers down. They are your evidence.

    What happenedWhich setting made it safe
    ECS started two v2 tasks next to the two v1 tasksMax 200%
    It waited until the v2 tasks passed /health before touching v1Min 100%
    It took the v1 tasks out of the target group and waited30-second deregistration delay
    It told the v1 tasks to stop; gunicorn finished its requests and exitedCMD in square brackets
  5. Release a broken version, and watch ECS undo it

    Create another revision, quickcart-orders:3, from revision 2, and change the image tag to :v9-broken. There is no such image.

    Start the load test in window 1 for ten minutes:

    Terminal
    bash load.sh https://ECS_ALB/ 600
    PartWhat it means
    600ten minutes, long enough for the circuit breaker to act

    Then update the orders service to revision 3, as before, and watch the Deployments and Events tabs.

    You should see

    New tasks keep failing to start. After several minutes the deployment is marked Failed, and the service goes back to revision 2 by itself. Window 1 shows zero failures: the v2 tasks were never removed.

    Find out why the tasks failed:

    In the AWS console: ECSClustersquickcart-clusterServicesordersTasksFilter: Stoppeda stopped task

    You should see

    A Stopped reason starting CannotPullContainerError and mentioning not found. There are no application logs, because the app never started. When a task won’t start, read the stopped reason first.

    Then remove the broken recipe, so nobody picks it by accident:

    In the AWS console: ECSTask definitionsquickcart-ordersquickcart-orders:3ActionsDeregister

You should see

Checkpoint: one written total from the v2 release (the target is failed: 0), and a note of how long the circuit breaker took to roll back the broken release.

Run it on EKS

The clock is now running at about $3.50 a day extra. Do this stage and the next together.

  1. Create the cluster

    In the AWS console: EKSClustersCreate cluster

    FieldEnter
    Configuration optionsCustom configuration
    Use EKS Auto ModeOff. Auto Mode costs extra and manages nodes differently from this guide
    Namequickcart-eks
    Cluster IAM rolechoose Create recommended role, accept the defaults in the new tab, then come back, refresh the list and pick it
    Kubernetes versionleave the default. Older versions move to extended support, which costs six times as much
    Upgrade policyStandard
    Cluster accessleave Allow cluster administrator access on

    On the Networking page:

    FieldEnter
    VPCthe default VPC
    Subnetsonly the us-east-1a and us-east-1b subnets
    Security groupsleave empty
    Cluster endpoint accessPublic and private

    On the Add-ons page, make sure Amazon EKS Pod Identity Agent is ticked alongside the defaults. Leave everything else as it is and choose Create.

    You should see

    Status Creating, then Active after about 10 to 15 minutes. Write down how long it took; it goes in your recommendation. ECS’s cluster took about a second.

  2. Add the nodes

    In the AWS console: EKSClustersquickcart-eksComputeAdd node group

    FieldEnter
    Namequickcart-nodes
    Node IAM roleCreate recommended role, accept the defaults, come back and pick it
    AMI typeleave the default, Amazon Linux 2023
    Instance typest3.small only. Remove any others
    Disk size20 GiB
    Desired, minimum, maximum size2, 2, 3
    Subnetsonly us-east-1a and us-east-1b

    Choose Create.

    You should see

    The node group becomes Active in about five minutes. EC2 > Instances now shows two new instances. They’re yours, and on your bill.

  3. Connect kubectl

    The console can’t run kubectl, so this part is always the terminal:

    Terminal
    aws eks update-kubeconfig --name quickcart-eks --region us-east-1
    PartWhat it means
    aws eks update-kubeconfigsave the cluster’s address and login method where kubectl looks for them
    --name quickcart-ekswhich cluster
    --region us-east-1where it is
    You should see
    Added new context arn:aws:eks:us-east-1:111122223333:cluster/quickcart-eks to /home/you/.kube/config
    Terminal
    kubectl get nodes
    PartWhat it means
    kubectl get nodeslist the servers in the cluster
    You should see

    Two nodes, each with STATUS Ready.

  4. Give the load balancer controller its permissions

    On ECS, attaching a load balancer was a dropdown. On EKS, a program inside the cluster, the AWS Load Balancer Controller, watches for Ingress objects and builds real load balancers. It needs AWS permissions to do that, and it gets them through EKS Pod Identity: an IAM role tied to one Kubernetes service account.

    First download AWS’s list of the permissions it needs, pinned to the controller’s version:

    Terminal
    curl -o lbc-policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v3.5.0/docs/install/iam_policy.json
    PartWhat it means
    curl -o lbc-policy.jsondownload a file and save it as lbc-policy.json
    the addressthe official policy for controller version 3.5.0

    In the AWS console: IAMPoliciesCreate policyJSON

    Open lbc-policy.json in your editor, copy all of it, and paste it into the box, replacing what’s there. Choose Next.

    FieldEnter
    Policy namequickcart-lbc-policy

    Choose Create policy. Now the role:

    In the AWS console: IAMRolesCreate role

    FieldEnter
    Trusted entity typeAWS service
    Service or use caseEKS, then EKS - Pod Identity
    Permissionstick quickcart-lbc-policy
    Role namequickcart-lbc-role

    Choose Create role. Then tie the role to the controller’s service account:

    In the AWS console: EKSClustersquickcart-eksAccessPod Identity associationsCreate

    FieldEnter
    IAM rolequickcart-lbc-role
    Kubernetes namespacekube-system
    Kubernetes service accountaws-load-balancer-controller

    Choose Create.

    You should see

    One association in the list, for kube-system and aws-load-balancer-controller. Only pods using that exact service account get the role, not every pod on the node.

  5. Install the controller

    Helm only runs in the terminal; there’s no console equivalent.

    Terminal
    helm repo add eks https://aws.github.io/eks-charts
    PartWhat it means
    helm repo addtell Helm where to find a set of charts
    eksthe short name you’ll use for it
    https://aws.github.io/eks-chartsAWS’s official chart repository
    You should see
    "eks" has been added to your repositories
    Terminal
    helm install aws-load-balancer-controller eks/aws-load-balancer-controller --version 3.5.0 -n kube-system --set clusterName=quickcart-eks --set region=us-east-1 --set vpcId=VPC_ID --set serviceAccount.name=aws-load-balancer-controller
    PartWhat it means
    helm install aws-load-balancer-controllerinstall a chart, and call this installation aws-load-balancer-controller
    eks/aws-load-balancer-controllerwhich chart, from the eks repository
    --version 3.5.0the chart version that matches the policy you downloaded
    -n kube-systemput it with the other system programs
    --set clusterName=quickcart-ekswhich cluster it runs in
    --set region=us-east-1 and --set vpcId=VPC_IDthe region and your default VPC’s ID. On newer nodes the controller often can’t discover these by itself, and fails
    --set serviceAccount.name=aws-load-balancer-controllerthe service account name, which must match the Pod Identity association exactly
    You should see

    Output that includes STATUS: deployed.

    Terminal
    kubectl get deployment -n kube-system aws-load-balancer-controller
    PartWhat it means
    kubectl get deploymentshow a deployment and how many of its pods are ready
    -n kube-systemin the system namespace
    aws-load-balancer-controllerwhich deployment
    You should see

    2/2 under READY. If it shows 0/2, wait a minute and run it again.

    Count what you just did: a policy, a role, an association and a Helm install, just to connect a load balancer. And this controller needs upgrading like everything else. That is a real cost of EKS; it goes in the recommendation.

  6. Turn on the readiness gate

    Terminal
    kubectl label namespace default elbv2.k8s.aws/pod-readiness-gate-inject=enabled
    PartWhat it means
    kubectl labeladd a label to something
    namespace defaultthe namespace your app will run in
    elbv2.k8s.aws/pod-readiness-gate-inject=enabledask the controller to add a readiness gate to every new pod here
    You should see
    namespace/default labeled

    With the gate, a new pod only counts as ready once the load balancer says it’s healthy. Without it, Kubernetes can remove an old pod before the load balancer is sending traffic to the new one, and requests fail. ECS does this for you.

  7. Describe the app in one file

    Create orders.yaml in the folder. Replace the account ID in the image, and replace CERT_ID with the end of your certificate’s ARN.

    orders.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: orders
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: orders
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 0
          maxSurge: 1
      template:
        metadata:
          labels:
            app: orders
        spec:
          terminationGracePeriodSeconds: 40
          containers:
            - name: app
              image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1
              ports:
                - containerPort: 8080
              readinessProbe:
                httpGet:
                  path: /health
                  port: 8080
                periodSeconds: 5
              resources:
                requests:
                  cpu: 100m
                  memory: 128Mi
                limits:
                  memory: 256Mi
              lifecycle:
                preStop:
                  exec:
                    command: ["sleep", "15"]
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: orders
    spec:
      selector:
        app: orders
      ports:
        - port: 80
          targetPort: 8080
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: orders
      annotations:
        alb.ingress.kubernetes.io/scheme: internet-facing
        alb.ingress.kubernetes.io/target-type: ip
        alb.ingress.kubernetes.io/healthcheck-path: /health
        alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
        alb.ingress.kubernetes.io/ssl-redirect: "443"
        alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:111122223333:certificate/CERT_ID
        alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
    spec:
      ingressClassName: alb
      rules:
        - http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: orders
                    port:
                      number: 80

    The --- lines split the file into three objects.

    SettingWhat it doesThe ECS equivalent
    Deployment replicas: 2keep two pods runningthe service’s desired tasks
    maxUnavailable: 0, maxSurge: 1during a release, add one new pod before removing an old oneMin 100%, Max 200%
    readinessProbe on /healthonly send traffic to a pod once /health answersthe target group’s health check
    requeststhe CPU and memory the scheduler reserves for the pod on a nodethe task size
    limits memory: 256Mithe most memory the pod may use; above it, it’s killedthe task’s memory
    preStop sleep 15wait 15 seconds before stopping, so the load balancer stops sending firstECS does this for you with the drain
    terminationGracePeriodSeconds: 40time allowed for the 15-second wait plus gunicorn’s 20-second finishECS’s stop timeout
    Servicea fixed name, orders, in front of pods that come and gothe target group
    Ingress and its annotationsask the controller for an internet-facing HTTPS load balancer that targets pod IPs, redirects HTTP to HTTPS, checks /health and drains for 30 secondsthe load balancer, listeners and target group you clicked

    Before applying, check you replaced the placeholder:

    Terminal
    grep -c 111122223333 orders.yaml
    PartWhat it means
    grep -ccount the lines that contain some text
    111122223333the example account ID
    orders.yamlthe file to search
    You should see
    0

    Any number other than 0 means the example account ID is still in the file.

  8. Start it

    Terminal
    kubectl apply -f orders.yaml
    PartWhat it means
    kubectl applycreate or update whatever a file describes
    -f orders.yamlthe file
    You should see
    deployment.apps/orders created
    service/orders created
    ingress.networking.k8s.io/orders created
    Terminal
    kubectl get pods
    PartWhat it means
    kubectl get podslist the pods in the default namespace
    You should see

    Two pods named orders- followed by random letters, each 1/1 READY and Running.

    Terminal
    kubectl get ingress orders
    PartWhat it means
    kubectl get ingress ordersshow the Ingress, including the address of the load balancer built for it
    You should see

    After two to three minutes, an ADDRESS starting k8s-default-orders-. Look in EC2 > Load Balancers: a new one exists. Your YAML file made a real AWS resource. Use this address wherever this page shows EKS_ALB.

  9. Test it over HTTPS

    Terminal
    curl -k https://EKS_ALB/orders
    PartWhat it means
    -kaccept the self-signed certificate
    https://EKS_ALB/ordersthe orders page, through the EKS load balancer
    You should see

    The same three orders, now served from EKS.

    Terminal
    for i in 1 2 3 4 5 6; do echo "request $i"; curl -sk https://EKS_ALB/; echo; done
    PartWhat it means
    the loopthe same six-request loop as on ECS, against the EKS load balancer
    You should see

    served_by switches between the two pod names.

You should see

Checkpoint: kubectl get pods shows two ready pods, and curl -k https://EKS_ALB/orders returns the orders.

Release v2 on EKS under load

  1. Release v2 while traffic runs

    Window 1:

    Terminal
    bash load.sh https://EKS_ALB/ 240
    PartWhat it means
    240four minutes of requests against the EKS load balancer

    Window 2:

    Terminal
    while true; do curl -sk https://EKS_ALB/; echo; sleep 1; done
    PartWhat it means
    the loopone request a second, showing which version answered, until Ctrl+C

    Window 3: release.

    Terminal
    kubectl set image deployment/orders app=111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2
    PartWhat it means
    kubectl set imagechange the image a deployment uses, which starts a rolling release
    deployment/orderswhich deployment
    app=…:v2the container called app should now run v2
    You should see
    deployment.apps/orders image updated
    Terminal
    kubectl rollout status deployment/orders
    PartWhat it means
    kubectl rollout statuswait, showing progress, until the release finishes
    You should see

    Progress lines, ending with deployment "orders" successfully rolled out. Window 2 moves from v1 to v2. Write down window 1’s totals. The target is failed: 0.

  2. Go back with one command

    Terminal
    kubectl rollout undo deployment/orders
    PartWhat it means
    kubectl rollout undogo back to the previous version of the deployment
    You should see
    deployment.apps/orders rolled back
    You should see

    Within about a minute, window 2 shows v1 again. Nothing was rebuilt: Kubernetes kept the previous version’s recipe.

  3. Release something broken

    Terminal
    kubectl set image deployment/orders app=111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v9-broken
    PartWhat it means
    :v9-brokenan image tag that doesn’t exist
    Terminal
    kubectl get pods
    PartWhat it means
    kubectl get podslist the pods
    You should see

    One new pod with STATUS ErrImagePull or ImagePullBackOff. The two old pods keep running and serving. Kubernetes stops and waits for you; it doesn’t go back by itself.

    Terminal
    kubectl rollout undo deployment/orders
    PartWhat it means
    kubectl rollout undogo back to the last good version

    That difference goes in your recommendation: ECS noticed the failure and went back by itself after a few minutes. Kubernetes kept the old pods safe but waited for a person.

You should see

Checkpoint: a second written total, from the EKS release, and a note of how long rollout undo took.

Prove it

You now have numbers. Put them in one table, then write the recommendation.

What you measuredECS on FargateEKS
Time to create the clusterabout 1 secondyour time, usually 10 to 15 minutes
Steps to connect a load balancerone dropdown in the service forma policy, a role, a Pod Identity association, a Helm install and an Ingress
Release under load: requests, failedyour numbers. Target: 0 failedyour numbers. Target: 0 failed
Undoing a broken releaseautomatic, a few minutesone command, seconds, but someone has to run it
Settings you added for zero downtimeMin 100%, 30-second drain, CMD in bracketsthose three, plus the readiness gate and the preStop wait
Cost before any app runs$0about $73 a month, plus the nodes
Servers you look afternonetwo nodes, and a Kubernetes upgrade every few months

Zero is the pass mark. If either release showed failures, read the failure lines in window 1: the times tell you which part of the release they happened in. The Troubleshooting section covers the usual causes.

The recommendation

QuickCart asked a question: should new services go on ECS or EKS? Answer it in writing, with your numbers. Use this shape. It works because it ends with what would change your mind, which shows the answer comes from facts rather than taste.

recommendation.md
Recommendation: ECS or EKS for new QuickCart services.

Why, in one sentence: the single deciding reason.

What we measured:
  Release under load on ECS: N requests, N failed.
  Release under load on EKS: N requests, N failed.
  Cluster creation: ECS about 1 second; EKS N minutes.
  Undoing a bad release: ECS automatic in N minutes; EKS one command in N seconds.

Operational burden: what each platform makes the team look after.
Cost at QuickCart's size: fixed monthly cost, and how it compares to the rest of the bill.
Team readiness: who knows each platform today, and how long learning the other takes.
Migration risk: what running two platforms at once would cost in releases and debugging.

The strongest argument against this, and our answer to it.

What would change our mind: for example, a real plan to run outside AWS,
a team large enough to own Kubernetes, or a tool the service needs that only one platform has.

Before you call it done, argue the other side. If you recommend ECS, expect “Kubernetes is the industry standard and easier to hire for” and “EKS stops us being locked in to AWS”. If you recommend EKS, expect “now we run two platforms; who looks after both?” and “which Kubernetes feature does this service actually need?”. Write down the strongest one and your answer. ECS or EKS? has more on weighing them.

Troubleshooting

Every one of these happened to someone building this project. Most are small settings, not code, which is true of real outages too. The skill is knowing where to look: the stopped reason, then the target health, then the network.

Clean up

EKS first: it’s the expensive one. And delete the app from EKS before the cluster, or its load balancer is left behind, still billing, with nothing to delete it.

  1. Delete the app from EKS

    Terminal
    kubectl delete -f orders.yaml
    PartWhat it means
    kubectl delete -f orders.yamldelete everything the file describes. The controller then deletes the load balancer it built
    You should see
    deployment.apps "orders" deleted
    service "orders" deleted
    ingress.networking.k8s.io "orders" deleted

    Wait two minutes, then check EC2 > Load Balancers: the k8s-default-orders- one must be gone before you go on. If it’s still there after five minutes, delete it by hand.

  2. Delete the EKS nodes and cluster

    In the AWS console: EKSClustersquickcart-eksComputequickcart-nodesDelete

    Type the node group’s name to confirm. Wait until it’s gone (about five minutes); the cluster can’t be deleted while it has a node group.

    In the AWS console: EKSClustersquickcart-eksDelete cluster

    Type the cluster’s name to confirm. The Pod Identity association is deleted with it.

  3. Delete the EKS roles and policy

    In the AWS console: IAMRoles

    Delete quickcart-lbc-role, and the cluster and node roles the console created for you (their names start with AmazonEKS). Then in IAM > Policies, delete quickcart-lbc-policy.

  4. Delete the ECS side, in this order

    OrderWhereWhat to do
    1ECS > Clusters > quickcart-cluster > Services > ordersDelete service, and tick Force delete so it stops the tasks for you
    2ECS > Clustersdelete quickcart-cluster
    3EC2 > Load Balancersdelete quickcart-ecs-alb
    4EC2 > Target Groupsdelete quickcart-ecs-tg
    5EC2 > Security Groupsdelete quickcart-app-sg, then quickcart-alb-sg. If one refuses, wait a minute; something is still letting go of it
    6ECS > Task definitions > quickcart-ordersderegister every revision, then delete them
    7ECR > Repositoriesdelete quickcart-orders. Project 4 creates it again in code
    8CloudWatch > Log groupsdelete /ecs/quickcart-orders
    9Certificate Managerdelete the imported certificate
    10VPC > Subnetsoptional: remove the kubernetes.io/role/elb tag from both subnets
  5. Delete the key from your computer

    Terminal
    rm -f key.pem key-rsa.pem cert.pem
    PartWhat it means
    rm -fdelete files, with no error if one of them doesn’t exist
    key.pem key-rsa.pem cert.pemthe certificate and both copies of its private key
  6. Clean-up step: Clean up

    Check nothing is left billing:

    WhereShould show
    EKS > Clustersnothing
    ECS > Clustersnothing
    EC2 > Load Balancersnothing from this project
    EC2 > Instancesno running instances from this project
    EC2 > Network Interfacesnothing with a description mentioning ELB or aws-K8S
    VPC > Your VPCs > default VPC > Resource mapno leftover load balancer or instance
    You should see

    All empty. The next day, Billing and Cost Management > Bills should show EKS, EC2 and Elastic Load Balancing charges stopping on the day you deleted them.

Built it, and cleaned up? Mark it done.