Autoscaling is easy to explain and hard to see. The docs tell you a HorizontalPodAutoscaler watches CPU and adds pods — but to watch it happen you need a workload that burns CPU on demand, a deployment that reports its resource usage honestly, and a way to generate real traffic. This post wires all three together with a tiny Quarkus app, the quarkus-openshift extension, and k6.

The full source is here: ozimakov-rh/quarkus-hello-world. It targets Quarkus 2.14 on Java 17.

A workload with a CPU knob

To make autoscaling visible you want load you can dial up. Computing π with the Leibniz series is perfect: a few lines of pure arithmetic, no I/O, and the cost is exactly the number of iterations you ask for.

@ApplicationScoped
public class PiService {

    public double calculatePi(double n) {
        double pi = 0;
        for (int i = 1; i < n; i++) {
            pi += Math.pow(-1, i + 1) / (2 * i - 1);
        }
        return 4 * pi;
    }
}

Wrap it in a REST endpoint where the path segment is the exponent — so /pi/7 runs 10⁷ iterations. That gives you a single number to turn CPU cost up or down, with guardrails so a caller can’t ask for something that never returns:

@Path("/pi")
@Produces(MediaType.APPLICATION_JSON)
public class PiResource {

    @Inject
    PiService piService;

    @GET
    @Path("/{n}")
    public PiResponse getPi(@PathParam("n") int n) {
        if (n <= 0) throw new RuntimeException("Complexity can't be negative");
        if (n > 8)  throw new RuntimeException("Complexity over 8 is not allowed");
        var complexity = Math.pow(10, n);
        return new PiResponse(complexity, piService.calculatePi(complexity));
    }

    public record PiResponse(double n, double pi) {}
}

A quick check that it works and the math is right:

$ curl -s localhost:8080/pi/4
{"n":10000.0,"pi":3.1416926}

/pi/4 is instant. /pi/7 takes real CPU time — that’s the request we’ll hammer.

Deploy to OpenShift in one command

The reason this fits in a short demo is the quarkus-openshift extension. It generates the OpenShift resources — DeploymentConfig, Service, Route — from your build, so there’s no YAML to hand-write.

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-openshift</artifactId>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-smallrye-health</artifactId>
</dependency>

Two things the autoscaler needs go in application.properties: a public route, and — crucially — CPU requests and limits. The HPA computes utilization as a percentage of the request, so without a request there is nothing to scale against.

# Expose a route so k6 (and you) can reach it
quarkus.openshift.route.expose=true

# The HPA measures CPU against these numbers
quarkus.openshift.resources.requests.cpu=100m
quarkus.openshift.resources.limits.cpu=500m
quarkus.openshift.resources.requests.memory=128Mi
quarkus.openshift.resources.limits.memory=256Mi

Then log in and let Quarkus build the image and deploy it, all from Maven:

oc login ...
./mvnw clean package -Dquarkus.kubernetes.deploy=true

That one flag triggers an OpenShift S2I build and applies the generated resources to your current project. When it finishes you have a running pod and a route:

$ oc get route hello-world-quarkus -o jsonpath='{.spec.host}'
hello.apps.zimakov.net

Health checks so scaling is safe

quarkus-smallrye-health exposes /q/health/live and /q/health/ready, and the extension wires them into the deployment as liveness and readiness probes automatically. This matters for autoscaling: when the HPA spins up a new pod, OpenShift won’t route traffic to it until the readiness probe passes — so scaling out never sends requests into a JVM that’s still warming up.

$ curl -s https://hello.apps.zimakov.net/q/health/ready
{"status":"UP","checks":[]}

Turn on the autoscaler

With a CPU request in place, one command creates the HPA: keep every pod near 50% CPU, between 1 and 10 replicas.

oc autoscale deployment/hello-world-quarkus --min=1 --max=10 --cpu-percent=50

At rest it sits at one pod:

$ oc get hpa
NAME                  REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS
hello-world-quarkus   Deployment/hello-world-quarkus   1%/50%    1         10        1

Generate real load with k6

Now the traffic. This k6 script ramps from 1 to 200 virtual users over five minutes, holds there for five, then winds down — every user hammering /pi/7 in a loop.

import { check } from 'k6';
import http from 'k6/http';

export const options = {
    stages: [
        { duration: '30s', target: 1 },
        { duration: '5m',  target: 200 },
        { duration: '5m',  target: 200 },
        { duration: '1m',  target: 0 },
    ],
};

export default function () {
    const res = http.get('https://hello.apps.zimakov.net/pi/7');
    check(res, {
        'is status 200': (r) => r.status === 200,
    });
}
k6 run k6/script.js

Watch it scale

Keep an eye on the HPA and the pods while k6 ramps. As 200 users pile onto the π computation, per-pod CPU blows past the 50% target and the autoscaler adds replicas:

$ oc get hpa -w
NAME                  TARGETS    REPLICAS
hello-world-quarkus   1%/50%     1
hello-world-quarkus   210%/50%   1
hello-world-quarkus   210%/50%   4
hello-world-quarkus   135%/50%   7
hello-world-quarkus   64%/50%    9
$ oc get pods -l app.kubernetes.io/name=hello-world-quarkus
NAME                          READY   STATUS    RESTARTS   AGE
hello-world-quarkus-1-8xk2p   1/1     Running   0          14m
hello-world-quarkus-1-b7mzq   1/1     Running   0          52s
hello-world-quarkus-1-c4vnd   1/1     Running   0          52s
...

More pods means the 200 users are spread thinner, per-pod CPU drops back toward 50%, and the count stabilizes. When k6 enters its final stage and traffic falls to zero, CPU collapses — and after its cooldown window the HPA scales back down to the single idle pod. The whole loop, out and back, in about twelve minutes.

What the demo actually shows

Nothing here is exotic; that’s the point. The moving parts are small and each earns its place:

  • A tunable CPU workload (/pi/{n}) so load is something you control, not something you wait for.
  • quarkus-openshift to go from source to a deployed, routable app in one Maven command — no YAML.
  • CPU requests/limits, without which the HPA has no denominator and can’t compute utilization.
  • Health probes from quarkus-smallrye-health so new replicas only take traffic once they’re ready.
  • k6 to produce load that looks like real ramping traffic, not a single looping curl.

Clone the repo, point the route and the k6 URL at your own cluster, and you can watch OpenShift breathe under load in a coffee break.