Skip to content

Step 5 of 7 in the DevOps journey · see the full journey

2025

Cluster Chronicles

Six virtual machines replaced by one Kubernetes cluster, declared in manifests and Helm charts.

Overview

I moved the application, CI and observability workloads from the VM fleet into a single-node Minikube lab. The work focused on declarative deployment, network-policy enforcement and checking how the cluster behaved under synthetic load.

context
Coursework
status
Archived learning project
role
Solo
started
Aug 2025
setting
Local single-node Minikube lab with Calico, Helm, in-cluster Jenkins and observability services.
built with
  • Kubernetes
  • Minikube
  • Calico
  • Helm
  • Prometheus Operator
  • Grafana
  • +4

Key decisions

Verify that the CNI actually enforces policy
A valid NetworkPolicy was insufficient with kindnet. I rebuilt the cluster with Calico and repeated the connectivity checks.
Include infrastructure traffic in access rules
Policies had to allow the ingress controller and Prometheus as well as the frontend, using the actual namespace and pod labels.
Give deployment automation one committed source
The local script and startup-triggered Jenkins pipeline both applied manifests. Tracing a reversion exposed the cost of deploying different versions.

Demonstrated results

Repeated tests distinguished enforced and unenforced policy
An unexpected HTTP response exposed the ineffective policy. After switching CNI, the disallowed connection timed out and ingress access was repaired separately.
The frontend scaled from two replicas to ten
The journal records a five-minute hey load test and an HPA progression of 2, 4, 8 and 10 replicas in the local cluster.

Known limitations

This was a single-node lab
The exercise demonstrates workload orchestration and scaling replicas on one node, not multi-node availability or production capacity.
A memory alert was tied to one machine
Its denominator was changed to a hardcoded 10 GiB value for Docker Desktop. That workaround is not portable to different resource limits.
Some alert coverage remained test-specific
A container-memory rule selects only the verification pod, leaving other containers outside that rule's coverage.

The policy that did nothing

The verification test was supposed to fail. A temporary pod without the required label was started, and expected to time out reaching the backend.

It got an HTTP response instead.

Receiving an application-level HTTP response is definitive proof that the TCP connection was successful. A working NetworkPolicy should block the connection at the network layer, resulting in a Connection timed out error.

The kind of response carries information. An HTTP response such as a 404 proves that a TCP connection to the responding server completed. A timeout alone does not: it can occur while establishing the connection or while waiting for a response. In this test, the unexpected HTTP response showed that the intended network isolation was absent.

The object was valid and present in the API. The cluster was simply not enforcing it, because Minikube's default kindnet does not implement NetworkPolicy at all.

minikube delete --all --purge
minikube start --driver=docker --network-plugin=cni --cni=calico --cpus=4 --memory=10240

Re-verified: failed: Operation timed out.

Then it blocked too much

Switching CNI fixed the silence and immediately caused a different failure: app.local returning 504 Gateway Time-out. The first hypothesis was a deadlock in the Go handler.

That was disproved by simulating the ingress controller from inside its own namespace:

kubectl run -it --rm=true ingress-debug-pod --namespace=ingress-nginx --image=alpine:latest -- /bin/sh
# wget -O- --timeout=5 http://<BACKEND_POD_IP>:5000/api/info

It timed out. So:

The 504 error was not an application bug, but a network firewall issue.

The final policy has three ingress rules: from the frontend, from the ingress controller, and from Prometheus on the metrics port.

Autoscaling under synthetic load

The journal records running hey -z 5m -c 50 against the frontend service while watching the HPA. Replica counts progressed from 2 → 4 → 8 → 10, reaching the configured maximum as CPU utilisation exceeded the target. The exercise also adjusted scale-down behaviour to make the demonstration easier to observe.

This checks the HPA control path in a single-node Minikube environment. Increasing pod replicas on that node does not add physical capacity or demonstrate multi-node failover, and the test is not a production throughput benchmark.

The ghost in the machine

Custom alerting rules appeared correctly after a cluster reset, then reverted to an older version minutes later.

The first diagnosis was a Helm chart name collision. The chart creates a PrometheusRule with the same metadata.name as the manually applied one. That was tested and disproved. The clue that broke it open was the state of the reverted rules: they were not disappearing, they were being replaced by an older version.

The ghost was the project's own CI. connect.sh deployed Jenkins, JCasC configured it to run its pipeline on startup, and that pipeline checked out main and ran kubectl apply -f manifests/, overwriting the correct local state with stale committed files.

When multiple automation systems (a local deployment script and a remote CI/CD pipeline) can affect the same resources, it is essential to ensure they are working from the same source of truth. The issue was not a bug in Helm or Kubernetes, but a flaw in the overall development workflow.

The alert that could not fire

A memory alert divided observed usage by Kubernetes' reported allocatable memory. Kubernetes said 16GiB. The Docker cgroup capped at 10GiB.

10 / 16 = 62.5%

So a > 0.9 threshold was arithmetically unreachable. The rule now divides by 10737418240 explicitly. A second discovery followed: node memory stops at about 89% in the recorded environment. The journal attributes the gap to reserved resources; the observed percentage is not a universal Kubernetes limit.

And one that silently did nothing

A Grafana dashboard ConfigMap was created with an empty data section:

data:
  app-performance-dashboard.json: ""

Helm did not error when it could not read the file. It created an empty resource.

Declarative systems can sometimes fail silently.

And the twelve rules, read against the twelve in the previous stage

Both rule files declare exactly twelve alerts: this one in three groups, sherlock.rules.yml in four. Counting them was easy; reading them side by side is where the interesting part is.

The same condition, two different thresholds.

# VM stage — 80% of the guest's own CPUs
(1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100 > 80

# Kubernetes stage — 80% of the container, normalised against a 16-CPU host
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 20

The annotation on the Kubernetes rule says "CPU load has been over 80%", and the number in the expression is 20. The file's comment attempts to justify this as host/container normalisation, but the stated counts of 16 and 8 CPUs do not yield the claimed 25% relationship. That arithmetic is not a verified explanation of the threshold. The rule needs resource-normalisation checks against the actual exporter and cgroup setup before being reused on another machine.

A general rule narrowed to one pod.

sum by(pod) (container_memory_working_set_bytes{pod="container-mem-test-pod"})
/
sum by(pod) (kube_pod_container_resource_limits{pod="container-mem-test-pod", resource="memory"})

Both sides are pinned to container-mem-test-pod, the workload used to verify that memory limits are enforced. The VM stage has the same rule covering every container with a limit set, page cache excluded, and an explicit limit > 0 guard against dividing by zero. The general case was not generalised here; it was replaced by the test case. On this cluster, a container genuinely leaking memory would not have fired it.

Three rules carry no for clause or for: 0m. BackendPodFrequentlyRestarting has no for at all and fires on first evaluation; ElasticsearchStatusRed and its VM counterpart write for: 0m explicitly to say the same thing. One is a default nobody chose, the other is a decision. They behave identically. That is why the AlertGallery records both forms rather than normalising them.

And the fix above, in context. Replacing 16GiB with 10737418240 did make the memory alert reachable on Docker Desktop, where the cgroup happens to be 10GiB. It is not a general fix; it is a second hardcoded constant that happens to be right for the machine the cluster ran on. The page called it a fix because at the time, on that machine, it was one. An appropriate allocatable or cgroup-limit series would avoid hardcoding capacity, but the numerator and denominator must still describe the same resource boundary.

Interactive models

Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.

VM and Kubernetes alert comparison

AlertGallery

11 shipped rules across 4 groups.

4 critical

the same rule, both environments

  • High CPU load

    vm: `> 80` — 80% of the guest's own CPUs

    k8s: `> 20` — 80% of the container, normalised against a 16-CPU host

    The same condition reads 20 on one environment and 80 on the other. Neither number is wrong; they measure different things. But an alert copied between the two files silently changes what it means, which is why the k8s file needed a comment explaining that 80 became 20.

  • Container memory over limit

    vm: All containers with a limit set, page cache excluded

    k8s: One named pod: `container-mem-test-pod`

    The general rule was narrowed to the test workload rather than generalised. On the cluster it could only fire for the pod used to verify the limit behaviour.

  • Cluster health

    vm: `ElasticsearchClusterHealthYellow` / `…Red`, `for: 1m` / `0m`

    k8s: `ElasticsearchStatusYellow` / `…Red`, `for: 2m` / `0m`

    Same three signals, renamed between environments. The yellow window differs too — one minute on VMs, two in the cluster — with no comment explaining the change.

  • Rules that never made it to Kubernetes

    vm: LowMemory, LowDiskSpaceCritical, LowDiskSpaceWarning, ContainerRestarting, DiskWillFillUpIn24Hours, HighApiErrorRate

    k8s: —

    Six rules from the VM set have no counterpart. Four of them have a differently-shaped replacement in the k8s set; `HighApiErrorRate` and the disk-trend prediction were simply not carried over.

counts: sherlock 12 defined / 11 shipped in 4 groups · prometheus 12 in 3

ansible/roles/prometheus_server/files/sherlock.rules.yml

client:visible

Reconstructed VM and Kubernetes rule sets. Use the rule-set selector to compare the thresholds and coverage of the two stages.
Stack and source figures

how it fits together

cluster
  • Minikube + kubectl One single-node cluster on the Docker driver, replacing six Vagrant VMs. Started with --cni=calico, because the default kindnet accepts NetworkPolicy manifests and ignores them.
networking
  • Calico + Ingress + Services External access via the ingress controller, internal service-to-service via Services and NetworkPolicy. The policy needed the controller's namespace exempted after it took the application down.
packaging
  • Helm Packages the observability services. The deployment script must install the operator and its CRDs before dependent custom resources; GitOps sync waves belong to the later stage.
observability
  • Prometheus Operator + Grafana + Alertmanager kube-prometheus-stack, plus twelve custom rules in three groups. Some thresholds are normalised against a 16-CPU host rather than the 80 the annotation claims.
logging
  • EFK stack Fluent Bit ships to Elasticsearch 8. A field that 8 removed, _type, silently stopped all log shipping until it was deleted from the pipeline config.
ci/cd
  • Jenkins in-cluster + JCasC Builds, tests and deploys with Trivy, Gosec and Hadolint in the pipeline.
cluster
1 Minikube, replacing 6 VMs.
manifests
34
Helm releases
6
custom alerts
12 In 3 groups.
checklist
1,607 lines Written to be reviewed against, not just ticked.
commits
124
Full issue record · 8 entries

what broke, and what happened

  • 7 fixed / 8
  • 1 workaround / 8

Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.

  1. fixed

    A NetworkPolicy that silently did nothing at all

    An HTTP response came back from an unlabelled pod when a timeout was expected. Minikube's default kindnet does not support NetworkPolicy.

    then: Minikube is now started with `--cni=calico`, which enforces policies. kindnet accepts the manifest and ignores it, so nothing failed and nothing worked.

  2. fixed

    After switching to Calico, the same policy worked too well and took down the application

    `app.local` returned `504 Gateway Time-out`. Simulating the ingress controller from inside `ingress-nginx` proved the policy was blocking it.

    then: Ingress controller namespace exempted from the policy, found by reproducing the request from inside the controller rather than from a laptop.

  3. fixed

    A selector typo found by reading live labels rather than assuming

    The label was `app.kubernetes.io/name`; the policy said `app.kubernetes.io/metadata.name`. Found with `kubectl get pods --show-labels`.

    then: Corrected to `app.kubernetes.io/name` in `backend-netpol.yml`, matched against `kubectl get pods --show-labels` rather than against what the label ought to be.

  4. fixed

    Custom alerting rules reverted to old values minutes after being applied, and the first diagnosis was wrong

    The initial hypothesis, a Helm chart name collision, was disproved by experiment. The real cause: Jenkins, configured via JCasC to run on startup, checked out main and re-applied the old committed manifests.

    then: The startup pipeline was identified as the writer applying the older committed files. Local deployment and CI must use the same committed manifests; ArgoCD ownership is introduced in the subsequent GitOps stage.

  5. workaround

    A memory alert that could never fire, because the arithmetic compared two different truths

    Kubernetes reported 16GiB allocatable; the Docker cgroup capped at 10GiB. The maximum expressible value was 10/16 = 62.5%, so a 90% threshold was unreachable.

    then: Divided by `10737418240` explicitly, which makes it fire on Docker Desktop. That is a second hardcoded constant rather than a fix. It is right for the machine this ran on and wrong on a real node.

  6. fixed

    An Alertmanager secret named alertmanager.yml when the operator wanted alertmanager.yaml

    `key not found in the config secret, using default Alertmanager configuration`

    then: Config selector names the `main` config in the chart values, so the operator resolves the secret it actually publishes rather than the one it guessed at.

  7. fixed

    The Elasticsearch exporter fought through six distinct failures before working

    A 401, a Helm templating type error, repeated-flag errors, missing client certs, silently ignored extraEnv, and a chart downgrade that failed differently.

    then: Client certificates mounted and the chart values corrected; six failures were six unrelated configuration mistakes rather than one deep problem.

  8. fixed

    Fluent Bit could not ship logs because it sent a field Elasticsearch 8 removed

    `illegal_argument_exception ... contains an unknown parameter [_type]`

    then: The `_type` field is gone from the pipeline config. Elasticsearch 8 removed it, and the reference survived from the 7.x defaults.