Step 5 of 7 in the DevOps journey · see the full journey
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.
- 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
NetworkPolicyshould block the connection at the network layer, resulting in aConnection timed outerror.
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
504error 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.
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
client:visible
Stack and source figures
how it fits together
- cluster
-
-
Minikube + kubectlOne 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 + ServicesExternal 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
-
-
HelmPackages 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 + Alertmanagerkube-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 stackFluent 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 + JCasCBuilds, 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.