Setting Up the Linode (Akamai) Cloud Provider for RKE2
On this page
Introduction
RKE2 runs fine on plain Linode instances, but without a cloud provider the cluster knows nothing about the platform underneath it. Service type=LoadBalancer stays <pending> forever, nodes have no provider ID or region labels, and there is no way to get a Block Storage Volume attached to a pod.
Two upstream projects fill that gap:
- Linode Cloud Controller Manager (CCM): sets node addresses and region/zone labels, and turns
LoadBalancerservices into NodeBalancers. - Linode Block Storage CSI driver: provisions, attaches and resizes Block Storage Volumes for
PersistentVolumeClaims.
This guide covers:
- Preparing the Linode instances (and the
localhosthostname trap) - Configuring RKE2 for an external cloud provider
- Installing the CCM with Helm
- Using NodeBalancer annotations
- Installing the CSI driver and using its StorageClasses
Prerequisites
- Linode instances in a single region for the RKE2 nodes.
- A Linode APIv4 Personal Access Token. The CCM chart documents the token as needing all permissions. Treat it like a root credential for the account and keep it out of Git.
kubectlandhelmon your workstation.- Kubernetes 1.22 or newer (the CCM’s documented minimum; any current RKE2 release qualifies).
Note the region ID (for example us-east), since both charts need it:
linode-cli regions list --text --format id,labelStep 1: Fix Node Hostnames Before Installing RKE2
Some Linode images boot with the hostname localhost. The RKE2 docs call this out specifically for Linode: every node registering as localhost collides, and the CCM cannot match nodes to instances.
Either set a real hostname on each instance:
hostnamectl set-hostname rke2-cp-1or pin the node name in the RKE2 config (Step 2). Name the Kubernetes node the same as the Linode label. The CCM looks up instances by name, so a mismatch leaves nodes uninitialized.
Step 2: Configure RKE2 for an External Cloud Provider
Disable RKE2’s built-in cloud controller and tell every Kubernetes component that an external provider is in charge.
Standalone RKE2 (/etc/rancher/rke2/config.yaml)
On server (control plane) nodes:
node-name: rke2-cp-1
disable-cloud-controller: true
kube-apiserver-arg:
- cloud-provider=external
kube-controller-manager-arg:
- cloud-provider=external
kubelet-arg:
- cloud-provider=externalOn agent (worker) nodes:
node-name: rke2-worker-1
kubelet-arg:
- cloud-provider=externalRancher-provisioned clusters
In the cluster YAML, use machineSelectorConfig to apply the same settings by role:
spec:
rkeConfig:
machineSelectorConfig:
- config:
disable-cloud-controller: true
kube-apiserver-arg:
- cloud-provider=external
kube-controller-manager-arg:
- cloud-provider=external
kubelet-arg:
- cloud-provider=external
machineLabelSelector:
matchExpressions:
- key: rke.cattle.io/control-plane-role
operator: In
values:
- 'true'
- config:
kubelet-arg:
- cloud-provider=external
machineLabelSelector:
matchExpressions:
- key: rke.cattle.io/worker-role
operator: In
values:
- 'true'With cloud-provider=external, the kubelet registers each node with the taint node.cloudprovider.kubernetes.io/uninitialized=true:NoSchedule. Ordinary workloads will not schedule until the CCM initializes the node, so install the CCM next.
Step 3: Install the Linode Cloud Controller Manager
Add the chart repository:
helm repo add ccm-linode https://linode.github.io/linode-cloud-controller-manager/
helm repo update ccm-linodeCreate ccm-values.yaml. Keep this file out of version control, or use a Secret you manage elsewhere (see the note below):
apiToken: "<your-linode-api-token>"
region: "us-east"Install into kube-system:
helm install ccm-linode \
--namespace kube-system \
-f ccm-values.yaml \
ccm-linode/ccm-linodeThe chart’s default tolerations already cover the control-plane taint, CriticalAddonsOnly, and node.cloudprovider.kubernetes.io/uninitialized. It schedules on a fresh RKE2 cluster without extra values.
Managing the token as a Secret: the chart can read the token and region from an existing Secret through secretRef (defaults: name ccm-linode, keys apiToken and region). That fits clusters where secrets come from External Secrets, Sealed Secrets or Vault rather than Helm values. The Secret must live in the same namespace as the release.
Verify
kubectl -n kube-system get pods -l app=ccm-linode
kubectl get nodes -o custom-columns=NAME:.metadata.name,PROVIDER:.spec.providerID,REGION:.metadata.labels.topology\.kubernetes\.io/region
kubectl describe nodes | grep -i taintsEvery node should show a linode:// provider ID and the region label, and the uninitialized taint should be gone. If a node keeps the taint, check the CCM logs (kubectl -n kube-system logs -l app=ccm-linode). The usual cause is a node name that does not match the Linode label (Step 1).
Step 4: Expose Services with NodeBalancers
Any Service of type: LoadBalancer now gets a NodeBalancer:
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
service.beta.kubernetes.io/linode-loadbalancer-default-protocol: "http"
service.beta.kubernetes.io/linode-loadbalancer-check-type: "http"
service.beta.kubernetes.io/linode-loadbalancer-check-path: "/healthz"
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080kubectl get svc web -wThe useful annotations, all prefixed service.beta.kubernetes.io/linode-loadbalancer-:
| Annotation | Default | Use |
|---|---|---|
default-protocol | tcp | tcp, udp, http or https |
default-proxy-protocol | none | v1 or v2 to pass client IPs to an ingress controller |
check-type | connection (none for UDP) | none, connection, http, http_body |
check-path / check-body | none | Path and expected body for HTTP checks |
throttle | 0 | Connection throttle per client IP, 0 to 20 (0 disables) |
firewall-id | none | Attach an existing Cloud Firewall |
firewall-acl | none | Have the CCM manage a firewall from a JSON allowList or denyList (not both) |
preserve | false | Keep the NodeBalancer when the Service is deleted |
nodebalancer-id | none | Adopt an existing NodeBalancer instead of creating one |
reserved-ipv4 | none | Use a reserved IPv4 address |
backend-vpc-name / backend-subnet-name | none | Send backend traffic over a VPC subnet |
tags | none | Comma-separated Linode tags for the NodeBalancer |
Two details trip people up:
- Annotation values must be strings. Quote numbers and booleans (
"true","20"), or the API server rejects the manifest. - Proxy protocol must match on both ends. If you set
default-proxy-protocol: "v2"on the Service in front of ingress-nginx, enableuse-proxy-protocolin the ingress controller too. Otherwise every request fails with a bad request or a broken connection.
For an ingress controller, preserve: "true" combined with reserved-ipv4 keeps the public IP (and your DNS records) stable across reinstalls.
Step 5: Install the Linode Block Storage CSI Driver
Add the repository and install, passing the same token and region:
helm repo add linode-csi https://linode.github.io/linode-blockstorage-csi-driver/
helm repo update linode-csi
helm install linode-csi-driver \
--namespace kube-system \
--set apiToken="${LINODE_API_TOKEN}" \
--set region="${REGION}" \
linode-csi/linode-blockstorage-csi-driverkubectl -n kube-system get pods | grep csi-linode
kubectl get storageclassThe driver creates two StorageClasses with provisioner linodebs.csi.linode.com:
| StorageClass | Reclaim policy | Notes |
|---|---|---|
linode-block-storage-retain | Retain | Cluster default |
linode-block-storage | Delete | Volume is destroyed with the PVC |
The default is Retain, so deleting a PVC leaves the Volume in your account, and on your bill. That is the safe choice for databases. For scratch or CI workloads, request linode-block-storage explicitly, or clean up released PVs on a schedule.
Test with a claim. Block Storage has a 10Gi minimum, so smaller requests are rounded up:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: linode-block-storage-retain
resources:
requests:
storage: 10GiTo grow a volume, raise spec.resources.requests.storage on the PVC. The StorageClasses allow expansion.
Encrypted volumes
For encryption at rest, create your own StorageClass with the Block Storage encryption parameter:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: linode-block-storage-retain-encrypted
provisioner: linodebs.csi.linode.com
reclaimPolicy: Retain
allowVolumeExpansion: true
parameters:
linodebs.csi.linode.com/encrypted: "true"Encryption applies only to new, empty volumes, and not every region offers it. The driver returns an error rather than silently creating an unencrypted volume. The driver also supports LUKS encryption with a key from a Kubernetes Secret (linodebs.csi.linode.com/luks-encrypted), but then key rotation is your job.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Nodes stuck with node.cloudprovider.kubernetes.io/uninitialized | CCM not running, or node name differs from the Linode label |
Several nodes named localhost | Hostname never set; see Step 1 |
LoadBalancer stays <pending> | Token lacks permissions, wrong region, or CCM crashlooping; check kubectl -n kube-system logs -l app=ccm-linode |
| NodeBalancer shows all backends down | Health check type or path does not match the app; try check-type: "connection" first |
PVC stays Pending | CSI controller cannot reach the API, or the region does not match the nodes |
| Volume will not attach | Volume and instance are in different regions, or the instance hit its attachment limit |
Conclusion
With the CCM and the CSI driver installed, RKE2 on Linode behaves like a managed cluster: LoadBalancer services get NodeBalancers with real health checks and firewalls, and PersistentVolumeClaims get Block Storage Volumes that survive pod and node replacement.
The upstream projects are linode-cloud-controller-manager and linode-blockstorage-csi-driver. Their docs/ directories have the full annotation and parameter reference.
Want more Kubernetes insights? Browse the RKE2 Training series for expert insights!