After deploying a VKS cluster in my VCF lab, I wanted to compare the experience of running an application on a traditional VM with running it on Kubernetes. I used Nginx for both, served the same static file, and tested requests, resource use, recovery, content updates, rollback, and scaling.
The useful result is an operational comparison of two real deployments. It is not a universal VM-versus-container speed ranking: the applications used different Nginx builds, resource limits, operating systems, and network paths. VCF Container Service is planned as a separate follow-up.
What I tested
| Setting | Native VM | VKS container |
|---|---|---|
| Operating system | Ubuntu 24.04.5 LTS | Alpine Linux 3.24 container |
| Nginx version | 1.24.0 (Ubuntu package 1.24.0-2ubuntu7.18) | 1.30.5 |
| Application resources | VM: 2 vCPUs, 4 GiB RAM; no matching service quota | Request: 50m CPU / 64 MiB; limit: 500m CPU / 256 MiB |
| Exposure | HTTP directly to the VM | LoadBalancer Service, port 80 to container port 8080 |
| Supervision | systemd service | One-replica Deployment with a readiness probe |
| Test content | 16,384-byte static file | Identical 16,384-byte file through a ConfigMap |
| Platform footprint | One application VM | One control-plane VM and one worker VM; 2 vCPUs / 4 GiB each |
The VKS cluster reported Kubernetes v1.36.2+vmware.2. The container used the cached nginxinc/nginx-unprivileged:stable-alpine image. I recorded its resolved image digest in the private evidence. Registry access matters when a replacement node needs an image that is not cached. The test did not exercise a new node or a fresh image pull.
I retained the existing Nginx configurations. For example, worker connections were 768 on the VM and 1,024 in the container. Requests did not ask for compression. These differences are another reason to describe the results as “as configured.”
How the request tests worked
The test ran on October 19, 2026, from one Windows client. Each target received 100 warm-up requests before the measured series. I then ran 1,000 requests per target at concurrency 1, 5, 10, and 25, with four repeats at each level. Targets ran sequentially, alternating which target went first. Every successful response had to return HTTP 200 and the expected SHA256 checksum.
The client used a connection per worker and measured time through the complete response body. Connection setup was included when needed. The table reports the median of four run-level results, with the range of run-level throughput in parentheses. Tail latency is the median of each run’s p95 or p99, not a percentile calculated by pooling all requests.
| Target | Concurrency | Requests/sec: median (range) | p95 ms | p99 ms | Errors |
|---|---|---|---|---|---|
| VKS | 1 | 1,664.07 (1,537.97–1,828.04) | 0.67 | 1.00 | 0 |
| VM | 1 | 1,976.58 (1,660.61–2,281.71) | 0.57 | 0.67 | 0 |
| VKS | 5 | 5,936.41 (5,013.95–6,135.19) | 0.88 | 1.40 | 0 |
| VM | 5 | 5,859.66 (5,518.05–6,516.55) | 0.95 | 1.91 | 0 |
| VKS | 10 | 6,432.01 (5,909.03–6,516.81) | 1.69 | 3.32 | 0 |
| VM | 10 | 6,420.99 (5,903.09–6,728.18) | 1.69 | 4.98 | 0 |
| VKS | 25 | 3,328.28 (3,107.91–3,657.57) | 7.10 | 26.08 | 0 |
| VM | 25 | 3,002.21 (2,842.44–3,275.30) | 6.84 | 22.77 | 0 |
The finite series completed 32,000 measured requests with 0 errors. These are closed-loop client results: each worker waits for a response before making its next request. They do not establish maximum server capacity, behavior under an independently imposed arrival rate, or a production latency guarantee. Low overall client CPU does not rule out a client-side bottleneck.
Both targets showed lower measured throughput at concurrency 25 than at concurrency 10. That observation alone does not identify the limiting component. The Python client, thread scheduling, network path and servers all participate in this measurement. The client and shared lab were not isolated from background activity. I would validate the load generator before using this curve to size either service.
Five-minute workload and resource observations
After the finite runs, I exercised each target separately for approximately five minutes with five workers and a 20 ms pause between requests per worker. The pause intentionally bounds the workload; its observed request rate is not a capacity measurement. Resource snapshots were collected roughly every 15–16 seconds, including collection overhead. The idle baseline lasted five minutes.
| Target | Requests | Measured batch seconds | Successful requests/sec | Errors |
|---|---|---|---|---|
| VM | 70000 | 300.45 | 232.99 | 0 |
| VKS | 69500 | 299.99 | 231.67 | 0 |
| Memory measure (MiB) | Idle median | Loaded median | Loaded range |
|---|---|---|---|
| VM Nginx cgroup memory | 2.50 | 8.28 | 4.51–12.08 |
| VKS container working-set memory | 3.00 | 3.00 | 3.00–3.00 |
| VM guest non-available memory | 433.35 | 441.29 | 439.60–469.75 |
| VKS worker working-set memory | 571.00 | 572.00 | 570.00–573.00 |
| VKS control-plane working-set memory | 1,960.50 | 1,990.00 | 1,958.00–2,080.00 |
These memory rows describe different accounting scopes. systemd cgroup memory, Linux MemTotal minus MemAvailable, and Kubernetes working-set memory are not interchangeable. Node totals include system services and other workloads. They illustrate the surrounding platform, but do not isolate the incremental cost of this Nginx container. The Supervisor, NSX, vCenter, storage, and other shared VCF management components are outside these totals.
During each target’s paced workload, the median VM Nginx CPU rate derived from systemd counters was 9.31 millicores; the container’s kubectl top median was 12.00 millicores. A millicore is one-thousandth of a CPU core. These sources use different sampling and accounting methods, so treat them as approximate observations rather than a precise efficiency ratio. Kubernetes describes kubectl top as a low-overhead autoscaling signal, not a perfectly accurate monitoring tool.
Restart, replacement, and reboot
I sent one HTTP probe per second with an 800 ms timeout during each action. Each test required ten consecutive correct responses afterward. A timeout counts as a failed probe. Short interruptions can fall between probes, and each recovery scenario was performed once; “no failed probes” does not prove zero downtime.
| Action | Failed probes | Longest failure sequence (probes) | First success after last failure, seconds from trigger | Validation |
|---|---|---|---|---|
| VM: systemctl restart nginx | 0 | 0 | No failed probe observed | Passed |
| VKS: gracefully stop Nginx PID 1 | 6 | 6 | 7.11 | Passed |
| VKS: delete the application pod | 6 | 6 | 7.09 | Passed |
| VM: reboot the guest | 7 | 7 | 8.64 | Passed |
These actions have different scopes. A service restart is not equivalent to a guest reboot, and replacing a pod is not equivalent to replacing a VM. The VM test explicitly asked systemd to restart Nginx. The container test sent SIGQUIT for a graceful Nginx shutdown, then observed Kubernetes restart the container. It was not a crash injection. The pod restart policy governs that container restart; the Deployment reconciles a deleted pod.
Content updates, rollback, and scaling
| Action | Failed HTTP probes | First correct content after trigger (s) | Action/command duration (s) | Validation |
|---|---|---|---|---|
| VM file update | 0 | 1.05 | 0.95 | Passed |
| VM file rollback | 0 | 1.04 | 0.94 | Passed |
| VKS ConfigMap update | 0 | 30.36 | 0.08 | Passed |
| VKS ConfigMap rollback | 0 | 53.62 | 0.08 | Passed |
| VKS: one to two replicas | 0 | 0.03 | 6.30 | Passed |
| VKS: two to one replica | 0 | 0.04 | 0.14 | Passed |
For content changes, “first correct content” means the first response with the new expected checksum; old content could still return HTTP 200 while propagation was pending. The VM action includes an SSH connection, file transfer and installation. The ConfigMap action includes the patch request, followed by asynchronous propagation to the mounted volume. Kubernetes documents that mounted ConfigMap updates can be delayed. This is not the same as deploying a new application image.
For scaling, the action duration includes waiting for the Deployment rollout. The first correct response can arrive before the new replica is ready because the existing replica keeps serving. I verified two ready replicas, then restored one. Both replicas used the same worker, so this demonstrates application replication, not worker or host high availability. A comparable horizontally scaled VM service would need another application VM and a load-balancing configuration.
Reproducing the workflow
Download the Nginx comparison test tools (ZIP). The archive includes the Python HTTP test script, local validation tests, a sample VKS application manifest, and a README. Extract the archive and read the README before running the examples.
Use a disposable test application and a dedicated namespace. Start by recording the actual Nginx builds, image digest, resource limits, node topology, readiness configuration and network path. Put the same small static file at /benchmark.html on both targets and confirm its checksum. Keep the original application page intact.
python compare_http.py --vm http://VM_ADDRESS/benchmark.html --vks http://VKS_ADDRESS/benchmark.html --requests 1000 --concurrency 5 --repeats 4 --interval 0 --expected-sha256 PAYLOAD_SHA256The comparison harness is a small Python standard-library script, not a built-in Kubernetes command. Its request CSVs contain status, body length, checksum and latency, and its JSON summaries retain every repeat. Change concurrency to 1, 10 and 25 for the other levels. Do not replace unavailable measurements with configured requests or limits.
kubectl top pods -n nginx-demo --containers
kubectl top nodes
kubectl get pods -n nginx-demo -o wide
kubectl get events -n nginx-demo --sort-by=.lastTimestamp
# On the Linux VM:
systemctl show nginx -p ActiveState -p MemoryCurrent -p CPUUsageNSec
nginx -vSave the working manifest and VM configuration before interruption tests. Probe from the client while running one action at a time, record timestamps, verify recovery, and restore the baseline. In this run I removed the temporary benchmark file and ConfigMap key, restored one VKS replica, and checked the original pages after testing.
What I would carry into the next test
Both approaches can serve this small static application, but they expose different operating workflows. The VM uses familiar guest files and systemd controls. VKS adds desired replica count, pod replacement, readiness, and declarative configuration. It also requires a Kubernetes platform whose resource use is separate from the container itself.
The next performance-focused experiment should align Nginx builds, configurations and effective application quotas, then use a load generator validated against the intended request rate. The next resilience test needs multiple workers and a planned host-failure scenario. This run did not test persistent volumes, host failure, reinstall times, automated VM recovery, or VCF Container Service. The deployment preparation time was not measured, and offline package/image preparation should be counted explicitly when that comparison is made.
For the broader blog series, this supplies the VM-versus-VKS phase and a workload to monitor in VCF Operations. The resource figures here came from Linux and Kubernetes tools; they are not results from a completed VCF Operations dashboard integration.
