How to Configure NFS Storage for CloudBees CI Managed Controllers HA on OpenShift

|
Last Updated:
|
|
How to Configure NFS Storage for CloudBees CI Managed Controllers HA on OpenShift

CloudBees CI HA on OpenShift starts with one requirement that can stop the deployment before it even begins: shared storage with ReadWriteMany (RWX) access. OpenShift Data Foundation (ODF) is a common choice, but if your OpenShift cluster does not have ODF, you do not need to deploy an entire storage platform just to provide RWX storage. NFS is a supported option for on premises environments, with CloudBees recommending NFS 4.1.

The real challenge is getting NFS right for CloudBees CI. Storage performance, SSD or NVMe selection, network speed, NFS configuration, filesystem settings, mount options, OpenShift integration, and failure handling all matter. This guide walks through the complete setup, from building and sizing the NFS server to configuring OpenShift and enabling HA for CloudBees CI Managed Controllers, with a focus on the settings that matter in production.

Configure NFS Storage for CloudBees CI Managed Controllers HA on OpenShift

How CloudBees CI HA actually Works

Before touching any YAML files, it is worth understanding what CloudBees CI High Availability (HA) is actually doing under the hood. Once you understand how the replicas share state, workloads, and persistent data, the NFS requirements make a lot more sense.

CloudBees CI HA runs multiple replicas of the same Managed Controller as one logical controller. The replicas work together to provide failover, workload distribution, and rolling operations:

  • Controller failover: If a replica fails, running builds can be adopted by another replica, allowing the controller to continue operating without the failed replica.
  • Horizontal scaling: A single logical controller can spread its workload across multiple replicas. CloudBees CI also provides explicit build load balancing, which can direct new builds toward the replica with the least workload.
  • Rolling restarts and upgrades: Replicas can be restarted or upgraded incrementally, allowing the controller to remain available during supported rolling operations.
  • Live state synchronization: CloudBees CI replicas use the embedded Hazelcast distributed computation library to keep their live state synchronized. No separate Hazelcast deployment or storage configuration is required.
  • Shared filesystem: This is the part that matters most for this guide. The replicas need access to shared persistent controller content. That means the storage backing the controller must support simultaneous read and write access from multiple pods, which is why ReadWriteMany (RWX) storage is required for a multi-replica HA controller.

This is fundamentally different from a standard non-HA Managed Controller, where a single controller pod can use storage with ReadWriteOnce (RWO) access. When you move that controller to HA with multiple replicas, the storage architecture changes with it. The replicas must be able to access the same shared filesystem, which is where an RWX-capable backend such as NFS becomes important for on-premises OpenShift environments. CloudBees’ current platform guidance specifically supports NFS 4.1 and higher. CloudBees’ NFS knowledge base article recommends NFS 4.1 or higher for CloudBees CI installations.

The architecture at a glance

CloudBees CI HA combines multiple Managed Controller replicas into a single logical controller. The replicas share workloads, synchronize live state through embedded Hazelcast, and access the same shared filesystem for persistent controller data. For resilience, CloudBees recommends placing HA replicas on different worker nodes and, where possible, across different availability zones.

 		  ┌───────────────────────────────────────────┐
                  │            OpenShift Cluster              │
                  │                                           │
                  │    CloudBees CI HA Managed Controller     │
                  │                                           │
                  │      ┌───────────┐     ┌───────────┐      │
                  │      │ Replica 1 │     │ Replica 2 │      │
                  │      └─────┬─────┘     └─────┬─────┘      │
                  │            │                 │            │
                  │            │   Hazelcast     │            │
                  │            │ synchronization │            │
                  │            └────────┬────────┘            │
                  │                     │                     │
                  │           ┌─────────▼─────────┐           │
                  │           │  Shared RWX PVC   │           │
                  │           │ NFS StorageClass  │           │
                  │           └─────────┬─────────┘           │
                  └─────────────────────┼─────────────────────┘
                                        │
                                        │ NFS 4.1+
                                        │ over TCP
                          ┌─────────────▼─────────────┐
                          │        NFS Server         │
                          │       RHEL / Linux        │
                          │       SSD / NVMe          │
                          │       High-speed NIC      │
                          └───────────────────────────┘

There are two separate paths to understand:

  • Hazelcast handles live state synchronization between the controller replicas, while
  • the shared RWX filesystem provides persistent controller storage.

CloudBees requires shared storage between HA replicas and recommends NFS 4.1 for on premises deployments.

Every Managed Controller replica mounts the same NFS-backed RWX volume concurrently. This makes the NFS layer a critical dependency for the controller’s persistent filesystem. Its storage media, network capacity, latency, NFS version, filesystem configuration, and failure behavior therefore become important parts of the overall CloudBees CI HA architecture.

NFS does not provide the HA functionality itself. It provides the shared storage layer that HA requires. The complete HA architecture combines multiple controller replicas, shared RWX storage, Hazelcast-based state synchronization, and the OpenShift scheduling and networking infrastructure that connects them.

Part 1: Sizing and Building the NFS Server

Hardware and Storage Type

CloudBees CI’s own reference architecture is clear about the storage requirement:

  • Pipelines typically read and write many files during execution, so CloudBees CI requires high speed storage.
  • CloudBees also notes that the underlying storage provider must provide sufficient IOPS, throughput, and low latency. For on premises deployments, CloudBees recommends NFS 4.1.

That matters even more when the storage is backing HA Managed Controllers. Multiple controller replicas can access the same persistent filesystem concurrently, so a slow NFS backend can quickly become the bottleneck for the entire controller.

For a production NFS server backing CloudBees CI, this is the baseline I would use:

ComponentRecommendationWhy it matters
Disk typeEnterprise SSD or NVMe, preferably with redundant storage such as RAID 10CloudBees CI generates frequent filesystem activity, including metadata operations, configuration updates, logs, plugin access, and build data. SSD or NVMe provides substantially lower latency and higher IOPS than spinning disks.
FilesystemXFS for a Linux NFS server, or the filesystem managed by your NAS platformXFS is a strong default for a high concurrency Linux NFS server. Ext4 is also viable when it better matches your existing storage standards.
RAID controller cacheBattery backed or flash backed write cache, where supportedA protected write cache can reduce write latency while preserving data integrity during a power or controller failure. Do not enable unprotected write back caching for controller data.
CPU4 or more cores as a starting pointNFS request processing and filesystem activity consume CPU. Increase CPU when monitoring shows the server becoming CPU bound.
RAM16 to 32 GB as a starting pointLinux uses available memory for the filesystem page cache, which can significantly reduce repeated reads from the underlying storage. Larger environments may benefit from more memory.
IOPSSize from measured workload rather than a fixed CloudBees numberCloudBees does not publish a universal IOPS requirement. The correct target depends on controller count, concurrent builds, filesystem activity, and workload characteristics.

The important point is that the table above is an engineering baseline, not a CloudBees minimum specification. CloudBees requires sufficient IOPS, throughput, and low latency, but there is no single IOPS figure that is correct for every installation.

If you are virtualizing the NFS server, pay just as much attention to the storage underneath the VM. A 16 core NFS VM with plenty of RAM will still perform badly if its virtual disks sit on an oversubscribed datastore. This is one of those problems that often looks like a CloudBees CI performance issue until you look at the storage latency underneath the NFS server.

For production, I would also avoid putting the CloudBees CI export on the same storage pool as unrelated workloads such as backup repositories, large file shares, or bulk archival jobs. CI workloads can produce sudden bursts of filesystem activity, and competing workloads can turn otherwise healthy storage into a latency problem.

Network Requirements

Because every HA controller replica accesses the shared $JENKINS_HOME over the network, NFS network performance needs to be treated as a core part of the storage design.

There is no single network speed that CloudBees defines as a requirement for NFS. The right size depends on the number of controllers, workload, concurrency, and storage performance. As a practical starting point:

Deployment sizeMinimum starting pointRecommended production targetTarget latency
Small / development1 GbE10 GbE< 1 ms
Standard production10 GbE10 GbE or higher< 1 ms
Large / multi team HA cluster10 GbE25 GbE or higher< 1 ms

These are infrastructure design targets, not CloudBees product requirements. Measure the actual network path between the OpenShift worker nodes and the NFS server before deciding that additional bandwidth is necessary.

A few practical rules make a significant difference:

  • Keep NFS traffic isolated where practical. A dedicated NIC, VLAN, or storage network prevents storage traffic from competing with unrelated high volume traffic such as backups, image transfers, or application ingress.
  • Keep the NFS server close to the OpenShift worker nodes. Avoid putting the shared controller filesystem across a WAN, across regions, or across a high latency network path.
  • Watch retransmissions and packet loss. A link can have plenty of available bandwidth and still deliver poor NFS performance when packets are being retransmitted.
  • Do not enable jumbo frames blindly. MTU 9000 can reduce network overhead for large transfers, but it must be configured consistently across the entire path. If the NFS server, OpenShift nodes, switches, VLAN interfaces, or routers disagree about the MTU, troubleshooting becomes much harder.
  • Benchmark with concurrent clients. A network that looks excellent with one NFS client can behave very differently when several controller replicas and other workloads are accessing the server simultaneously.

For an HA deployment, I would establish a baseline for latency, throughput, packet loss, TCP retransmissions, and peak interface utilization before putting production controllers on the storage.

NFS Server Software

Use NFS 4.1 or later for this deployment. CloudBees supports NFS v3.x and NFS v4.1 and higher, but does not support NFS 4.0 because of known performance issues. For CloudBees CI on premises, CloudBees specifically recommends NFS 4.1.

If you are running a Linux based NFS server, install and maintain the NFS server packages provided by your distribution’s supported repositories. For example, RHEL uses nfs-utils. Keep the operating system and NFS components patched and within the vendor’s supported lifecycle.

For NFS 4.1, do not blindly copy older NFSv3 configuration guides. NFSv4 does not require rpcbind, so an NFS 4.1 only server does not need it for NFS communication. Install and enable the NFS server components required by your operating system, then verify that the server is actually exporting the filesystem over NFS 4.1.

If you are using NFSv4 identity mapping, make sure the UID and GID mapping behavior is consistent between the NFS server and clients. With OpenShift, however, do not assume that containers will run as root. OpenShift requires containers to run as non root users and commonly assigns arbitrary UIDs, so permissions need to be designed accordingly.

This guide assumes you already have an NFS server installed and running. If you need to set one up from scratch, package installation, enabling the service, and firewalld are already covered in our NFS server installation guides. The rest of this section covers two things a generic install guide will not:

  • an SELinux setting specific to exporting a non-standard directory, and
  • a squash policy decision specific to how csi-driver-nfs operates.

SELinux. On RHEL, SELinux blocks NFS from exporting files by default, independent of the Unix permissions on the directory. This is a separate control from the root_squash and ownership settings covered below, and missing it produces a similar looking permission denied error, so it is worth checking first.

Check the current state:

getsebool -a | grep nfs_export

What you want to see, for a read write export:

nfs_export_all_ro --> on
nfs_export_all_rw --> on

If either shows off, set it:

setsebool -P nfs_export_all_rw on
setsebool -P nfs_export_all_ro on

The -P flag makes this persist across reboots. Without it, the setting is lost on the next boot and the export silently breaks again. These booleans are not required if the export directory is labeled with the public_content_rw_t SELinux context instead, but for a directory outside the standard public content paths, such as /data/cloudbees in this guide, the booleans are the simpler option. Re-check with the same getsebool command afterward to confirm the change took.

Example /etc/exports:

cat /etc/exports
# CloudBees CI NFS export
/path/to/cloudbees-ci 10.185.10.0/24(rw,sync,no_subtree_check)

Replace both the path and network subnet with the ones from your environment:

  • /path/to/cloudbees-ci is the directory or filesystem you created for CloudBees CI on the NFS server. It could be something such as /srv/nfs/cloudbees-ci, /export/cloudbees-ci, or a mount point backed by a dedicated storage volume.
  • 10.0.0.0/16 is the IP network containing the OpenShift worker nodes that need to mount the export. Replace it with your actual OpenShift node subnet or, where appropriate, a specific list of allowed client networks.

A few export options deserve particular attention:

  • rw allows the OpenShift clients to read and write the CloudBees CI filesystem.
  • sync is the safer choice for persistent controller data. The NFS server does not acknowledge writes before the changes are committed to stable storage.
  • no_subtree_check avoids unnecessary subtree checking when exporting a directory from a larger filesystem.
  • Restrict the export to the OpenShift nodes or networks that actually require access. The NFS export rules should be backed by host firewall and network controls rather than being treated as the only security boundary.

root_squash and csi-driver-nfs

csi-driver-nfs’s controller was granted the privileged SCC in Part 2, with no runAsUser override, so its nfs container runs as UID 0, actual root, inside the container. When it creates a subdirectory on this export, that write genuinely originates from root. Standard root_squash applies to it and will squash it to the anonymous UID, and an anonymous UID has no write access to a root:root, mode 755 directory. This is confirmed directly in the driver’s own issue tracker: exports with default root_squash produce provisioned directories owned by the anonymous UID and fail; exports with no_root_squash produce directories owned by actual root, and work.

Because this export is restricted to the OpenShift node subnet, and the only thing on that subnet writing to it as root is the csi-driver-nfs controller running inside a privileged, infrastructure-only namespace, no_root_squash is the correct setting here.

See our NFS export configurarion then:

cat /etc/exports
# CloudBees CI NFS export
/data/cloudbees 10.185.10.0/24(rw,sync,no_subtree_check,no_root_squash)

Leave the export directory owned root:root. No chown is needed.

If you would rather not grant root over NFS at all, the alternative is all_squash with anonuid and anongid mapped to a dedicated, no login service account that owns the export directory:

useradd -r -s /sbin/nologin cloudbees-nfs
chown cloudbees-nfs:cloudbees-nfs /data/cloudbees

Look up the UID and GID this account was actually assigned:

id cloudbees-nfs

Sample output:

uid=985(cloudbees-nfs) gid=985(cloudbees-nfs) groups=985(cloudbees-nfs)

Take the uid and gid values from that output, 985 and 985 above, and use them in the export line:

/data/cloudbees 10.185.10.0/24(rw,sync,no_subtree_check,all_squash,anonuid=985,anongid=985)

Replace 985 with your own account’s actual values. System accounts created with useradd -r are assigned from a reserved range, not a fixed number, so yours will very likely differ from the example above. This avoids granting root over NFS entirely, at the cost of one extra account to manage. Either configuration, this or no_root_squash, is valid; this guide uses no_root_squash for the rest of the walkthrough.

exportfs -arv

Tuning the NFS Server Daemon

Do not start by throwing a collection of NFS kernel parameters at the server or arbitrarily setting the NFS server thread count to 16 because a tuning guide says 16 is a good number.

The number of NFS server threads is a fixed value you configure, it does not scale up or down automatically. There is still no single number that is right for every CloudBees CI environment, since the right value depends on your hardware and workload; the NFS server’s CPU capacity, storage performance, network capacity, number of connected clients. Start with the operating system defaults, measure performance under realistic CloudBees CI load, and tune only when the measurements show that NFS request processing is actually limiting performance.

Start with the default configuration and establish a baseline under realistic CloudBees CI load.

On the NFS server, use nfsstat to check server-side NFS activity and retransmissions:

sudo nfsstat -s

Use iostat to check storage latency, utilization, and queue depth:

iostat -xz 1

Use vmstat to get a quick view of CPU, memory, and I/O pressure:

vmstat 1

Use sar to check network traffic and interface utilization:

sar -n DEV 1

On an NFS client, you can also inspect client side NFS statistics:

nfsstat -c

Watch for:

  • High disk latency
  • Increasing storage queue depth
  • CPU saturation
  • Network saturation
  • NFS retransmissions
  • Memory pressure
  • Increasing NFS operation latency

If the server is CPU bound while the storage and network still have capacity, increasing NFS concurrency may be worth testing. If the disks are already saturated, increasing NFS threads will not make the storage faster. It will simply put more requests into the queue.

That distinction matters. Tune the bottleneck, not the NFS server by folklore.

If the NFS server is also being used as a backup target, registry storage, general file server, or anything else with heavy I/O, isolate the CloudBees CI storage whenever possible. A build storm combined with a large backup job is exactly the kind of workload that can turn a healthy NFS server into a controller performance problem.

The goal is not to find a magic NFS configuration. The goal is to give CloudBees CI a fast, predictable, low latency shared filesystem with enough IOPS, throughput, and headroom to remain stable during peak workload. That is the foundation the HA configuration depends on.

If you are looking at guides on how to install and setup NFS server, refer to our guides.

Part 2: Wiring NFS into OpenShift as a StorageClass

Deploy the NFS CSI Driver

At this point, you have an NFS server with a directory exported to your OpenShift nodes. The next step is to make that storage available to OpenShift as a StorageClass.

A StorageClass allows OpenShift to create Persistent Volumes automatically when CloudBees CI requests storage. Instead of manually creating a PV for every controller, the driver creates a directory on the NFS export for each dynamically provisioned volume.

This guide uses csi-driver-nfs, the CSI-compliant NFS driver maintained under Kubernetes SIG Storage. It integrates with Kubernetes’ current storage architecture and supports dynamic provisioning, volume expansion, and snapshots, making it a good fit for a new production deployment. A separate project, the NFS Subdir External Provisioner, provides NFS dynamic provisioning through a different, older approach and is not covered here.

One thing to watch with csi-driver-nfs: versions before v4.13.1 are affected by CVE-2026-3864, a path traversal vulnerability involving the subDir parameter. A crafted volumeHandle could cause cleanup to delete files outside the intended export directory. The vulnerability is fixed in v4.13.1 and later. Every command below pins a later version, so do not install an older release.

Before starting, have these two values from your NFS server configuration:

  • NFS server address: for example, 10.185.10.199
  • NFS export path: for example, /data/cloudbees

The export path is the path you configured in /etc/exports. It is not necessarily /data/cloudbees, so use the actual path from your environment.

First, add the csi-driver-nfs Helm repository:

helm repo add csi-driver-nfs https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts

Update the Helm repository index to fetch the latest chart information.

helm repo update

Before installing, check which chart version is actually current. Do not copy a version number from this guide since it will most likely be out of date by the time you read it:

helm search repo csi-driver-nfs/csi-driver-nfs --versions

Sample output, at the time of writing:

NAME                          	CHART VERSION	APP VERSION	DESCRIPTION
csi-driver-nfs/csi-driver-nfs	4.13.4       	4.13.4     	CSI NFS Driver for Kubernetes
csi-driver-nfs/csi-driver-nfs	4.13.3       	4.13.3     	CSI NFS Driver for Kubernetes
csi-driver-nfs/csi-driver-nfs	4.13.2       	4.13.2     	CSI NFS Driver for Kubernetes
csi-driver-nfs/csi-driver-nfs	4.13.1       	4.13.1     	CSI NFS Driver for Kubernetes
csi-driver-nfs/csi-driver-nfs	4.13.0       	4.13.0     	CSI NFS Driver for Kubernetes
csi-driver-nfs/csi-driver-nfs	4.12.1       	4.12.1     	CSI NFS Driver for Kubernetes

The top row is the current release. Chart version and app version match in this repo, so either column tells you the same thing. Take that value, 4.13.4 in the output above, and use it below. Yours may already be higher by the time you read this.

This version must be v4.13.1 or later.

Install the driver into its own namespace, using the version you found. OpenShift already ships the external snapshotter CRDs, so the chart’s attempt to create them is disabled to avoid an ownership conflict.

A few things to note:

  • Replace 4.13.4 with whatever version your own helm search repo output showed at the top
  • If your cluster is a compact or Single Node OpenShift deployment where workloads run on control plane nodes, add --set controller.runOnControlPlane=true and reduce controller.replicas to 1.
helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs \
  --create-namespace --namespace csi-driver-nfs \
  --version 4.13.4 \
  --set controller.replicas=2 \
  --set externalSnapshotter.enabled=true \
  --set externalSnapshotter.customResourceDefinitions.enabled=false

This chart does not take NFS server connection details or create a StorageClass by default. It only deploys the driver itself, a controller Deployment and a node DaemonSet. The server address and export path are supplied later, in the StorageClass, not at Helm install time.

Verify the controller and node pods:

oc get pods -n csi-driver-nfs

You should see one or more csi-nfs-controller pods and one csi-nfs-node pod per worker node, all Running.

NAME                                   READY   STATUS    RESTARTS   AGE
snapshot-controller-666cbcc594-cjzhv   1/1     Running   0          40s

Allow the driver to mount NFS on OpenShift

The node component performs actual host level mount and unmount operations on each worker node, which the default restricted-v2 SCC does not permit. The CSI node plugin needs the privileged SCC to do this. This is expected and matches how every CSI driver, not just NFS, runs on OpenShift.

Why this needs full privileged, not a narrower SCC

Check the actual pod spec the chart deploys and the reason becomes concrete rather than theoretical:

oc get ds csi-nfs-node -n csi-driver-nfs -o yaml

The nfs container in that DaemonSet runs with:

securityContext:
  allowPrivilegeEscalation: true
  capabilities:
    add:
    - SYS_ADMIN
    drop:
    - ALL
  privileged: true

The pod spec also sets hostNetwork: true, and mounts /var/lib/kubelet/pods from the host with mountPropagation: Bidirectional, which is what lets a mount made inside the container become visible on the host and vice versa. That bidirectional propagation is how the actual NFS mount ends up usable by the kubelet and, in turn, by application pods.

Compare that against hostmount-anyuid (oc describe scc hostmount-anyuid):

Allow Privileged:        false
Allowed Capabilities:    <none>
Allow Host Network:      false

Three separate fields the pod needs, three separate fields this SCC denies. It is not a matter of degree or caution; the pod is rejected at admission under hostmount-anyuid regardless of which of the three you look at first. The same is true of hostmount-anyuid-v2. Neither grants SYS_ADMIN, privileged mode, or host networking, all three of which this daemonset’s node container requires to perform the actual mount operation and make it visible to the host’s kubelet.

This is not specific to csi-driver-nfs. Every CSI node plugin that mounts external storage on OpenShift needs the same combination, for the same reason: it is not reading and writing files on the host, it is issuing mount and unmount system calls into the host’s mount namespace and propagating the result back out. Ceph CSI, vSphere CSI, HPE CSI, and Synology CSI all document an identical requirement for their node components. The old NFS Subdir External Provisioner avoided this because its pod consumed an NFS mount the same way any application pod does, through a Kubernetes nfs: volume, rather than performing the mount itself. A CSI node plugin’s whole job is to be the thing performing that mount, which is why it sits at a different privilege tier.

As such, you need to grant the plugin privileged SCC for it to work. Therefore, first confirm the service account names the chart created. The defaults are csi-nfs-controller-sa and csi-nfs-node-sa, but verify against your release:

oc get sa -n csi-driver-nfs
NAME                    AGE
builder                 25m
csi-nfs-controller-sa   25m
csi-nfs-node-sa         25m
default                 25m
deployer                25m
snapshot-controller     25m
  • builder, default, and deployer are not created by this chart. OpenShift creates them automatically in every project.
  • snapshot-controller is created by the chart, but only because externalSnapshotter.enabled=true was set in the install command earlier; it runs the external snapshot sidecar and does not need the SCC grants below.

The two that matter for the mount operations described above are worth understanding on their own:

  • csi-nfs-controller-sa is used by the controller Deployment. It handles provisioning and deprovisioning: creating and deleting the per volume subdirectory on the NFS export when a PVC is created or removed. To do that, its nfs container mounts the export itself, which is why it carries the same privileged and SYS_ADMIN requirement as the node component, not because it talks to the Kubernetes API, which it also does, but specifically because provisioning a new subdirectory means mounting the share first.
  • csi-nfs-node-sa is used by the node DaemonSet, one pod per worker node. It handles attaching an already provisioned volume into a specific pod: the actual mount and unmount operation that makes the NFS share appear inside a container’s filesystem, and the bidirectional mount propagation that makes that mount visible to the host kubelet.

Both perform a real mount operation, just at different points in the volume’s lifecycle: the controller when the directory is created or removed, the node when the volume is attached to or detached from a pod. That is why both need the same SCC grant, and why snapshot-controller does not.

Grant the privileged SCC to both:

oc adm policy add-scc-to-user privileged -z csi-nfs-controller-sa -n csi-driver-nfs
oc adm policy add-scc-to-user privileged -z csi-nfs-node-sa -n csi-driver-nfs

Restart the controller and node components to apply the newly configured SCC permissions.

oc rollout restart deployment -n csi-driver-nfs
oc rollout restart daemonset -n csi-driver-nfs

Treat the csi-driver-nfs namespace as infrastructure, restrict who can create pods in it, and do not grant privileged to application workloads elsewhere in the cluster.

Create the StorageClass

The provisioner name for csi-driver-nfs is: nfs.csi.k8s.io.

Create managed-nfs-storage.yaml:

cat > managed-nfs-storage.yaml << 'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: managed-nfs-storage
provisioner: nfs.csi.k8s.io
parameters:
  server: 10.185.10.199
  share: /data/cloudbees
  onDelete: archive
  mountPermissions: "0770"
reclaimPolicy: Delete
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions:
  - hard
  - nfsvers=4.1
EOF

The important settings here are the NFS version, the mount behaviour, and the reclaim policy. CloudBees recommends NFS 4.1 for on-premises deployments, so explicitly specifying NFS 4.1 avoids accidentally mounting the export with a different protocol version.

Replace server and share with your actual NFS server address and export path from Part 1.

  • onDelete controls what happens to data on PVC deletion. Values are delete (default), retain, or archive. archive renames the subdirectory instead of removing it, giving recoverability without an orphaned PV.
  • mountPermissions controls the mode bits csi-driver-nfs applies to each subdirectory it creates. Its default is 0, meaning the driver performs no chmod and the directory inherits whatever permissions your export already grants. Setting it non-zero, as above, makes the driver chmod each provisioned subdirectory after mount. This solves a different problem than root_squash, covered in Part 1: root_squash governs whether the csi-driver-nfs controller itself, running as root, can create the subdirectory in the first place. mountPermissions governs whether the pod that later consumes that PVC, such as the Jenkins controller, can write to it. That consuming pod normally runs under the restricted-v2 SCC with a non-root, arbitrary UID assigned from the project’s range, not as root, so the subdirectory’s mode bits need to grant it access some other way, typically through group permissions matched to the pod’s fsGroup, set in Part 3.

The two mount options above are the important starting point:

  • hard keeps retrying an NFS operation if the server becomes temporarily unavailable instead of immediately returning an I/O error to the application. This is appropriate for persistent controller data.
  • nfsvers=4.1 explicitly selects NFS 4.1, which is the version CloudBees recommends for on-premises deployments.

What about rsize, wsize, actimeo, and nconnect?

You will find many NFS tuning guides recommending values such as rsize=262144, wsize=262144, actimeo=60, or nconnect=4. Do not treat those values as universal CloudBees CI requirements.

Modern NFS clients can negotiate appropriate read and write sizes, and the best values for attribute caching or multiple connections depend on the kernel, NFS server, network, and workload. Changing these settings without measuring the result can improve one workload while making another worse.

Start with the documented NFS 4.1 configuration and establish a performance baseline. If testing shows that the NFS client is the bottleneck, tune these options based on measurements from your specific environment.

For example, nconnect can be useful on high-bandwidth networks by allowing multiple connections to an NFS server, but support and behaviour depend on the client kernel and NFS server. Treat it as an optimisation to test, not a requirement for CloudBees CI HA.

Apply and verify:

oc apply -f managed-nfs-storage.yaml
oc get storageclass managed-nfs-storage -o jsonpath='{.provisioner}{"\n"}'

This must print nfs.csi.k8s.io.

A StorageClass declares no access modes itself. RWX capability comes from NFS supporting concurrent read write mounts, which the two pod validation later in this section confirms directly.

At this point, the path is:

NFS Server
    │
    └── /data/cloudbees
            │
            ▼
    csi-driver-nfs
            │
            ▼
    managed-nfs-storage (or your SC name)
            │
            ▼
    CloudBees CI PVC
            │
            ▼
    RWX Persistent Volume

The next step is to prove that dynamic provisioning actually works by creating a small RWX test PVC before involving CloudBees CI. This catches NFS permissions, connectivity, StorageClass, and OpenShift security issues early, instead of discovering them later when the Managed Controller fails to start.

Decide: Cluster Default or Explicit StorageClass

Once the NFS StorageClass has been created and verified, CloudBees CI can use it in two ways:

  1. Make the NFS StorageClass the OpenShift cluster default.
  2. Keep the existing OpenShift default unchanged and configure CloudBees CI to use the NFS StorageClass explicitly.

Both approaches are supported by CloudBees. For a shared production OpenShift cluster, leaving the existing cluster default unchanged and explicitly configuring the CloudBees CI storage class is generally the more controlled approach.

The NFS StorageClass name used in this guide, managed-nfs-storage, is an example. Replace it with the actual name of the NFS StorageClass created in your OpenShift cluster.

Option 1: Make the NFS StorageClass the cluster default

Warning!
Before doing this, know what it actually exposes. Red Hat’s own documentation for OpenShift explicitly states that NFS backed storage has known issues with core platform services, specifically the internal image registry, Prometheus monitoring storage, and Elasticsearch or LokiStack logging storage, and separately excludes NFS as a recommended backend for etcd. None of those components specify a StorageClass by name in a default installation. If the NFS StorageClass becomes the cluster default, any of them that provisions a PVC without specifying one will land on NFS, and Red Hat’s own guidance is that this is not recommended for those workload

A PVC that does not specify a storageClassName uses the cluster’s default StorageClass. Therefore, making the NFS StorageClass the OpenShift default allows CloudBees CI to use NFS without specifying the StorageClass separately.

For example:

oc patch storageclass managed-nfs-storage \
  -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

If another StorageClass is currently marked as the default, remove that designation:

oc patch storageclass <existing-default-storage-class> \
  -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'

Verify the result:

oc get storageclass

The NFS StorageClass should now be marked as the default.

This approach is straightforward, but the change is cluster-wide. The default StorageClass is an OpenShift/Kubernetes cluster setting, not a CloudBees CI setting. Any PVC that does not explicitly specify a StorageClass can therefore use the new default.

For example, if the existing default StorageClass provides block storage and is used by other applications in the cluster, changing the default to NFS can affect those applications as well.

For that reason, changing the cluster default is generally not appropriate on a shared OpenShift platform unless NFS is intentionally intended to become the default storage backend for the cluster.

If the OpenShift cluster is dedicated to CloudBees CI and there is a deliberate requirement for NFS to be the cluster-wide default, this approach is valid.

For the deployment described in this guide, the existing OpenShift default remains unchanged and the NFS StorageClass is configured explicitly for CloudBees CI.

Option 2: Configure CloudBees CI to use the NFS StorageClass

The CloudBees CI Helm chart provides the Persistence.StorageClass value for selecting the StorageClass used for CloudBees CI persistent storage.

The chart defines this value at the top level:

Persistence:
  StorageClass: null

The default value is null. This does not mean that CloudBees CI uses NFS. When Persistence.StorageClass is unset, CloudBees CI uses the default StorageClass configured in the Kubernetes/OpenShift cluster.

You can verify the current chart value with:

helm show values cloudbees/cloudbees-core | grep -iC 5 storageclass

The relevant chart definition is:

Persistence:
...
  StorageClass: null
...

For current CloudBees CI versions, CloudBees documents that setting Persistence.StorageClass changes the StorageClass used by both the Operations Center and Managed Controllers.

This distinction is important because CloudBees CI has separate persistent storage for the Operations Center and for each Managed Controller. The Operations Center has its own $JENKINS_HOME, and each Managed Controller has its own $JENKINS_HOME. When new Managed Controllers are provisioned, the Operations Center creates the Kubernetes resources and persistent storage required for them.

The correct configuration therefore depends on when the NFS StorageClass is introduced.

Scenario 1: NFS is available before CloudBees CI is installed

For a new CloudBees CI installation, create and verify the NFS StorageClass first, then decide how widely it should apply.

Persistence.StorageClass applies to both the Operations Center and Managed Controllers. Setting it places every CloudBees CI component on that StorageClass, including components that do not require RWX. There are two valid configurations.

Configuration 1: NFS for everything

Set Persistence.StorageClass at install:

helm install cloudbees-core cloudbees/cloudbees-core \
  --set OperationsCenter.HostName='<hostname>' \
  --set OperationsCenter.Route.tls.Enable=true \
  --set Subdomain=true \
  --set Agents.SeparateNamespace.Enabled=true \
  --set Agents.SeparateNamespace.Create=true \
  --set CasCBundleService.enabled=true \
  --set Persistence.StorageClass='<nfs-sc-name>' \
  -n cloudbees-ci

Replace <nfs-sc-name> with the actual name of the NFS StorageClass in your cluster.

This is simple and uniform, with a single storage dependency for the whole installation. The cost is that the Operations Center and non-HA controllers run on NFS rather than block storage, which is slower for the small-file I/O Jenkins generates.

Configuration 2: NFS only where HA requires it

Omit Persistence.StorageClass so the Operations Center and non-HA controllers use the cluster default block storage:

helm install cloudbees-core cloudbees/cloudbees-core \
  --set OperationsCenter.HostName='<hostname>' \
  --set OperationsCenter.Route.tls.Enable=true \
  --set Subdomain=true \
  --set Agents.SeparateNamespace.Enabled=true \
  --set Agents.SeparateNamespace.Create=true \
  --set CasCBundleService.enabled=true \
  -n cloudbees-ci

Then select the NFS StorageClass per controller using the Storage Class Name field when provisioning an HA Managed Controller, as described under “HA Managed Controllers” below.

This guide follows Configuration 2, and the summary diagram at the end reflects it. RWX is applied where HA requires it and nowhere else.

Under either option, establishing the storage backend before CloudBees CI creates any persistent storage avoids a migration later. Changing Persistence.StorageClass after installation does not move existing data, as covered in Scenario 2.

Scenario 2: CloudBees CI is already installed and NFS is added later

NFS can also be introduced after CloudBees CI has already been installed.

First create and verify the NFS StorageClass. Then update the existing CloudBees CI Helm release so that Persistence.StorageClass points to the NFS StorageClass.

Because the CloudBees CI release already exists, use helm upgrade rather than running helm install again.

For example:

helm upgrade cloudbees-core cloudbees/cloudbees-core \
  --reuse-values \
  --set Persistence.StorageClass='managed-nfs-storage' \
  -n cloudbees-ci

The --reuse-values option is useful when the original installation was performed with --set parameters and no separate values file. It tells Helm to retain the values already stored for the release while applying the new Persistence.StorageClass value. This flag also carries forward old chart defaults if you are upgrading to a newer chart version at the same time, which can leave you on outdated settings without realizing it.

If you’re changing chart versions, save the current values first with:

helm get values cloudbees-core -n cloudbees-ci -o yaml > values.yaml

then run the command below instead.

helm upgrade -f values.yaml

The important point is that changing Persistence.StorageClass does not migrate an existing PVC.

If the Operations Center already has a PVC backed by another StorageClass, changing the Helm value does not change that PVC’s StorageClass or move its data to NFS. CloudBees explicitly documents that StorageClass changes do not apply to an existing PV/PVC. Moving existing data to a different StorageClass requires provisioning new storage and migrating the data.

Therefore, after introducing NFS into an existing installation:

  • The existing Operations Center PVC remains on its current storage.
  • The existing Managed Controller PVCs remain on their current storage.
  • Persistence.StorageClass becomes the configured StorageClass for subsequent CloudBees CI storage provisioning.
  • Existing CloudBees CI data is not automatically copied to NFS.
  • If existing data must actually be moved to NFS, perform a separate storage migration.

Do not delete an existing CloudBees CI PVC simply to force the new StorageClass to be used. The PVC contains the persistent $JENKINS_HOME data. CloudBees provides separate procedures for recreating or migrating persistent storage when a StorageClass change is required.

Managed Controllers Provisioned After NFS Is Available

The Operations Center provisions Managed Controllers and creates the Kubernetes resources and persistent storage required for them.

If Persistence.StorageClass has been configured to use the NFS StorageClass, that configuration applies to new Managed Controllers as well as the Operations Center in current CloudBees CI versions.

Therefore, a new ordinary Managed Controller does not require a separate Helm StorageClass configuration simply because it is being created after NFS was introduced.

CloudBees also provides a Storage Class Name setting in Managed Controller provisioning. This can be used when a particular controller should use a specific StorageClass instead of the default configured for the Kubernetes cluster or provisioning configuration. Current CloudBees documentation states that when provisioning a controller, the administrator can set the storage class name if the cluster default should not be used.

For example:

Storage Class Name: <nfs-storage-class>

Use the actual NFS StorageClass name from the cluster.

This is especially relevant in environments with multiple Kubernetes clusters or controller provisioning endpoints, where storage configuration may need to be selected for a particular provisioning target.

HA Managed Controllers

HA introduces an additional storage requirement.

A Managed Controller running in HA mode has multiple replicas that must access the same shared filesystem. The StorageClass used for that filesystem must therefore provide ReadWriteMany.

CloudBees’ current HA documentation provides a Storage Class Name field specifically for the replica’s shared filesystem. If the field is left empty, CloudBees uses the default StorageClass defined in the Kubernetes cluster.

Therefore, when the NFS StorageClass is not the OpenShift cluster default, explicitly select the NFS StorageClass when configuring the HA Managed Controller:

Storage Class Name: <nfs-storage-class>

For example:

Storage Class Name: managed-nfs-storage

The name above is only the example used in this guide. Use the actual NFS StorageClass name in your environment.

The cluster can therefore retain its existing default StorageClass while CloudBees CI uses NFS for the HA shared filesystem.

For example:

OpenShift default StorageClass:  <existing-default>
NFS StorageClass:                <nfs-storage-class>

There is no requirement to make the NFS StorageClass the OpenShift cluster default simply to provision an HA Managed Controller.

The important requirement is that the StorageClass selected for the HA replica shared filesystem supports ReadWriteMany. CloudBees explicitly requires RWX-capable shared storage for HA.

Existing Managed Controllers and HA

An existing Managed Controller cannot be converted to HA merely by changing the StorageClass selection.

If its existing $JENKINS_HOME is backed by a StorageClass that does not support ReadWriteMany, the controller must first be migrated to RWX-capable storage.

CloudBees provides a specific migration procedure for this situation. The procedure creates a new volume using the RWX StorageClass, copies the existing controller data to the new volume, updates the controller configuration to use the new storage, and then enables HA.

This is necessary because changing a StorageClass configuration does not change the storage backend of an existing PVC.

For example:

Existing Managed Controller
        |
        +-- Existing PVC
              |
              +-- RWO StorageClass

cannot simply become:

Existing Managed Controller
        |
        +-- Existing PVC
              |
              +-- RWX NFS StorageClass

The existing PVC remains associated with its original storage. A new RWX volume must be provisioned and the controller’s persistent data migrated.

For a new Managed Controller, no such migration is required. The controller can be provisioned using the NFS StorageClass from the beginning.

For a shared on-premises OpenShift cluster, the recommended approach is:

  1. Create and verify the NFS StorageClass.
  2. Keep the existing OpenShift cluster default unchanged unless NFS is intentionally required as the default storage backend for the entire cluster.
  3. For a new CloudBees CI installation, choose Configuration 1 or Configuration 2 as described under Scenario 1. This guide uses Configuration 2: leave Persistence.StorageClass unset and select the NFS StorageClass per HA controller.
  4. If CloudBees CI is already installed, update the existing Helm release with helm upgrade and add Persistence.StorageClass. When the original installation used --set parameters rather than a values file, --reuse-values can be used to retain the existing release values.
  5. Understand that changing Persistence.StorageClass does not migrate existing PVCs. Existing Operations Center and Managed Controller data remains on its existing storage until a separate migration is performed.
  6. When provisioning a new Managed Controller, the configured CloudBees CI StorageClass applies by default. A specific Storage Class Name can also be selected in the Managed Controller provisioning configuration when required.
  7. When creating an HA Managed Controller, explicitly select the NFS StorageClass in the Storage Class Name field for the replicas’ shared filesystem when NFS is not the cluster default.
  8. Verify that the selected NFS StorageClass provides ReadWriteMany.
  9. If an existing Managed Controller must be converted to HA and its current storage is not RWX-capable, migrate its $JENKINS_HOME to RWX storage before enabling HA.

The key distinction is between three different storage decisions:

  • OpenShift cluster default StorageClass: a cluster-wide Kubernetes/OpenShift setting that affects PVCs that do not specify another StorageClass.
  • CloudBees CI Persistence.StorageClass: the CloudBees CI Helm configuration that, in current versions, establishes the StorageClass used for persistent storage by both the Operations Center and Managed Controllers.
  • HA Managed Controller Storage Class Name: the StorageClass specifically selected for the shared filesystem used by the HA replicas. If left empty, the cluster default is used.

For this reason, there is no need to change the OpenShift cluster default simply to introduce NFS for CloudBees CI HA. Keep the cluster default appropriate for the wider OpenShift environment, configure CloudBees CI’s persistent storage explicitly, and select the RWX-capable NFS StorageClass for the HA shared filesystem.

Verify RWX dynamic provisioning

Do not proceed to performance benchmarking or a production CloudBees CI deployment until the storage path has been validated end-to-end. The objective of this step is not to benchmark NFS; it is to establish that the configured StorageClass can dynamically provision a volume, that the resulting PVC binds successfully, and that the volume can be mounted read-write by multiple pods.

This validation should use a disposable PVC and test pods rather than a CloudBees CI Operations Center or Managed Controller $JENKINS_HOME. A successful PVC bind alone is not sufficient evidence that the storage path is usable by the workload.

The path being validated is:

  • NFS export
  • csi-driver-nfs
  • StorageClass
  • PVC
  • dynamically provisioned PV
  • RWX mount in pods
  • read/write verification

Each stage must be verified independently. A failure to provision, bind, mount, or write must be diagnosed before continuing. Do not infer that the entire path is working from a single successful Kubernetes object or from documentation describing a different version or configuration.

1. Confirm the StorageClass and provisioner configuration

Before creating the test PVC, verify that the StorageClass actually exists and that its provisioner matches the driver installed in the cluster:

oc get storageclass <storage-class-name> -o yaml
oc get pods -n csi-driver-nfs

Confirm the following before continuing:

  • <storage-class-name> is the StorageClass you intend to test.
  • Its provisioner value is nfs.csi.k8s.io.
  • The controller and node pods are running and healthy.
  • The server and share parameters on the StorageClass point at the intended NFS server and export path.

For csi-driver-nfs, dynamic provisioning creates a subdirectory on the NFS share for each PVC. Do not substitute a StorageClass name or provisioner configuration from an older installation guide. Verify the values against the version actually deployed in the cluster.

2. Create a disposable RWX test PVC

Assuming your storageClass name is managed-nfs-storage, create a small test claim using the StorageClass under test:

oc new-project cbci-nfs-demo
oc apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-rwx-test-pvc
  namespace: cbci-nfs-demo
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: managed-nfs-storage
  resources:
    requests:
      storage: 1Gi
EOF

Watch the claim:

oc get pvc nfs-rwx-test-pvc -n cbci-nfs-demo

The expected result is: STATUS: Bound

oc get pvc nfs-rwx-test-pvc -n cbci-nfs-demo
NAME               STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS          VOLUMEATTRIBUTESCLASS   AGE
nfs-rwx-test-pvc   Bound    pvc-4476e212-0f66-4e33-9eec-3cb08f1acbcb   1Gi        RWX            managed-nfs-storage   <unset>                 41m

OpenShift defines ReadWriteMany (RWX) as a volume that can be mounted read-write by many nodes, and NFS supports RWX.

If the PVC remains Pending, stop here. Inspect the claim and the driver rather than proceeding to pod-level testing:

oc describe pvc nfs-rwx-test-pvc -n cbci-nfs-demo
oc get events -n cbci-nfs-demo --sort-by='.lastTimestamp'

Provisioning happens on the controller, so check its logs:

oc logs -n csi-driver-nfs -l app=csi-nfs-controller -c nfs

Do not assume that a Pending claim is an NFS network problem. The failure may be in StorageClass selection, SCC permissions on the controller or node service accounts, provisioning, or access to the configured export.

3. Mount the claim from a test pod and perform a write/read test

Once the PVC is Bound, mount it from a disposable pod:

oc apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: nfs-rwx-test-pod
  namespace: cbci-nfs-demo
spec:
  containers:
    - name: test
      image: registry.access.redhat.com/ubi9/ubi-minimal:latest
      command: ["sleep", "3600"]
      volumeMounts:
        - name: nfs-test-vol
          mountPath: /mnt/nfs-test
  volumes:
    - name: nfs-test-vol
      persistentVolumeClaim:
        claimName: nfs-rwx-test-pvc
EOF

Apply the pod and wait for it to become ready:

oc wait --for=condition=Ready pod/nfs-rwx-test-pod -n cbci-nfs-demo --timeout=60s

Then perform an explicit write and read:

oc exec -n cbci-nfs-demo nfs-rwx-test-pod -- sh -c \
  'echo "rwx-check-$(date +%s)" > /mnt/nfs-test/hello.txt && cat /mnt/nfs-test/hello.txt'

The command must successfully create and read the file.

Sample output:

rwx-check-1786476861

If it fails with Permission denied, diagnose the effective UID and GID, the pod security context, the NFS export permissions, and the mountPermissions value on the StorageClass. Do not treat no_root_squash as a generic fix or weaken NFS export security merely to make the test pass.

Validate the permission model used by the version and deployment configuration in your environment rather than applying a permission workaround from an unrelated example.

4. Verify shared visibility from a second pod

A successful write/read from one pod proves that that pod can use the mounted volume. It does not by itself demonstrate that the same PVC can be mounted and accessed concurrently by another pod.

Create a second disposable pod using the same PVC:

oc run nfs-rwx-test-pod-2 \
  -n cbci-nfs-demo \
  --image=registry.access.redhat.com/ubi9/ubi-minimal:latest \
  --restart=Never \
  --overrides='{
    "spec": {
      "containers": [{
        "name": "nfs-rwx-test-pod-2",
        "image": "registry.access.redhat.com/ubi9/ubi-minimal:latest",
        "command": ["sleep", "3600"],
        "volumeMounts": [{
          "name": "v",
          "mountPath": "/mnt/nfs-test"
        }]
      }],
      "volumes": [{
        "name": "v",
        "persistentVolumeClaim": {
          "claimName": "nfs-rwx-test-pvc"
        }
      }]
    }
  }'

Wait for the second pod:

oc wait --for=condition=Ready pod/nfs-rwx-test-pod-2 -n cbci-nfs-demo --timeout=60s

Then read the file created by the first pod:

oc exec -n cbci-nfs-demo nfs-rwx-test-pod-2 -- cat /mnt/nfs-test/hello.txt

The second pod must be able to see the file created by the first pod.

Sample output:

rwx-check-1786476861

This verifies the important workload behavior: the same PVC can be mounted read-write by multiple pods and changes made through one mount are visible through another. OpenShift’s definition of RWX is specifically read-write mounting by many nodes; the two-pod test provides an additional practical verification of the shared filesystem behavior.

5. Verify the provisioned directory on the NFS server

If you have administrative access to the NFS server, independently verify that the test file exists in the export.

For example:

On the NFS server

find <NFS_EXPORT_PATH> -name hello.txt -exec cat {} \;

Where:

<NFS_EXPORT_PATH>

should be replaced with the actual NFS export, for example:

find /data/cloudbees -name hello.txt -exec cat {} \;

The file should contain the value written by the test pod.

This provides an independent check that the write reached the NFS export rather than relying solely on the Kubernetes-side view.

6. Record the validation result

Before proceeding, confirm all of the following:

  • The intended StorageClass exists.
  • The StorageClass references nfs.csi.k8s.io.
  • The controller and node components are running and configured for the intended NFS export.
  • The test PVC reaches Bound.
  • The resulting PV is associated with the test PVC.
  • A pod can mount the PVC successfully.
  • The pod can create and read a file.
  • A second pod can mount the same PVC and read the first pod’s file.
  • The file is visible on the NFS export when server-side verification is available.
  • No permission or security workaround was introduced solely to make the test pass.

If any item fails, stop and resolve the underlying storage configuration before continuing.

7. Clean up

After the validation is complete:

oc delete pod nfs-rwx-test-pod nfs-rwx-test-pod-2 -n cbci-nfs-demo
oc delete pvc nfs-rwx-test-pvc -n cbci-nfs-demo
oc delete project cbci-nfs-demo

If the StorageClass is configured with onDelete: archive, also verify the resulting directory on the NFS server and remove the test data according to the retention policy you actually configured.

Example:

ls /data/cloudbees/
archived-pvc-4476e212-0f66-4e33-9eec-3cb08f1acbcb
ls /data/cloudbees/archived-pvc-4476e212-0f66-4e33-9eec-3cb08f1acbcb/
hello.txt

8. Proceed to performance testing only after functional validation

Only after the end-to-end functional test succeeds should you proceed to the fio benchmarking described later in this guide.

Do not use benchmark results as evidence that the storage configuration is correct. Functional validation and performance validation are separate tests. First establish that the storage provisions, binds, mounts, and provides the required shared read/write behavior. Then measure whether its performance is suitable for the intended CloudBees CI workload.

Likewise, do not point a production Operations Center or Managed Controller at the StorageClass until this disposable validation has succeeded.

For CloudBees CI specifically, verify the storage configuration against the CloudBees documentation and the exact CloudBees CI/chart version being deployed. Current CloudBees documentation identifies Persistence.StorageClass as the Helm value used to configure the storage class, and notes that storage-class changes do not modify an existing PV/PVC; a new volume and data migration are required.

The governing rule for this guide is simple: current configuration must be researched and validated before it is presented as fact. Do not carry forward a key path, command, Helm value, or behavior from an older KB or installation guide without confirming that it applies to the versions currently being deployed.

Part 3: Installing CloudBees CI with HA Managed Controllers on NFS

Two storage requirements exist here, and they are not the same thing:

  • The Operations Center needs persistent storage for its own $JENKINS_HOME. A normal default StorageClass covers this.
  • A Managed Controller running in HA mode needs shared ReadWriteMany storage, because its replicas access the same $JENKINS_HOME concurrently. This is what managed-nfs-storage is for.

CloudBees documents HA, replicas, and shared storage as features of Managed Controllers, not the Operations Center. The Operations Center has its own built-in fault tolerance on modern cloud platforms and does not run in an HA mode itself. Keep the Operations Center on the cluster’s normal default StorageClass. Put NFS only where HA actually requires it: the Managed Controller’s shared filesystem.

Pre-installation Checklist for OpenShift

CloudBees’ current OpenShift requirements specify:

  • A currently supported OpenShift version.
  • Network access to the required container images, either through Docker Hub or a private registry.
  • An OpenShift project with permission to create Role and RoleBinding objects.
  • DNS and TLS requirements prepared for the CloudBees CI deployment.
  • A defined default StorageClass for persistent storage.
  • Containers running as non-root and non-privileged on OpenShift.

For HA Managed Controllers specifically, add one more: a StorageClass supporting ReadWriteMany. That is the only place RWX is required. Do not treat it as a blanket requirement for the whole installation:

  • Operations Center: persistent storage; a suitable default StorageClass is sufficient.
  • Non-HA Managed Controller: persistent storage; RWX is not inherently required.
  • HA Managed Controller: shared persistent storage; the StorageClass must support RWX.

Install CloudBees CI

With the checklist above satisfied and managed-nfs-storage validated in Part 2, install CloudBees CI using the current OpenShift installation procedure:

Deploying CloudBees CI on OpenShift: Architecture, Installation, Security, and Production Best Practices

Treat that guide as the authoritative installation procedure for the release you are deploying, and verify the actual chart values for that release rather than assuming values from an older CloudBees CI version still apply.

One decision to make at install time: do not point the Operations Center at NFS. It does not need RWX simply because Managed Controllers will later run in HA mode. If the cluster’s default StorageClass is suitable, let CloudBees CI use it by default, meaning the Helm configuration does not need a storage class override for the Operations Center at all:

OperationsCenter:
  HostName: cjoc.example.com

Omitting the override does not mean the Operations Center has no storage. It means the cluster’s configured default StorageClass is used. Reserve managed-nfs-storage for the one place that actually needs RWX: the shared filesystem of an HA Managed Controller, configured next.

Enable HA on a Managed Controller

HA is configured on the Managed Controller, not the Operations Center. CloudBees’ current HA documentation describes it as multiple replicas of one logical Managed Controller sharing storage. Once enabled, the Operations Center dashboard shows a Pod status column with the replica count and how many are available.

Walking through it in the CJOC UI:

  1. Name the controller. From the CJOC dashboard, use New Controller, enter a name, for example controller-02, and click Go. CJOC shows the expected endpoint it will provision, for example https://controller-02.sno.kifarunix.com/, before you continue.
  2. Set the Docker image and disk size. Leave the Docker image at the current CloudBees CI Managed Controller version unless you have a specific reason to pin an older one. Jenkins Controller Disk Space in GB is not a separate disk, it is the size request for the same jenkins-home PVC whose backend you select in the Storage Class Name field below. In HA mode that PVC is the shared, NFS-backed $JENKINS_HOME every replica mounts. This field stays editable after creation only because managed-nfs-storage was created with allowVolumeExpansion: true in Part 2; without that, CJOC greys the field out.
  3. Enable High Availability. Check the High Availability box. Three fields appear:
    • Managed controller replicas: the number of controller pods. Two is the minimum meaningful HA configuration.
    • Maximum number of replicas: only relevant if autoscaling. Leave at 0 to disable autoscaling. The UI itself explains the behavior: with autoscaling off, the maximum is set to the same number as the replica count.
    • CPU threshold in percent: the autoscaling trigger. Only meaningful once a maximum above the replica count is set.
  4. Set the Storage Class Name. Further down the same form, enter managed-nfs-storage, or whatever you named the StorageClass in Part 2. CJOC validates this against the cluster in real time and confirms directly under the field: “Storage class managed-nfs-storage exists.” If that confirmation does not appear, or shows an error instead, stop here. It means either the name does not match the StorageClass created in Part 2, or CJOC’s service account cannot list StorageClasses in this cluster; either way, the controller will fail to provision if you continue.
  5. Set memory and CPU. Jenkins Controller Memory in MB and Jenkins Controller CPUs size the controller pod itself, separately for each HA replica. These are ordinary Kubernetes resource requests and have no special interaction with NFS; size them the same way you would for a non-HA controller.
  6. Set the FS Group to match this project’s allowed range. CJOC sets FS Group to 1000 by default, which becomes the pod’s securityContext.fsGroup and, combined with the mountPermissions: "0770" set on the StorageClass in Part 2, determines whether the controller pod can actually write to the mounted volume. On OpenShift, every project is allocated its own UID and supplemental group range, and a pod requesting an fsGroup outside that project’s range is rejected at admission under the restricted-v2 SCC, not silently ignored. Check the range this project was actually assigned before relying on the default:
    oc get project <cloudbees-namespace> -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.supplemental-groups}{"\n"}'
    This prints something like 1000740000/10000, meaning the project’s allowed group range starts at 1000740000. The default 1000 is almost certainly outside that range on a real OpenShift project, since these ranges are allocated per project starting well above the low numbers used in generic Kubernetes examples. Either set FS Group explicitly to a value inside the range this command returns, or leave the field empty, which is CloudBees’ own documented workaround, and let OpenShift assign a compliant value automatically from the project’s range rather than guessing one by hand.
  7. Leave Jenkins Command Line arguments and Global Java Options alone unless you have a specific reason to change them. CJOC pre-populates Global Java Options with the settings that wire the controller into this Operations Center’s networking and agent configuration, for example the -Dcom.cloudbees.networking.hostname and -Dcom.cloudbees.jenkins.plugins.kube.ServiceAccountFilter.defaultServiceAccount flags shown on the form. These are generated, not something to hand-edit.

Save, and CJOC provisions the controller with the replica count and StorageClass set above.

Confirm from the OpenShift side once it comes up. Since HA controllers run as a Deployment, their pods are named with a hash suffix rather than a fixed name, so filter by the controller name prefix rather than guessing a label:

oc get pods -n <cloudbees-namespace> | grep controller-02

Sample output, for two replicas:

controller-02-77d47f47b6-95rpb   1/1   Running   0             7m
controller-02-77d47f47b6-s6vjb   1/1   Running   1 (5m41s ago) 7m15s

You should see one pod per replica configured above, both 1/1 Running. For a controller-side view rather than the OpenShift side, go to Manage Jenkins > CloudBees CI High Availability on the controller itself. This shows a per-replica table: host name, address, per-replica uptime, a Ready checkbox, version, which replica currently holds the Operations Center channel and the sticky session, and running builds, connected agents, queue size, and load broken out per replica, with a total load figure underneath. Confirm every replica shows Ready checked and the same version number, rather than relying on a single summary figure.

cloudbees ci managed controller ha

One thing that fails silently: when HA is enabled, CloudBees CI manages the controller pods with a Deployment, not a StatefulSet. If you are supplying Advanced YAML or migrating older customizations, any leftover kind: StatefulSet is not rejected with an error, it is simply ignored, and the controller fails to provision in HA mode without a clear message pointing at the cause. Check the kind field first if an HA controller silently fails to come up.

If provisioning through Configuration as Code instead of the UI shown above, use the current CloudBees CI items.yaml schema for the exact version being deployed. Do not copy a replication example from an older release, HA settings are version-sensitive. Validate against the current items.yaml reference before deployment. At a conceptual level, the fields above map to:

items:
  - kind: managedController
    name: "controller-02"
    configuration:
      kubernetes:
        replication:
          config:
            replicas: 2
            maxReplicas: 0
            targetCPUUtilizationPercentage: 80

If autoscaling is not required, configure only the replica count supported by that release rather than assuming omitted fields have a particular default.

Validate NFS Performance before Production

Functional validation and performance validation are separate activities. The previous section verified that managed-nfs-storage can provision an RWX PVC and that multiple pods can access the same volume. This section measures the performance of that same storage path.

Verify the documented NFS requirements

Complete these before benchmarking. Measuring a misconfigured export produces numbers that describe the misconfiguration rather than the storage.

RequirementCheckExpected
NFS version 4.1 or higher (4.0 is not supported)mount | grep <nfs-path> on a nodevers=4.1 or higher
Export optionsgrep <export> /etc/exports on the NFS serverrw,sync,no_subtree_check. async improves write performance, but the server acknowledges writes before they reach stable storage. Only consider async with a protected write cache, and record the trade-off.
nfsd thread count recordedgrep threads /etc/nfs.conf (RHEL 8 and 9) or grep RPCNFSDCOUNT /etc/sysconfig/nfs (RHEL 7)Record the current value as part of the baseline. Do not raise it before measuring; see “Tuning the NFS Server Daemon” above.
SSD-backed exportConfirm with the storage ownerSSD recommended

For reference, the CloudBees NFS knowledge base article uses the following client mount options: _netdev,rw,bg,hard,intr,rsize=32768,wsize=32768,vers=4.1,proto=tcp,timeo=600,retrans=2,noatime,nodiratime,async

Treat this as an example rather than a target configuration. As noted in the StorageClass section above, modern NFS clients negotiate read and write sizes, and 32768 is conservative by current standards. Note also that intr has had no effect since Linux kernel 2.6.25 and is retained only for historical compatibility. The async mount option here is a client-side setting and is unrelated to the async export option discussed above.

Create the temporary project

oc new-project nfs-performance-test

All resources created below are placed in the current nfs-performance-test project.

Create the test PVC

Use the same StorageClass that will be used by the HA Managed Controllers:

oc apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-fio-test-pvc
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: managed-nfs-storage
  resources:
    requests:
      storage: 5Gi
EOF

Wait for the PVC to become Bound:

oc get pvc nfs-fio-test-pvc

Do not continue until the PVC is Bound.

Create two FIO pods on the same PVC. The second is required for the concurrency test below.

for i in 1 2; do
oc apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: nfs-fio-test-${i}
spec:
  restartPolicy: Never
  containers:
    - name: fio
      image: quay.io/cloud-bulldozer/fio:latest
      command: ["/bin/sh", "-c", "sleep 3600"]
      volumeMounts:
        - name: nfs-test
          mountPath: /mnt/test-nfs
  volumes:
    - name: nfs-test
      persistentVolumeClaim:
        claimName: nfs-fio-test-pvc
EOF
done
oc wait --for=condition=Ready pod/nfs-fio-test-1 pod/nfs-fio-test-2 --timeout=120s

The cloud-bulldozer/fio image is used here because it ships fio preinstalled. Under the restricted-v2 SCC, OpenShift assigns an arbitrary UID to the container, so confirm the pod can write to the mount before running the benchmarks:

oc exec nfs-fio-test-1 -- sh -c 'id; touch /mnt/test-nfs/.write-check && echo OK'

If this fails, the benchmark will fail for permission reasons rather than performance reasons. Resolve it using the same UID and ownership approach described in Part 1.

Confirm the mount and the negotiated NFS version:

oc exec nfs-fio-test-1 -- sh -c 'df -h /mnt/test-nfs; mount | grep test-nfs'

Establish the single-client baseline

The small-block workload models JENKINS_HOME config and build-record I/O, which determines controller responsiveness. The large-block workload models artifact and log handling.

oc exec nfs-fio-test-1 -- \
  fio --name=metadata --directory=/mnt/test-nfs --rw=randrw --bs=4k \
      --size=512M --numjobs=8 --iodepth=16 --runtime=60 --time_based \
      --group_reporting
oc exec nfs-fio-test-1 -- \
  fio --name=throughput --directory=/mnt/test-nfs --rw=readwrite --bs=1M \
      --size=2G --numjobs=4 --iodepth=8 --runtime=60 --time_based \
      --group_reporting

Record four values per workload, for read and write separately: IOPS, BW, clat p99, and clat p99.99. Averages conceal stalls; percentiles do not.

While each run is active, capture server-side data on the NFS host. Without it, a poor result cannot be attributed to disk, network, or NFS configuration.

iostat -x 1            # check latency (await) and queue depth, not just %util
nfsstat -s             # rising badcalls indicate server or network fault
sar -n DEV 1       # interface throughput and errors

On an OpenShift worker node (NFS client) running one of the test pods:

nfsstat -c         # client-side NFS call and retransmission counts
nfsiostat 1        # per-operation NFS latency, read from client mountstats

nfsiostat reads /proc/self/mountstats on the NFS client, so it must be run on the OpenShift node, not on the NFS server. On OpenShift’s default node operating system these commands aren’t directly runnable on the node itself; use oc debug node/ — chroot /host instead

Test HA-level concurrency

HA controllers hold the same RWX volume simultaneously. This result carries more weight than the single-client baseline. Both clients must run at the same time; sequential runs measure nothing new.

oc exec nfs-fio-test-1 -- mkdir -p /mnt/test-nfs/pod1
oc exec nfs-fio-test-2 -- mkdir -p /mnt/test-nfs/pod2

oc exec nfs-fio-test-1 -- \
  fio --name=metadata --directory=/mnt/test-nfs/pod1 --rw=randrw --bs=4k \
      --size=512M --numjobs=8 --iodepth=16 --runtime=60 --time_based \
      --group_reporting > pod1-concurrent.log 2>&1 &

oc exec nfs-fio-test-2 -- \
  fio --name=metadata --directory=/mnt/test-nfs/pod2 --rw=randrw --bs=4k \
      --size=512M --numjobs=8 --iodepth=16 --runtime=60 --time_based \
      --group_reporting > pod2-concurrent.log 2>&1 &

wait

Interpret the results

CloudBees defines no numeric pass/fail threshold, so these ranges are used to detect misconfiguration, not to gate deployment. They describe normal behaviour for SSD-backed NFS over a LAN.

MetricNormalInvestigate
4k random IOPS500 to 5,000+Under 100
4k p99 latencyUnder approximately 20msSustained hundreds of ms
1M sequential bandwidth100 to 400+ MB/s, link dependentUnder 20 to 30 MB/s
Read vs write symmetryWithin approximately 2xWrite an order of magnitude below read

Two rules govern the deployment decision.

  • Rule 1: Single-client results below the normal range are usually a configuration fault, not a hardware ceiling. Check NFS version (4.0 has known performance issues), rsize/wsize negotiated lower than requested, nfsd thread starvation, and the storage underneath the export. Note that sync exports cost write performance by design; that is the integrity trade documented in Part 1, not a misconfiguration. HA will not improve a poor single-client result.
  • Rule 2: Concurrency must degrade proportionally, not collapse. Sum the two pods’ IOPS from above. A combined total near the single-client baseline, with latency rising by a modest multiple, confirms the storage shares capacity correctly and HA is viable. A combined total well below the baseline, or p99 latency rising by an order of magnitude, indicates the storage path saturates under concurrency. Resolve this through NFS server tuning, a dedicated export, or an alternative RWX backend before deploying HA.

Record the single-client and concurrent figures side by side. That comparison is the deliverable of this section and becomes the environment baseline for future capacity planning.

Clean up

oc delete project nfs-performance-test

If the namespace stalls in Terminating:

oc get namespace nfs-performance-test -o json \
  | jq 'del(.spec.finalizers)' \
  | oc replace --raw "/api/v1/namespaces/nfs-performance-test/finalize" -f -

Confirm the outcome on the NFS server. With onDelete: archive set on the StorageClass, the driver renames the directory with an archived- prefix rather than removing it. Delete the archived benchmark directory manually once the results have been recorded.

Common Pitfalls

Using RWX for the Operations Center simply because controllers use HA

  • This is unnecessary.
  • The Operations Center requires persistent storage, but CloudBees’ OpenShift requirements distinguish the normal default StorageClass requirement from the additional RWX requirement for HA.
  • Use the default StorageClass for the Operations Center unless there is an explicit architectural reason to use NFS.

Assuming “persistent storage” means NFS

  • CloudBees requires dynamically provisioned persistent storage for the Operations Center and Managed Controllers. HA adds the requirement that the Managed Controller’s storage support RWX.

Configuring HA without RWX

  • An HA Managed Controller’s replicas must share the same filesystem. CloudBees explicitly requires a ReadWriteMany StorageClass for HA.
  • Do not enable HA against an RWO-only storage class.

Assuming a Bound PVC proves that HA storage works

  • Bound proves the provisioner works. It proves nothing about concurrent access, throughput, or latency.
  • Validate in three stages before production: provisioning, multi-pod concurrent access, then measured performance.
  • CloudBees states that the storage must provide sufficient IOPS, throughput, and low latency, but publishes no numeric threshold. Establishing the environment baseline is the operator’s responsibility.
  • This staged validation is engineering practice rather than a documented CloudBees procedure.

Manually modifying generated controller resources

  • The Operations Center manages Managed Controller Kubernetes resources. Configure the Managed Controller through the supported CloudBees configuration mechanisms rather than treating its generated Deployment/StatefulSet as an independently managed workload.

Using soft mounts to avoid hangs.

  • Trades data integrity for the appearance of resilience; soft returns I/O errors on timeout and can corrupt $JENKINS_HOME. Use hard and fix the capacity problem underneath.

Summary

What the storage architecture looks like:

                         CloudBees CI
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
 Operations Center    Managed Controller    Managed Controller
                         (non-HA)                 (HA, 3 replicas)
        │                     │                     │
   $JENKINS_HOME        $JENKINS_HOME          $JENKINS_HOME
        │                     │                     │
   RWO block storage    RWO block storage      RWX storage class
    [recommended]        [recommended]         managed-nfs-storage
                                                 [REQUIRED]
                                                     │
                                              NFS shared filesystem
                                                     │
                                    ┌────────────────┼────────────────┐
                                 Replica 1        Replica 2        Replica 3

Read the labels carefully, because they aren’t equally binding.

[REQUIRED] is documented. CloudBees states that to install HA on OpenShift you must set up a storage class with ReadWriteMany access mode, and that an existing controller not already on an RWX class must be migrated before it can run HA. That arrow is not negotiable.

[recommended] is engineering judgement, mine, not a CloudBees requirement. Nothing in the documentation says the operations center or a non-HA controller must avoid shared storage. The reasoning is that RWX buys you nothing without HA and NFS is slower than block storage for the small-file, metadata-heavy I/O Jenkins generates, so paying the shared-storage cost where it isn’t needed is waste.

There is a legitimate case against that recommendation, and it’s CloudBees’ own. In its EKS guidance, CloudBees notes that putting both the operations center and managed controllers on EFS (shared, RWX storage) lets pods be rescheduled onto healthy nodes during an availability-zone outage. If your OpenShift cluster spans failure domains and you want that rescheduling property for the operations center too, putting the OC on NFS is a deliberate availability trade, slower I/O in exchange for not being pinned to one zone’s block storage. Decide it on your own topology; don’t treat either arrangement as the mandated one.

So the one-line version: RWX where HA forces it, everything else your call, with a real trade-off to weigh if you’re multi-zone.

Further Reading

SUPPORT US VIA A VIRTUAL CUP OF COFFEE

We're passionate about sharing our knowledge and experiences with you through our blog. If you appreciate our efforts, consider buying us a virtual coffee. Your support keeps us motivated and enables us to continually improve, ensuring that we can provide you with the best content possible. Thank you for being a coffee-fueled champion of our work!

Photo of author
Kifarunix
DevOps Engineer and Linux Specialist with deep expertise in RHEL, Debian, SUSE, Ubuntu, FreeBSD... Passionate about open-source technologies, I specialize in Kubernetes, Docker, OpenShift, Ansible automation, and Red Hat Satellite. With extensive experience in Linux system administration, infrastructure optimization, information security, and automation, I design and deploy secure, scalable solutions for complex environments. Leveraging tools like Terraform and CI/CD pipelines, I ensure seamless integration and delivery while enhancing operational efficiency across Linux-based infrastructures.

Leave a Comment

document.addEventListener("DOMContentLoaded", function() { document.querySelectorAll(".scroll-box").forEach(function(box) { box.style.position = "relative"; // Needed for absolute positioning of button var button = document.createElement("button"); button.className = "copy-icon-btn"; button.setAttribute("aria-label", "Copy code"); button.innerHTML = ''; box.appendChild(button); button.addEventListener("click", function() { var text = box.innerText; navigator.clipboard.writeText(text).then(function() { button.querySelector("svg").setAttribute("fill", "#4CAF50"); setTimeout(function() { button.querySelector("svg").setAttribute("fill", "white"); }, 1500); }).catch(function(err) { console.error("Copy failed: ", err); }); }); }); });