Laptop to Production

Releases without downtime

Why requests get dropped, and the three fixes.

Time
60 minutes
Level
Intermediate
Read first
ECS on Fargate

Part 1: The problem

QuickCart, release day

QuickCart releases the order service every few days. Each time, the same thing happens. For about 30 seconds, some customers tapping Place order get an error. Theo, who is on call, watches the error graph jump every release, then settle again.

Nobody changed any code that was broken. The new version works. The old version worked. The errors happen in between, while one is replacing the other.

One Friday a release goes out that really is broken. It takes forty minutes to notice, find the previous version, and put it back.

QuickCart wants to release in the middle of a busy lunchtime with zero failed requests, and to undo a broken release in minutes without anyone having to be awake. This lesson finds the three moments where requests get dropped, closes each one, and proves it with a count.

Part 2: The idea

A rolling deployment replaces the old copies of your app with new ones, a few at a time, while the service keeps running. On ECS, pointing a service at a new task definition revision starts one.

It looks seamless from a distance. Close up, every task goes through a life: it starts, becomes ready, serves traffic, is removed from the load balancer, and is stopped. Requests fail at the edges of that life.

Key point: A release drops requests at three moments: when new copies start, when old ones leave the load balancer, and when they’re stopped. Each needs its own fix.

The three moments, and the three fixes

MomentWhat goes wrongThe fix
A new task startsThe load balancer sends it real requests before the app can answer themA health check that only passes when the app is ready, and a health check grace period so a slow starter isn’t killed
An old task leaves the load balancerRequests it’s still answering are cut off, and the load balancer takes a few seconds to stop sending new onesA deregistration delay: stop sending new requests, and let the ones in progress finish
An old task is stoppedThe app dies mid-request instead of finishingGraceful shutdown: the app hears the stop signal, finishes what it’s doing, then exits

Rolling settings: how many at once

Two numbers control how ECS swaps tasks, both as a percentage of the desired count. The console calls them Min running tasks % and Max running tasks %; in the ECS API they’re minimumHealthyPercent and maximumPercent, part of the service’s deployment configuration.

SettingMeaningWith 2 desired tasks
Min running tasks 100%Never fewer than 100% healthy tasks during a releaseat least 2 healthy, all the time
Max running tasks 200%Never more than 200% tasks in totalup to 4 at once, so 2 new ones can start beside the 2 old ones
Min 50% / Max 100%Remove before adding: cheaper, but capacity halves during the release1 healthy at the worst moment

100 and 200 is the safe choice. New tasks start first, and old ones are only removed once the new ones are healthy. You briefly pay for four tasks instead of two.

Moment 1: starting, and being ready

The load balancer decides whether a target gets traffic from its health check. So the health check must answer one question honestly: can this copy serve an order right now? QuickCart’s /health only answers once gunicorn and the app have loaded. A check that only tests whether a port is open can pass while the app is still loading.

A separate setting protects slow starters. The health check grace period tells ECS to ignore failed load balancer health checks for the first few seconds of a task’s life. Without it, a task that takes 40 seconds to warm up is marked unhealthy and replaced, over and over, and the release never finishes.

SettingWhereQuickCart usesToo lowToo high
Health check pathTarget group/health——
Healthy threshold × intervalTarget group2 × 10 secondsA flaky check lets a bad task inEvery release waits longer
Health check grace periodECS service30 secondsSlow starters are killed and replaced in a loopA task that really is broken takes longer to be replaced

Moment 2: leaving the load balancer

When ECS removes a task, it first deregisters it from the target group. The load balancer stops sending it new requests, but lets the ones already in progress carry on for up to the deregistration delay. When they’ve finished, or the delay runs out, ECS stops the task.

The default delay is 300 seconds. That’s safe, but it makes every release wait five minutes per batch of tasks. QuickCart’s slowest request takes a few seconds, so 30 seconds is plenty. Choose a number longer than your slowest normal request.

Deregistration delayWhat happens
0The task is stopped almost at once, while requests are in progress and while the load balancer is still catching up
30 secondsIn-progress requests finish; releases take about 30 seconds per batch longer
300 seconds (the default)Safe, but slow releases

Moment 3: stopping

To stop a task, ECS sends its container SIGTERM, the polite “please stop” signal. It then waits for the stop timeout, 30 seconds by default on Fargate and at most 120, and if the process is still running it sends SIGKILL, which can’t be refused.

A well-behaved app hears SIGTERM, stops accepting new work, finishes what it’s doing and exits. gunicorn does this: --graceful-timeout 20 gives its workers up to 20 seconds to finish. That has to be shorter than the stop timeout, or gunicorn is killed before it’s done.

Dockerfile lineWho receives SIGTERMResult
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]gunicorn itselfIt finishes its requests and exits, usually in under a second
CMD gunicorn --bind 0.0.0.0:8080 app:appa shell, /bin/sh -c, which runs gunicornDepending on the shell, gunicorn may never hear it. It’s killed after the stop timeout, mid-request if it has one

The square brackets are the exec form. The Dockerfile lesson covers it in full.

When a release is broken: the circuit breaker

If the new version can’t start, or keeps failing its health checks, ECS would keep trying for ever. Meanwhile the old tasks are still serving, because the new ones never became healthy, so customers see nothing. But the release is stuck, and someone has to notice.

The deployment circuit breaker notices for you. After a number of failed tasks, which depends on the desired count and is at least a few, it marks the release as failed. With rollback turned on, it returns the service to the last deployment that completed successfully.

Changing the database during a release

During a rolling release, old and new versions run at the same time, against the same database. So a database change must not break the old version. Do it in three separate releases:

ReleaseChangeWhy it’s safe
1Add the new columnThe old code ignores a column it doesn’t know
2Release code that uses the new columnBoth versions work with the new database
3Remove the old column, once nothing uses itNo running version needs it any more

Renaming or deleting a column in one step breaks the version that’s still running, instantly.

Part 3: See it

Below, QuickCart releases v2 while customers send about ten requests a second. First release with all three fixes on. Then switch one fix off at a time, release again, and watch the failed count.

Readiness check
Drain (deregistration delay)
Graceful shutdown
Customers send requests to the load balancer, which forwards them to the order service's tasks: two v1 tasks now, and room for two v2 tasks during the release.Customers10 requests a secondLoad balancerquickcart-albOld versionNew versionv1 task Ahealthy, in servicev1 task Bhealthy, in servicev2 task Anot startedv2 task Bnot started
Sent 0OK 0Failed 0
    Before the releaseTwo v1 tasks share about ten requests a second. Set the three fixes, then choose Release v2.

    What to notice: each fix protects one moment, and the failures it prevents are different. Readiness protects the start, the drain protects the handover, and graceful shutdown protects the stop. And the results don’t simply add up: with the drain on, a missing graceful shutdown got away with it. On Kubernetes, where removal and stopping happen at the same moment, it wouldn’t.

    Part 4: Do it

    You’ll release v2 of the order service while a script sends it traffic, and count the failed requests. Then you’ll switch one fix off and see the difference, and release a broken version to watch the circuit breaker undo it. About 45 minutes.

    This lab uses the service from ECS on Fargate: the quickcart cluster, the order-service service with 2 tasks, quickcart-alb and quickcart-orders-tg. If you deleted it, rebuild it with that lesson first. You also need Docker and your order-service folder from Storing images: ECR, and Python 3, which macOS and Ubuntu (including Ubuntu on WSL) already have.

    1. Check the service is healthy

      In the AWS console: Amazon Elastic Container ServiceClustersquickcartServicesorder-service

      You should see

      2/2 tasks running, and one deployment, marked Completed. In EC2 → Target groups → quickcart-orders-tg → Targets, two healthy targets.

      Copy the load balancer’s DNS name from EC2 → Load balancers → quickcart-alb. It looks like quickcart-alb-1234567890.us-east-1.elb.amazonaws.com. You’ll use it several times.

    2. Check the three fixes are in place

      Check each setting where it lives. The ECS lab set most of them already.

      FixWhere to lookShould be
      ReadinessEC2 → Target groups → quickcart-orders-tg → Health checksPath /health, healthy threshold 2, interval 10 seconds
      Grace periodECS → order-service → Configuration and networking (or the service’s Update form)Health check grace period 30 seconds
      DrainEC2 → Target groups → quickcart-orders-tg → AttributesDeregistration delay 30 seconds
      Graceful shutdownYour DockerfileThe last line is CMD ["gunicorn", followed by the rest, with square brackets

      Then check the rolling settings and turn on the circuit breaker:

      In the AWS console: Amazon Elastic Container ServiceClustersquickcartServicesorder-serviceUpdate service

      Open Deployment options. The console moves sections around from time to time, so find each field by its name:

      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

      Choose Update. Some consoles tick the circuit breaker for new services already; if so, leave it on.

      You should see

      A new deployment starts and completes within a few minutes, with the same revision. Changing service settings is itself a small rolling release.

    3. Write the load test

      You need a way to count failed requests during a release, not a feeling that “it looked fine”. In your order-service folder, create load.py:

      load.py
      import sys
      import time
      import urllib.error
      import urllib.request
      
      url = sys.argv[1]
      seconds = int(sys.argv[2]) if len(sys.argv) > 2 else 60
      
      ok = 0
      failed = 0
      end = time.time() + seconds
      
      while time.time() < end:
          try:
              with urllib.request.urlopen(url, timeout=3) as response:
                  response.read()
              ok += 1
          except urllib.error.HTTPError as err:
              failed += 1
              print(time.strftime("%H:%M:%S"), "FAILED with HTTP", err.code)
          except (urllib.error.URLError, OSError) as err:
              failed += 1
              reason = getattr(err, "reason", err)
              print(time.strftime("%H:%M:%S"), "FAILED:", reason)
          time.sleep(0.1)
      
      print("----")
      print("requests:", ok + failed, "  ok:", ok, "  failed:", failed)
      LineWhat it does
      import linesload parts of Python’s standard library. Nothing to install
      url = sys.argv[1]the first thing you type after load.py: the address to test
      seconds = int(sys.argv[2])the second thing you type: how long to run. If you leave it out, 60 seconds
      while time.time() < end:keep going until the time is up
      urllib.request.urlopen(url, timeout=3)fetch the page. Give up after 3 seconds, and count that as a failure
      ok += 1the page came back with a success code, so add one to ok
      except urllib.error.HTTPErrorthe load balancer answered with an error code, like 502 or 503. Count it and print the time and code
      except (urllib.error.URLError, OSError)no answer at all: refused, reset or timed out. Count it and print why
      time.sleep(0.1)wait a tenth of a second, so it sends roughly ten requests a second
      the last two print linesthe totals, when the time is up

      Try it for 10 seconds. Put your DNS name in place of ALB_DNS_NAME, keeping http:// in front and / after it:

      Terminal
      python3 load.py http://ALB_DNS_NAME/ 10
      PartWhat it means
      python3 load.pyrun the script with Python 3
      http://ALB_DNS_NAME/the page to keep asking for: the home page, through the load balancer
      10run for 10 seconds
      You should see
      ----
      requests: 84   ok: 84   failed: 0

      Your number of requests will differ. It depends on how long each round trip to us-east-1 takes from where you are. What matters is failed: 0.

    4. Build and push v2

      The repository is immutable, so v2 needs a new tag. In your order-service folder:

      Terminal
      docker build --platform linux/amd64 --provenance=false --build-arg APP_VERSION=v2 -t order-service:v2 .
      PartWhat it means
      docker buildbuild an image from the Dockerfile in this folder
      --platform linux/amd64for the x86 processors the task definition asks for
      --provenance=falsea single plain image, so ECR shows one row for it
      --build-arg APP_VERSION=v2stamp this image as v2. The app prints it, so you can see which version answered
      -t order-service:v2name it order-service, tag v2
      .use the files in this folder

      Give it its ECR name, with your account ID:

      Terminal
      docker tag order-service:v2 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2
      PartWhat it means
      docker taggive the image a second name. Nothing is copied
      order-service:v2the image you just built
      the long name after itthe new name: your registry (111122223333.dkr.ecr.us-east-1.amazonaws.com), the repository (quickcart-orders) and the tag (v2)
      Terminal
      docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2
      PartWhat it means
      docker pushupload the image to ECR
      You should see

      Most layers say Layer already exists: only the layers that changed are uploaded. It ends with a line containing v2: digest: sha256:. If it says no basic auth credentials, your ECR login has expired after 12 hours: log in again as in Storing images: ECR.

    5. Make revision 2 of the task definition

      In the AWS console: Amazon Elastic Container ServiceTask definitionsorder-serviceCreate new revisionCreate new revision with JSON

      Replace everything in the editor with this. It’s revision 1 with two changes: the image tag is now v2, and stopTimeout is written out, so everyone can see how long a task gets to stop. Put your account ID in both highlighted places.

      order-service-task.json
      {
        "family": "order-service",
        "requiresCompatibilities": ["FARGATE"],
        "networkMode": "awsvpc",
        "cpu": "256",
        "memory": "512",
        "runtimePlatform": {
          "operatingSystemFamily": "LINUX",
          "cpuArchitecture": "X86_64"
        },
        "executionRoleArn": "arn:aws:iam::111122223333:role/ecsTaskExecutionRole",
        "containerDefinitions": [
          {
            "name": "app",
            "image": "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2",
            "essential": true,
            "stopTimeout": 30,
            "portMappings": [
              { "containerPort": 8080, "protocol": "tcp" }
            ],
            "logConfiguration": {
              "logDriver": "awslogs",
              "options": {
                "awslogs-group": "/ecs/order-service",
                "awslogs-region": "us-east-1",
                "awslogs-stream-prefix": "ecs"
              }
            }
          }
        ]
      }
      SettingWhat it doesIf you leave it outWhen you’d change it
      imageNow ends in :v2, the image you just pushed—Every release
      stopTimeoutSeconds between SIGTERM and SIGKILL. Must be longer than gunicorn’s --graceful-timeout 2030 seconds on Fargate, the same as here, but invisible to anyone reading the fileUp to 120 on Fargate, if requests can legitimately take longer

      Every other setting is explained in ECS on Fargate. Choose Create.

      You should see

      order-service:2. Revision 1 is still there, unchanged. That’s what makes going back easy.

    6. Release v2 under load

      Open two terminal windows, both in your order-service folder.

      Window 1: start the load test for 4 minutes:

      Terminal
      python3 load.py http://ALB_DNS_NAME/ 240
      PartWhat it means
      240run for 240 seconds: long enough to cover the whole release

      Window 2: watch which version answers, once a second:

      Terminal
      while true; do curl -s ALB_DNS_NAME/; echo; sleep 1; done
      PartWhat it means
      while true; do … donerepeat everything between do and done, for ever, until you press Ctrl+C
      curl -s ALB_DNS_NAME/fetch the home page, which says which version answered. -s hides curl’s progress output
      echoprint a new line, so each answer is on its own line
      sleep 1wait one second before the next one

      Now start the release, in the console:

      In the AWS console: Amazon Elastic Container ServiceClustersquickcartServicesorder-serviceUpdate service

      FieldEnter
      Task definition revision2 (latest)

      Choose Update, then open the service’s Deployments tab to watch.

      You should see

      Window 2 shows "version":"v1", then a mix of v1 and v2, then only v2. On the Tasks tab, four tasks for a while, then two. After a few minutes the new deployment says Completed.

      When window 1 finishes:

      You should see
      ----
      requests: 1968   ok: 1968   failed: 0

      Your number of requests will differ. Write the numbers down: this is your evidence. Press Ctrl+C in window 2 to stop it.

    7. Switch the drain off, and release again

      Now remove one fix to see it matter. Set the deregistration delay to zero:

      In the AWS console: EC2Target groupsquickcart-orders-tgAttributesEdit

      FieldEnter
      Deregistration delay0 seconds

      Choose Save changes. Start load.py for 240 seconds again in window 1, then release revision 1 (v1) the same way as the last step. Going back to an older revision is a release like any other.

      You should see

      Some runs show a handful of lines like FAILED with HTTP 502 while the old tasks are stopped, and a failed: total above zero. How many depends on timing and on how busy each task is; a run can occasionally get lucky and show none. With the delay at 30, the same release should show zero.

      Put it back: set the deregistration delay to 30 seconds again.

    8. Release a broken version

      Make a revision that can’t possibly start. Open Task definitions → order-service, tick revision 2, and choose Create new revision (the normal form this time). In the container’s Image URI, change the tag at the end to v9-broken. There’s no such image. Choose Create to make revision 3.

      Start load.py for 600 seconds in window 1, then update the service to revision 3. Watch the Deployments and Events tabs.

      You should see

      New tasks start, stop, and are replaced, several times. After a few minutes, the new deployment is marked as failed and a rollback deployment appears, back to the last one that completed. The events mention the circuit breaker and the rollback; the exact wording varies. Through all of it, load.py shows no failures: the old tasks were never removed, because the new ones never became healthy.

      Find out why the tasks failed. On the Tasks tab, change the filter to Stopped and open one of the failed tasks.

      Its stopped reason starts roughly like this
      CannotPullContainerError: pull image manifest has been retried

      The rest of the message says the v9-broken tag was not found. There are no app logs, because the app never started. When a task won’t start, the stopped reason is always the first place to look.

      Finally, remove the broken revision so nobody picks it by accident: tick order-service:3 in Task definitions, then Actions → Deregister.

    9. Clean-up step: Clean up

      Everything from ECS on Fargate is still running. Delete it all now, in this order.

      1. The service. ECS → Clusters → quickcart → Services → order-service → Delete service. If the console asks, tick Force delete. Wait until the service and its tasks are gone.

      2. The cluster. ECS → Clusters → quickcart → Delete cluster, then type the confirmation text.

      3. The task definition. Task definitions → order-service: tick every revision, choose Actions → Deregister, then with them still ticked, Actions → Delete.

      4. The load balancer. EC2 → Load balancers: tick quickcart-alb, Actions → Delete load balancer, type confirm.

      5. The target group. EC2 → Target groups: tick quickcart-orders-tg, Actions → Delete.

      6. The security groups. EC2 → Security Groups: delete quickcart-app-sg first, then quickcart-alb-sg. If one won’t delete, wait a minute and try again.

      7. The log group. CloudWatch → Log groups: tick /ecs/order-service, Actions → Delete log group(s).

      8. The image repository, unless you’re going on to Kubernetes and EKS, which uses the same images: Amazon ECR → Repositories, tick quickcart-orders, Delete, and type delete.

      You should see

      ECS → Clusters, EC2 → Load balancers and EC2 → Target groups are all empty. Nothing from this lab is still billing, except the ECR repository if you kept it, which costs pennies a month.

    Part 5: Check yourself

    During a release, new tasks get traffic while the app inside is still loading, and those requests fail. Which fix is missing?

    A service has 2 tasks, Min running tasks 100% and Max running tasks 200%. What does ECS do first when you release a new version?

    What does the deregistration delay give a task that's being removed?

    A Dockerfile ends with CMD gunicorn --bind 0.0.0.0:8080 app:app, with no square brackets. Why can that hurt releases?

    A new version can't start at all. Its tasks keep failing. What does the deployment circuit breaker with rollback do?

    Part 6: Cheat sheet

    On one card
    Setting or ideaWhat to remember
    Rolling deploymentReplace copies a few at a time while the service keeps running
    Min 100% / Max 200%Start new tasks before removing old ones. Capacity never drops
    Health checkMust only pass when the app can really serve a request
    Health check grace periodIgnore failed checks while a task starts. Longer than your real start-up time
    Deregistration delayLet in-progress requests finish. Longer than your slowest request; QuickCart uses 30 seconds, the default is 300
    SIGTERM, then SIGKILL“Please stop”, then after the stop timeout “stop now”
    stopTimeout30 seconds by default on Fargate, 120 at most. Longer than the app’s own graceful timeout
    Exec form CMD ["gunicorn", …]The app itself receives SIGTERM
    Circuit breaker + rollbackA release that keeps failing goes back to the last good one by itself
    Stopped reasonThe first place to look when a task won’t start
    Database changesAdd, then use, then remove: three releases, never one
    ProofCount failed requests during a release. Target: 0

    Finished the build and the quiz? Mark it done to fill in its stop on the route map.