A lightweight Kubernetes node IP address controller that runs as a DaemonSet and automatically detects and sets node IP addresses using netlink on bare-metal and on-premise clusters.
local-ccm solves the problem of automatically setting NodeInternalIP and NodeExternalIP addresses on Kubernetes nodes in environments without a cloud provider. Each node runs its own instance that detects local IP addresses and updates the node object accordingly.
- DaemonSet Architecture: Runs on every node, each managing itself
- Automatic IP Detection: Uses netlink API to detect source IP addresses for routing to target
- Configurable Targets: Separate configuration for internal and external IP detection
- Non-Destructive Updates: Preserves existing addresses (Hostname, InternalIP from kubelet), updates only managed fields
- Taint Removal: Automatically removes
node.cloudprovider.kubernetes.io/uninitializedtaint - Minimal Dependencies: No external tools required, uses native netlink
- Lightweight: Small memory footprint (~32MB per node)
- Continuous Reconciliation: Periodically checks and updates IP addresses
- Kubelet starts with
--cloud-provider=externalflag (optional) - If kubelet has
--cloud-provider=external, it adds taintnode.cloudprovider.kubernetes.io/uninitialized:NoSchedule local-ccmpod starts on the node via DaemonSet- Pod detects node's IP addresses using netlink API to query routes to target IPs:
- Queries route to target (e.g., 8.8.8.8)
- Extracts source IP from the route
- Example: Route to 8.8.8.8 via 192.168.1.1 has source IP 192.168.1.100
- Pod updates only managed addresses (preserves other addresses):
- Always updates:
ExternalIP - Updates
InternalIPonly if--internal-ip-targetis set - Preserves all other addresses (Hostname, InternalIP from kubelet, etc.)
- Always updates:
- Pod removes the initialization taint (if present)
- Pod continues to run, reconciling addresses every 10 seconds (configurable)
- Kubernetes cluster 1.28+
- Linux nodes with netlink support (standard in all modern kernels)
- Apply the manifests:
kubectl apply -f https://raw.githubusercontent.com/cozystack/local-ccm/main/deploy/rbac.yaml
kubectl apply -f https://raw.githubusercontent.com/cozystack/local-ccm/main/deploy/daemonset.yaml- Verify deployment:
kubectl -n kube-system get ds local-ccm
kubectl -n kube-system get pods -l app=local-ccm- Check node addresses:
kubectl get nodes -o wide
kubectl get node <node-name> -o jsonpath='{.status.addresses}' | jqFor Talos Linux clusters, use the following configuration:
machine:
kubelet:
extraArgs:
cloud-provider: external
cluster:
manifests:
- url: https://raw.githubusercontent.com/cozystack/local-ccm/main/deploy/rbac.yaml
- url: https://raw.githubusercontent.com/cozystack/local-ccm/main/deploy/daemonset.yamlThis configuration:
- Enables
--cloud-provider=externalflag for kubelet automatically - Applies the
node.cloudprovider.kubernetes.io/uninitializedtaint on node startup - Deploys local-ccm manifests during cluster bootstrap
- local-ccm removes the taint after setting node addresses
Configuration is done via command-line arguments in the DaemonSet spec. Edit the DaemonSet to customize:
kubectl -n kube-system edit daemonset local-ccmThe following command-line flags are available:
| Flag | Description | Default | Required |
|---|---|---|---|
--node-name |
Name of the node to update (use NODE_NAME env var) | - | Yes |
--internal-ip-target |
Target IP for internal IP detection via netlink. If empty, internal IP detection is disabled | "" (disabled) |
No |
--external-ip-target |
Target IP for external IP detection via netlink | "8.8.8.8" |
No |
--remove-taint |
Remove node.cloudprovider.kubernetes.io/uninitialized taint | true |
No |
--reconcile-interval |
Interval between reconciliation loops | 10s |
No |
--run-once |
Run once and exit instead of running in a loop | false |
No |
--kubeconfig |
Path to kubeconfig file (for local testing only) | In-cluster config | No |
--v |
Log level (0-5) | 0 |
No |
args:
- --node-name=$(NODE_NAME)
- --external-ip-target=8.8.8.8
- --reconcile-interval=10sResult:
{
"addresses": [
{"type": "Hostname", "address": "node1"},
{"type": "ExternalIP", "address": "203.0.113.10"}
]
}args:
- --node-name=$(NODE_NAME)
- --internal-ip-target=10.0.0.1
- --external-ip-target=8.8.8.8
- --reconcile-interval=10sResult:
{
"addresses": [
{"type": "Hostname", "address": "node1"},
{"type": "InternalIP", "address": "10.0.0.5"},
{"type": "ExternalIP", "address": "203.0.113.10"}
]
}After updating the DaemonSet args, restart the pods:
kubectl -n kube-system rollout restart ds/local-ccmWhile not strictly required, you can configure kubelet with --cloud-provider=external to set the uninitialized taint which local-ccm will remove:
kubelet \
--cloud-provider=external \
--node-ip=<node-ip> # Optional: for bootstrap before local-ccm startsOr in kubelet config file:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cloudProvider: externalThe local-ccm binary supports the following flags:
| Flag | Description | Default |
|---|---|---|
--node-name |
Name of the node to update (env: NODE_NAME) | Required |
--internal-ip-target |
Target IP for internal IP detection. If empty, disabled | "" |
--external-ip-target |
Target IP for external IP detection | "8.8.8.8" |
--remove-taint |
Remove node.cloudprovider.kubernetes.io/uninitialized taint | true |
--run-once |
Run once and exit instead of running in a loop | false |
--reconcile-interval |
Interval between reconciliation loops | 10s |
--kubeconfig |
Path to kubeconfig file (for local testing) | In-cluster config |
--v |
Log level (0-5) | 0 |
┌─────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Node 1 │ │
│ │ │ │
│ │ ┌────────────────┐ ┌──────────────────┐ │ │
│ │ │ local-ccm Pod │───▶│ Node 1 Object │ │ │
│ │ │ (DaemonSet) │ │ via API │ │ │
│ │ └────────────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ │ hostNetwork: true │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Host Network │ │ │
│ │ │ netlink API │ │ │
│ │ └──────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Node 2 │ │
│ │ │ │
│ │ ┌────────────────┐ ┌──────────────────┐ │ │
│ │ │ local-ccm Pod │───▶│ Node 2 Object │ │ │
│ │ │ (DaemonSet) │ │ via API │ │ │
│ │ └────────────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ │ hostNetwork: true │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Host Network │ │ │
│ │ │ netlink API │ │ │
│ │ └──────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
- DaemonSet Controller: Ensures one pod runs on each node
- IP Detector: Uses netlink API to query routes and extract source IPs
- Node Updater: Updates node addresses via Kubernetes API
cd local-ccm
go mod tidy
GOWORK=off CGO_ENABLED=0 go build -o local-ccm ./cmd/local-ccmdocker build -t ghcr.io/cozystack/local-ccm:latest .local-ccm/
├── cmd/
│ └── local-ccm/
│ └── main.go # Main entrypoint
├── pkg/
│ ├── node/
│ │ └── updater.go # Node address/taint updater
│ └── detector/
│ └── ip_detector.go # IP detection logic
├── deploy/
│ ├── rbac.yaml # ServiceAccount + ClusterRole
│ └── daemonset.yaml # DaemonSet
├── Containerfile
├── go.mod
└── README.md
go run ./cmd/local-ccm \
--node-name=$(hostname) \
--external-ip-target=8.8.8.8 \
--internal-ip-target=10.0.0.1 \
--kubeconfig=$HOME/.kube/config \
--run-once \
--v=4Check DaemonSet status:
kubectl -n kube-system describe ds local-ccm
kubectl -n kube-system get pods -l app=local-ccmCheck pod logs:
kubectl -n kube-system logs -l app=local-ccm --tail=100Common causes:
- No route to target IP (check routing table)
- Network namespace issues (ensure hostNetwork: true)
- Missing CAP_NET_ADMIN capability
-
Check RBAC permissions:
kubectl auth can-i patch nodes --as=system:serviceaccount:kube-system:local-ccm
-
Verify arguments are correct:
kubectl -n kube-system get ds local-ccm -o yaml | grep -A10 args -
Enable debug logging: Edit DaemonSet and change
--v=2to--v=5
Edit the DaemonSet:
kubectl -n kube-system edit ds local-ccmChange the args:
args:
- --v=5 # Debug level logging- CPU: ~10m per node (requests), ~100m (limits)
- Memory: ~32Mi per node (requests), ~64Mi (limits)
- Network: Minimal (only API calls to update node object)
| Feature | DaemonSet (local-ccm) | Cloud Controller Manager |
|---|---|---|
| Architecture | Distributed (one pod per node) | Centralized (control-plane) |
| Complexity | Simple, direct | Complex, requires CCM framework |
| Dependencies | Only client-go | Full cloud-provider stack |
| Leader Election | ❌ Not needed | ✅ Required |
| Scaling | Linear with nodes | Single control-plane component |
| Network | Direct from each node | Centralized API calls |
| Best For | Simple bare-metal setups | Cloud environments, complex logic |
Apache License 2.0