Search Tech Journey

Find topics, journeys and posts

6-month learning plan67 / 130
back to blog
systemsintermediate 55m read

S067 · Kubernetes II — ConfigMaps, Secrets, HPA, Network Policies

The next four objects you'll use every week — ConfigMaps for tuning, Secrets for credentials, HPA for autoscaling, NetworkPolicies for zero-trust inside the cluster.

⚙️SystemsM07 · Systems & Infrastructure· Session 067 of 130 90 min

🎯 Inject config + secrets into a Deployment, autoscale it on CPU with HPA, and lock down pod-to-pod traffic with a NetworkPolicy.

Why this session exists

Once you know Pod / Deployment / Service (S066), the next four objects you'll touch weekly are ConfigMap, Secret, HorizontalPodAutoscaler, and NetworkPolicy. These take a "toy" deployment and turn it into something you can actually run at scale — configurable, secret-safe, elastic, and network-isolated.

You will be able to
  • Inject configuration two ways (env vars, mounted files) and know when to prefer each.
  • Store, rotate, and consume secrets — and explain why K8s Secrets are NOT encryption-at-rest without extra work.
  • Configure HPA on CPU + custom metrics and understand its stabilisation windows.
  • Write a NetworkPolicy that denies-all + allows only the specific pod-to-pod paths you want.

Prerequisites

  • S066 · Kubernetes basics — Pod, Deployment, Service, YAML shape.
  • S063 · Caching strategies — you understand load-driven scaling triggers.
  • S085 · Security (later) — this session sets up the primitives that S085 hardens.


(a) Intuition · 5 min

A hotel with dial-in room settings and a keycard system
🌍 Real world

Each hotel room has thermostat, TV region, and Wi-Fi SSID settings that can be tuned per guest without redecorating the room. Same room hardware, per-guest configuration.

The safe in the room takes a keycard the guest gets at check-in. Housekeeping never sees the keycard. If the guest checks out, the card is invalidated — the safe is opaque to everyone else.

At peak times, the hotel opens overflow floors automatically; at 3am it closes them. Guests never notice — the concierge routes them to whatever rooms are open.

💻 Code world

ConfigMap is the thermostat + TV settings — non-secret configuration mounted into the pod as env vars or files. Secret is the room safe — credentials the pod can read but a nosy sidecar can't easily leak. HPA is the overflow-floor policy — kicks in when CPU or a custom metric crosses a threshold. NetworkPolicy is the ‘guest can walk to the gym but not the kitchen’ rule — pod-to-pod firewall inside the cluster.

What each object actually is

Four objects, four jobs
  • ConfigMap — arbitrary key-value data (usually app settings). Mount as env vars OR files. Not encrypted; do not store secrets in it.
  • Secret — same shape as ConfigMap but base64-encoded and treated specially by kubectl. Base64 is not encryption — you MUST enable etcd encryption-at-rest, or use SealedSecrets / External Secrets / Vault for real security.
  • HorizontalPodAutoscaler — controller that watches metrics (CPU, memory, custom via Prometheus adapter) and scales a Deployment's replica count up/down between minReplicas and maxReplicas.
  • NetworkPolicy — Kubernetes' pod firewall. Default posture is ‘allow all’. Once ANY policy selects a pod, that pod's traffic becomes deny-by-default and only explicit allow rules pass.

A quick history so the ecosystem makes sense

  1. 2016
    ConfigMap + Secret in K8s 1.2
    The original two config objects. Baseline for every K8s deploy since.
  2. 2016
    HPA v1 (CPU only)
    The first autoscaler — only CPU utilization, no custom metrics.
  3. 2018
    HPA v2 · custom + external metrics
    Scale on QPS from Prometheus, queue depth from SQS, anything with a metrics adapter.
  4. 2019
    NetworkPolicy widely adopted
    Calico, Cilium, Weave — CNI plugins that actually enforce NetworkPolicy. Before this, the object existed but nothing enforced it.
  5. 2022
    KEDA · event-driven autoscaling
    ‘HPA on Kafka lag / SQS depth / cron’ — the pattern most teams adopt for anything but pure request-CPU workloads.
  6. 2024
    PodSecurity Admission GA
    Namespace-level enforcement replaces the deprecated PodSecurityPolicy. Baseline / Restricted labels.

(b) Visual walkthrough · 15 min

How config + secrets flow into a pod

Two ways to inject config, side-by-side

env vars (envFrom / env)

Simple, but requires pod restart on change

  • envFrom: pulls ALL keys as env vars
  • env: pulls one key at a time (rename possible)
  • Available to any process in the container
  • Changes DO NOT propagate — pod must be restarted
volumeMount (files)

Hot-updatable, ideal for large or structured config

  • Mounted as files at /etc/config/ (one file per key)
  • K8s updates the files in-place when the CM/Secret changes
  • App must re-read the file (SIGHUP or inotify)
  • Better for TLS certs, JSON blobs, big config files

How HPA thinks — the algorithm in one sentence

The four things HPA needs to work

1
metrics-server installed

Exposes pod CPU/memory to the K8s metrics API. First-time gotcha on kind clusters — you must install it manually.

2
resources.requests set on the pod

HPA computes utilisation as `usage / requests`. No requests = no HPA (utilisation is undefined).

3
HPA manifest

Targets a Deployment/StatefulSet by name. Specifies min/max replicas and target metric(s).

4
Real load

Metrics need actual traffic to move. `hey` or `k6` or `wrk` from a client pod is the standard test tool.

NetworkPolicy — the mental model

NetworkPolicy in three rules

Empty pod-selector = affects nothing
A NetworkPolicy exists but selects no pods. Cluster defaults (allow-all) still apply. Common trap.
no-op
Any policy on a pod = deny-by-default
The moment ONE policy selects your pod, all traffic to/from that pod that isn't explicitly allowed is dropped.
shift
Ingress + Egress rules are additive
Multiple policies on the same pod combine as OR. If policy A allows traffic from web and policy B allows traffic from admin, the pod accepts both.
combine
Requires a CNI that enforces it
The object is meaningless without Calico, Cilium, Weave, or a managed cloud CNI. AWS VPC CNI on EKS needs Calico policy add-on.
cni

Common misconception
✗ What most people think

"An operator is just a controller with a fancy name. If I can write a controller loop, I understand operators."

✓ What is actually true

The controller loop is the easy part. What makes an operator hard is that it encodes operational knowledge — how to safely upgrade a stateful cluster, how to add a replica without losing quorum, how to restore from backup — and it must do so idempotently, from any partial state, with no memory of what it was doing when it was killed mid-operation. The loop is a hundred lines; the correctness of the state machine underneath is the whole engineering problem.

Why the myth is so sticky

The myth is sticky because tutorials show the reconcile loop and a CRD, and that genuinely is the shape of the code. What tutorials omit is that your operator will be restarted at an arbitrary point during a multi-step operation and must resume correctly having observed only the current cluster state — because it stored nothing. Every operator bug of consequence lives in that gap: a step that is not idempotent, or a state that the reconciler cannot distinguish from a different state requiring different action.

Prove it to yourself

The real test of any controller you write is not whether it works, but whether it is safe to interrupt:

# 1. trigger a multi-step reconcile
kubectl apply -f cluster-scale-up.yaml

# 2. kill the operator mid-operation
kubectl delete pod -n operators my-operator-xyz

# 3. does it converge, or is it wedged?
kubectl get myresource -o yaml | yq '.status'

If reconcile cannot infer the correct next action from observed state alone, the operator has a hidden dependency on its own memory — and it will corrupt something eventually.

From first principles
Start with the question

Why must a Kubernetes controller be level-triggered rather than edge-triggered? Reacting to change events sounds more efficient.

  1. 1
    Controllers learn about changes through watches, which are streams delivered over network connections.
    forced by · the API server pushes events to many watchers; there is no synchronous call per controller
  2. 2
    Network connections drop, controllers restart, and watch streams expire. Events are therefore missable — during any of those, changes happen that no event ever reports.
    forced by · a stream that is not connected cannot deliver, and there is no durable per-consumer queue guaranteeing replay of every transition
  3. 3
    An edge-triggered controller reacting only to "what changed" would therefore permanently miss transitions and leave the system in a wrong state forever, with no mechanism to notice.
    forced by · a missed edge is unrecoverable — the transition it described has already passed and will not recur
  4. 4
    Therefore the controller must act on the current state, not on the transition: given what exists now versus what is desired now, do whatever closes the gap.
    forced by · current state is always re-observable, whereas a past transition is not
  5. 5
    This forces reconcile to be idempotent and free of history: running it twice must be identical to running it once, and it must produce the correct action from any starting state.
    forced by · the controller cannot know whether it has already partially acted, so every run must be safe regardless
⇒ Therefore

Therefore level-triggered reconciliation is not a stylistic preference — it is the only design that is correct when events can be lost, which they always can be. Events are merely an optimisation that makes reconciliation prompt.

And note what this predicts: every controller must also resync periodically even with no events at all, because a missed event would otherwise leave the gap unclosed until the next unrelated change. That is exactly why controller-runtime has a resync period. It also predicts why finalizers exist: deletion is an edge that cannot be re-observed once the object is gone, so the object must be kept alive — marked with a deletion timestamp — until cleanup confirms completion. The one transition that cannot be made level-triggered is the one that required a special mechanism.

Mental modelExtend the API, not the tooling

Kubernetes is a database of typed objects plus loops that act on them. Extending it does not mean writing scripts that call kubectl — it means adding a new object type (a CRD) and a loop that reconciles it. Your abstraction then behaves exactly like a built-in: RBAC, kubectl get, events, and GitOps all work on it for free.

Everything advanced follows this shape. An admission webhook is a hook into writes. An HPA is a controller adjusting replica counts. A scheduler plugin changes placement. Same substrate, different loop.

  • Spec is user intent, status is observed reality, and controllers must never write spec. Blurring them creates a feedback loop where the controller fights the user, and it makes GitOps impossible because the declared file no longer matches the object.
  • Admission control has two phases and the order matters: mutating webhooks rewrite the object, then validating webhooks accept or reject it. A webhook on the write path is a hard availability dependency for the whole cluster — a failing webhook with failurePolicy: Fail can block all writes, including the ones you need to fix it.
  • Scheduling is a constraint solver you configure declaratively: node affinity for where a pod may go, pod anti-affinity for spreading replicas across failure domains, topology spread constraints for balance, taints and tolerations for reserving nodes. Untuned defaults will happily place all three replicas of a service on one node.
  • StatefulSets provide stable identity and ordered lifecycle, which is the minimum a distributed database needs — but they do not provide operational knowledge. Nothing built in knows how to safely rebalance shards or roll a cluster without losing quorum, and that gap is precisely what an operator fills.
🔔 Fires when you see

Fire this model when you see: a bash script polling kubectl in a loop · all replicas on one node after a failure · pods rejected cluster-wide after a webhook deploy · a StatefulSet upgrade that lost quorum · a resource stuck in Terminating.

The tradeoff

You need to run a stateful system — a database, a message broker, a search cluster — in Kubernetes. Operator, StatefulSet, or managed service outside the cluster?

Managed service outside the cluster
+ you gain someone else owns backups, failover, patching and the 3am page. Your team spends zero engineering time on the hardest part of the stack, and the failure modes are documented and supported.
− you pay cost per unit is higher, configuration flexibility is limited to what the provider exposes, and you accept vendor lock-in on your most stateful and hardest-to-migrate component.
pick when almost always for a primary database, unless you have a genuine requirement the managed service cannot meet or a team whose actual job is running databases
Community or vendor operator in-cluster
+ you gain operational knowledge is encoded and maintained by people who understand the system deeply. You get automated failover, backup and upgrade without writing it, while keeping full configuration control and staying inside your cluster.
− you pay you now depend on a third-party operator's quality and release cadence, and debugging requires understanding both the system and the operator's state machine. When the operator does something wrong to your data, you are recovering from two problems at once.
pick when a mature, widely-deployed operator for a system your team already understands well enough to intervene manually when it goes wrong
Plain StatefulSet with your own runbooks
+ you gain minimal moving parts and complete transparency — every action is something a human ran and can inspect. No operator logic to misunderstand, and no automated process making irreversible decisions unattended.
− you pay every operational task is manual, so failover speed is bounded by human response time and correctness is bounded by whether the runbook was updated after the last change. This does not scale past a couple of clusters.
pick when a small number of instances where the team already has deep expertise, or a system with genuinely simple operations
What a senior engineer actually does

Use managed services for primary datastores. The engineering effort to reach the reliability a managed service provides by default is enormous, and it is effort spent on something that is not your product.

Write your own operator only when you are encoding knowledge that exists nowhere else — an internal platform abstraction specific to your organisation, not a reimplementation of something a vendor already maintains. And if you do write one: reconcile must be idempotent, must never assume it completed its last run, and must express progress in status so a human can see what it thinks is happening. An operator that cannot explain its own state is worse than a runbook, because at least a runbook does not act on its own.


(c) Hands-on · 25 min

Extend the S066 web app with ConfigMap, Secret, an HPA, and a NetworkPolicy — end-to-end on kind.

#!/usr/bin/env bash# s067-k8s-advanced.sh ConfigMap + Secret + HPA + NetworkPolicy.# Requires: kind cluster from S066 (s066) or run `kind create cluster --name s066`.set -euo pipefail DIR="$HOME/projects/learning/s067"mkdir -p "$DIR" && cd "$DIR"log() { printf "\033[1;36m %s\033[0m\n" "$*"; } log "1/6 Ensure a cluster + metrics-server"kubectl config use-context kind-s066 >/dev/null

What each block does

Anatomy of the manifests

envFrom · configMapRef + secretRef
Pulls every key in the CM/Secret as an env var. `env` (singular) is more targeted; `envFrom` is bulk. Prefer specific `env` in production so you can rename.
env
volumeMount · /etc/config
Mounts each ConfigMap key as a file. Great for JSON blobs and TLS certs. Kubelet updates these in-place on ConfigMap change — no restart needed IF your app re-reads.
files
resources.requests (CPU)
HPA's utilisation math is `usage / requests`. Without requests, `kubectl top pod` shows numbers but HPA sees ‘unknown’ and refuses to scale.
hpa-precond
HPA behavior stanza
Scale-up is fast (15s window, 100%/30s). Scale-down is slow (5min window, 50%/60s). Asymmetric on purpose — a false-positive scale-down can drop traffic.
tuning
NetworkPolicy #1 default-deny
Empty ingress rules on the app=web selector flip the pod into deny-by-default posture. This is a foot-gun if you don't add ‘allow’ policies alongside — the app becomes unreachable.
netpol
NetworkPolicy #2 allow-clients
Explicit allow: pods labelled role=client can reach web on port 80. Everything else (including other web pods) still denied.
netpol
Try itRotate a Secret and see it propagate to a file mount (env vars won't update!)
kubectl create secret generic app-secrets \
  --from-literal=DB_PASSWORD='new-password-9999' \
  --from-literal=API_KEY='sk-newkey' \
  --dry-run=client -o yaml | kubectl apply -f -
 
POD=$(kubectl get pod -l app=web -o jsonpath='{.items[0].metadata.name}')
# env vars: still the OLD value (env only reads at container start)
kubectl exec "$POD" -- env | grep DB_PASSWORD
# mounted file: updates within ~1 minute
kubectl exec "$POD" -- cat /etc/secrets/DB_PASSWORD

Lesson: mount secrets as files if you want hot-rotation. Env vars are frozen at container start. Some frameworks (Vault Agent, cert-manager) auto-restart the pod on ConfigMap/Secret hash change — a common pattern.

💡 Hint · Change the Secret with `kubectl create secret ... --dry-run=client -o yaml | kubectl apply -f -`, then exec into the pod and cat the mounted file. The change appears within seconds.

(d) Production reality · 15 min

War story Common failure mode · secrets in git· 2024daily on GitHub
🔥 What broke
A team commits a K8s Secret manifest with data: {`{ DB_PASSWORD: c3VwZXItc2VjcmV0 }`}. Because base64 looks encrypted to the untrained eye, no one questions it. Two months later a bot on GitHub decodes it, and prod is compromised.
🧯 The fix
Use SealedSecrets (Bitnami) or External Secrets Operator. SealedSecrets encrypts with a cluster-only key so only THAT cluster can decrypt — safe to commit. External Secrets reads from Vault/AWS Secrets Manager/Azure Key Vault at runtime. Either pattern makes ‘accidentally committed a secret’ impossible.
🎓 Lesson to steal
Base64 is encoding, not encryption. If your Secret YAML can be committed to a public repo without risk, you're doing it wrong. Adopt SealedSecrets on day one of your cluster's life.
War story Common failure mode · HPA flapping under bursty load· 2024widespread
🔥 What broke
An app has 2 replicas and a bursty workload (traffic spikes every 5 min). HPA with default settings scales up to 8 during the burst, then scales back to 2 within a minute. Next burst: same. Replicas churn constantly, pods spend more time starting up than serving traffic.
🧯 The fix
Tune the behavior.scaleDown.stabilizationWindowSeconds to 300–600 (default 300, but often needs more for bursty patterns). Also set a floor via minReplicas that comfortably absorbs the burst without scaling. Consider VPA (Vertical Pod Autoscaler) for sizing recommendations that HPA can then work from.
🎓 Lesson to steal
HPA defaults optimise for smooth-load systems. Bursty traffic needs asymmetric behavior (fast up, slow down) + a sensible minReplicas. Watch kubectl get hpa for a week before trusting defaults.
War story Common failure mode · empty NetworkPolicy locks out the app· 2024every K8s novice
🔥 What broke
A well-meaning engineer applies a "default-deny-all" NetworkPolicy to a namespace to improve security. They forget to add the corresponding "allow DNS to kube-dns" and "allow ingress from the ingress-controller" policies. Every pod in the namespace becomes unreachable AND can't resolve DNS. Debugging is hard because kubectl commands still work (they hit the API server, not pods).
🧯 The fix
Always ship default-deny + explicit allow rules for kube-dns (UDP 53), the ingress controller namespace, and cross-namespace pods that legitimately need to reach you. Test in a staging namespace first. Tools like `netpol-validator` and Cilium's Hubble visualise policy effect before enforcement.
🎓 Lesson to steal
NetworkPolicy is a firewall. Firewalls without explicit allows drop everything. Default-deny is a POSTURE; a working system needs the paired allow-list.

Common footguns

  • ConfigMap change with envFrom — env vars don't hot-reload. Trigger a rollout on config change: annotate the Deployment with a hash of the config, so any change bumps the hash and triggers a rolling restart.
  • HPA on non-CPU metric with no adapter — Prometheus Adapter or KEDA is needed to expose custom/external metrics. Vanilla K8s only gives you CPU + memory via metrics-server.
  • NetworkPolicy on a CNI that doesn't enforce it — kindnet, some early managed clouds. The manifest applies cleanly and enforces nothing. Verify with a deny-all test.
  • Secrets as env vars logged accidentally — a stack trace including os.environ dumps every secret to your log aggregator. Filter env vars in your logger; prefer file-mounted secrets when possible.

Where this shows up in the rest of the plan

Advanced K8s primitives feed the ops sessions
S068 · Azure Cloud
AKS ships these primitives with Azure Policy overlays. Enterprise defaults.
S069 · IaC · Terraform
Terraform provisions the cluster, kubectl or Helm applies these manifests.
S078 · SRE · SLOs
HPA tuning is guided by SLO burn rate. Autoscale before you burn budget.
S085 · Security · pod security
NetworkPolicy is the ‘network firewall’ layer; PSA is the ‘pod security profile’ layer.
S086 · Container security
SealedSecrets, image signing (cosign), admission control — the security stack around this.
S090 · System design · scale to millions
HPA + cluster autoscaler + KEDA is the pattern for elastic microservices.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. What's the difference between ConfigMap and Secret, honestly?
  2. What does an HPA need to work at all?
  3. What is the ‘default posture’ of a pod once ANY NetworkPolicy targets it?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.