Production Checklist and the Kubernetes API Client
The idea in one sentence
Section titled “The idea in one sentence”A production-ready workload is not one feature but a checklist of independent controls — resource limits, probes, disruption budgets, least-privilege RBAC, network policy, real secret encryption, bounded autoscaling, spread-out replicas, and namespace quotas — each covering a failure mode the others do not.
The checklist
Section titled “The checklist”Every item below closes a specific gap. None of them is optional in a real production cluster, and none of them substitutes for another.
- Resource
requests/limits— set both, not just one.requestsdrives scheduling and determines a Pod’s QoS class (Guaranteed,Burstable, orBestEffort), which the kubelet uses to decide eviction order under node pressure.limitscaps how much a runaway container can take from its neighbors. - Readiness AND liveness probes — a readiness probe controls whether a Pod receives Service traffic (fixes “sends traffic to a Pod that is not ready yet”); a liveness probe controls whether the kubelet restarts the container (fixes “process is alive but hung, and traffic keeps failing”). They solve different problems and a workload needs both.
- PodDisruptionBudget (PDB) for any multi-replica service — bounds how many replicas a voluntary disruption (node drain, cluster upgrade) is allowed to take down at once, so a rolling node upgrade cannot accidentally take an entire service to zero replicas.
- RBAC scoped to least privilege — Roles and RoleBindings (or ClusterRole/ClusterRoleBinding) granting exactly the verbs and resources a workload’s ServiceAccount needs, not
cluster-adminby default. - Default-deny NetworkPolicy where appropriate — without one, every Pod can reach every other Pod in the cluster by default; a default-deny policy plus explicit allow rules makes lateral movement from a compromised Pod much harder.
- Real secret protection — encryption at rest for etcd (Secrets are base64-encoded, not encrypted, unless an
EncryptionConfigurationis applied) or an external secret manager, not base64 alone as a security boundary. - A sensibly bounded HorizontalPodAutoscaler —
minReplicasprevents scaling to zero for a service that must always be available,maxReplicascaps cost and downstream load (databases, dependencies) during a traffic spike. - Multiple replicas spread across nodes/zones — Pod anti-affinity or topology spread constraints, so a single node or availability zone failure does not take down every replica at once; replica count alone does not guarantee this.
- ResourceQuota/LimitRange per namespace — a
ResourceQuotacaps total resource consumption for a namespace, and aLimitRangesupplies sane per-container defaults so a Pod deployed without explicitrequests/limitsdoes not silently becomeBestEffort.
apiVersion: apps/v1kind: Deploymentmetadata: name: orders-apispec: replicas: 3 selector: matchLabels: app: orders-api template: metadata: labels: app: orders-api spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - topologyKey: 'kubernetes.io/hostname' labelSelector: matchLabels: app: orders-api containers: - name: orders-api image: myregistry/orders-api:2.3.0 resources: requests: cpu: '250m' memory: '256Mi' limits: cpu: '500m' memory: '512Mi' readinessProbe: httpGet: path: /healthz/ready port: 8080 periodSeconds: 5 livenessProbe: httpGet: path: /healthz/live port: 8080 periodSeconds: 10---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: orders-api-pdbspec: minAvailable: 2 selector: matchLabels: app: orders-apiClosing the loop with a programmatic verification script
Section titled “Closing the loop with a programmatic verification script”kubectl rollout status covers the interactive case, but a deployment pipeline often needs the same check as code — for example, gating a promotion step on a Deployment actually reaching its desired replica count, not just on kubectl apply returning successfully. @kubernetes/client-node’s KubeConfig.loadFromDefault() resolves credentials the same way whether the script runs on an engineer’s laptop (via KUBECONFIG or ~/.kube/config) or inside the cluster itself as a CI job or Job Pod (via the mounted ServiceAccount token) — no branching logic needed for either environment:
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();// Resolves KUBECONFIG env var, then ~/.kube/config, then in-cluster ServiceAccountkc.loadFromDefault();
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
async function verifyRollout(name: string, namespace: string, timeoutMs = 120_000): Promise<void> { const start = Date.now();
while (Date.now() - start < timeoutMs) { const dep = await appsApi.readNamespacedDeployment({ name, namespace });
const desired = dep.spec?.replicas ?? 0; const ready = dep.status?.readyReplicas ?? 0; const updated = dep.status?.updatedReplicas ?? 0;
console.log(`${namespace}/${name}: ${ready}/${desired} ready, ${updated}/${desired} updated`);
if (ready === desired && updated === desired) { console.log('rollout complete'); return; }
await new Promise((resolve) => setTimeout(resolve, 3000)); }
throw new Error(`rollout of ${namespace}/${name} did not complete within ${timeoutMs}ms`);}
await verifyRollout('orders-api', 'default');This is the exact case where a programmatic client beats a shell script wrapping kubectl: the result feeds directly into a pipeline’s pass/fail decision, with typed access to status.readyReplicas and status.updatedReplicas instead of parsing CLI text output.
ApiException: real error handling, not string-matching stderr
Section titled “ApiException: real error handling, not string-matching stderr”Every failed request from @kubernetes/client-node 2.x throws a typed ApiException carrying the real HTTP status code and the API server’s own Status body — proven against a Deployment that genuinely does not exist:
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();const appsApi = kc.makeApiClient(k8s.AppsV1Api);
try { await appsApi.readNamespacedDeployment({ name: 'does-not-exist', namespace: 'default' });} catch (err) { if (err instanceof k8s.ApiException) { console.log(`[ApiException] code=${err.code} body=${JSON.stringify(err.body)}`); } else { throw err; }}$ npx tsx src/ssa-and-errors.ts[ApiException] code=404 body="{\"kind\":\"Status\",\"apiVersion\":\"v1\",...,\"message\":\"deployments.apps \\\"does-not-exist\\\" not found\",\"reason\":\"NotFound\",\"code\":404}\n"err.code is the real HTTP status (404), and err.body is the API server’s actual Status object (reason: "NotFound", the same shape kubectl itself parses) — a pipeline can branch on err.code === 404 to mean “not deployed yet” versus err.code === 409 to mean “conflict” versus anything else being a genuine failure, all without ever parsing a CLI error string.
Server-Side Apply from the TypeScript client — including a real conflict
Section titled “Server-Side Apply from the TypeScript client — including a real conflict”The Server-Side Apply mechanism this course covers for kubectl apply --server-side is available from @kubernetes/client-node 2.x too, via k8s.setHeaderOptions setting the Content-Type: application/apply-patch+yaml header and a fieldManager on the request. Proven by attempting to SSA-patch a Deployment already managed by Helm’s own field manager (the same orders-orders-api Release from the Helm lesson):
// (excerpt, continues from ssa-and-errors.ts above -- reuses kc/appsApi, not independently valid)const patch: k8s.V1Deployment = { apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: 'orders-orders-api', namespace: 'default' }, spec: { replicas: 4, selector: { matchLabels: { app: 'orders' } }, template: { metadata: { labels: { app: 'orders' } }, spec: { containers: [{ name: 'orders-api', image: 'nginx:1.27-alpine' }] }, }, },};
try { const result = await appsApi.patchNamespacedDeployment( { name: 'orders-orders-api', namespace: 'default', body: patch, fieldManager: 'ts-client-demo' }, k8s.setHeaderOptions('Content-Type', 'application/apply-patch+yaml'), ); console.log(`[SSA patch] replicas now ${result.spec?.replicas}`);} catch (err) { if (err instanceof k8s.ApiException) { console.log(`[SSA conflict] code=${err.code} body=${JSON.stringify(err.body)}`); }}$ npx tsx src/ssa-and-errors.ts[SSA conflict] code=409 body="{\"kind\":\"Status\",...,\"message\":\"Apply failed with 1 conflict: conflict with \\\"helm\\\": .spec.replicas\",\"reason\":\"Conflict\",\"details\":{\"causes\":[{\"reason\":\"FieldManagerConflict\",\"field\":\".spec.replicas\",...}]},\"code\":409}\n"A real 409 Conflict, naming the exact field (.spec.replicas) and the exact competing field manager ("helm") — identical in shape to the kubectl apply --server-side conflict this course proves in the foundations lesson, because it is the same API-server mechanism underneath. The fix is the same one too: either don’t manage a field another manager already owns, or pass force: true in the patch options (the PatchStrategy/force-conflicts equivalent) to take ownership deliberately.
Watch: a real event stream, not polling
Section titled “Watch: a real event stream, not polling”k8s.Watch opens a genuine long-lived HTTP connection to the API server and streams ADDED/MODIFIED/DELETED events as they happen — proven by watching a single Deployment and aborting the connection cleanly on a timer:
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();const watch = new k8s.Watch(kc);
const req = await watch.watch( '/apis/apps/v1/namespaces/default/deployments', { fieldSelector: 'metadata.name=orders-orders-api' }, (type: string, obj: any) => { console.log(`[${type}] replicas=${obj.spec?.replicas} readyReplicas=${obj.status?.readyReplicas}`); }, (err: any) => { if (err) console.error('watch ended with error', err); },);
setTimeout(() => req.abort(), 5000);$ npx tsx src/watch-deployment.ts[ADDED] replicas=3 readyReplicas=3watch ended with error DOMException [AbortError]: This operation was abortedThe initial ADDED event delivers the object’s current state the instant the watch opens (the same “list, then watch from that resourceVersion” pattern kubectl get --watch uses, proven elsewhere in this course); every subsequent real change to the object streams as its own MODIFIED event with no polling interval to tune. req.abort() is a real AbortController call, which is why the error callback fires with a genuine AbortError rather than the watch silently leaking a connection — makeInformer (proven in the Operators lesson) wraps exactly this Watch class with a local cache and automatic relist, and is almost always the better choice for a real controller; raw Watch is the right tool for a one-off “stream events for N seconds” script like this one.
In-cluster auth, proven from inside a real Pod
Section titled “In-cluster auth, proven from inside a real Pod”KubeConfig.loadFromDefault() resolving to the mounted ServiceAccount token when running inside a cluster (loadFromCluster() internally, triggered automatically when KUBERNETES_SERVICE_HOST is set) is not a black box — proven by reading the exact same files and environment variables by hand, from inside a real Pod with a minimal, least-privilege ServiceAccount:
# ServiceAccount + Role + RoleBinding + Pod applied in order (real apply, not dry-run) --# under dry-run=server nothing persists between documents in the same fence, so the# Pod's serviceAccountName lookup would fail a cross-document dry-run; no k8s/ path hereapiVersion: v1kind: ServiceAccountmetadata: name: api-client-demo namespace: default---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: api-client-demo-role namespace: defaultrules: - apiGroups: ['apps'] resources: ['deployments'] verbs: ['get', 'list']---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: api-client-demo-binding namespace: defaultsubjects: - kind: ServiceAccount name: api-client-demo namespace: defaultroleRef: kind: Role name: api-client-demo-role apiGroup: rbac.authorization.k8s.io---apiVersion: v1kind: Podmetadata: name: api-client-demo namespace: defaultspec: serviceAccountName: api-client-demo containers: - name: curl image: curlimages/curl:8.11.1 command: ['sleep', '3600']$ kubectl exec api-client-demo -- sh -c 'TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)curl -sS --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ -H "Authorization: Bearer $TOKEN" \ "https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/apis/apps/v1/namespaces/default/deployments/orders-orders-api" \ | head -c 400'{ "kind": "Deployment", "apiVersion": "apps/v1", "metadata": { "name": "orders-orders-api", ...That is the entirety of what loadFromCluster() automates: read the token from /var/run/secrets/kubernetes.io/serviceaccount/token, the CA from the sibling ca.crt, and the API server address from KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT — no external configuration, no kubeconfig file inside the Pod at all. RBAC applies exactly the same way it does to any other identity — proven by attempting a verb the Role does not grant:
$ kubectl exec api-client-demo -- sh -c '... -X DELETE "https://.../deployments/orders-orders-api"'{ "kind": "Status", "status": "Failure", "message": "deployments.apps \"orders-orders-api\" is forbidden: User \"system:serviceaccount:default:api-client-demo\" cannot delete resource \"deployments\" in API group \"apps\" in the namespace \"default\"", "reason": "Forbidden", "code": 403}A real 403, naming the exact identity, verb, resource, and namespace — the api-client-demo ServiceAccount’s Role only grants get/list, so delete is rejected at the authorization layer exactly like the RBAC lesson proves for every other identity in this course. loadFromCluster() (and, transitively, loadFromDefault() running inside a Pod) is not a different authorization system — it is the exact same bound-token, RBAC-checked identity this course covers throughout, reached through a slightly different bootstrap path.
The production checklist, upgraded with what this course has now proven
Section titled “The production checklist, upgraded with what this course has now proven”Every item below links to the lesson that proved it live, not just described it:
- Resource requests/limits and QoS classes — proven in The Scheduler and Resource Requests.
- Readiness and liveness probes, and the real restart-loop failure mode of pointing both at the same endpoint — proven in Probes: Liveness, Readiness, Startup.
preStophooks and the termination grace period — proven in Pods: The Atomic Unit.- PodDisruptionBudget, and that it blocks voluntary drains but not preemption — proven in Pod Disruption and Cluster Autoscaling.
- Topology spread constraints and
nodeTaintsPolicy: Honor— proven in Node Affinity, Taints, and Tolerations. - Pod Security Admission (
restricted) and ValidatingAdmissionPolicy — proven in Pod and Cluster Security. - Least-privilege RBAC, bound ServiceAccount tokens, and the
cluster-adminanti-pattern — proven in RBAC and Service Accounts and re-proven above with a real403. - Server-Side Apply field-manager discipline — proven in kubectl and Declarative YAML and re-proven above with a real
409from this very TypeScript client. - A sensibly bounded HorizontalPodAutoscaler — proven in Horizontal Pod Autoscaling.
flowchart TB dep["Deployment: orders-api (3 replicas, spread across nodes)"] dep --> probes["Readiness + liveness probes"] dep --> resources["requests/limits (QoS)"] dep --> pdb["PodDisruptionBudget (minAvailable: 2)"] dep --> rbac["RBAC: least-privilege ServiceAccount"] dep --> netpol["Default-deny NetworkPolicy + explicit allows"] dep --> hpa["HorizontalPodAutoscaler (bounded min/max)"] ns["Namespace"] --> quota["ResourceQuota / LimitRange"] quota --> dep
Mental Model
Section titled “Mental Model”@kubernetes/client-node is not a separate authorization system from kubectl — proven above, its ApiException codes, its Server-Side Apply conflicts, and its in-cluster bootstrap all resolve to the exact same API-server mechanisms (Status objects, field managers, bound ServiceAccount tokens, RBAC) this course proves throughout with kubectl. The entire value of the TypeScript client is typed, programmatic access to those same mechanisms — err.code === 409 instead of grepping stderr for “conflict,” result.spec?.replicas instead of parsing kubectl get -o jsonpath output — never a different set of rules underneath.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
kubectl scripted in a shell pipeline | Fast to write, no dependencies to install | Proven above: error handling means parsing text output; a 404 and a 403 look the same until you grep the message string |
@kubernetes/client-node typed client | Proven above: real ApiException.code, typed result.spec?.readyReplicas, no string parsing | Requires a Node/TS toolchain in the pipeline, more setup than a bare kubectl call |
Raw Watch class | Proven above: minimal, real event stream for a short-lived script | No cache, no automatic relist on error — proven in the Operators lesson, makeInformer adds both |
makeInformer (client-node) | Local cache + automatic relist-on-error, right choice for a long-running controller | More moving parts than a one-shot Watch for a script that only needs to observe briefly |
loadFromDefault() everywhere (laptop and in-cluster) | Proven above: identical code path, no environment branching in application code | The RBAC the in-cluster ServiceAccount holds must still be deliberately scoped — proven above, a Role that’s too broad is just as dangerous as a hardcoded laptop kubeconfig with too much access |
Common Misconceptions
Section titled “Common Misconceptions”- “A client library error is just whatever text the API server happened to return.” Proven above:
ApiException.codeis the real, structured HTTP status (404,409), and.bodyis the API server’s actual parsedStatusobject — a pipeline can branch on it reliably, not fuzzy-match a string. - “Server-Side Apply conflicts are a kubectl-only concept, the TypeScript client doesn’t have them.” Proven above with a real
409: the identical field-manager conflict mechanism applies to any client sending an apply-patch content type,kubectlor otherwise. - “Watch and an Informer are the same thing with different names.” Proven above and in the Operators lesson:
Watchis a raw event stream with no cache and no reconnect logic;makeInformerwraps it with both, which is why a real controller should almost always reach for the latter. - “loadFromCluster() must be doing something different or more privileged than a normal ServiceAccount.” Proven above by reading the exact same token/CA files by hand with curl: it is the identical bound-token, RBAC-checked mechanism as any other ServiceAccount, reached by a different bootstrap path, not a special-cased escalation.
- “If the client library call didn’t throw, the operation must have fully succeeded as intended.” A successful Server-Side Apply patch can still silently drop fields the caller expected to set if another field manager already owns them and no conflict was triggered because the values happened to match — proven indirectly above, a conflict only surfaces when the values actually differ, not merely when two managers both reference the same field.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated TypeScript/Node code that talks to the Kubernetes API, check for:
- A bare
try/catchthat logserr.messageorString(err)instead of checkingerr instanceof k8s.ApiExceptionand branching onerr.code— proven above, the structured code and body are right there and far more reliable than string matching. @kubernetes/client-node1.x-style positional-argument API calls (e.g.readNamespacedDeployment(name, namespace)) instead of 2.x’s object-parameter form (readNamespacedDeployment({ name, namespace })) used throughout this course — a 1.x-trained assistant’s code will not type-check against a 2.x install.- A Server-Side Apply patch with no
fieldManagerset, or one reused across genuinely different logical owners — proven above, the conflict message names the exact field manager at fault, so an unset or shared one makes a real conflict much harder to attribute later. - Code that reaches for a hand-rolled polling loop (
setInterval+readNamespacedDeployment) to detect a change, whenWatchormakeInformer(both proven above) deliver the same information as a real push the moment it happens, with less code and no polling-interval trade-off to tune. - A Pod’s ServiceAccount granted
cluster-admin“so the client library definitely has whatever permissions it needs” — proven above with a real403, RBAC applies to the client exactly as it does to any other identity; the fix is a scoped Role naming only the verbs the code actually calls. KubeConfig.loadFromDefault()wrapped in anif (inCluster) { loadFromCluster() } else { loadFromDefault() }branch — proven above,loadFromDefault()already does this resolution internally; the branch is unnecessary code that can silently diverge from what the library itself does.
🐛 Spot the bug
import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();const appsApi = kc.makeApiClient(k8s.AppsV1Api);
async function isRolledOut(name: string, namespace: string): Promise<boolean> {try { const dep = await appsApi.readNamespacedDeployment({ name, namespace }); return dep.status?.readyReplicas === dep.spec?.replicas;} catch (err) { console.log('rollout check failed: ' + err); return false;}}Show the answer
The catch block swallows every error into the same false return and a plain string log, whether the Deployment genuinely does not exist yet (a real, expected 404 early in a rollout), the caller lacks permission (a real 403 that should fail loudly, not silently report “not rolled out”), or the API server is unreachable. Proven above, err instanceof k8s.ApiException gives a real, structured err.code to branch on — the fix distinguishes err.code === 404 (genuinely not created yet, keep polling) from anything else (rethrow or alert), instead of collapsing every failure mode into one indistinguishable false.