Jenkins Master-Agent on AWS ECS
A deep-dive into how Jenkins ECS agents work internally — the inbound agent protocol, ECS task provisioning, scaling mechanics, and the optimizations that reduced costs 60% and build times from 35 to 18 minutes.
The Problem: Monolithic Jenkins
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph Monolithic["Traditional: Single Jenkins Server"]
SERVER["t2.2xlarge EC2 $300/month Running 24/7"]:::green
SERVER --> BUILD1["Build 1"]:::orange
SERVER --> BUILD2["Build 2"]:::orange
SERVER --> IDLE["Idle 70% of time still paying full price"]:::blue
end
subgraph MasterAgent["Master-Agent on ECS"]
CTRL["t2.small EC2 Jenkins Controller only ~$15/month"]:::orange --> PLUGIN["ECS Plugin"]:::orange
PLUGIN -->|"job queued"| FARGATE["Fargate Task pay per second spins up on demand"]:::orange
PLUGIN -->|"heavy job"| EC2_AGENT["EC2 ECS Task spot instance DinD capable"]:::orange
FARGATE -->|"job done"| TERMINATE["Task terminated billing stops"]:::red
end
The shift: Controller is now just a scheduler — lightweight, small instance. All build work happens on ephemeral ECS tasks that exist only for the duration of the job.
How the Inbound Agent Works Internally
This is the most important thing to understand. The connection is agent-initiated, not controller-initiated.
sequenceDiagram
participant CTRL as Jenkins Controller
participant PLUGIN as ECS Plugin (on controller)
participant ECS as AWS ECS API
participant TASK as ECS Task (jenkins-agent process)
Note over CTRL: Job queued, no available agent
CTRL->>PLUGIN: NodeProvisioner requests new agent
PLUGIN->>ECS: ecs:RunTask (task definition + overrides)
ECS-->>PLUGIN: taskArn returned, task PENDING
Note over TASK: Container starts, pulls image from ECR
TASK->>TASK: entrypoint: /usr/local/bin/jenkins-agent
TASK->>CTRL: JNLP connect: agent.jar -url http://CTRL:8080 -secret TOKEN -name AGENT_NAME
Note over CTRL: Controller is LISTENING on JNLP port (50000)
CTRL-->>TASK: Connection accepted, agent marked ONLINE
CTRL->>TASK: Send build steps to execute
TASK->>TASK: Execute pipeline stages
TASK-->>CTRL: Stream logs + return results
Note over TASK: Job complete
CTRL->>PLUGIN: Agent idle, remove
PLUGIN->>ECS: ecs:StopTask
ECS->>TASK: Container killed
Key internal details:
agent.jar(inside the container) connects outbound to the controller on the JNLP port (default 50000, configurable). The controller never SSH-es into the agent.- The controller creates a secret token per agent. The agent presents this token on connection — authentication.
- Agent name format:
{label}-{job-name}-{build-number}-{5-random-chars}e.g.fargate-my-app-build-17-a3f2b - The "agent is offline" message in console is normal — it appears while the ECS task is still starting. Jenkins suspends the build executor and resumes when the agent connects.
Why outbound connection matters for ECS: The controller doesn't need to reach the agent's IP. The agent reaches out to the controller. This means agents can be in private subnets with no inbound rules from the controller — only the controller needs an inbound rule on port 50000 from the agent's security group.
ECS Fargate: Full Provisioning Flow
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph Controller["Jenkins Controller (EC2 t2.small)"]
QUEUE["Build Queue: job-a waiting"]:::orange
PROVISIONER["NodeProvisioner checks every ~10s: are queued jobs > idle executors?"]:::orange
PLUGIN_FARGATE["ECS Plugin Cloud: fargate-cloud has ECS task definition template"]:::orange
end
subgraph AWS["AWS"]
ECS_API["ECS Service API"]:::orange
ECR["ECR custom agent image"]:::yellow
subgraph FARGATE_TASK["ECS Fargate Task (awsvpc network mode)"]
ENI["Dedicated ENI gets VPC IP from subnet"]:::teal
CONTAINER["jenkins-agent container CPU: 1024 (1 vCPU) Memory: 2048 MB Ephemeral storage: 20GB"]:::yellow
AGENT_PROC["java -jar agent.jar connects to controller:50000"]:::purple
end
CW["CloudWatch Logs awslogs driver"]:::yellow
end
QUEUE --> PROVISIONER
PROVISIONER --> PLUGIN_FARGATE
PLUGIN_FARGATE -->|"RunTask API call"| ECS_API
ECS_API --> FARGATE_TASK
ECR -->|"image pull via S3 Gateway Endpoint (free)"| CONTAINER
CONTAINER --> AGENT_PROC
AGENT_PROC -->|"JNLP connect to controller:50000"| PROVISIONER
CONTAINER --> CW
Fargate specifics:
- Network mode must be
awsvpc— each task gets its own ENI and VPC IP - No privileged mode — cannot mount
/var/run/docker.sock. No DinD. - AWS manages the underlying EC2 host — you never see it, patch it, or worry about it
- Billing: per-second from when task enters RUNNING state to when it stops
- Cold start: image pull (mitigated by SOCI) + JVM startup = typically 30-90 seconds
ECS EC2 Launch Type: How It Differs
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph Controller["Jenkins Controller"]
PLUGIN_EC2["ECS Plugin Cloud: ec2-cloud launch type: EC2"]:::orange
end
subgraph ASG["Auto Scaling Group (Spot instances: m5.xlarge, m5.2xlarge)"]
EC2_A["EC2 instance: i-abc123 ECS Agent running Capacity: 4 vCPU, 16GB"]:::green
EC2_B["EC2 instance: i-def456 ECS Agent running"]:::green
end
subgraph Task["ECS Task on EC2 (bridge network mode)"]
CONTAINER_EC2["jenkins-agent container"]:::blue
DOCKER_SOCK["/var/run/docker.sock mounted from host enables DinD"]:::dark
CONTAINER_EC2 --- DOCKER_SOCK
end
PLUGIN_EC2 -->|"RunTask — ECS schedules on EC2"| ASG
ASG --> Task
subgraph ScaleOut["When no EC2 capacity available"]
CAPACITY_PROV["ECS Capacity Provider (linked to ASG)"]:::orange
ASG_SCALE["ASG scales out: launches new EC2 Spot"]:::orange
CAPACITY_PROV --> ASG_SCALE
end
EC2 launch type specifics:
- Network mode is
bridge(orhost) — NOTawsvpc. Container shares the host's network namespace. - DinD works because you mount
/var/run/docker.sockfrom the EC2 host into the container via Container Mount Points in the ECS task definition. - When no EC2 capacity is available, the ECS Capacity Provider triggers the ASG to launch a new instance. This is a two-stage cold start: EC2 boot time (~2 min) + image pull + JVM startup. This is why the Golden AMI matters — pre-baked tools eliminate the install time.
- Underlying EC2 instances are yours to manage: patching, AMI updates, instance type selection.
DinD mount in task definition:
{
"mountPoints": [{
"sourceVolume": "docker-sock",
"containerPath": "/var/run/docker.sock"
}],
"volumes": [{
"name": "docker-sock",
"host": { "sourcePath": "/var/run/docker.sock" }
}]
}
Scaling: How 1 Job = 1 Agent Works
Each queued job that needs a new agent results in one ecs:RunTask API call. There is no shared pool — agents are created on demand, one per job.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph T0["t=0: 10 jobs queued simultaneously"]
J1["job-1 waits"]:::orange & J2["job-2 waits"]:::orange & J3["...job-10 waits"]:::orange
end
subgraph T30["t=30s: NodeProvisioner fires"]
PROV["NodeProvisioner detects 10 queued jobs, 0 idle agents calls ECS plugin 10 times"]:::orange
PROV --> RT1["RunTask: fargate-cloud"]:::orange
PROV --> RT2["RunTask: fargate-cloud"]:::orange
PROV --> RT3["RunTask: x10"]:::blue
end
subgraph T90["t=90s: All 10 Fargate tasks RUNNING"]
A1["Agent 1 online"]:::blue --> J1_RUN["job-1 executing"]:::blue
A2["Agent 2 online"]:::blue --> J2_RUN["job-2 executing"]:::blue
A10["Agent 10 online"]:::blue --> J10_RUN["job-10 executing"]:::blue
end
subgraph T_END["jobs finish: tasks terminated immediately"]
STOP["ECS Plugin calls StopTask billing stops per-second"]:::red
end
Fargate scaling: Effectively unlimited concurrency. AWS allocates the underlying compute — you never provision or manage hosts. 10 jobs or 100 jobs: each gets its own Fargate task.
EC2 scaling (two-stage):
- ECS tries to place the task on existing EC2 instances in the cluster
- If no capacity → Capacity Provider signals the ASG → ASG launches new EC2 Spot instance → EC2 boots (~2 min) → ECS agent on EC2 registers → task placed → container starts
Max concurrent agents: Controlled in the ECS plugin by "Restrict max number of agents". Setting 0 = unlimited. Setting 20 = queue builds beyond 20 until an agent frees up.
NodeProvisioner tuning (reduces queue wait time):
-Dhudson.slaves.NodeProvisioner.initialDelay=0
-Dhudson.slaves.NodeProvisioner.MARGIN=50
-Dhudson.slaves.NodeProvisioner.MARGIN0=0.85
These JVM flags make the provisioner react faster to queued jobs instead of waiting for the standard smoothing interval.
Custom Images Per Job Type
The ECS plugin's task definition template acts as a base. Any Jenkinsfile can override the image at runtime.
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
BASE["jenkins/inbound-agent:alpine3.22-jdk21 Base image: agent.jar + java only"]:::blue
subgraph CustomImages["Custom images in ECR"]
JAVA_IMG["agent-java:latest + JDK 21 + Maven 3.9 For Java app builds"]:::orange
TF_IMG["agent-terraform:latest + Terraform 1.11 + kubectl For infra deploys"]:::green
NODE_IMG["agent-node:latest + Node 20 + npm For frontend builds"]:::orange
DIND_IMG["agent-dind:latest + docker-cli For docker image builds (EC2 only)"]:::orange
end
BASE --> JAVA_IMG
BASE --> TF_IMG
BASE --> NODE_IMG
BASE --> DIND_IMG
Jenkinsfile image override:
// Use base template but override the image for this specific job
pipeline {
agent {
ecs {
inheritFrom 'fargate-cloud' // inherits CPU, memory, network config
image '123456789.dkr.ecr.us-east-1.amazonaws.com/agent-java:latest'
}
}
stages {
stage('Build') {
steps {
sh 'mvn clean package -DskipTests'
}
}
}
}
Dockerfile pattern — multi-stage to minimize image size:
ARG ALPINE_VERSION=3.22
ARG JDK_VERSION=jdk21
# Stage 1: builder — install tools, compile, download deps
FROM jenkins/inbound-agent:alpine${ALPINE_VERSION}-${JDK_VERSION} AS builder
USER root
RUN apk add --no-cache curl unzip git
# Install terraform
RUN curl -fsSL -o /tmp/tf.zip https://releases.hashicorp.com/terraform/1.11.0/terraform_1.11.0_linux_amd64.zip \
&& unzip /tmp/tf.zip -d /usr/bin && rm /tmp/tf.zip
# Stage 2: runtime — copy only the final binaries, not build tools
FROM jenkins/inbound-agent:alpine${ALPINE_VERSION}-${JDK_VERSION}
USER root
COPY --from=builder /usr/bin/terraform /usr/bin/terraform
COPY --from=builder /usr/local/bin/kubectl /usr/local/bin/kubectl
USER jenkins
ENTRYPOINT ["/usr/local/bin/jenkins-agent"]
If using a non-Jenkins base image, you must copy the agent binaries from inbound-agent:
FROM jenkins/inbound-agent:alpine3.22-jdk21 AS jnlp
FROM ubuntu:22.04
COPY --from=jnlp /usr/local/bin/jenkins-agent /usr/local/bin/jenkins-agent
COPY --from=jnlp /usr/share/jenkins/agent.jar /usr/share/jenkins/agent.jar
COPY --from=jnlp /usr/share/jenkins/slave.jar /usr/share/jenkins/slave.jar
ENTRYPOINT ["/usr/local/bin/jenkins-agent"]
Agent Reuse (Avoid Cold Start on Every Build)
By default, the ECS plugin creates a new task per build and terminates it when done. You can reuse a running agent within a time window.
// Forces new ECS task every build (default)
agent {
ecs {
inheritFrom 'fargate-cloud'
image '...ecr.../agent-java:latest'
}
}
// Reuses existing agent by label if still alive (idle timeout window)
agent {
label 'fargate-cloud'
}
With label, if an agent from a previous build is still alive (within the task idle timeout), Jenkins assigns the next build to it directly — no ECS RunTask call, no cold start. The task idle timeout in the ECS plugin config controls how long an idle agent stays alive waiting for the next job.
Networking: Security Group Requirements
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
CTRL_SG["Controller Security Group"]:::purple
AGENT_SG["Agent Security Group (Fargate tasks in private subnet)"]:::orange
CTRL_SG -->|"Inbound: TCP 50000 from AGENT_SG (JNLP port — agent connects to controller)"| CTRL["Jenkins Controller"]:::purple
CTRL_SG -->|"Inbound: TCP 8080 from AGENT_SG (agent fetches job config from controller API)"| CTRL
AGENT_SG -->|"Outbound: TCP 50000 to CTRL_SG"| CTRL
AGENT_SG -->|"Outbound: TCP 443 to ECR (via VPC endpoint)"| ECR["ECR VPC Endpoint"]:::teal
AGENT_SG -->|"Outbound: TCP 443 to S3 (via Gateway endpoint, free)"| S3["S3 Gateway Endpoint"]:::orange
Common misconfiguration (90% of issues): The JNLP port on the controller's security group is not open to the agent's security group. The agent starts, tries to connect, fails silently — you see "agent is offline" forever.
Controller tunnel config: In the ECS plugin cloud settings:
Tunnel connection through: private IP of controller (e.g.10.0.1.50:50000)Alternative Jenkins URL:http://10.0.1.50:8080(private, not public URL)
Optimizations That Achieved 60% Cost Reduction
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph CostReduction["Cost Levers"]
SPOT["Fargate Spot + EC2 Spot Up to 50-90% cheaper than On-Demand For fault-tolerant CI builds"]:::orange
GRAVITON["Graviton ARM processors 20-30% lower cost 10-25% faster builds"]:::orange
RIGHT_SIZE["Right-sized task definitions Fargate: 0.25-1 vCPU per job Instead of a fixed large EC2"]:::green
VPC_EP["VPC Endpoints for ECR and S3 Eliminate NAT Gateway costs ECR layers stored in S3 = free pulls"]:::orange
EPHEMERAL["Ephemeral agents No idle compute Pay only when building"]:::orange
end
subgraph SpeedReduction["Build Time Levers"]
SOCI["SOCI (Seekable OCI) Lazy-loads container images 50%+ faster Fargate startup"]:::orange
GOLDEN_AMI["Golden AMI for EC2 Pre-baked tools via Packer No install time at boot"]:::orange
S3_CACHE["Job Cacher plugin + S3 Persists node_modules and .m2 between ephemeral builds"]:::orange
LAYER_CACHE["Optimized Dockerfiles Dependency layers before source layers Cache hits on dep-unchanged commits"]:::blue
end
VPC Endpoint impact (biggest networking saving):
Without VPC endpoints (ECR image pulls via NAT):
100 builds/day × 500MB image = 50GB/day × $0.045/GB = $2.25/day = $821/year
With VPC endpoints:
ECR Interface Endpoint: ~$7/month
S3 Gateway Endpoint: free (ECR layers are in S3)
Net saving: ~$750/year just from image pulls
Groovy-as-Glue: Controller Load Anti-Pattern
Every Groovy function in a Jenkinsfile that isn't inside a sh step runs on the controller. For heavy processing, this degrades the controller for everyone.
// ANTI-PATTERN: runs on controller, consumes controller heap + CPU
stage('Get Version') {
steps {
script {
def content = readFile 'package.json' // controller reads file
def pkg = new groovy.json.JsonSlurper().parseText(content) // controller parses JSON
env.APP_VERSION = pkg.version
}
}
}
// BEST PRACTICE: runs entirely on agent, only the final string sent to controller
stage('Get Version') {
steps {
script {
env.APP_VERSION = sh(
script: "jq -r '.version' package.json", // agent runs jq
returnStdout: true
).trim()
}
}
}
Rule: Use Groovy only as "glue" — control flow, variable assignment, calling sh. All actual data processing, file reading, API calls should be shell commands running on the agent.
Fargate vs EC2 Agents: Decision Matrix
| Concern | Fargate | EC2 Launch Type |
|---|---|---|
| Docker daemon access (DinD) | No (no privileged mode) | Yes (mount docker.sock) |
| Cold start time | 30-90s (SOCI reduces to 15-30s) | 2-5min (Golden AMI: 60-90s) |
| Scaling | Instant, unlimited | Bounded by ASG scale-out speed |
| Infrastructure management | None (AWS manages hosts) | You manage EC2 fleet, patching, AMI |
| Spot savings | ~50% via FARGATE_SPOT | ~70-90% via EC2 Spot |
| Privileged containers | Not supported | Supported |
| Network mode | awsvpc only | bridge or host |
| Best for | Standard CI: compile, test, lint, deploy | Docker builds, resource-intensive compilations |
Hybrid approach (what was implemented):
- Fargate: default for all standard jobs — testing, linting, terraform deploys, kubectl applies
- EC2: only for jobs that need DinD (building Docker images) or require very high CPU/memory
- Fargate Spot: dev environment builds, non-critical jobs, any job with retry on failure
IAM Requirements
Two distinct IAM roles are needed. Getting these wrong is the second most common failure point after security groups.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph ControllerRole["EC2 Instance Role (attached to controller EC2)"]
ECS_PERMS["ecs:RunTask, ecs:StopTask, ecs:DescribeTasks, ecs:ListClusters, ecs:DescribeTaskDefinition, ecs:RegisterTaskDefinition"]:::red
IAM_PASS["iam:PassRole: allows passing the task execution role to ECS when running tasks"]:::green
EC2_DESC["ec2:Describe* — ECS plugin queries VPC/subnet info"]:::orange
end
subgraph TaskExecRole["ECS Task Execution Role (used by ECS to start the task)"]
ECR_PULL["ecr:GetAuthorizationToken, ecr:BatchGetImage, ecr:GetDownloadUrlForLayer — pull custom agent image from ECR"]:::yellow
CW_LOGS["logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents — write to CloudWatch"]:::yellow
SSM_READ["ssm:GetParameters — if using SSM secrets in task definition"]:::yellow
end
subgraph TaskRole["ECS Task Role (the IAM identity the agent container runs as)"]
S3_CACHE["s3:GetObject, s3:PutObject on cache bucket — Job Cacher plugin"]:::purple
ECR_BUILD["ecr:* on build target repos — if agent builds and pushes images"]:::orange
ASSUME["sts:AssumeRole — if agent needs to deploy to different accounts"]:::green
LEAST_PRIV["Per-template: create one task role per job type, each with minimum needed perms"]:::blue
end
CTRL["Jenkins Controller"]:::purple --> ControllerRole
ECS_SVC["ECS Service"]:::orange --> TaskExecRole
AGENT_CONTAINER["Agent container process"]:::blue --> TaskRole
Task execution role vs task role:
- Task execution role — used by ECS infrastructure to bootstrap the container (pull image, write logs). Your code never touches this.
- Task role — the identity your
jenkins-agentprocess assumes. This is whataws s3 cp,terraform apply,kubectl applyetc. use inside the build.
Per-job least privilege: Create multiple ECS agent templates in the plugin, each with a different task role ARN:
// Deploy job — needs S3 and ECS perms
agent {
ecs {
inheritFrom 'fargate-cloud'
taskrole 'arn:aws:iam::123456789:role/jenkins-agent-deploy'
}
}
// Test job — needs only S3 for cache, nothing else
agent {
ecs {
inheritFrom 'fargate-cloud'
taskrole 'arn:aws:iam::123456789:role/jenkins-agent-test'
}
}
Minimum controller instance role policy:
{
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecs:RunTask", "ecs:StopTask", "ecs:DescribeTasks",
"ecs:ListClusters", "ecs:DescribeContainerInstances",
"ecs:DescribeTaskDefinition", "ecs:RegisterTaskDefinition",
"ecs:ListTaskDefinitions"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::123456789:role/jenkins-agent-task-exec-role",
"arn:aws:iam::123456789:role/jenkins-agent-*"
]
}
]
}
Workspace Caching: EFS vs S3
Your original setup used EFS for caching. There are two valid patterns — choose based on access pattern.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
subgraph EFSCache["EFS Shared Cache Mount"]
EFS["Amazon EFS
shared NFS filesystem"]:::blue
AGENT_A["Fargate Task A builds project-x
writes .m2 to /cache/project-x/"]:::orange --> EFS
AGENT_B["Fargate Task B builds project-x (next run)
mounts same EFS, cache hit on .m2"]:::orange --> EFS
EFS_NOTE["Pros: instant cache access, no upload/download step
Cons: EFS throughput costs, potential cache corruption if concurrent writes"]:::blue
end
subgraph S3Cache["S3 Job Cacher Plugin"]
S3["S3 Bucket
build-cache/"]:::orange
AGENT_C["Agent starts: download cache tarball from S3
~10-30s for 500MB .m2 cache"]:::yellow --> S3
S3 --> AGENT_D["Agent ends: upload updated cache to S3
only if changed (hash comparison)"]:::yellow
S3_NOTE["Pros: durable, versioned, cheap storage
Cons: upload/download latency per build"]:::orange
end
EFS mount in Fargate task definition:
{
"volumes": [{
"name": "build-cache",
"efsVolumeConfiguration": {
"fileSystemId": "fs-abc12345",
"rootDirectory": "/jenkins-cache",
"transitEncryption": "ENABLED"
}
}],
"mountPoints": [{
"sourceVolume": "build-cache",
"containerPath": "/home/jenkins/.m2"
}]
}
S3 Job Cacher (Jenkinsfile):
// Requires: Job Cacher plugin + s3-jobcacher-storage-plugin
stage('Build') {
steps {
cache(maxCacheSize: 500, caches: [
[$class: 'ArbitraryFileCache',
path: '/home/jenkins/.m2/repository',
cacheValidityDecidingFile: 'pom.xml',
compressionMethod: 'TARGZ']
]) {
sh 'mvn clean package'
}
}
}
When to use which:
| EFS | S3 Job Cacher | |
|---|---|---|
| Cache size | Any (NFS mount) | Up to ~2GB practical |
| Latency | Instant (network mount) | 10-60s download per build |
| Cost | ~$0.30/GB/month + throughput | ~$0.023/GB/month + tiny transfer |
| Concurrent builds | Risk of corruption without locking | Safe (separate per-job cache key) |
| Best for | Large caches (>1GB), many daily builds | Standard Maven/npm caches |
Important: EFS is fine for the agent cache mount. It is not suitable for $JENKINS_HOME on a busy controller (write-heavy random I/O). Use EBS for the controller home directory.
Observability: Debugging Failed ECS Agents
When an agent fails to come online or a build mysteriously dies, this is the investigation path.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
OFFLINE["Agent stuck OFFLINE or build fails silently"]:::red --> CHECK_TASK
CHECK_TASK["1. Find the ECS task
aws ecs list-tasks --cluster jenkins-cluster
aws ecs describe-tasks --cluster jenkins-cluster --tasks TASK_ARN"]:::orange
CHECK_TASK --> STOPPED{Task stopped?}
STOPPED -->|"Yes — check StoppedReason"| STOP_REASON["Common StoppedReasons:
CannotPullContainerError: image not found in ECR
ResourceInitializationError: ENI attachment failed
EssentialContainerExited: agent process crashed"]:::red
STOPPED -->|"No — still running"| CW_LOGS
CW_LOGS["2. Check CloudWatch Logs
Log group: /jenkins/agents/ or as configured in awslogs
Log stream: jenkins-agent/jenkins-agent/TASK_ID"]:::yellow
CW_LOGS --> LOG_CHECK{Errors in logs?}
LOG_CHECK -->|"Connection refused to controller:50000"| SG_FIX["SG misconfiguration
Controller SG missing inbound TCP 50000 from agent SG"]:::purple
LOG_CHECK -->|"Authentication failed"| AUTH_FIX["Wrong tunnel/URL config in ECS plugin
Check Alternative Jenkins URL and Tunnel fields"]:::red
LOG_CHECK -->|"OutOfMemoryError"| MEM_FIX["Task memory too low
Increase memory reservation in task definition"]:::blue
LOG_CHECK -->|"Clean log, agent never logged"| IMAGE_FIX["Image pull failed silently
Check ECR permissions in task execution role"]:::red
CLI debugging commands:
# 1. List running tasks in your cluster
aws ecs list-tasks --cluster jenkins-cluster --region us-east-1
# 2. Get details including StoppedReason
aws ecs describe-tasks --cluster jenkins-cluster --tasks arn:aws:ecs:us-east-1:123456789:task/jenkins-cluster/abc123 --region us-east-1 --query 'tasks[0].{status:lastStatus,stopped:stoppedReason,containers:containers[*].{name:name,reason:reason,exit:exitCode}}'
# 3. Fetch logs for a specific task
aws logs get-log-events --log-group-name /jenkins/agents --log-stream-name "jenkins-agent/jenkins-agent/abc123def456" --region us-east-1
# 4. Correlate Jenkins build to ECS task
# Agent name in Jenkins console: fargate-my-app-17-a3f2b
# ECS task tags set by plugin include the agent name — search by tag:
aws ecs list-tasks --cluster jenkins-cluster --filter "tag:JenkinsAgentName=fargate-my-app-17-a3f2b"
CloudWatch Logs Insights query for failed agents:
fields @timestamp, @message
| filter @logStream like /jenkins-agent/
| filter @message like /ERROR|Exception|refused|failed/
| sort @timestamp desc
| limit 50
Enable Execute Command (ECS Exec) for live debugging:
# SSH-equivalent into a running Fargate task
aws ecs execute-command --cluster jenkins-cluster --task TASK_ARN --container jenkins-agent --interactive --command "/bin/sh"
# Requires: EnableExecuteCommand=true in task definition (already set in plugin config)
Fargate Spot Interruption Handling
AWS can reclaim Fargate Spot capacity with a 2-minute warning. If this happens mid-build, the job fails and needs to retry.
sequenceDiagram
participant AWS as AWS (Spot capacity reclaimed)
participant TASK as Fargate Spot Task (mid-build)
participant CTRL as Jenkins Controller
AWS->>TASK: SIGTERM (2-minute warning)
Note over TASK: Build is mid-stage, e.g. running mvn package
TASK->>TASK: Jenkins agent receives SIGTERM
TASK->>CTRL: Agent disconnects (connection lost)
CTRL->>CTRL: Build marked FAILED (agent went offline)
AWS->>TASK: SIGKILL (2 minutes later, task forcibly stopped)
Note over CTRL: If retry configured: build re-queued
CTRL->>CTRL: ECS plugin provisions new agent (may land on On-Demand if Spot unavailable)
CTRL->>CTRL: Build retries from beginning
Capacity provider fallback strategy (recommended):
{
"capacityProviderStrategy": [
{
"capacityProvider": "FARGATE_SPOT",
"weight": 3,
"base": 0
},
{
"capacityProvider": "FARGATE",
"weight": 1,
"base": 0
}
]
}
This runs 75% of tasks on Spot, falls back to On-Demand when Spot capacity is unavailable. ECS makes this decision automatically per RunTask call.
Making pipelines tolerant of Spot interruption:
pipeline {
options {
// Retry the entire pipeline up to 2 times on agent failure
retry(2)
// Timeout the whole build — prevents runaway retries
timeout(time: 1, unit: 'HOURS')
}
agent {
ecs {
inheritFrom 'fargate-spot-cloud'
}
}
stages {
stage('Build') {
steps {
// Make stages idempotent — safe to re-run
sh 'mvn clean package -DskipTests'
}
}
stage('Test') {
steps {
// Use S3 cache so re-run doesn't re-download deps
sh 'mvn test'
}
}
}
post {
always {
// Clean workspace — next retry starts clean
deleteDir()
}
}
}
Jobs NOT suitable for Fargate Spot:
- Builds that take >45 min (higher interruption risk)
- Jobs with side effects that aren't idempotent (e.g., deploy to production — use On-Demand or EC2)
- Jobs that upload large artifacts mid-build and can't resume
Jobs ideal for Fargate Spot:
- Unit tests, integration tests
- Linting, security scans
- Development branch builds
- Any job where "just retry it" is acceptable