Project 3: One service, two runtimes
The same container on ECS and EKS, released with zero failures.
| What you build | The same container on ECS Fargate and on EKS, both behind HTTPS load balancers. |
| What it proves | A 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
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.
| Piece | ECS side | EKS side |
|---|---|---|
| The image | quickcart-orders:v1 and v2 in ECR | the same image |
| Load balancer | quickcart-ecs-alb, created by you, HTTPS on 443 | created by the AWS Load Balancer Controller from an Ingress |
| What keeps two copies running | an ECS service with 2 tasks | a Kubernetes Deployment with 2 pods |
| Where they run | Fargate: no servers in your account | 2 t3.small EC2 nodes in your account |
| Network | the default VPC, two zones | the same default VPC and zones |
| Certificate | one self-signed certificate in ACM | the 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.
| Read | Why you need it here |
|---|---|
| Images and containers and Writing a Dockerfile | You build the image in the first stage |
| Storing images: ECR | Immutable tags and scanning |
| Security groups and Load balancers | The load balancer to app chain |
| Domain names and HTTPS | Why the certificate here is self-signed |
| ECS on Fargate and Releases without downtime | Every ECS setting you choose |
| Kubernetes and EKS | Pods, 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.
| Tool | Used for |
|---|---|
| AWS CLI | ECR login, and connecting kubectl to the cluster |
| Docker | building and running the image |
curl and openssl | testing, and making the certificate. Both come with macOS and Ubuntu, including Ubuntu on WSL |
kubectl | talking to the Kubernetes cluster |
helm | installing the load balancer controller into the cluster |
Install the last two with Homebrew:
brew install kubectl helm| Part | What it means |
|---|---|
brew install | install programs with Homebrew |
kubectl | the command-line tool for any Kubernetes cluster |
helm | installs packaged add-ons, called charts, into a cluster |
Check your AWS login works in the terminal:
aws sts get-caller-identity| Part | What it means |
|---|---|
aws sts get-caller-identity | ask AWS which identity your terminal is using |
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
Costs money — about $6 a day while both runtimes run, and about $2.50 a day of that is EKS. Delete it the same day.
| What | Price 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.small | about $0.021 an hour each | $1.00 |
| 2 Application Load Balancers | about $0.0225 an hour each, plus a little per unit of traffic | $1.10 |
| 2 Fargate tasks, 0.25 vCPU and 0.5 GB each | about $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 logs | a few cents | pennies |
| The imported certificate in ACM | free | $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.
Create the three files
Make a folder called
quickcart-ordersand open it in your editor. Create these three files in it.app.pyimport 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)Page What it returns Who uses it /healthokand the versionThe load balancer, to decide whether a copy may get traffic /the version, and the name of the copy that answered You, to see which version and which copy served you /ordersthree example orders You, to check the app really works requirements.txtflask==3.1.3 gunicorn==23.0.0Line What 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 DockerfileFROM 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"]Line What it does Why it matters in this project FROM python:3.12-slimstart from a small Linux with Python small 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 .thenRUN pip installinstall the libraries before copying the code a code change reuses the cached library layer, so builds take seconds COPY app.py .copy the code in the part that changes most, so it goes last RUN useraddandUSER 1001create a normal user and run as it a break-in only gets a normal user’s power ARGandENV APP_VERSIONstamp a version into the image when you build it this is how you tell v1 from v2 during a release EXPOSE 8080a note that the app listens on 8080 only root may use ports below 1024 CMD [...]in square bracketsstart gunicorn directly, with no shell in between gunicorn 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 stop shorter than the 30-second drain you set later Build the image
In a terminal, go into the folder, then build:
Terminalcd quickcart-ordersPart What it means cd quickcart-ordersmove into the project folder. Every later command runs here Terminaldocker build --platform linux/amd64 --provenance=false -t quickcart-orders:v1 .Part What it means docker buildmake an image from the Dockerfile --platform linux/amd64build for Intel and AMD processors, which is what your Fargate tasks and t3nodes 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, tagv1.use the files in this folder You should seeA list of build steps, ending with a line that names the image
quickcart-orders:v1.Run it on your computer
Terminaldocker run -d -p 8080:8080 --name orders quickcart-orders:v1Part What 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 ordersso later commands can use the namequickcart-orders:v1the image to start You should seeA 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.
Terminalcurl localhost:8080/healthPart What 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:
Terminaldocker exec orders whoamiPart What it means docker execrun a command inside a running container orderswhich container whoamiprint the name of the current user You should seeappuserStop and remove it:
Terminaldocker rm -f ordersPart What it means docker rmdelete a container -fstop it first if it’s running orderswhich container You should seeorders
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.
Create the repository
In the AWS console: ECRPrivate registryRepositoriesCreate repository
Field Enter Repository name quickcart-ordersImage tag mutability Immutable Scan on push On. It may sit under Image scanning settings Encryption leave the default, AES-256 Choose Create.
Setting Why Immutable once v1is pushed, nothing can replace it.v1always means the same code, which is what makes going back safeScan on push every image is checked for known security problems as it arrives Log Docker in to ECR
Terminalaws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.comPart What 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 seeLogin SucceededThe login lasts 12 hours.
Tag and push v1
Docker needs the image named with the registry’s full address.
docker taggives it a second name; it copies nothing.Terminaldocker tag quickcart-orders:v1 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1Part What it means docker taggive an existing image another name quickcart-orders:v1the image you built the second argument the new name: your registry’s address, then /quickcart-orders:v1It prints nothing when it works.
Terminaldocker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1Part What it means docker pushupload an image to a registry the address which image, by its full name You should seeEach layer uploads, then a last line starting
v1: digest: sha256:.Read the scan
In the AWS console: ECRRepositoriesquickcart-ordersv1Vulnerabilities
Give it a minute after the push.
You should seeA count of findings by severity. The slim Python image usually has a few low or medium findings and no critical ones.
Checkpoint: quickcart-orders in ECR holds one image tagged v1, with a finished scan.
Network, security groups and the certificate
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 down Where 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-1aThe subnet ID in us-east-1bthe row for us-east-1bTag 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
Field Enter Key kubernetes.io/role/elbValue 1Choose Save, then do the same on the
us-east-1bsubnet.Create the load balancer's security group
In the AWS console: EC2Security GroupsCreate security group
Field Enter Security group name quickcart-alb-sgDescription QuickCart load balancerVPC the default VPC Inbound rule type Port Source HTTPS 443 Anywhere-IPv4 HTTP 80 Anywhere-IPv4 Leave the outbound rule as it is. Choose Create security group.
Create the app's security group
In the AWS console: EC2Security GroupsCreate security group
Field Enter Security group name quickcart-app-sgDescription QuickCart order service tasksVPC the default VPC Inbound rule type Port Source Custom TCP 8080Custom, then choose quickcart-alb-sgLeave the outbound rule allowing everything: the tasks need it to download their image.
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-ordersfolder:Terminalopenssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj "/CN=orders.quickcart.test"Part What 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 seeTwo new files in the folder:
cert.pemandkey.pem.Import it into Certificate Manager
Print each file so you can copy it:
Terminalcat cert.pemPart What it means catprint a file cert.pemthe certificate You should seeText starting
-----BEGIN CERTIFICATE-----and ending-----END CERTIFICATE-----. Do the same withcat key.pem.In the AWS console: Certificate ManagerImport
Field Enter Certificate body all of cert.pem, including theBEGINandENDlinesCertificate private key all of key.pem, including theBEGINandENDlinesCertificate chain leave empty Choose Import certificate.
You should seeThe 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.
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
Create the target group
In the AWS console: EC2Target GroupsCreate target group
Field Enter Target type IP addresses Target group name quickcart-ecs-tgProtocol : Port HTTP : 8080VPC the default VPC Health check path /healthHealthy threshold (under Advanced health check settings) 2Unhealthy threshold 2Interval 10secondsChoose Next. Don’t register any targets; ECS adds them itself. Choose Create target group.
Setting Why IP addresses a 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 2 the default of 5 makes each new task wait 50 seconds for traffic; 2 makes it 20 Shorten the drain
In the AWS console: EC2Target Groupsquickcart-ecs-tgAttributesEdit
Field Enter Deregistration delay 30secondsChoose 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.
Create the load balancer
In the AWS console: EC2Load BalancersCreate load balancerApplication Load BalancerCreate
Field Enter Load balancer name quickcart-ecs-albScheme Internet-facing Load balancer IP address type IPv4 VPC the default VPC Availability Zones tick us-east-1aandus-east-1bSecurity groups quickcart-alb-sgonly. RemovedefaultUnder Listeners and routing, change the first listener, then add a second:
Listener Default action HTTPS : 443 Forward to quickcart-ecs-tgHTTP : 80 Redirect to URL: HTTPS, port 443, status code 301Adding the HTTPS listener shows Secure listener settings:
Field Enter Security policy leave the recommended default Default SSL/TLS server certificate From ACM, then choose the certificate you imported Choose Create load balancer. It takes a few minutes to become Active.
Create the ECS cluster
In the AWS console: ECSClustersCreate cluster
Field Enter Cluster name quickcart-clusterInfrastructure Fargate only Choose Create.
You should seeThe cluster in the list within seconds. Look in EC2 > Instances: nothing new. On Fargate a cluster is just a name.
Create the task definition
In the AWS console: ECSTask definitionsCreate new task definition
Field Enter Task definition family quickcart-ordersLaunch type AWS Fargate Operating system/Architecture Linux/X86_64, to match --platform linux/amd64CPU .25 vCPUMemory .5 GBTask role none Task execution role Create new role, or ecsTaskExecutionRoleif it’s already listedContainer name appImage URI 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v1, with your account IDContainer port 8080, protocol TCP, app protocol HTTPLog collection leave Use log collection ticked Choose Create.
You should seequickcart-orders:1: revision 1 of the recipe. Every change you make creates a new revision, and the old ones stay exactly as they were.Role Used by This app Execution role ECS itself, before your code starts: to pull the image and write logs needs one Task role your code while it runs, to call AWS services needs none; the app never calls AWS Create the service
In the AWS console: ECSClustersquickcart-clusterServicesCreate
Field Enter Compute options Launch type, then FARGATE Task definition family quickcart-orders, revision LATESTService name ordersDesired tasks 2Under Deployment options:
Field Enter Deployment type Rolling update Min running tasks % 100Max running tasks % 200Deployment failure detection tick Use the Amazon ECS deployment circuit breaker and Rollback on failures Under Networking:
Field Enter VPC the default VPC Subnets only the us-east-1aandus-east-1bonesSecurity group Use an existing security group: quickcart-app-sgonly. RemovedefaultPublic IP Turned on Under Load balancing:
Field Enter Load balancer type Application Load Balancer Load balancer Use an existing load balancer: quickcart-ecs-albContainer app 8080:8080Listener Use an existing listener: 443:HTTPSTarget group Use an existing target group: quickcart-ecs-tgHealth check grace period 30secondsChoose Create.
Setting In plain words Desired tasks 2 always 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 rollback if the new version keeps failing to start, stop and go back by itself Public IP on lets each task reach ECR to download its image, because there’s no NAT gateway You should seeAfter one to three minutes, the service shows 2/2 tasks running. In EC2 > Target Groups > quickcart-ecs-tg > Targets, two IP addresses are Healthy.
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 showsECS_ALB.Terminalcurl -k https://ECS_ALB/ordersPart What 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 seeThe three orders, with
"version":"v1".Now ask six times in a row, to see the load balancer share the work:
Terminalfor i in 1 2 3 4 5 6; do echo "request $i"; curl -sk https://ECS_ALB/; echo; donePart What it means for i in 1 2 3 4 5 6; dorepeat what follows six times, with icounting 1 to 6echo "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 seeSix answers.
served_byswitches between two different names: the two tasks.Watch it heal itself
In the AWS console: ECSClustersquickcart-clusterServicesordersTasks
Tick one task and choose Stop, then Stop again to confirm.
You should seeThe 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.
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.
Create the load-testing script
In the
quickcart-ordersfolder, createload.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"Line What it does url="$1"andseconds="${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))SECONDSis bash’s own stopwatch. This works out when to stopwhile [ "$SECONDS" -lt "$end" ]keep going until that time code=$(curl ...)fetch the page and keep only the status code, like 200or502--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 000if [ "$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 echolinesprint the totals: your evidence Try it for ten seconds:
Terminalbash load.sh https://ECS_ALB/ 10Part What 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 seeAfter ten seconds, one line like
requests: 48 ok: 48 failed: 0. Your count depends on your connection; anything from 30 to 90 is normal.Build and push v2
Terminaldocker build --platform linux/amd64 --provenance=false --build-arg APP_VERSION=v2 -t quickcart-orders:v2 .Part What it means --build-arg APP_VERSION=v2stamp this image as v2. Nothing else changes-t quickcart-orders:v2name it with tag v2everything else the same as for v1 Terminaldocker tag quickcart-orders:v2 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2Part What it means docker taggive the v2 image its ECR name Terminaldocker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2Part What it means docker pushupload it You should seeMost layers say
Layer already exists. Only what changed is uploaded.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
:v1to:v2. Choose Create.You should seequickcart-orders:2. Revision 1 is still there, untouched. That’s what makes going back easy.Release while traffic runs
Open two terminal windows, both in the
quickcart-ordersfolder.Window 1: the load test, for four minutes.
Terminalbash load.sh https://ECS_ALB/ 240Part What it means 240run for four minutes, long enough to cover the whole release Window 2: watch which version answers.
Terminalwhile true; do curl -sk https://ECS_ALB/; echo; sleep 1; donePart What 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
Field Enter Revision 2Choose Update, and watch the service’s Deployments tab.
You should seeWindow 2 shows
v1, then a mix ofv1andv2, then onlyv2. When window 1 finishes, it prints its totals, likerequests: 1150 ok: 1150 failed: 0. Write the numbers down. They are your evidence.What happened Which setting made it safe ECS started two v2 tasks next to the two v1 tasks Max 200% It waited until the v2 tasks passed /healthbefore touching v1Min 100% It took the v1 tasks out of the target group and waited 30-second deregistration delay It told the v1 tasks to stop; gunicorn finished its requests and exited CMDin square bracketsRelease 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:
Terminalbash load.sh https://ECS_ALB/ 600Part What it means 600ten minutes, long enough for the circuit breaker to act Then update the
ordersservice to revision3, as before, and watch the Deployments and Events tabs.You should seeNew 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 seeA Stopped reason starting
CannotPullContainerErrorand mentioningnot 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
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.
Create the cluster
In the AWS console: EKSClustersCreate cluster
Field Enter Configuration options Custom configuration Use EKS Auto Mode Off. Auto Mode costs extra and manages nodes differently from this guide Name quickcart-eksCluster IAM role choose Create recommended role, accept the defaults in the new tab, then come back, refresh the list and pick it Kubernetes version leave the default. Older versions move to extended support, which costs six times as much Upgrade policy Standard Cluster access leave Allow cluster administrator access on On the Networking page:
Field Enter VPC the default VPC Subnets only the us-east-1aandus-east-1bsubnetsSecurity groups leave empty Cluster endpoint access Public 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 seeStatus 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.
Add the nodes
In the AWS console: EKSClustersquickcart-eksComputeAdd node group
Field Enter Name quickcart-nodesNode IAM role Create recommended role, accept the defaults, come back and pick it AMI type leave the default, Amazon Linux 2023 Instance types t3.smallonly. Remove any othersDisk size 20GiBDesired, minimum, maximum size 2,2,3Subnets only us-east-1aandus-east-1bChoose Create.
You should seeThe node group becomes Active in about five minutes. EC2 > Instances now shows two new instances. They’re yours, and on your bill.
Connect kubectl
The console can’t run
kubectl, so this part is always the terminal:Terminalaws eks update-kubeconfig --name quickcart-eks --region us-east-1Part What it means aws eks update-kubeconfigsave the cluster’s address and login method where kubectllooks for them--name quickcart-ekswhich cluster --region us-east-1where it is You should seeAdded new context arn:aws:eks:us-east-1:111122223333:cluster/quickcart-eks to /home/you/.kube/configTerminalkubectl get nodesPart What it means kubectl get nodeslist the servers in the cluster You should seeTwo nodes, each with STATUS
Ready.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:
Terminalcurl -o lbc-policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v3.5.0/docs/install/iam_policy.jsonPart What it means curl -o lbc-policy.jsondownload a file and save it as lbc-policy.jsonthe address the official policy for controller version 3.5.0 In the AWS console: IAMPoliciesCreate policyJSON
Open
lbc-policy.jsonin your editor, copy all of it, and paste it into the box, replacing what’s there. Choose Next.Field Enter Policy name quickcart-lbc-policyChoose Create policy. Now the role:
In the AWS console: IAMRolesCreate role
Field Enter Trusted entity type AWS service Service or use case EKS, then EKS - Pod Identity Permissions tick quickcart-lbc-policyRole name quickcart-lbc-roleChoose Create role. Then tie the role to the controller’s service account:
In the AWS console: EKSClustersquickcart-eksAccessPod Identity associationsCreate
Field Enter IAM role quickcart-lbc-roleKubernetes namespace kube-systemKubernetes service account aws-load-balancer-controllerChoose Create.
You should seeOne association in the list, for
kube-systemandaws-load-balancer-controller. Only pods using that exact service account get the role, not every pod on the node.Install the controller
Helm only runs in the terminal; there’s no console equivalent.
Terminalhelm repo add eks https://aws.github.io/eks-chartsPart What 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 repositoriesTerminalhelm 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-controllerPart What it means helm install aws-load-balancer-controllerinstall a chart, and call this installation aws-load-balancer-controllereks/aws-load-balancer-controllerwhich chart, from the eksrepository--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-1and--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 seeOutput that includes
STATUS: deployed.Terminalkubectl get deployment -n kube-system aws-load-balancer-controllerPart What 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 see2/2under READY. If it shows0/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.
Turn on the readiness gate
Terminalkubectl label namespace default elbv2.k8s.aws/pod-readiness-gate-inject=enabledPart What 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 seenamespace/default labeledWith 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.
Describe the app in one file
Create
orders.yamlin the folder. Replace the account ID in the image, and replaceCERT_IDwith the end of your certificate’s ARN.orders.yamlapiVersion: 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: 80The
---lines split the file into three objects.Setting What it does The ECS equivalent Deployment replicas: 2keep two pods running the service’s desired tasks maxUnavailable: 0,maxSurge: 1during a release, add one new pod before removing an old one Min 100%, Max 200% readinessProbeon/healthonly send traffic to a pod once /healthanswersthe target group’s health check requeststhe CPU and memory the scheduler reserves for the pod on a node the task size limitsmemory: 256Mithe most memory the pod may use; above it, it’s killed the task’s memory preStopsleep 15wait 15 seconds before stopping, so the load balancer stops sending first ECS does this for you with the drain terminationGracePeriodSeconds: 40time allowed for the 15-second wait plus gunicorn’s 20-second finish ECS’s stop timeout Service a fixed name, orders, in front of pods that come and gothe target group Ingress and its annotations ask the controller for an internet-facing HTTPS load balancer that targets pod IPs, redirects HTTP to HTTPS, checks /healthand drains for 30 secondsthe load balancer, listeners and target group you clicked Before applying, check you replaced the placeholder:
Terminalgrep -c 111122223333 orders.yamlPart What it means grep -ccount the lines that contain some text 111122223333the example account ID orders.yamlthe file to search You should see0Any number other than
0means the example account ID is still in the file.Start it
Terminalkubectl apply -f orders.yamlPart What it means kubectl applycreate or update whatever a file describes -f orders.yamlthe file You should seedeployment.apps/orders created service/orders created ingress.networking.k8s.io/orders createdTerminalkubectl get podsPart What it means kubectl get podslist the pods in the defaultnamespaceYou should seeTwo pods named
orders-followed by random letters, each1/1READY and Running.Terminalkubectl get ingress ordersPart What it means kubectl get ingress ordersshow the Ingress, including the address of the load balancer built for it You should seeAfter 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 showsEKS_ALB.Test it over HTTPS
Terminalcurl -k https://EKS_ALB/ordersPart What it means -kaccept the self-signed certificate https://EKS_ALB/ordersthe orders page, through the EKS load balancer You should seeThe same three orders, now served from EKS.
Terminalfor i in 1 2 3 4 5 6; do echo "request $i"; curl -sk https://EKS_ALB/; echo; donePart What it means the loop the same six-request loop as on ECS, against the EKS load balancer You should seeserved_byswitches between the two pod names.
Checkpoint: kubectl get pods shows two ready pods, and curl -k https://EKS_ALB/orders returns the orders.
Release v2 on EKS under load
Release v2 while traffic runs
Window 1:
Terminalbash load.sh https://EKS_ALB/ 240Part What it means 240four minutes of requests against the EKS load balancer Window 2:
Terminalwhile true; do curl -sk https://EKS_ALB/; echo; sleep 1; donePart What it means the loop one request a second, showing which version answered, until Ctrl+C Window 3: release.
Terminalkubectl set image deployment/orders app=111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v2Part What it means kubectl set imagechange the image a deployment uses, which starts a rolling release deployment/orderswhich deployment app=…:v2the container called appshould now runv2You should seedeployment.apps/orders image updatedTerminalkubectl rollout status deployment/ordersPart What it means kubectl rollout statuswait, showing progress, until the release finishes You should seeProgress lines, ending with
deployment "orders" successfully rolled out. Window 2 moves fromv1tov2. Write down window 1’s totals. The target isfailed: 0.Go back with one command
Terminalkubectl rollout undo deployment/ordersPart What it means kubectl rollout undogo back to the previous version of the deployment You should seedeployment.apps/orders rolled backYou should seeWithin about a minute, window 2 shows
v1again. Nothing was rebuilt: Kubernetes kept the previous version’s recipe.Release something broken
Terminalkubectl set image deployment/orders app=111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders:v9-brokenPart What it means :v9-brokenan image tag that doesn’t exist Terminalkubectl get podsPart What it means kubectl get podslist the pods You should seeOne new pod with STATUS
ErrImagePullorImagePullBackOff. The two old pods keep running and serving. Kubernetes stops and waits for you; it doesn’t go back by itself.Terminalkubectl rollout undo deployment/ordersPart What 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.
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 measured | ECS on Fargate | EKS |
|---|---|---|
| Time to create the cluster | about 1 second | your time, usually 10 to 15 minutes |
| Steps to connect a load balancer | one dropdown in the service form | a policy, a role, a Pod Identity association, a Helm install and an Ingress |
| Release under load: requests, failed | your numbers. Target: 0 failed | your numbers. Target: 0 failed |
| Undoing a broken release | automatic, a few minutes | one command, seconds, but someone has to run it |
| Settings you added for zero downtime | Min 100%, 30-second drain, CMD in brackets | those three, plus the readiness gate and the preStop wait |
| Cost before any app runs | $0 | about $73 a month, plus the nodes |
| Servers you look after | none | two 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: 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.
Delete the app from EKS
Terminalkubectl delete -f orders.yamlPart What it means kubectl delete -f orders.yamldelete everything the file describes. The controller then deletes the load balancer it built You should seedeployment.apps "orders" deleted service "orders" deleted ingress.networking.k8s.io "orders" deletedWait 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.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.
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 withAmazonEKS). Then in IAM > Policies, deletequickcart-lbc-policy.Delete the ECS side, in this order
Order Where What to do 1 ECS > Clusters > quickcart-cluster > Services > orders Delete service, and tick Force delete so it stops the tasks for you 2 ECS > Clusters delete quickcart-cluster3 EC2 > Load Balancers delete quickcart-ecs-alb4 EC2 > Target Groups delete quickcart-ecs-tg5 EC2 > Security Groups delete quickcart-app-sg, thenquickcart-alb-sg. If one refuses, wait a minute; something is still letting go of it6 ECS > Task definitions > quickcart-orders deregister every revision, then delete them 7 ECR > Repositories delete quickcart-orders. Project 4 creates it again in code8 CloudWatch > Log groups delete /ecs/quickcart-orders9 Certificate Manager delete the imported certificate 10 VPC > Subnets optional: remove the kubernetes.io/role/elbtag from both subnetsDelete the key from your computer
Terminalrm -f key.pem key-rsa.pem cert.pemPart What 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 Clean-up step: Clean up
Check nothing is left billing:
Where Should show EKS > Clusters nothing ECS > Clusters nothing EC2 > Load Balancers nothing from this project EC2 > Instances no running instances from this project EC2 > Network Interfaces nothing with a description mentioning ELBoraws-K8SVPC > Your VPCs > default VPC > Resource map no leftover load balancer or instance You should seeAll 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.