DevOps Multi-cloud

Set Up Jenkins on Kubernetes with the Kubernetes Plugin and Ephemeral Agent Pods

A 200-engineer platform team is bleeding money on a row of permanently-on Jenkins agent VMs that idle at 8% utilization overnight and still buckle at 9am when every squad pushes at once. Worse, the agents have drifted: one has JDK 17, another JDK 21, a third has a stale Trivy binary, and “works on the build server” has stopped meaning anything. The fix is to stop treating build capacity as a fleet of pets and start treating it as ephemeral pods. The Jenkins Kubernetes plugin asks the cluster for a fresh agent pod the moment a job needs one, runs the build inside per-job container templates pinned to exact tool versions, and deletes the pod the instant the build finishes. You pay only for the seconds a build actually runs, every build starts from an identical image, and the controller’s entire configuration lives in version control as code.

This is an implementation guide, and the centerpiece is a hands-on lab you can run end to end. We deploy a long-lived Jenkins controller with Helm, wire up the Kubernetes cloud and reusable pod templates with JCasC (Jenkins Configuration as Code), write a real declarative pipeline using agent { kubernetes { yaml ... } } with Maven, Kaniko and Trivy containers sharing a workspace, then tune scaling, caching and concurrency, lock the whole thing down with RBAC and a dedicated service account, and finish on the three failures that consume most of a beginner’s first week — agents that never connect, image pulls that throttle every build, and pods that get OOM-killed at the worst possible moment.

By the end you will understand the agent connection model well enough to debug it from kubectl describe pod alone, know exactly which JCasC keys make agents genuinely ephemeral (and which quietly leave zombie pods stranding your node pool), and be able to hand a developer a five-line podTemplate that gives their pipeline precisely the toolchain it needs and nothing else. The mental shift is small but total: the controller schedules and renders a UI; the cluster is the build farm.

What problem this solves

Static Jenkins agents are a triple tax. First, cost: a VM agent bills 24/7 whether it builds or sleeps, and capacity is a fixed ceiling — too small and the 9am queue stretches to forty minutes, too large and you pay for idle metal all night. Second, drift: every agent is a long-lived mutable host that accumulates manually-installed tools, cached dependencies, leftover Docker layers and half-applied patches, until two agents that should be identical produce different build results and nobody can say why. Third, blast radius: a shared agent runs every team’s build in the same workspace tree under the same user, so one job’s leaked credential, poisoned dependency or rm -rf is everyone’s problem.

Ephemeral agent pods dissolve all three. Capacity becomes elastic — pods are created on demand and the cluster autoscaler adds nodes under load and reclaims them after, so you pay per build-second instead of per VM-hour. Drift becomes impossible — each build runs in a fresh pod built from immutable, version-pinned container images, so “works on the build server” is true by construction because the build server is a brand-new pod every time. Blast radius shrinks to one build — a pod is isolated by namespace, service account, RBAC and (with a NetworkPolicy) network, and it is deleted seconds after the build ends, so a compromised dependency has minutes of access to a scoped identity, not standing access to a shared host.

Who hits this: any team running Jenkins at more than a trivial scale, especially after a migration to Kubernetes for the apps themselves leaves the CI farm as the last fleet of pets. It bites hardest on teams with spiky load (a monorepo where a merge triggers fifty jobs), heterogeneous toolchains (Java here, Node there, Go elsewhere, plus image builds and security scans), and compliance pressure (auditors who want to know exactly what ran, as what identity, with what access). The cure is the model this guide builds: one stateless controller, throwaway pods, and configuration as code.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with core Kubernetes objects — Pod, Deployment/StatefulSet, Service, Namespace, ServiceAccount, Role/RoleBinding — and able to read kubectl get/describe/logs output. You should know what a container image and registry are, have used Jenkins at least as a user (you know what a job, a build and a Jenkinsfile are), and be able to read YAML and a little Groovy. Helm 3 familiarity helps but the lab walks every command.

You need a working Kubernetes cluster (1.28+ — EKS, AKS, GKE, k3d, kind or minikube all work for the lab), a kubectl context with cluster-admin for the initial install, Helm 3.12+, a default StorageClass for the controller’s persistent home, and outbound access to pull the Jenkins and tool images. The lab is built to run on a local kind/minikube cluster at zero cloud cost; the production notes call out where a managed cluster (and a cluster autoscaler / Karpenter on a Spot node pool) changes the picture.

This sits in the CI/CD platform-engineering track. Upstream of it are the pipeline fundamentals in The CI/CD Pipeline Explained and the registry mechanics in Container Artifact & Registry Management. It pairs naturally with GitOps with Argo CD and Flux (which can keep this very controller’s Helm release and JCasC in sync with Git), Deploy Harbor on Kubernetes with Trivy, Replication and Signing for the registry the agents pull from, and Pipeline Secrets Management for the runtime-secret story. Downstream, the same container toolchain underpins Progressive Delivery: Canary, Blue-Green and GitOps.

A quick map of who owns which layer, so you escalate to the right person during an incident:

Layer What lives here Who usually owns it Failures it causes
Cluster / node pool Nodes, autoscaler, StorageClass, CNI Platform / SRE Pods Pending (no capacity), PVC won’t bind
jenkins namespace Controller StatefulSet, PVC, Services, SA Jenkins admin Controller down, JCasC won’t load
jenkins-agents namespace Ephemeral agent pods, agent SA, RBAC Jenkins admin + Platform Agents won’t connect, RBAC denied, OOM
Kubernetes plugin + cloud config How the controller talks to the API Jenkins admin No agents scheduled, wrong namespace
Pod templates / Jenkinsfile Containers, resources, volumes per job App / dev team Wrong image, step in wrong container, OOM
Registry Agent + tool images Platform / dev ImagePullBackOff, slow cold starts
IdP / secrets backend OIDC login, runtime build secrets Identity / Security Can’t log in, secret resolves empty

Core concepts

Six mental models make every later step obvious.

The controller is a scheduler and a UI — nothing more. The Jenkins controller runs as a single long-lived pod (a StatefulSet, so it keeps a stable identity and its persistent volume across restarts) with $JENKINS_HOME on a PVC holding jobs, build history, plugins and credentials. It holds no build executors of its own in this model. When a pipeline needs to run, it asks for an agent, and the controller delegates the actual work to a freshly-created pod. Treat the controller like a database: precious, stateful, backed up, and not where you run untrusted build code.

An agent is a pod the plugin creates, then deletes. The Kubernetes plugin registers a “cloud” — a connection to the Kubernetes API. When a job requests a label the cloud can satisfy, the plugin calls the API to create a pod (from a pod template or inline YAML) in the agents namespace. That pod runs the build; when the build ends the plugin deletes the pod. There is no warm pool to manage, no agent to patch, no SSH key to rotate. The pod is the agent and it lives exactly as long as the build (plus its configured retention).

Every agent pod has a jnlp container that phones home. Inside each agent pod the plugin injects a container conventionally named jnlp, running the jenkins/inbound-agent image. On startup it dials back to the controller (over a WebSocket on the agent port, 50000 by default, or HTTP/WebSocket through the controller URL) using a one-time secret the plugin generated, and registers as an executor. This is the single most important thing to understand: the connection is agent → controller (outbound from the pod), not controller → agent. If the pod can’t reach the controller’s Service, the agent shows Running in Kubernetes but never appears in Jenkins and the build hangs in the queue.

Your tool containers are siblings of jnlp, sharing the workspace. A pod template declares one or more tool containersmaven, node, kaniko, trivy, whatever the job needs — alongside the injected jnlp. All containers in the pod share the workspace volume (an emptyDir the plugin mounts at the same path in each), so a step that compiles in the maven container produces an artifact the trivy container can scan. Pipeline steps choose which container to run in with container('maven') { sh '...' }. The classic mistake is naming one of your own containers jnlp, which replaces the agent and breaks the build.

Ephemerality is a configuration choice, not a default. Pods are not automatically deleted the way you’d hope unless you set the retention policy correctly. podRetention controls when a finished pod is removed: never (delete immediately — the production default you want), onFailure (keep failed pods “for debugging” — quietly fills your node pool), or always (keep everything — almost never right). idleMinutes keeps an agent warm for reuse after a build; 0 means “delete as soon as idle.” Get these wrong and you accumulate zombie pods that the autoscaler dutifully keeps nodes alive for.

The container cap is your blast-radius and your bill ceiling. containerCapStr is the maximum number of concurrent agent pods this cloud will create. Without it, a flood of queued jobs tries to schedule hundreds of pods at once and exhausts the cluster (or your cloud bill). It is the throttle that converts “infinite elastic capacity” into “elastic, but bounded by what I can afford and the cluster can hold.”

The vocabulary in one table

Pin down every moving part before the deep sections; the glossary repeats these for lookup.

Term One-line definition Where it lives Why it matters
Controller Long-lived Jenkins pod: scheduler + UI jenkins ns (StatefulSet) Holds state; never runs build code
Kubernetes cloud The plugin’s connection to the K8s API JCasC jenkins.clouds Defines where/how agents are created
Pod template Reusable spec for an agent pod JCasC templates or inline yaml Declares containers, resources, SA
Agent pod The ephemeral build pod jenkins-agents ns Created per build, then deleted
jnlp container Injected inbound-agent that connects back Inside every agent pod The actual Jenkins agent process
Tool container maven/node/kaniko/… Inside the agent pod Where your build steps run
jenkinsUrl Controller HTTP URL the agent reaches Cloud config Agent registration target
jenkinsTunnel host:50000 agent port endpoint Cloud config JNLP/WebSocket connect target
containerCapStr Max concurrent agent pods Cloud config Cluster/cost blast-radius limit
podRetention When to delete a finished pod Cloud / template never = genuinely ephemeral
idleMinutes Keep agent warm after build Template 0 = delete when idle
JCasC Configuration-as-code for Jenkins ConfigMap / Helm values Reproducible controller config
Workspace volume Shared emptyDir across containers The agent pod Lets steps hand off artifacts
Service account (agent) Identity the agent pod runs as jenkins-agents ns Scopes what a build can do

The agent connection model — how a pod becomes an executor

Before configuring anything, internalize the request flow, because every “agent won’t connect” bug is a break somewhere along it. The sequence when a pipeline requests a Kubernetes agent:

Step What happens Where it can break
1 Pipeline hits agent { kubernetes { ... } }; controller queues the build needing a label Label typo → no cloud matches → stuck in queue
2 Kubernetes plugin picks a matching cloud + pod template (or inline YAML) Wrong cloud name/label → no match
3 Plugin calls the K8s API to create the pod in the agents namespace Controller SA lacks create pods → RBAC denied
4 Scheduler places the pod on a node No capacity / bad nodeSelector → Pending
5 Kubelet pulls images for jnlp + tool containers Bad image / no creds → ImagePullBackOff
6 jnlp container starts, reads the secret + controller URL from env
7 jnlp dials the controller: WebSocket via jenkinsUrl, or TCP via jenkinsTunnel:50000 Wrong tunnel/URL, NetworkPolicy, agent port disabled → never registers
8 Controller authenticates the secret, agent comes online, build runs
9 Build ends; plugin deletes the pod per podRetention onFailure/always → zombie pods linger

The two connection styles you must choose between, set by directConnection:

Mode directConnection How the agent connects When to use Gotcha
Through controller (default) false Agent → controller Service over WebSocket (jenkinsUrl) or TCP jenkinsTunnel:50000 Almost always; works behind a single controller Service jenkinsTunnel must point at the agent Service/port (50000), not the UI port (8080)
Direct true Controller-supplied address; agent connects directly Special networking; rarely needed Requires reachable direct address; fragile across namespaces

Two Services the Helm chart creates, and what each is for:

Service Port Purpose Used by
jenkins 8080 The web UI + HTTP API Humans, jenkinsUrl, WebSocket agents
jenkins-agent 50000 The inbound-agent (JNLP/TCP) port jenkinsTunnel for TCP-mode agents

The single most common connection bug is pointing jenkinsTunnel at jenkins:8080 (the UI) instead of jenkins-agent:50000 (the agent port). The pod starts, the jnlp container runs, and it tries to speak the agent protocol to the HTTP port — which silently fails, so the pod looks healthy in kubectl get pods while the build hangs forever. With modern Jenkins you can avoid TCP entirely by enabling WebSocket agents, which tunnel the agent protocol over the same 8080 HTTP(S) endpoint — simpler through ingress and firewalls, and the recommendation for new setups.

Step 1 — Namespaces and the agent service account

Isolate the precious controller from the throwaway agents. The agents get their own namespace and a tightly-scoped service account; they must never be able to mutate the controller.

kubectl create namespace jenkins
kubectl create namespace jenkins-agents

# Service account the agent pods run as (least privilege; see Security notes)
kubectl -n jenkins-agents create serviceaccount jenkins-agent

Grant the controller’s service account permission to manage pods in the agents namespace only — a namespaced Role and RoleBinding, never a cluster-wide ClusterRoleBinding. The controller needs to create, watch, delete and exec into agent pods (exec is how the plugin runs steps inside containers):

# agent-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: jenkins-agent-manager
  namespace: jenkins-agents
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/exec", "pods/log"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["pods/portforward"]
    verbs: ["create"]
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get"]            # only if a template references a Secret
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: jenkins-controller-manages-agents
  namespace: jenkins-agents
subjects:
  - kind: ServiceAccount
    name: jenkins                 # created by the Helm chart in step 2
    namespace: jenkins
roleRef:
  kind: Role
  name: jenkins-agent-manager
  apiGroup: rbac.authorization.k8s.io
kubectl apply -f agent-rbac.yaml

Exactly which verbs the plugin needs, and what breaks without each:

Resource / verb Why the plugin needs it Symptom if missing
pods · create Spawn the agent pod Agents never appear; queue stalls; Forbidden in controller log
pods · delete Remove the pod after the build Zombie pods accumulate
pods · get/list/watch Track pod lifecycle/status Plugin can’t tell when the agent is ready
pods/exec · create Run sh steps inside containers Steps hang / fail to start
pods/log · get Stream container logs to the build No container output in console
secrets · get Read a Secret a template references Volume/env from Secret fails

Step 2 — Install the Jenkins controller with Helm

Use the official chart. The points that matter: pin the agents to the right namespace, give the controller a persistent home, expose only ClusterIP (front it with an ingress, never a raw LoadBalancer), and install the plugins you need — the Kubernetes plugin, JCasC, and Pipeline.

helm repo add jenkins https://charts.jenkins.io
helm repo update
# values.yaml  (in production: managed by Terraform / synced by Argo CD)
controller:
  image:
    tag: "2.452.3-lts-jdk17"      # pin an LTS line, not :latest
  installPlugins:
    - kubernetes:4253.v7700d91739e8
    - configuration-as-code:1810.v9b_c30a_249a_4c
    - workflow-aggregator:600.vb_57cdd26fdd7   # Pipeline
    - git:5.2.2
    - credentials-binding:681.vf91669a_32e45
  serviceType: ClusterIP          # exposed via Ingress, not LoadBalancer
  installLatestPlugins: false     # reproducible: pin, don't auto-upgrade
  resources:
    requests: { cpu: "1",   memory: "2Gi" }
    limits:   { cpu: "2",   memory: "4Gi" }
  JCasC:
    defaultConfig: true
    configScripts:
      jenkins-casc: |
        # filled in step 3
persistence:
  enabled: true
  storageClass: "standard"        # gp3 on EKS, managed-csi on AKS, standard locally
  size: "20Gi"
agent:
  enabled: false                  # we declare agents in JCasC, not chart defaults
serviceAccount:
  create: true
  name: jenkins                   # matches the RoleBinding subject in step 1

The chart values most relevant to this setup, with the trade-off of each:

Value What it controls Recommended Trade-off / gotcha
controller.image.tag Jenkins version A pinned LTS (*-lts-jdkNN) latest is not reproducible; breaks on surprise upgrades
controller.installPlugins Plugins baked in at boot Explicit pinned versions Unpinned → version drift; missing one → JCasC fails
controller.serviceType How the controller is exposed ClusterIP + Ingress LoadBalancer puts the UI on a public IP
controller.JCasC.configScripts Inline JCasC config Your cloud + templates Large configs better as a separate ConfigMap
persistence.enabled / size The PVC for $JENKINS_HOME true, 20–50Gi false → you lose all jobs/history on restart
agent.enabled Chart’s default agent pod template false Leave the cloud/templates to JCasC for clarity
serviceAccount.name Controller SA name jenkins Must equal the RBAC RoleBinding subject

Install it:

helm upgrade --install jenkins jenkins/jenkins \
  --namespace jenkins \
  --values values.yaml \
  --wait --timeout 10m

Fetch the initial admin password (you’ll keep local auth for the lab; production swaps this for OIDC):

kubectl -n jenkins exec -it sts/jenkins -c jenkins -- \
  cat /run/secrets/additional/chart-admin-password

Confirm the controller is up and both Services exist:

kubectl -n jenkins get pods,pvc,svc
# Expect: pod jenkins-0 Running 2/2; PVC Bound; svc jenkins (8080) + jenkins-agent (50000)

Step 3 — Configure the cloud and pod templates with JCasC

This is the heart of the setup. Everything below goes in the JCasC.configScripts.jenkins-casc block from step 2 (or a separate ConfigMap the chart mounts). JCasC declares the Kubernetes cloud — how the controller talks to the API and where agents land — plus one or more reusable pod templates.

jenkins:
  clouds:
    - kubernetes:
        name: "k8s"
        serverUrl: "https://kubernetes.default.svc"
        namespace: "jenkins-agents"              # agents land here
        jenkinsUrl: "http://jenkins.jenkins.svc.cluster.local:8080"
        jenkinsTunnel: "jenkins-agent.jenkins.svc.cluster.local:50000"
        directConnection: false                  # connect via the controller Service
        webSocket: true                          # tunnel agent protocol over 8080 (recommended)
        containerCapStr: "20"                    # hard ceiling on concurrent agent pods
        connectTimeout: 100                      # seconds to wait for the API
        readTimeout: 200
        maxRequestsPerHostStr: "32"
        podRetention: "never"                    # delete the pod when the build ends
        templates:
          - name: "base"
            label: "k8s-base"
            namespace: "jenkins-agents"
            serviceAccount: "jenkins-agent"
            idleMinutes: 0                        # do not keep idle agents warm
            instanceCap: 10
            yamlMergeStrategy: "merge"
            containers:
              - name: "jnlp"                      # the injected inbound agent
                image: "jenkins/inbound-agent:3261.v9c670a_4748a_9-1"
                resourceRequestCpu: "500m"
                resourceRequestMemory: "512Mi"
                resourceLimitCpu: "1"
                resourceLimitMemory: "1Gi"
unclassified:
  location:
    url: "http://jenkins.example.com/"           # external URL for links/emails

The cloud-level keys, end to end — this is the reference you’ll come back to:

Key What it does Default / typical When to change Gotcha
name Cloud identifier; pipelines target it implicitly via labels kubernetes Multiple clusters → unique names Must be unique per cloud
serverUrl K8s API endpoint https://kubernetes.default.svc Out-of-cluster controller → real API URL In-cluster: leave the default
namespace Where agent pods are created controller’s ns Always set to jenkins-agents Must match the RBAC Role namespace
jenkinsUrl Controller HTTP URL agents reach derived Set explicitly to the in-cluster Service Wrong host → agent can’t register
jenkinsTunnel host:50000 agent-port endpoint TCP-mode agents Point at jenkins-agent:50000, not 8080
webSocket Tunnel agent protocol over HTTP(S) 8080 false Set true for new setups (simpler through ingress) With true, jenkinsTunnel is unused
directConnection Agent connects directly vs via controller false Leave false true is fragile cross-namespace
containerCapStr Max concurrent agent pods (whole cloud) unlimited Always set a real ceiling Unset → a job flood can exhaust the cluster
connectTimeout Seconds to wait connecting to the API 5 Raise on busy/slow clusters Too low → spurious failures under load
readTimeout Seconds to wait reading from the API 15 Raise under heavy churn
maxRequestsPerHostStr API client connection pool size 32 Raise at very high concurrency Too low throttles pod creation
podRetention When to delete a finished pod never onFailure only for short debugging onFailure/always strand pods

The pod-template-level keys you’ll set most:

Key What it does Recommended Gotcha
name Template identifier descriptive (base, maven)
label Label a pipeline requests (agent { label 'k8s-base' }) unique per template Typo → no match → stuck queue
namespace Override cloud namespace for this template jenkins-agents Must have RBAC there
serviceAccount SA the pod runs as jenkins-agent Wrong SA → wrong permissions/secrets
idleMinutes Keep the agent warm for reuse after a build 0 (ephemeral) >0 keeps pods alive → cost
instanceCap Max pods from this template per-team budget Separate from the cloud-wide cap
yamlMergeStrategy How inline pipeline YAML merges with the template merge override discards the template
podRetention Per-template retention override inherit cloud Same zombie-pod risk
activeDeadlineSeconds Hard kill the pod after N seconds optional Backstop against runaway builds

A few choices teams get wrong, called out explicitly:

Apply by upgrading the release, then reload config without a full restart:

helm upgrade jenkins jenkins/jenkins -n jenkins --values values.yaml --wait
# The chart's sidecar reloads JCasC on a ConfigMap change; or force it:
kubectl -n jenkins exec sts/jenkins -c jenkins -- \
  curl -s -X POST localhost:8080/reload-configuration-as-code/ \
  --user "admin:$ADMIN_PWD"

Confirm the cloud registered:

kubectl -n jenkins logs sts/jenkins -c jenkins | grep -i "Configuration as Code"
# Expect a line confirming JCasC applied with no errors

Step 4 — Pod templates: containers, resources, volumes, selectors

There are two ways to define an agent pod, and you’ll use both. JCasC pod templates (step 3) are central, reusable building blocks the platform team owns — a base template every team inherits. Inline pipeline YAML (agent { kubernetes { yaml ... } }) lets a single pipeline declare exactly the containers it needs, merged onto the base. Here is how each pod element is expressed.

Containers and the jnlp injection

Every agent pod has the injected jnlp container plus your tool containers. Your tool containers almost always need a long-running command so the container stays up while the plugin execs steps into it — command: ["cat"] + tty: true, or command: ["sleep"] + args: ["infinity"]. Without it the container’s default entrypoint runs, exits, and your container('maven') block has nothing to exec into.

containers:
  - name: maven                              # NOT "jnlp" — that replaces the agent
    image: maven:3.9.6-eclipse-temurin-17
    command: ["cat"]                         # keep-alive so steps can exec in
    tty: true

The keep-alive idiom by container type:

Container Keep-alive command Why
maven / node / python command: ["cat"], tty: true Interactive shell stays up for sh steps
kaniko command: ["sleep"], args: ["infinity"] Kaniko’s entrypoint would run and exit
docker:dind (avoid) runs as a daemon Needs privileged — prefer Kaniko/Buildkit
jnlp (injected) (the plugin sets it) Don’t override unless you know exactly why

Resource requests and limits

Every container should request CPU/memory so the scheduler can bin-pack, and limit them so one build can’t starve a node. Under-request and pods pile onto one node and thrash; over-request and you strand capacity and pay for nodes you barely use.

resources:
  requests: { cpu: "1",   memory: "1Gi" }     # scheduler reserves this
  limits:   { cpu: "2",   memory: "2Gi" }     # hard ceiling; memory over = OOM-killed

How requests vs limits behave for each resource — this directly explains the OOM failures later:

Resource Request means Limit means Over the limit →
CPU Guaranteed share; scheduler reserves it Throttle ceiling Throttled (build slows, not killed)
Memory Reserved for scheduling Hard cap OOM-killed (pod dies, exit 137)
ephemeral-storage Reserved scratch space Disk cap Pod evicted when exceeded

Volumes and the shared workspace

The plugin mounts the workspace as an emptyDir into every container at the same path, automatically — that is how maven builds and trivy scans the same files. You add volumes for caches, Docker config, or persistent state:

volumes:
  - name: m2-cache
    persistentVolumeClaim:
      claimName: maven-cache        # a real PVC, shared, for ~/.m2 (see Caching)
  - name: docker-config
    secret:
      secretName: registry-creds    # mounted into kaniko at /kaniko/.docker

The volume types you’ll reach for, and when:

Volume type Use for Lifetime Note
emptyDir Workspace (auto), scratch Pod Gone when the pod dies — that’s the point
emptyDir + medium: Memory Fast tmp (tmpfs) Pod Counts against the pod’s memory limit
persistentVolumeClaim Dependency cache (~/.m2, npm) Survives pods ReadWriteMany if multiple agents share
secret Registry creds, signing keys Pod Prefer runtime injection over baking in
configMap Settings files, CA bundles Pod Read-only config
hostPath (avoid) Node-local data Node Security risk; ties pod to a node

Node selectors, tolerations and affinity

Pin agents to a dedicated, often Spot/Preemptible, node pool so builds don’t compete with production workloads and you get the discount. The pod template carries the selector/tolerations:

nodeSelector:
  workload: ci                     # node pool labeled for CI
tolerations:
  - key: "dedicated"
    operator: "Equal"
    value: "ci"
    effect: "NoSchedule"           # tolerate the taint on the CI pool

The placement controls and what each achieves:

Control Effect Use for
nodeSelector Pod only schedules on nodes with the label Steer agents to a CI/Spot pool
tolerations Pod tolerates a node taint Land on a tainted dedicated pool
nodeAffinity Soft/hard placement rules Prefer Spot, fall back to on-demand
podAntiAffinity Spread pods across nodes Avoid stacking builds on one node
priorityClassName Scheduling priority / preemption Let prod preempt CI under pressure

Step 5 — A real multi-container declarative pipeline

Now use it. A pipeline declares its own pod inline so each job gets exactly the tool containers it needs — a Maven build, a Kaniko image build with no Docker daemon, and a Trivy scan, all in one ephemeral pod sharing the workspace.

// Jenkinsfile
pipeline {
  agent {
    kubernetes {
      yaml '''
        apiVersion: v1
        kind: Pod
        spec:
          serviceAccountName: jenkins-agent
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            fsGroup: 1000
          containers:
            - name: maven
              image: maven:3.9.6-eclipse-temurin-17
              command: ["cat"]
              tty: true
              resources:
                requests: { cpu: "1", memory: "1Gi" }
                limits:   { cpu: "2", memory: "2Gi" }
              volumeMounts:
                - name: m2-cache
                  mountPath: /root/.m2
            - name: kaniko
              image: gcr.io/kaniko-project/executor:v1.23.2-debug
              command: ["sleep"]
              args: ["infinity"]
              resources:
                requests: { cpu: "1", memory: "1Gi" }
                limits:   { cpu: "2", memory: "3Gi" }
              volumeMounts:
                - name: docker-config
                  mountPath: /kaniko/.docker
            - name: trivy
              image: aquasec/trivy:0.53.0
              command: ["cat"]
              tty: true
          volumes:
            - name: m2-cache
              persistentVolumeClaim:
                claimName: maven-cache
            - name: docker-config
              secret:
                secretName: registry-creds
      '''
    }
  }
  options {
    timeout(time: 30, unit: 'MINUTES')        // cap the build
    disableConcurrentBuilds()                  // one build per branch at a time
  }
  stages {
    stage('Build & test') {
      steps {
        container('maven') {
          sh 'mvn -B -ntp clean verify'
        }
      }
      post {
        always { junit 'target/surefire-reports/*.xml' }
      }
    }
    stage('Image build (no daemon)') {
      steps {
        container('kaniko') {
          sh '''/kaniko/executor \
            --context=`pwd` \
            --dockerfile=Dockerfile \
            --destination=ghcr.io/acme/app:${BUILD_NUMBER} \
            --cache=true --cache-repo=ghcr.io/acme/app/cache'''
        }
      }
    }
    stage('Scan') {
      steps {
        container('trivy') {
          sh 'trivy image --exit-code 1 --severity HIGH,CRITICAL ghcr.io/acme/app:${BUILD_NUMBER}'
        }
      }
    }
  }
}

The mechanics worth calling out:

A scripted-pipeline variant of the same idea, for jobs that need imperative logic:

podTemplate(yaml: readTrusted('build-pod.yaml')) {
  node(POD_LABEL) {                            // POD_LABEL is set by podTemplate
    stage('Build') {
      container('maven') { sh 'mvn -B -ntp clean verify' }
    }
  }
}

Declarative vs scripted vs JCasC template — pick by who owns it:

Approach Defined where Owned by Best for
JCasC templates Controller config Platform team Org-wide base pods every job inherits
Declarative agent { kubernetes { yaml } } Jenkinsfile App team Per-pipeline toolchain; readable
Scripted podTemplate { node {} } Jenkinsfile App team Imperative/dynamic pod logic

Step 6 — Scaling, concurrency and caching

Ephemeral agents are elastic, but elasticity needs limits and caching or it gets slow and expensive.

Concurrency and the caps that bound it

Three independent caps control how many pods can run at once; the effective limit is the smallest of them plus what the cluster can actually schedule:

Cap Scope Set in Effect
containerCapStr Whole cloud Cloud config Max agent pods across all jobs
instanceCap One pod template Template Max pods from this template
Executors per agent One pod jnlp container env / template Usually 1 (one build per pod)
Cluster capacity Nodes Autoscaler / node pool Pods Pending if exceeded

Keep one executor per agent pod (the default and the point — a pod is a build). Raise concurrency by letting the cloud create more pods, not by stacking executors on one pod, so isolation holds. Bound per-team spend with instanceCap on each team’s template while containerCapStr protects the cluster overall.

Autoscaling the nodes underneath

The plugin creates pods; it does not create nodes. When pods go Pending for lack of capacity, a cluster autoscaler (or Karpenter on EKS) must add nodes, and reclaim them when the pods vanish. The build-burst lifecycle:

Phase What the plugin does What the autoscaler does
Idle No agent pods Scales the CI pool toward its minimum
Push storm Creates N pods (up to the caps) Sees Pending pods → adds nodes
Builds running Pods on nodes, executing Holds the nodes
Builds done Deletes pods (podRetention: never) Nodes empty → scales back down

This is why podRetention: never and idleMinutes: 0 matter for cost: lingering pods keep nodes alive and defeat scale-down.

Caching — the difference between 30-second and 5-minute builds

A fresh pod has an empty dependency cache and must pull images. Two caches to address:

Cache Without it With it How
Dependency cache (~/.m2, npm, Go) Every build re-downloads all deps Warm cache, fast resolve Shared ReadWriteMany PVC mounted into the tool container
Container image layers Cold docker pull per build (minutes) Layers cached on node / nearby Same-region registry + pull-through cache; pre-pull DaemonSet
Build/image build cache Kaniko rebuilds every layer Reuses unchanged layers Kaniko --cache=true --cache-repo=...

A shared Maven cache PVC, mounted in step 5, removes the dominant cost of Java builds. Be careful with ReadWriteMany semantics and concurrent writers — use a cache that tolerates concurrency (or per-branch caches) so two builds don’t corrupt a shared ~/.m2. For images, a same-region registry (or a registry mirror / pull-through cache) plus a small base image turns a 90-second pull into a few seconds; a pre-pull DaemonSet warms the node’s image cache for your hottest tool images.

The caching trade-offs:

Technique Speeds up Cost Risk
Shared dependency PVC Dependency resolution PVC storage Concurrent-write corruption if misused
Per-branch cache Resolution, safely More storage Cold for new branches
Same-region registry Image pulls Registry hosting Cross-region egress if mis-sited
Pull-through cache / mirror Image pulls Cache infra Staleness if not refreshed
Pre-pull DaemonSet Image pulls (hot images) Idle node memory/disk Maintaining the image list
Kaniko layer cache Image builds Cache-repo storage Cache poisoning if shared widely

Architecture at a glance

The shape is deliberately simple, and that is the point. A single long-lived Jenkins controller runs as a StatefulSet in the jenkins namespace, with its $JENKINS_HOME on a persistent volume so jobs, build history and credentials survive a pod restart. The controller holds no build capacity of its own — it is a scheduler and a UI exposed over a ClusterIP Service (jenkins:8080) behind an ingress, with a second Service (jenkins-agent:50000) for inbound agents. When a pipeline requests an agent, the Kubernetes plugin calls the Kubernetes API and creates an ephemeral agent pod in the separate jenkins-agents namespace, scoped by a namespaced RBAC Role so the controller can create/delete pods there and nowhere else. That pod contains the injected jnlp container (which phones home to the controller over WebSocket/JNLP) plus one or more per-job tool containersmaven, kaniko, trivy, whatever the pipeline’s pod spec declares. The build steps run inside those containers, all sharing one workspace volume; when the pipeline ends, the plugin deletes the pod, and the cluster autoscaler reclaims the now-empty node. Trace the diagram left to right: controller and its PVC and Services on the left; the API call and RBAC boundary in the middle; the throwaway agent pod with its labeled containers, node selector, runtime secrets and shared workspace on the right; and the registry, secrets backend and observability stack feeding in around the edges.

Jenkins controller StatefulSet in the jenkins namespace creating ephemeral agent pods in jenkins-agents via the Kubernetes plugin and API, each pod holding a jnlp container plus maven/kaniko/trivy tool containers sharing a workspace, scoped by namespaced RBAC, pulling from a registry and connecting back over port 50000

Real-world scenario

Meridian Retail runs a 60-service e-commerce monorepo on AKS with a 22-person platform team. Their old CI was eight Standard_D8s_v5 Jenkins agent VMs — about ₹2.6 lakh/month — sized for the 9am merge storm and idle by 11am. Builds were flaky: agent #3 had a JDK patch the others lacked, so one service’s tests passed there and failed elsewhere, and an afternoon was regularly lost to “re-run on a different agent.” A security audit flagged the worst part: every build ran as the same Linux user on a shared host with a long-lived Docker socket and a service principal that could read the production key vault — one poisoned npm dependency from a very bad day.

They moved to the model in this guide. The controller went onto a 2-vCPU/4-GiB pod via Helm, JCasC-configured and synced from Git by Argo CD. The eight VMs were deleted; agents became pods on a Spot node pool labeled workload: ci, with the cluster autoscaler scaling 0→18 nodes during the storm and back to 1 by mid-morning. Each team got a base pod template plus inline containers in their Jenkinsfile; image builds switched from the Docker socket to Kaniko, killing the node-escape path. The agent service account was scoped to nothing in production — build secrets are leased at runtime from the secrets backend with a 20-minute TTL, bound to exactly the jenkins-agent SA in jenkins-agents.

The first week surfaced two real bugs, both in this guide’s troubleshooting section. Agents hung in the queue because jenkinsTunnel pointed at jenkins:8080; switching to webSocket: true fixed it in one line. Then Java builds got OOM-killed at the integration-test stage — the maven container’s 1 GiB limit was below the JVM’s actual footprint, so the pod died with exit 137; raising the limit to 3 GiB and setting -XX:MaxRAMPercentage=75 cured it.

The outcome after a month: CI cost fell to about ₹78,000/month (a ~70% cut), because they paid for build-seconds on Spot instead of eight always-on VMs. The flaky-agent class of failure disappeared entirely — every build runs the same pinned images, so “re-run on another agent” stopped being a thing. And the audit finding closed: builds run non-root, with no Docker socket, under a scoped identity that holds short-lived secrets and is deleted minutes after the build ends. The platform lead’s summary: “We stopped patching build servers. There are no build servers.”

Advantages and disadvantages

Advantages Disadvantages
Pay per build-second, not per VM-hour (big cost cut on spiky load) Cold-start latency: pod schedule + image pull before the build starts
Zero agent drift — every build runs a fresh, pinned image A Kubernetes cluster to run and understand (steeper than VM agents)
Elastic capacity via the cluster autoscaler; no fixed ceiling More moving parts: cloud config, pod templates, RBAC, networking
Strong isolation — namespace, SA, RBAC, NetworkPolicy, then deleted New failure modes (Pending, ImagePullBackOff, OOM) to learn
Config-as-code (JCasC) — reproducible, reviewable controller Caching needs deliberate design or builds are slow
No Docker socket needed (Kaniko/Buildkit) — smaller attack surface Debugging a deleted pod is harder (logs must be shipped)
Toolchain identical across teams and reusable across CI systems The controller is still a stateful single point — needs backup/HA care

When each side matters: the advantages dominate for teams with bursty load, many toolchains, or compliance pressure — exactly where static agents hurt most. The disadvantages matter most for tiny teams with steady, light, single-language load and no existing Kubernetes footprint, where a couple of static agents may genuinely be simpler. The cold-start cost is the one users feel; it is the thing caching and a same-region registry exist to minimize, and it is why you keep base images small.

Hands-on lab

Stand up a Jenkins controller on a local cluster, configure the Kubernetes cloud and a pod template with JCasC, run a multi-container pipeline that spawns an ephemeral pod, watch the pod appear and vanish, then tear it all down. This runs at zero cloud cost on kind (or minikube/k3d). Allow ~30 minutes.

Step 1 — Create a local cluster.

kind create cluster --name jenkins-lab
kubectl cluster-info --context kind-jenkins-lab

Expected: cluster info prints; kubectl get nodes shows one Ready control-plane node.

Step 2 — Namespaces and the agent service account.

kubectl create namespace jenkins
kubectl create namespace jenkins-agents
kubectl -n jenkins-agents create serviceaccount jenkins-agent

Step 3 — Apply the agent RBAC (controller can manage pods only in jenkins-agents).

cat <<'EOF' | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: jenkins-agent-manager, namespace: jenkins-agents }
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/exec", "pods/log"]
    verbs: ["get", "list", "watch", "create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: jenkins-controller-manages-agents, namespace: jenkins-agents }
subjects: [{ kind: ServiceAccount, name: jenkins, namespace: jenkins }]
roleRef: { kind: Role, name: jenkins-agent-manager, apiGroup: rbac.authorization.k8s.io }
EOF

Expected: role.../jenkins-agent-manager created and rolebinding.../... created.

Step 4 — Write values.yaml with the cloud + a base pod template in JCasC.

cat > values.yaml <<'EOF'
controller:
  installPlugins:
    - kubernetes:4253.v7700d91739e8
    - configuration-as-code:1810.v9b_c30a_249a_4c
    - workflow-aggregator:600.vb_57cdd26fdd7
    - git:5.2.2
  JCasC:
    defaultConfig: true
    configScripts:
      k8s-cloud: |
        jenkins:
          clouds:
            - kubernetes:
                name: "k8s"
                serverUrl: "https://kubernetes.default.svc"
                namespace: "jenkins-agents"
                jenkinsUrl: "http://jenkins.jenkins.svc.cluster.local:8080"
                webSocket: true
                containerCapStr: "5"
                podRetention: "never"
                templates:
                  - name: "base"
                    label: "k8s-base"
                    namespace: "jenkins-agents"
                    serviceAccount: "jenkins-agent"
                    idleMinutes: 0
                    containers:
                      - name: "jnlp"
                        image: "jenkins/inbound-agent:3261.v9c670a_4748a_9-1"
persistence:
  enabled: true
  size: "8Gi"
agent:
  enabled: false
serviceAccount:
  create: true
  name: jenkins
EOF

Step 5 — Install the controller with Helm.

helm repo add jenkins https://charts.jenkins.io && helm repo update
helm upgrade --install jenkins jenkins/jenkins \
  -n jenkins --values values.yaml --wait --timeout 10m

Expected: the release deploys; kubectl -n jenkins get pods shows jenkins-0 going Running 2/2.

Step 6 — Get the admin password and open the UI.

ADMIN_PWD=$(kubectl -n jenkins exec sts/jenkins -c jenkins -- \
  cat /run/secrets/additional/chart-admin-password)
echo "admin / $ADMIN_PWD"
kubectl -n jenkins port-forward svc/jenkins 8080:8080 >/dev/null 2>&1 &
# Browse http://localhost:8080 and log in as admin

Step 7 — Confirm JCasC applied and the cloud is registered.

kubectl -n jenkins logs sts/jenkins -c jenkins | grep -i "Configuration as Code"
# Expect a successful-apply line, no JCasC errors

In the UI, Manage Jenkins → Clouds should list a cloud named k8s.

Step 8 — Create a Pipeline job with an inline multi-container pod. In the UI: New Item → Pipeline → OK, then paste this script (it uses a public sample so no registry is needed):

pipeline {
  agent {
    kubernetes {
      yaml '''
        apiVersion: v1
        kind: Pod
        spec:
          serviceAccountName: jenkins-agent
          securityContext: { runAsNonRoot: true, runAsUser: 1000 }
          containers:
            - name: maven
              image: maven:3.9.6-eclipse-temurin-17
              command: ["cat"]
              tty: true
              resources:
                requests: { cpu: "500m", memory: "512Mi" }
                limits:   { cpu: "1",   memory: "1Gi" }
      '''
    }
  }
  stages {
    stage('Prove the pod') {
      steps {
        container('maven') {
          sh 'echo "running in: $(hostname)"; mvn -v; nproc; free -m'
        }
      }
    }
  }
}

Step 9 — In a second terminal, watch agents come and go, then build.

kubectl -n jenkins-agents get pods -w
# In the UI, click "Build Now" on the job

Expected: a pod named like k8s-base-xxxxx-yyyyy goes Pending → Running → Completed/Terminating within the build, then disappears. The build console shows the Maven version, CPU count and memory of the throwaway pod.

Step 10 — Confirm zero idle agents remain.

kubectl -n jenkins-agents get pods --no-headers | wc -l
# Expect: 0 between builds — agents are genuinely ephemeral

Validation checklist. You deployed a controller with no executors of its own, declared a Kubernetes cloud and pod template entirely in JCasC, ran a build inside a pod that was created on demand and deleted when it finished, and proved no agent lingers idle. The steps mapped to what each proves:

Step What you did What it proves
3 Namespaced RBAC Controller manages agents only in jenkins-agents
5 Helm install One stateless controller, persistent home
7 JCasC applied The cloud/template is config-as-code, reproducible
9 Watch + build Pods are created on demand and run the build
10 wc -l = 0 podRetention: never + idleMinutes: 0 = ephemeral

Teardown.

kubectl -n jenkins-agents delete pods --all      # kill any in-flight agents first
helm uninstall jenkins -n jenkins
kubectl delete namespace jenkins jenkins-agents  # deletes the PVC/JENKINS_HOME too
kind delete cluster --name jenkins-lab           # removes the whole local cluster

Cost note. On kind/minikube this lab is free — it runs on your laptop. On a managed cluster, an hour on a couple of small Spot nodes is well under ₹50, and the teardown removes everything.

Common mistakes & troubleshooting

The three failures below — agent won’t connect, image pull failure, resource-limit/OOM kills — are where most of a beginner’s first week goes. First the scannable table, then the full reasoning for each.

# Symptom Root cause Confirm (exact command) Fix
1 Build stuck “waiting for executor”; no pod created Label mismatch, or controller SA can’t create pods kubectl -n jenkins logs sts/jenkins -c jenkins | grep -i forbidden; check the job label vs template label Match the label; apply the agent RBAC Role/RoleBinding
2 Pod Running in K8s but agent never appears in Jenkins; build hangs jenkinsTunnel/jenkinsUrl wrong; agent can’t reach controller; agent port disabled kubectl -n jenkins-agents logs <pod> -c jnlp (connection-refused/timeout) Set webSocket: true, or point jenkinsTunnel at jenkins-agent:50000; open NetworkPolicy
3 Pod stuck Pending No node capacity, or unschedulable nodeSelector/taint kubectl -n jenkins-agents describe pod <pod> → Events (FailedScheduling) Add capacity/autoscaler; fix selector; add toleration
4 ImagePullBackOff / ErrImagePull Wrong image name/tag, or no registry credentials kubectl -n jenkins-agents describe pod <pod> → Events (pull access denied / not found) Fix the image ref; add an imagePullSecret to the SA/pod
5 Pod dies mid-build, exit code 137; restarts OOM-killed — memory usage exceeded the container limit kubectl -n jenkins-agents describe pod <pod>Last State: Terminated, Reason: OOMKilled Raise the memory limit; cap JVM/Node heap to the limit
6 mvn: not found / kaniko: not found Step ran outside container() (so in jnlp), which lacks the tool The console shows the command running on the agent, not the tool container Wrap the sh in container('maven') { ... }
7 Pods never get deleted; node pool fills podRetention left at onFailure/always, or a finalizer kubectl -n jenkins-agents get pods shows Completed pods lingering Set podRetention: never; delete stuck pods
8 Whole agent replaced; build behaves oddly A tool container was named jnlp, overriding the injected agent The pod has only one container, named jnlp, that isn’t the inbound-agent Rename the tool container (maven/node/…)
9 Container exits immediately; step has nothing to exec into Tool container has no keep-alive command kubectl -n jenkins-agents describe pod <pod> shows the container Completed early Add command: ["cat"], tty: true (or sleep infinity)
10 JCasC won’t apply; controller errors on boot A referenced plugin isn’t installed, or a YAML/key typo kubectl -n jenkins logs sts/jenkins -c jenkins | grep -i "casc|configuration as code" Add the plugin to installPlugins; fix the JCasC key

Failure 1 — Agent won’t connect (the big one)

This is two distinct problems that look identical from the queue: no pod is created versus a pod is created but never registers. Diagnose by looking first at Kubernetes.

No pod is created. The plugin either found no cloud/template matching the requested label, or the controller’s service account lacks permission to create pods. Confirm:

# Did the job's label match a template label?  (job label vs template 'label')
# Then check for an RBAC denial in the controller log:
kubectl -n jenkins logs sts/jenkins -c jenkins | grep -i "forbidden\|cannot create"
# A line like: pods is forbidden: User "system:serviceaccount:jenkins:jenkins"
#   cannot create resource "pods" in API group "" in the namespace "jenkins-agents"

Fix: make the pipeline’s label match the template’s label, and apply the namespaced Role/RoleBinding from step 1 so the jenkins SA can create pods in jenkins-agents.

A pod is created but never registers. The pod shows Running but the agent never comes online and the build hangs. The connection is agent → controller, so read the jnlp container’s logs:

POD=$(kubectl -n jenkins-agents get pods -o name | head -1)
kubectl -n jenkins-agents logs $POD -c jnlp
# Telltales:
#   "Connection refused" / "Failed to connect to ..."  -> wrong tunnel/URL or blocked
#   "WebSocket close 1006"                              -> URL reachable but handshake failed

The usual causes, in order of likelihood:

Cause How to confirm Fix
jenkinsTunnel points at 8080 (UI), not 50000 jnlp log: connection-refused on the agent protocol Set webSocket: true (recommended), or point jenkinsTunnel at jenkins-agent:50000
jenkinsUrl host wrong / unreachable jnlp log: name resolution / connect timeout Set jenkinsUrl to the in-cluster Service FQDN
TCP agent port disabled on the controller Manage Jenkins → Security → Agents shows port off Enable a fixed agent port (or use webSocket: true)
NetworkPolicy blocks pod→controller Temporarily relax policy → connects Allow egress from jenkins-agents to the controller
Clock skew / very short timeouts under load Intermittent failures under burst Raise connectTimeout/readTimeout

For new setups the cleanest fix is webSocket: true: the agent tunnels over the same HTTPS endpoint as the UI, so there is no separate 50000 port to misconfigure and it traverses ingress and firewalls cleanly.

Failure 2 — Image pull failure (ImagePullBackOff)

The pod is scheduled but the kubelet can’t pull an image. kubectl get pods shows ImagePullBackOff or ErrImagePull. Always read the Events:

kubectl -n jenkins-agents describe pod <pod> | sed -n '/Events:/,$p'

Map the Event message to the cause:

Event message Cause Fix
manifest ... not found / not found: name unknown Wrong image name or tag (typo, deleted tag) Correct the image reference; pin a real, immutable tag/digest
pull access denied / unauthorized Private registry, no credentials Add an imagePullSecret to the agent SA or the pod
dial tcp ... i/o timeout Registry unreachable from nodes (network/DNS) Fix node egress/DNS; use a same-region or mirrored registry
toomanyrequests (Docker Hub rate limit) Anonymous Hub pulls throttled Authenticate to Hub, or pull from your own registry/mirror

To pull from a private registry, create a docker-registry Secret and attach it to the agent service account so every agent pod inherits it:

kubectl -n jenkins-agents create secret docker-registry regcred \
  --docker-server=ghcr.io --docker-username=<user> --docker-password=<token>
kubectl -n jenkins-agents patch serviceaccount jenkins-agent \
  -p '{"imagePullSecrets":[{"name":"regcred"}]}'

Beyond fixing the error, image pulls are the dominant cold-start cost: keep base images small, prefer a same-region registry, and consider a pull-through cache or a pre-pull DaemonSet for your hottest tool images so a build waits seconds, not minutes.

Failure 3 — Resource-limit / OOM kills

A build runs, then the pod dies and restarts, or the build fails with a non-obvious error. The signature is exit code 137 and OOMKilled:

kubectl -n jenkins-agents describe pod <pod> | grep -A3 "Last State"
# Last State:  Terminated
#   Reason:    OOMKilled
#   Exit Code: 137

What’s happening: the container’s memory usage crossed its limits.memory, so the kernel OOM-killer terminated it. CPU over-limit only throttles (the build slows); memory over-limit kills. The two common root causes and their fixes:

Root cause Confirm Fix
Limit simply too low for the workload Working set near the limit before the kill Raise limits.memory (and the request, to schedule it)
Runtime ignores the cgroup limit (JVM/Node heap > limit) JVM defaults its heap to a fraction of node RAM, not the limit Cap the heap to the container: -XX:MaxRAMPercentage=75 (JVM), --max-old-space-size (Node)

For Java the second cause is the classic trap. Set the JVM to respect the container:

- name: maven
  image: maven:3.9.6-eclipse-temurin-17
  command: ["cat"]
  tty: true
  env:
    - { name: MAVEN_OPTS, value: "-XX:MaxRAMPercentage=75 -XX:+UseContainerSupport" }
  resources:
    requests: { cpu: "1", memory: "2Gi" }
    limits:   { cpu: "2", memory: "3Gi" }

A reusable rule of thumb for sizing each container’s limit:

Workload Memory request Memory limit Note
Maven/Gradle (Java) 1–2 Gi 2–4 Gi Cap JVM heap to ~75% of the limit
Node/npm build 512Mi–1Gi 1–2 Gi --max-old-space-size to ~75% of limit
Kaniko image build 1 Gi 2–4 Gi Large images/contexts need more
Trivy scan 256–512Mi 512Mi–1Gi DB download spikes memory briefly
jnlp (agent) 256–512Mi 512Mi–1Gi The agent itself is light

Because the pod is deleted after the build, ship logs/metrics out (the controller already captures console output; for OOM forensics, watch the events or scrape pod metrics) so you can diagnose a kill that happened on a pod that no longer exists.

Best practices

Security notes

A minimal NetworkPolicy that default-denies and then allows only egress to DNS and the controller (extend it for your registry/secrets backend CIDRs):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: agents-restrict-egress
  namespace: jenkins-agents
spec:
  podSelector: {}                    # all agent pods
  policyTypes: [Egress]
  egress:
    - to:                            # DNS
        - namespaceSelector: {}
      ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
    - to:                            # the controller
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: jenkins }
      ports: [{ protocol: TCP, port: 8080 }, { protocol: TCP, port: 50000 }]

Cost & sizing

This design pays for itself on spiky load: permanently-on agents bill 24/7 regardless of load; ephemeral pods bill only for the seconds a build runs. What drives the bill and how to control it:

Cost driver What you pay for How to control it Rough figure
Controller node/pod 1 small node-share, always on Modest SKU (2 vCPU/4 GiB); it just schedules ~₹3,000–6,000/mo
$JENKINS_HOME PVC 20–50 Gi persistent disk Right-size; prune old build artifacts ~₹200–600/mo
Agent compute Build-seconds × pod size Spot node pool + autoscaler; right-size limits The big variable — pay per build
Image pulls / egress Cross-region/registry egress Same-region registry, mirror, pre-pull Small if co-located
Cache storage Dependency-cache PVC(s) Share or per-branch; cap size ~₹200–800/mo
Registry Hosting agent + app images Own registry or managed Varies

Sizing rules that matter most:

The combined effect for a Meridian-shaped team: the always-on VM bill goes to near zero, peak capacity becomes elastic instead of a fixed ceiling, and every build runs on an identical, version-pinned toolchain — the “works on the build server” problem solved by construction, at a fraction of the cost.

Interview & exam questions

1. In the Kubernetes-plugin model, what does the Jenkins controller actually do during a build? It schedules and renders the UI; it holds no build capacity of its own. When a pipeline requests an agent, the plugin calls the Kubernetes API to create an ephemeral pod, the build runs inside that pod’s containers, and the plugin deletes the pod when the build ends. The controller is a stateful scheduler, not a worker.

2. Which direction is the agent↔controller connection, and why does it matter? The connection is initiated agent → controller (outbound from the pod, to the controller’s Service over WebSocket/JNLP on 50000 or HTTP 8080). It matters because a pod can be Running in Kubernetes yet never register if it can’t reach the controller — so “agent won’t connect” is diagnosed from the jnlp container’s logs and the controller URL/tunnel, not from controller→agent reachability.

3. What makes an agent pod genuinely ephemeral, and what quietly breaks it? podRetention: never (delete the pod when the build ends) and idleMinutes: 0 (don’t keep it warm). The default-looking onFailure retention keeps failed pods “for debugging,” which strands pods and keeps autoscaler nodes alive — a silent cost and capacity leak.

4. What is containerCapStr and why set it? It’s the maximum number of concurrent agent pods the cloud will create. Without it, a flood of queued jobs tries to schedule hundreds of pods at once and exhausts the cluster (or your bill). It converts unbounded elasticity into bounded, affordable elasticity; instanceCap does the same per template.

5. A build hangs with “waiting for executor” and no pod is created. What two things do you check? First, label matching — the pipeline’s requested label must match a pod template’s label. Second, RBAC — the controller’s service account must have create pods in the agents namespace; a pods is forbidden line in the controller log means the Role/RoleBinding is missing or mis-scoped.

6. A pod is Running but the agent never appears in Jenkins. Most likely cause? A connection misconfiguration: jenkinsTunnel pointing at the UI port (8080) instead of the agent port (jenkins-agent:50000), a wrong/unreachable jenkinsUrl, a NetworkPolicy blocking egress, or the TCP agent port being disabled. Read the jnlp container logs for connection-refused/timeout; the cleanest fix for new setups is webSocket: true.

7. Why must tool containers carry a keep-alive command, and what happens if one is named jnlp? A tool container’s default entrypoint runs and exits, leaving nothing for the plugin to exec build steps into — so you give it command: ["cat"], tty: true (or sleep infinity) to stay up. Naming a tool container jnlp replaces the injected inbound-agent, breaking the agent connection entirely.

8. A Java build’s pod dies mid-build with exit code 137. Diagnosis and fix? It was OOM-killed — memory usage exceeded the container’s limits.memory (kubectl describe pod shows Reason: OOMKilled). Fix by raising the limit, and crucially cap the JVM heap to the container with -XX:MaxRAMPercentage (the JVM otherwise sizes its heap to node RAM, not the cgroup limit). CPU over-limit only throttles; memory over-limit kills.

9. You see ImagePullBackOff on an agent pod. How do you find the cause? kubectl -n jenkins-agents describe pod <pod> and read the Events: not found means a wrong image/tag; pull access denied/unauthorized means missing registry credentials (add an imagePullSecret to the agent SA); toomanyrequests means Docker Hub rate-limiting (authenticate or use your own registry/mirror).

10. The plugin creates pods but they sit Pending. Whose job is it to fix, and how? That’s a scheduling/capacity problem, not a Jenkins one. kubectl describe pod shows FailedScheduling — no node matches the resources/nodeSelector/taints. The fix is on the cluster side: a cluster autoscaler/Karpenter to add nodes, or correcting the selector/toleration so the pod can land on the CI node pool.

11. How do you keep ephemeral builds from being slow? Address the two cold-start costs: dependency download (share a ~/.m2/npm cache via a PVC, or per-branch caches) and image pull (small base images, a same-region registry or pull-through cache/mirror, a pre-pull DaemonSet for hot images, and Kaniko layer caching for image builds).

12. Why is this model more secure than shared static agents? Each build runs in a fresh pod isolated by namespace, a scoped service account, namespaced RBAC and a NetworkPolicy, as a non-root user with no Docker socket (Kaniko/Buildkit), holding only short-TTL runtime secrets — and the pod is deleted minutes after the build. A poisoned dependency gets minutes of access to a scoped, throwaway identity instead of standing access to a shared host.

These map to the CKA/CKAD (RBAC, pods, scheduling, resources/limits, NetworkPolicy) and to general DevOps/CI-CD platform interviews. A compact mapping:

Question theme Maps to
Controller role, agent lifecycle, ephemerality DevOps platform design; CKAD (pod lifecycle)
RBAC, service accounts, least privilege CKA/CKAD (authorization); security review
Scheduling: Pending, selectors, autoscaler CKA (scheduling); cluster operations
Resources, limits, OOM CKAD (resources); SRE reliability
NetworkPolicy, non-root, no Docker socket CKS-adjacent; supply-chain security

Quick check

  1. In this model, how many build executors does the Jenkins controller run, and where do builds actually execute?
  2. Which two JCasC/template settings make agent pods genuinely ephemeral, and which retention value secretly strands pods?
  3. A pod shows Running in kubectl but the agent never appears in Jenkins and the build hangs. Which container’s logs do you read, and what’s the cleanest one-setting fix for new clusters?
  4. A pod dies mid-build with exit code 137. What happened, and besides raising the memory limit, what must you do for a JVM build?
  5. Name the single setting that bounds how many agent pods the cloud will create at once, and say why it matters.

Answers

  1. Zero of its own — the controller schedules and serves the UI. Builds execute inside ephemeral agent pods the Kubernetes plugin creates on demand (and deletes when the build ends).
  2. podRetention: never and idleMinutes: 0 make them ephemeral. onFailure retention quietly keeps failed pods around “for debugging,” stranding pods and keeping autoscaler nodes alive.
  3. Read the jnlp container’s logs (kubectl -n jenkins-agents logs <pod> -c jnlp) — the connection is agent→controller. The cleanest fix for new setups is webSocket: true, which tunnels the agent over the 8080 HTTPS endpoint and removes the 50000-port misconfiguration.
  4. The pod was OOM-killed — memory exceeded limits.memory. For a JVM build you must also cap the heap to the container (e.g. -XX:MaxRAMPercentage=75), because the JVM otherwise sizes its heap to node RAM, not the cgroup limit, and OOMs again at the same limit.
  5. containerCapStr — the maximum concurrent agent pods. Without it, a flood of queued jobs tries to schedule hundreds of pods and exhausts the cluster or the bill; it’s the throttle that makes elastic capacity bounded.

Glossary

Next steps

You can now stand up a stateless Jenkins controller, give pipelines ephemeral pods with exactly the toolchain they need, and debug the agents/images/limits that bite first. Build outward:

JenkinsKubernetesJCasCCI/CDHelmPod TemplatesEphemeral AgentsRBAC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading