Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 6: Horizontal Scaling, Redis Caching & Query Optimization - NextGenBeing
Back to discoveries
Part 6 of 8

Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 6: Horizontal Scaling, Redis Caching & Query Optimization

Make a Laravel 12 app stateless with Redis, fix a slow query with EXPLAIN, stop cache stampedes, and watch a Kubernetes HPA scale it on kind.

Comprehensive Tutorials 17 min read
Bekzod Erkinov

Bekzod Erkinov

May 12, 2026 • 143 views
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 6: Horizontal Scaling, Redis Caching & Query Optimization
Size:
Height:
17 min read 6,449 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 8 sections

Laravel Horizontal Scaling with Redis: Caching, Query Optimization and an HPA

Production-Grade E-Commerce with Laravel 12, Part 6

You want to run more than one copy of a Laravel 12 app without logins vanishing, caches disagreeing, or one expired key flattening your database. By the end you'll have a stateless app on Redis, a slow query fixed with EXPLAIN, a cache that survives 50 concurrent requests, and an HPA scaling pods on a local kind cluster, including the one load-test mistake that makes the extra pods useless.

Parts 1 to 5 built the shop and containerised it; none of that is needed here. If you are new to the Redis side, Redis vs Memcached in 2026 and Redis Sentinel vs Cluster cover the store choice, and graceful shutdown for Laravel queue workers matters as soon as an HPA starts killing pods. Every number below comes from one Windows laptop (8 CPUs given to Docker Desktop, everything sharing them): single run unless a range is shown, relative only.

Minimal setup

A fresh Laravel 12 app, predis for Redis, MySQL 8.4, Redis 7, and two php-fpm (PHP 8.4) replicas behind nginx. The Kubernetes part uses kind and metrics-server. The Laravel 12 docs ask for predis ~2.0 or phpredis; I ran predis 3.x and did not test 2.x or phpredis.

composer create-project laravel/laravel:^12.0 shop && cd shop
composer require predis/predis

docker network create p6net
docker run -d --name p6-mysql --network p6net -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop mysql:8.4
docker run -d --name p6-redis --network p6net redis:7-alpine

p6-nginx (used by every load test below) is the nginx container from the config further down, started on the same network with docker run -d --name p6-nginx --network p6net -p 8088:80 -v $PWD/conf.d:/etc/nginx/conf.d nginx:alpine (the nginx.conf with worker_processes 1 replaces the image's default).

.env points the app at those containers and moves all three kinds of state into Redis:

DB_CONNECTION=mysql
DB_HOST=p6-mysql
DB_DATABASE=shop
DB_USERNAME=root
DB_PASSWORD=secret
REDIS_CLIENT=predis
REDIS_HOST=p6-redis
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis

The data is two tables, products (id, category_id, name, price_cents, status, timestamps) and orders (id, user_id, product_id, total_cents, created_at), filled with a recursive CTE inside the migration. Here is the seeding part, which gives 200,000 products in 50 categories and 500,000 orders over 20,000 users:

DB::statement('SET SESSION cte_max_recursion_depth = 1000000');
DB::statement("INSERT INTO products (category_id, name, price_cents, status, created_at, updated_at)
    WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 200000)
    SELECT 1 + (i % 50), CONCAT('Product ', i), 500 + (i * 37 % 20000), IF(i % 10 = 0, 'archived', 'active'), NOW(), NOW() FROM n");
DB::statement("INSERT INTO orders (user_id, product_id, total_cents, created_at)
    WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 500000)
    SELECT 1 + (i * 7919 % 20000), 1 + (i * 104729 % 200000), 500 + (i * 53 % 30000),
           NOW() - INTERVAL (i % 365) DAY - INTERVAL (i % 86400) SECOND FROM n");

The image is php:8.4-fpm plus pdo_mysql and opcache, with the app copied in and a static pool. p6-app is the app directory packaged as an image (FROM scratch + COPY . /app). Build it first from the app root with docker build -t p6-app ., then the fpm image below with docker build -t p6-fpm:lab .:

FROM php:8.4-fpm
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=p6-app /app /app
RUN printf '[www]\npm = static\npm.max_children = 8\nlisten = 9000\nclear_env = no\n' > /usr/local/etc/php-fpm.d/zz-pool.conf \
 && chown -R www-data:www-data /app/storage /app/bootstrap/cache
WORKDIR /app

Two containers from that image (p6-app1, p6-app2, started with php-fpm -F and --hostname app1 / app2) sit behind nginx:

# nginx.conf, main context. Demo-only: one worker gives a single sequential
# client a strict app1/app2 alternation.
worker_processes 1;

# conf.d/default.conf
upstream fpm { server p6-app1:9000; server p6-app2:9000; }
server {
  listen 80;
  location / {
    include fastcgi_params;
    fastcgi_pass fpm;
    fastcgi_param SCRIPT_FILENAME /app/public/index.php;
    fastcgi_param SCRIPT_NAME /index.php;
  }
}

All the routes the benchmarks use live in one file. /whoami tells you which replica answered; expensiveReport() is defined in the stampede section below:

// routes/web.php
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;

function categoryPage($id) {
    return DB::table('products')->where('category_id', $id)->where('status', 'active')
        ->orderBy('price_cents')->limit(24)->get();
}

Route::get('/whoami', function (Request $r) {
    $r->session()->increment('hits');
    return ['pod' => gethostname(), 'hits' => $r->session()->get('hits'), 'session_driver' => config('session.driver')];
});
Route::get('/users/{id}/orders', fn ($id) => DB::table('orders')->where('user_id', $id)->orderByDesc('created_at')->limit(20)->get());
Route::get('/category/{id}', fn ($id) => categoryPage($id));
Route::get('/cached/category/{id}', fn ($id) => Cache::tags(['products', "category:$id"])
    ->remember("category:$id:page1", 600, fn () => categoryPage($id)));
Route::get('/report/naive', fn () => Cache::remember('report:naive', 5, fn () => expensiveReport()));
Route::get('/report/flexible', fn () => Cache::flexible('report:flex', [5, 30], fn () => expensiveReport()));
Route::get('/report/lock', function () {
    if (($v = Cache::get('report:lock')) !== null) {
        return $v;
    }
    try {
        return Cache::lock('report:lock:build', 30)->block(10, function () {
            return Cache::get('report:lock')
                ?? tap(expensiveReport(), fn ($r) => Cache::put('report:lock', $r, 5));
        });
    } catch (LockTimeoutException) {
        return Cache::get('report:lock') ?? abort(503);   // stale-ish value, or "try again"
    }
});

php -l accepts this file. The benchmark numbers below were measured on these routes, except the lock rows (see the stampede section).

All load tests below use hey as a container on the same Docker network (docker run --rm --network p6net williamyeh/hey ...), aimed at the nginx container.

Laravel horizontal scaling: make the app stateless with Redis

Horizontal scaling only works if any replica can serve any request. Three things break that: sessions on local disk, a cache that isn't shared, and uploads written to the container's filesystem.

Here's the failure. I set SESSION_DRIVER=file on both replicas (-e SESSION_DRIVER=file) and sent six requests with one cookie jar through nginx (curl -s -b jar -c jar http://localhost:8088/whoami, six times). That is why the nginx config above sets worker_processes 1: with auto, each worker keeps its own round-robin position, and a single sequential client can stick to one replica:

== SESSION_DRIVER=file
{"pod":"app1","hits":1,"session_driver":"file"}
{"pod":"app2","hits":1,"session_driver":"file"}
{"pod":"app1","hits":2,"session_driver":"file"}
{"pod":"app2","hits":2,"session_driver":"file"}
{"pod":"app1","hits":3,"session_driver":"file"}
{"pod":"app2","hits":3,"session_driver":"file"}

Each replica keeps its own copy of the "same" session, so the counter runs twice. For a real login this means being logged in on one request and anonymous on the next. With SESSION_DRIVER=redis:

== SESSION_DRIVER=redis
{"pod":"app1","hits":1,"session_driver":"redis"}
{"pod":"app2","hits":2,"session_driver":"redis"}
{"pod":"app1","hits":3,"session_driver":"redis"}
{"pod":"app2","hits":4,"session_driver":"redis"}
{"pod":"app1","hits":5,"session_driver":"redis"}
{"pod":"app2","hits":6,"session_driver":"redis"}

Laravel's session docs list the drivers and note that database is the default; Redis needs either phpredis or predis/predis. If you want sessions on a different Redis connection than the cache, SESSION_CONNECTION selects it. Once Redis holds every login it is also a single point of failure, which is the Sentinel vs Cluster question.

Two gotchas worth knowing before production:

  • Know your eviction policy. Run redis-cli config get maxmemory-policy on your instance; the eviction docs don't state the default policy. The Redis eviction docs say that under noeviction the server "will return an error when you try to execute commands that cache new data" once maxmemory is reached, while reads keep working. They also say maxmemory 0, meaning no limit, is the default on 64-bit systems, so this only bites once you configure a limit. If sessions and cache share one Redis, a full cache can break logins. Switching to allkeys-lru fixes that by evicting any key, including sessions. The docs suggest separate instances when you mix cache and keys you can't lose. I'd run two instances rather than pick one policy that's wrong for one of them.
  • Locks need one shared store. Laravel's atomic locks only work if every server talks to the same central cache server. A per-pod file or array cache silently gives you one lock per pod, i.e. no lock.

The queue is the third piece of shared state: with QUEUE_CONNECTION=redis, any replica can enqueue and any worker can run the job. Workers get killed on every scale-down, so apply the graceful shutdown patterns before you put an HPA on them.

Files go to object storage (not run)

I didn't run this part (no bucket here). Per the filesystem docs, install league/flysystem-aws-s3-v3 "^3.0", set the AWS_* variables and FILESYSTEM_DISK=s3 so uploads stop landing on a pod's disk, and use Storage::temporaryUrl() for private files.

Fix the query before you cache it

Caching a slow query hides it until the key expires. Look at it first. EXPLAIN ANALYZE in MySQL 8.4 runs the statement and shows actual time and rows per step, which is what you want, not just the optimiser's guess.

The route is "a user's latest orders":

DB::table('orders')->where('user_id', $id)->orderByDesc('created_at')->limit(20)->get();

With no index on user_id (EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id=4242 ORDER BY created_at DESC LIMIT 20):

-> Limit: 20 row(s)  (cost=50214 rows=20) (actual time=652..652 rows=20 loops=1)
    -> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk  (cost=50214 rows=499334) (actual time=652..652 rows=20 loops=1)
        -> Filter: (orders.user_id = 4242)  (cost=50214 rows=499334) (actual time=18.1..652 rows=25 loops=1)
            -> Table scan on orders  (cost=50214 rows=499334) (actual time=2.67..546 rows=500000 loops=1)

It read all 500,000 rows to return 20. Add a composite index with the equality column first and the sort column second:

ALTER TABLE orders ADD INDEX orders_user_created (user_id, created_at);
-> Limit: 20 row(s)  (cost=8.75 rows=20) (actual time=0.188..0.196 rows=20 loops=1)
    -> Index lookup on orders using orders_user_created (user_id=4242) (reverse)  (cost=8.75 rows=25) (actual time=0.187..0.192 rows=20 loops=1)

Plain EXPLAIN confirms there is no filesort:

id  select_type  table   type  possible_keys        key                  key_len  ref    rows  filtered  Extra
1   SIMPLE       orders  ref   orders_user_created  orders_user_created  4        const  25    100.00    Backward index scan

That's about 650 ms down to about 0.2 ms (two runs each). Through nginx, docker run --rm --network p6net williamyeh/hey -z 10s -c 20 http://p6-nginx/users/4242/orders gave 7.3 requests/s (average 2.60 s) once without the index, then 80.9 and 78.3 requests/s (average about 0.25 s) in two runs with it.

The category page has a filter plus a sort: WHERE category_id = 7 AND status = 'active' ORDER BY price_cents LIMIT 24. Three index states, same query, 200,000 rows (two runs each, the range is shown):

Index Plan Actual time
none table scan of 200,000 rows, then sort 182 to 234 ms
(category_id) 4,000 rows via index, filter, then sort 30 to 51 ms
(category_id, status, price_cents) index lookup, stops after 24 rows, no sort 1.0 to 1.2 ms

The middle row is the common trap: an index exists, EXPLAIN shows ref, and the query still sorts 4,000 rows every time. The composite index also needs the columns in filter-then-sort order for the scan to stop early.

Cache with tags, but check the driver

Once the query is indexed, here's the surprise: caching it barely helped here. hey -z 10s -c 20, composite index in place:

Route Requests/s Avg latency
/category/7 (indexed query, no cache) 85.1 0.233 s
/cached/category/7 (Redis, tagged) 86.5 0.229 s

I ran each route once, so I have no spread to put next to 85.1 versus 86.5; a 1.6% difference from single runs is not evidence of anything. The honest reading is "no improvement I could measure on this route", not "caching doesn't help". The ceiling here is the framework itself (middleware, session read/write over predis, php-fpm on a shared laptop), and one MySQL on the same box is nowhere near saturated. With many replicas all hitting one database, a cache still removes that load, and this single-box test cannot show it. My rule: index first, then cache only what is still expensive after the index (aggregates, joins over big ranges, calls to other services). A 1 ms query behind a 1-2 ms cache read isn't worth an invalidation problem.

When you do cache a list, tags let you flush one slice instead of the whole store:

Cache::tags(['products', "category:$id"])->remember("category:$id:page1", 600, fn () =>
    DB::table('products')->where('category_id', $id)->where('status', 'active')->orderBy('price_cents')->limit(24)->get());

// on write:
Cache::tags(["category:{$p->category_id}"])->flush();

To check the flush only touches one category I ran this script with php artisan tinker tags.php:

Cache::flush();
Cache::tags(['products','category:7'])->put('category:7:page1', 'A', 600);
Cache::tags(['products','category:8'])->put('category:8:page1', 'B', 600);
echo "cat7 before: ", Cache::tags(['products','category:7'])->get('category:7:page1'), PHP_EOL;
Cache::tags(['category:7'])->flush();
var_dump(Cache::tags(['products','category:7'])->get('category:7:page1'));
echo "cat8 after flushing cat7: ", Cache::tags(['products','category:8'])->get('category:8:page1'), PHP_EOL;
try { Cache::store('database')->tags(['x'])->put('k','v',60); } catch (Throwable $e) { echo get_class($e), ': ', $e->getMessage(), PHP_EOL; }
cat7 before: A
NULL
cat8 after flushing cat7: B
BadMethodCallException: This cache store does not support tagging.

The last line is a limit: the cache docs say tags aren't supported on the file, dynamodb or database drivers. Laravel 12's default store is database, so a fresh app hits this. Two more details from the docs: you must pass the same ordered tags to read an item back, and Cache::flush() ignores your prefix and clears the entire store, including sessions if they share it.

Tags on Redis: cost

Tags are not free on Redis. From reading the 12.x source (RedisTagSet, RedisTaggedCache; I did not benchmark this): every tagged put also does a ZADD into a sorted set named tag:<name>:entries, one per tag, holding the key of every item written under that tag. That is extra memory per cached key per tag, and a flush() has to read all of a tag's entries and delete them in chunks of 1,000. A broad tag such as products over millions of keys makes that flush a long operation on a single-threaded Redis, and expired items leave stale entries in the set until they are pruned. So keep tags narrow (category:7, not one tag on everything), and time a flush on production-sized data before wiring it to a write path.

Laravel Redis cache stampede: lock vs flexible

A "stampede" is many requests missing the same expired key at once and all rebuilding it. To measure it I used an aggregate that takes 2.0 to 2.4 s on this data (revenue per category over 90 days) and counted how many times it really ran, with Cache::increment('computations') inside the closure. The two helpers, as I'd write them to reproduce this (the 2.0 to 2.4 s timing belongs to the query I used on this data):

function expensiveReport() {
    Cache::increment('computations');   // counts real builds; read it with Cache::get('computations')
    return DB::table('orders')->join('products', 'products.id', '=', 'orders.product_id')
        ->where('orders.created_at', '>=', now()->subDays(90))
        ->groupBy('products.category_id')->selectRaw('products.category_id, SUM(orders.total_cents) AS revenue')->get();
}

Three versions of the route, TTL 5 s (the lock variant follows as a full handler):

// naive
Cache::remember('report:naive', 5, fn () => expensiveReport());

// stale-while-revalidate: fresh for 5 s, servable stale for 30 s more
Cache::flexible('report:flex', [5, 30], fn () => expensiveReport());

The lock variant as a complete route handler. It needs use Illuminate\Contracts\Cache\LockTimeoutException;, and block() returns the closure's result, so the handler returns it directly:

Route::get('/report/lock', function () {
    if (($v = Cache::get('report:lock')) !== null) {
        return $v;
    }
    try {
        return Cache::lock('report:lock:build', 30)->block(10, function () {
            return Cache::get('report:lock')
                ?? tap(expensiveReport(), fn ($r) => Cache::put('report:lock', $r, 5));
        });
    } catch (LockTimeoutException) {
        return Cache::get('report:lock') ?? abort(503);   // stale-ish value, or "try again"
    }
});

Why this reads the cache twice: the first Cache::get keeps the hot path to one Redis call with no lock traffic. The second one, inside the lock, matters because 49 requests can be queued on the lock while one builds; without the re-check each of them would rebuild in turn, and you'd have a serial stampede instead of a parallel one.

The pitfall: block(10) waits at most 10 s, then throws LockTimeoutException, which becomes a 500 for every waiter unless you catch it as above. The build must finish well inside the block timeout, and the lock TTL (30) must exceed the build time. I never made the timeout fire; the catch branch is from the docs.

The inputs for every stampede run: TTL 5 s, a report build of 2.0 to 2.4 s, and hey -z 40s -c 50 http://p6-nginx/report/<naive|lock|flexible> (50 connections for 40 s). I warmed the key with one request, reset the cache between runs, and read the counter before and after:

Strategy Real computations in 40 s Avg latency p99 Slowest
remember 48 1.26 s 9.9 s 12.2 s
lock + block (earlier variant, not the handler above) 6 0.78 s 3.5 s 4.7 s
flexible([5, 30]) 6 0.47 s 1.17 s 1.45 s

With a 5 s TTL, the value can expire at most once per 5 s, so a well-behaved cache rebuilds at most about 40 s / 5 s = 8 times (a build of 2+ s only makes that slower). remember did it 48 times because that estimate assumes an instant rebuild: each expiry opens a window of at least one build (2.0 to 2.4 s) in which every arriving request misses and runs the query itself, and 50 concurrent connections can fill that window with up to 50 builds, so 48 in 40 s is roughly one window's worth of connections rather than eight single rebuilds. The lock rows come from an earlier variant of the handler, so treat them as approximate.

The lock fixes the count but not the latency: the waiters sit in block(10) until the builder finishes, which is why the slowest request was 4.7 s. Cache::flexible fixes both: during the stale window it serves the old value immediately and refreshes after the response. The docs describe it as the stale-while-revalidate pattern. In the 12.69.3 source (Repository::flexible, which starts at line 561 in that tag) the stale-path refresh is wrapped in a lock:

$refresh = function () use ($key, $ttl, $callback, $lock, $created) {
    $this->store->lock(
        "illuminate:cache:flexible:lock:{$key}",
        $lock['seconds'] ?? 0,
        $lock['owner'] ?? null,
    )->get(function () use ($key, $callback, $created, $ttl) {
        if ($created !== $this->get("illuminate:cache:flexible:created:{$key}")) {
            return;   // someone already refreshed
        }
        ...

So only the request that wins that lock recomputes during the stale window, which fits the 6 computations above.

The catch: flexible only protects you once a value exists. On a cold start I ran the same test with an empty cache (hey -z 20s -c 50), one run per row:

Strategy Real computations Avg latency Slowest
remember 32 2.54 s 13.9 s
lock + block (earlier variant) 3 0.89 s 4.3 s
flexible([5, 30]) 16 2.83 s 17.0 s

With nothing cached there is no stale value to serve. In the same source, the branch taken when the value or its created marker is missing (in_array(null, [$value, $created], true)) just calls $callback and stores the result, with no lock, so every request that arrives before the first store computes. That is what the source says; the exact counts (16, 32) are single runs and I never repeated them, so don't rank flexible against remember from this table. The only firm reading is lock at 3 versus tens for the other two. So: warm hot keys on deploy (a scheduled command or a curl loop in your release step), and use the lock pattern when a cold miss is possible and serving stale data isn't acceptable. My decision rule:

  • Value can be a few seconds stale: flexible, and pre-warm.
  • Must be fresh, rebuild is rare: lock + block with a re-check inside the lock.
  • Cheap query (a few ms): plain remember, don't bother.

Autoscale on a local kind cluster

Everything here ran on a single-node kind cluster. The fpm image was loaded with kind load docker-image p6-fpm:lab and the pod uses imagePullPolicy: Never. The HPA needs a metrics source, so I installed metrics-server with --kubelet-insecure-tls, which is for kind only. Until it is ready, the HPA reports <unknown> targets; that is not a bug in your config, just wait.

Redis and the shop Service come first: the load pod calls http://shop/... and the app finds Redis at redis. The nginx ConfigMap nginx-conf is the server block from above with fastcgi_pass 127.0.0.1:9000; and no upstream; create it with kubectl create configmap nginx-conf --from-file=default.conf. The /whoami route used in the HPA test only touches Redis (sessions), so MySQL isn't needed in the cluster.

apiVersion: apps/v1
kind: Deployment
metadata: {name: redis}
spec:
  selector: {matchLabels: {app: redis}}
  template:
    metadata: {labels: {app: redis}}
    spec:
      containers:
      - {name: redis, image: "redis:7-alpine", ports: [{containerPort: 6379}]}
---
apiVersion: v1
kind: Service
metadata: {name: redis}
spec:
  selector: {app: redis}
  ports: [{port: 6379}]
---
apiVersion: v1
kind: Service
metadata: {name: shop}
spec:
  selector: {app: shop}
  ports: [{port: 80, targetPort: 80}]

Then the Deployment and HPA (php-fpm and nginx as sidecars in one pod):

apiVersion: apps/v1
kind: Deployment
metadata: {name: shop}
spec:
  # no "replicas:" on purpose - the HPA owns it
  selector: {matchLabels: {app: shop}}
  template:
    metadata: {labels: {app: shop}}
    spec:
      enableServiceLinks: false   # otherwise k8s injects REDIS_PORT=tcp://..., which Laravel reads
      containers:
      - name: fpm
        image: p6-fpm:lab
        imagePullPolicy: Never
        command: ["php-fpm", "-F"]
        env:
        - {name: REDIS_HOST, value: redis}
        - {name: SESSION_DRIVER, value: redis}
        - {name: CACHE_STORE, value: redis}
        resources:
          requests: {cpu: 200m, memory: 128Mi}
          limits: {cpu: 500m, memory: 256Mi}
      - name: nginx
        image: nginx:alpine
        ports: [{containerPort: 80}]
        readinessProbe: {httpGet: {path: /whoami, port: 80}, periodSeconds: 3}
        volumeMounts: [{name: conf, mountPath: /etc/nginx/conf.d}]
      volumes: [{name: conf, configMap: {name: nginx-conf}}]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: shop}
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: shop}
  minReplicas: 1
  maxReplicas: 4
  metrics:
  - type: Resource
    resource: {name: cpu, target: {type: Utilization, averageUtilization: 50}}
  behavior:
    scaleDown: {stabilizationWindowSeconds: 60}

Three settings matter:

  • enableServiceLinks: false. Kubernetes injects REDIS_PORT=tcp://10.x.x.x:6379 for a Service named redis, and Laravel's Redis config reads REDIS_PORT expecting a number.
  • No replicas: in the Deployment. The HPA docs section Migrating Deployments and StatefulSets to horizontal autoscaling recommends removing spec.replicas; otherwise applying the manifest (e.g. kubectl apply -f deployment.yaml) scales the pods back to that value, which can cause flapping.
  • CPU utilisation is relative to requests. 50% of a 200m request is 100m per pod; no request, no metric. The same page's Tolerance section gives a default cluster-wide tolerance of 10% (kube-controller-manager's --horizontal-pod-autoscaler-tolerance), so small deviations from the target don't trigger scaling.

I shortened scaleDown to 60 s so the demo finishes; the default is 300 s, and I'd keep the default in production so you don't flap.

Run 1, with keep-alive. kubectl run hey --image=williamyeh/hey --restart=Never -- -z 200s -c 60 http://shop/whoami, a pod inside the cluster, 60 connections for 200 s. I sampled kubectl get hpa shop and kubectl top pods in a loop every few seconds from a second terminal (kubectl get hpa -w shows the same HPA columns); the lines below are reformatted from that script's output (time since I started the load pod, HPA CPU, replicas, then kubectl top pods per pod):

t+27s  | cpu   9%/50% | replicas 1 | 361m
t+42s  | cpu 144%/50% | replicas 1 | 519m
t+56s  | cpu 207%/50% | replicas 3 | 517m 217m 101m
t+70s  | cpu 207%/50% | replicas 4 | 147m 516m 34m 101m
t+99s  | cpu  58%/50% | replicas 4 | 18m 516m 21m 21m
t+212s | cpu  56%/50% | replicas 4 | 12m 519m 13m 14m   (4/4 ready)
t+241s | cpu   6%/50% | replicas 4 | 14m 25m 12m 14m     (load finished)
t+284s | cpu   7%/50% | replicas 3 | scale-down begins

The HPA saw 144% at t+42 s, had 3 replicas by t+56 s and the maximum of 4 by t+70 s. But load never spread out. One pod sat at about 516m, its 500m CPU limit, for the whole run while the other three idled at 12 to 21m. The result was 15.8 requests/s, 3.76 s average, which is one throttled pod's throughput. The metric also stayed at 56 to 58%, above the 50% target, even at 4 replicas: an average dragged up by one pinned pod can't be lowered by adding more pods.

The cause is the client, not the HPA. Service load balancing happens per connection, and hey kept its 60 connections open from the moment only one pod existed. New pods get new connections only, and there were none. Pod startup added delay too: the fourth pod only reported ready around t+212 s (3/4 from t+56 s). I didn't chase the cause; measure your own startup time, because it is added to every scale-up.

Run 2, without keep-alive. The fix for the test is -disable-keepalive, so every request opens a new connection and can land on a new pod:

t+198s | cpu 202%/50% | replicas 4 | 379m 524m 527m 504m
t+209s | cpu 202%/50% | replicas 4 | 523m 483m 434m 506m   (4/4 ready)

Now all four pods work at once, which Run 1 never achieved. I'm not quoting a throughput for this run: the scale-up came late (the HPA showed <unknown> for a while at the start and I did not capture kubectl describe hpa events, so I can't tell you why), which left the 200 s run too short to say anything about steady state. The lesson is only this: in production the equivalent of hey's keep-alive is an ingress or client that holds long-lived connections, so check connection reuse and limits before blaming the HPA.

Not tested

phpredis (I used predis), a multi-node cluster, object storage, a managed Redis, a read replica, queue workers under load, and LockTimeoutException actually firing.

What to do next

  1. Set SESSION_DRIVER=redis and CACHE_STORE=redis, then run the /whoami check through your own balancer. If the counter doesn't climb by one per request, something is still local.
  2. Check redis-cli config get maxmemory-policy on your Redis. If sessions share it with the cache, decide deliberately between noeviction and an eviction policy, or split them into two instances.
  3. Run EXPLAIN ANALYZE on your five slowest routes. Look for Table scan, a Sort over thousands of rows, and rows= far larger than the LIMIT. Add composite indexes with equality columns first, sort column last.
  4. Only then add caching, and only for what's still slow. Use Cache::tags() on Redis (not database or file), and call Cache::flush() nowhere in production code.
  5. For each cached expensive value, pick a stampede rule: Cache::flexible plus a warm-up on deploy, or Cache::lock()->block() with a re-check inside and a build time well under the block timeout.
  6. Move uploads to an S3-compatible disk and generate temporaryUrl() links; then confirm no pod writes under storage/app.
  7. Put an HPA on CPU with real requests, remove replicas: from the Deployment, and load test with -disable-keepalive or clients that reconnect. Keep the 300 s scale-down default until you've watched it in production.
  8. Continue with Part 7 on testing and security and Part 8 on production deployment and monitoring.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.