Workspaces
Declare, map, bind
ws-workspacews-pipeline-mappingws-mount-pathws-read-onlyws-emptydirws-configmap-secretws-projected-csi1apiVersion: tekton.dev/v12kind: Task3metadata: { name: fetch }4spec:5 workspaces:6 - name: output7 mountPath: /workspace/output8 readOnly: false9 steps:10 - name: clone11 image: alpine/git12 script: git clone $REPO $(workspaces.output.path)
1kind: Pipeline2spec:3 workspaces:4 - name: shared # pipeline-level name5 tasks:6 - name: fetch7 taskRef: { name: fetch }8 workspaces:9 - name: output # Task's name10 workspace: shared11 - name: build12 runAfter: [fetch]13 taskRef: { name: go-build }14 workspaces:15 - name: source16 workspace: shared
1kind: PipelineRun2spec:3 pipelineRef: { name: fetch-and-build }4 workspaces:5 - name: shared6 emptyDir: {}
two TaskRuns = two Pods
Press Run. The fetch TaskRun gets its own Pod; the build TaskRun gets another.
Try: run with emptyDir and watch build find an empty directory; switch to volumeClaimTemplate and run again. Then pick secret, or tick readOnly, and watch the clone fail on a read-only mount.
ReadWriteOnce, many nodes, and the Affinity Assistant
ws-access-modesws-affinity-assistantws-coschedulenode-1
0/3 podsnode-2
0/3 podsnode-3
0/3 podsnot yet on a node
1apiVersion: v12kind: ConfigMap3metadata:4 name: feature-flags5 namespace: tekton-pipelines6data:7 coschedule: "disabled"
No assistant. kube-scheduler spreads Pods across nodes; with an RWO volume, a Pod landing on a second node is stuck in ContainerCreating with a Multi-Attach error until the volume is free.
Try: with disabled + ReadWriteOnce, play and watch test and lint land on other nodes and stall with Multi-Attach. Switch to workspaces: everything follows the assistant to one node. Then tick the 2nd PVC (workspaces mode rejects the run, pipelineruns accepts it), or set 2 runs with isolate-pipelinerun and 1 node.
two PipelineRuns of the same Pipeline, different commits
PVC shared-ws
no owner · reusedresults (after both runs finish)
| run | wanted | built | tested | verdict |
|---|---|---|---|---|
| run-a | a1f | a1f+b7c | b7c | wrong/torn sources, tested other commit |
| run-b | b7c | b7c | b7c | correct |
1kind: PipelineRun2metadata: { generateName: build- }3spec:4 workspaces:5 - name: ws6 persistentVolumeClaim:7 claimName: shared-ws # same PVC every run
The static PVC has no owner: deleting PipelineRuns leaves it, and its files, for the next run.
Try: on the static PVC, start run-b at t=1 and play: run-a builds a mix of a1f and b7c. Untick deleteExisting to see the stale legacy_test.go survive. Switch to volumeClaimTemplate and both runs are correct; then delete a PipelineRun and its PVC disappears.
subPath: one volume, separate directories
ws-subpathPVC ws (one volume)
1tasks:2 - name: clone3 workspaces:4 - name: output5 workspace: ws6 - name: frontend7 runAfter: [clone]8 workspaces:9 - name: source10 workspace: ws11 - name: output12 workspace: ws13 - name: backend14 runAfter: [clone]15 workspaces:16 - name: source17 workspace: ws18 - name: output19 workspace: ws
Try: step through all writes without subPath and look at report.xml; then tick use subPath and look from each container: the same /workspace/output path now lands in different directories of one PVC.
optional workspace + $(workspaces.cache.bound)
1kind: Task2spec:3 workspaces:4 - name: cache5 optional: true6 mountPath: /cache7 steps:8 - name: build9 image: golang:1.2310 script: |11 if [ "$(workspaces.cache.bound)" = "true" ]; then12 export GOCACHE=$(workspaces.cache.path)/go-build13 fi14 go build ./...
if [ "false" = "true" ]; then export GOCACHE=/go-build fi go build ./...
In a Pipeline the same variable gates whole tasks: when: [{input: "$(workspaces.cache.bound)", operator: in, values: ["true"]}]
isolated workspaces: who can read /workspace/creds?
1spec:2 workspaces:3 - name: source4 - name: creds # bound to a Secret5 steps:6 - name: fetch-deps7 - name: compile8 - name: third-party-scan9 - name: push10 sidecars:11 - name: docker-daemon
| container | source | creds |
|---|---|---|
| fetch-deps step | mounted | mounted (doesn't need it) |
| compile step | mounted | mounted (doesn't need it) |
| third-party-scan step | mounted | mounted (doesn't need it) |
| push step | mounted | mounted |
| docker-daemon sidecar | — | not mounted (sidecars get only what they list) |
3 containers that never need the credentials can still read them, including a third-party scanner image.
1kind: Task2spec:3 stepTemplate:4 env:5 - name: REGISTRY_PASSWORD6 valueFrom:7 secretKeyRef:8 name: registry-creds # fixed in the Task9 key: password10 steps:11 - name: compile12 - name: third-party-scan13 - name: push14 script: docker login -u ci -p "$REGISTRY_PASSWORD" ...
what happens when…
$ env HOME=/tekton/home REGISTRY_PASSWORD=hunter2-prod ← now in the TaskRun logs
Try: click each probe under env, then switch to the Secret workspace. A file mounted only into the step that needs it doesn't end up in logs, child processes or other images. Catalog Tasks follow this pattern: git-clone takes ssh-directory / basic-auth workspaces, and buildah / kaniko take a dockerconfig workspace.
- takeawayTasks of a Pipeline are separate Pods. Only a volume both Pods mount carries files between them, so an emptyDir dies with its TaskRun.
- takeawayPrefer volumeClaimTemplate for build directories: a clean PVC per run, garbage-collected with its PipelineRun. Use subPath to split one volume between tasks.
- takeawayRWO volumes tie Pods to one node, which is why the Affinity Assistant exists. Keep credentials in step-scoped Secret workspaces, not env.
Key concepts · 17
book chapter 6- Workspace
A named slot for a filesystem that a Task declares and a TaskRun or PipelineRun fills with a real volume.
It keeps Tasks portable: the Task only says it needs a directory called source; whether that's an emptyDir, a PVC or a Secret is decided at run time.
spec.workspaces[]$(workspaces.source.path)↑ see it in the visualization - Pipeline workspace mapping
A Pipeline declares its own workspaces and maps each pipeline task's workspace name to one of them.
Mapping several tasks onto the same pipeline workspace is how clone, build and test end up sharing one volume, even though each Task names its slot differently.
tasks[].workspaces[].nametasks[].workspaces[].workspace↑ see it in the visualization - mountPath
Where the workspace appears inside every step container; defaults to /workspace/<name>.
Scripts should use $(workspaces.<name>.path) rather than hard-coding the path, so the Task keeps working if someone overrides mountPath.
workspaces[].mountPath$(workspaces.output.path)↑ see it in the visualization - readOnly workspace
A Task-side flag that mounts the volume read-only for that Task's steps.
A test or scan Task that should never modify the source can declare it; writes then fail with 'Read-only file system'. ConfigMap, Secret and projected sources are read-only regardless.
workspaces[].readOnly: true↑ see it in the visualization - emptyDir
A scratch volume created with the Pod and deleted with it.
It is shared by the steps of one TaskRun but not across tasks of a Pipeline: each TaskRun is a separate Pod and gets a fresh empty directory.
emptyDir: {}↑ see it in the visualization - persistentVolumeClaim
Bind an existing PVC by claimName; it outlives every run.
Good for a cache you deliberately keep. Dangerous as a build directory: concurrent runs write into the same files and later runs see earlier runs' leftovers.
persistentVolumeClaim.claimName↑ see it in the visualization - volumeClaimTemplate
A PVC spec the controller instantiates once per PipelineRun (or TaskRun), with an ownerReference to it.
Each run gets a clean, private volume, and deleting or pruning the run garbage-collects the PVC. It is not deleted when the run finishes, so pruning old runs matters for storage quota.
volumeClaimTemplate.specmetadata.ownerReferencestkn pipelinerun delete --keep 5↑ see it in the visualization - Access modes (RWO / RWX)
ReadWriteOnce volumes can be attached to one node at a time; ReadWriteMany volumes can be mounted from many nodes.
Most block-storage classes are RWO only. Parallel TaskRun Pods scheduled to different nodes then stall with a Multi-Attach error, which is why Tekton co-schedules them.
accessModes: [ReadWriteOnce]ReadWriteMany (NFS, EFS, CephFS)↑ see it in the visualization - Affinity Assistant
A placeholder StatefulSet Pod the controller creates per PVC workspace (or per PipelineRun); TaskRun Pods get pod affinity to it.
It pins every Pod that shares the volume to the same node, so an RWO PVC can be used by parallel tasks. The cost: those Pods compete for one node's capacity.
affinity-assistant-<hash>podAffinity↑ see it in the visualization - coschedule feature flag
Chooses the co-scheduling policy: workspaces (default), pipelineruns, isolate-pipelinerun, or disabled.
workspaces pins per PVC and rejects a task binding two PVC workspaces; pipelineruns pins a whole run to one node; isolate-pipelinerun additionally keeps one run per node; disabled leaves placement to the scheduler.
feature-flags: coscheduledisable-affinity-assistant (deprecated)↑ see it in the visualization - subPath
Mount only a subdirectory of the bound volume, set on a pipeline task's workspace binding or on the run's binding.
Lets several tasks share one PVC (one attach, one affinity assistant) while writing into separate directories, so parallel tasks don't overwrite each other's outputs.
tasks[].workspaces[].subPathworkspaces[].subPath↑ see it in the visualization - Optional workspace
A workspace declared optional: true may be left unbound by the run.
Lets one Task support an optional cache or credentials. A required workspace that isn't bound makes the run fail validation before any Pod starts.
workspaces[].optional: true↑ see it in the visualization - $(workspaces.<name>.bound)
Resolves to "true" or "false" depending on whether an optional workspace was bound.
Scripts branch on it, and Pipelines use it in when expressions to skip tasks that need the workspace. When unbound, the path variable resolves to an empty string.
$(workspaces.cache.bound)$(workspaces.cache.path)$(workspaces.cache.claim)$(workspaces.cache.volume)↑ see it in the visualization - Isolated workspaces
Listing a workspace under specific steps[].workspaces or sidecars[].workspaces mounts it only into those containers.
Credentials can reach the one step that pushes, not every image in the Task. Workspaces that no step lists stay visible to all steps, as before.
steps[].workspacessidecars[].workspaces↑ see it in the visualization - configMap & secret workspaces
Bind a ConfigMap or Secret so its keys appear as read-only files in the workspace.
Config and credentials arrive as files the tools already understand, chosen by the run rather than hard-coded in the Task.
configMap.namesecret.secretNameitems[].key / path↑ see it in the visualization - projected & csi workspaces
projected merges several ConfigMaps/Secrets into one directory; csi mounts a volume provided by a CSI driver.
A CSI driver such as the Secrets Store driver can fetch credentials from Vault or a cloud KMS at mount time, so they never live as Kubernetes Secrets.
projected.sources[]csi.driver: secrets-store.csi.k8s.io↑ see it in the visualization - Secrets via workspaces
Deliver credentials (docker config, SSH keys, .git-credentials) as files in a Secret workspace instead of environment variables.
Env vars leak through env dumps, set -x traces, child processes and every step of a stepTemplate. A step-scoped file doesn't, and kubelet refreshes it when the Secret rotates.
git-clone: ssh-directory, basic-authbuildah/kaniko: dockerconfigDOCKER_CONFIG↑ see it in the visualization