Nginx on a VM vs VKS: A VCF Lab Comparison

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

SettingNative VMVKS container
Operating systemUbuntu 24.04.5 LTSAlpine Linux 3.24 container
Nginx version1.24.0 (Ubuntu package 1.24.0-2ubuntu7.18)1.30.5
Application resourcesVM: 2 vCPUs, 4 GiB RAM; no matching service quotaRequest: 50m CPU / 64 MiB; limit: 500m CPU / 256 MiB
ExposureHTTP directly to the VMLoadBalancer Service, port 80 to container port 8080
Supervisionsystemd serviceOne-replica Deployment with a readiness probe
Test content16,384-byte static fileIdentical 16,384-byte file through a ConfigMap
Platform footprintOne application VMOne 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.

TargetConcurrencyRequests/sec: median (range)p95 msp99 msErrors
VKS11,664.07 (1,537.97–1,828.04)0.671.000
VM11,976.58 (1,660.61–2,281.71)0.570.670
VKS55,936.41 (5,013.95–6,135.19)0.881.400
VM55,859.66 (5,518.05–6,516.55)0.951.910
VKS106,432.01 (5,909.03–6,516.81)1.693.320
VM106,420.99 (5,903.09–6,728.18)1.694.980
VKS253,328.28 (3,107.91–3,657.57)7.1026.080
VM253,002.21 (2,842.44–3,275.30)6.8422.770

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.

TargetRequestsMeasured batch secondsSuccessful requests/secErrors
VM70000300.45232.990
VKS69500299.99231.670
Memory measure (MiB)Idle medianLoaded medianLoaded range
VM Nginx cgroup memory2.508.284.51–12.08
VKS container working-set memory3.003.003.00–3.00
VM guest non-available memory433.35441.29439.60–469.75
VKS worker working-set memory571.00572.00570.00–573.00
VKS control-plane working-set memory1,960.501,990.001,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.

ActionFailed probesLongest failure sequence (probes)First success after last failure, seconds from triggerValidation
VM: systemctl restart nginx00No failed probe observedPassed
VKS: gracefully stop Nginx PID 1667.11Passed
VKS: delete the application pod667.09Passed
VM: reboot the guest778.64Passed

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

ActionFailed HTTP probesFirst correct content after trigger (s)Action/command duration (s)Validation
VM file update01.050.95Passed
VM file rollback01.040.94Passed
VKS ConfigMap update030.360.08Passed
VKS ConfigMap rollback053.620.08Passed
VKS: one to two replicas00.036.30Passed
VKS: two to one replica00.040.14Passed

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_SHA256

The 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 -v

Save 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.