Node Agent: kubelet¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
The Agent is Shepherd's kubelet. It runs on every node, registers with the API Server, sends heartbeats, and starts containers for assigned pods.
Structure¶
type Agent struct {
nodeName string
apiAddr string
mgr *container.Manager
logger *log.Logger
capacity NodeResources
}
The Agent knows its name, the API Server address, and has access to the container.Manager (Sheep runtime).
Registration with Retry¶
func (a *Agent) Run(stopCh <-chan struct{}) error {
a.mgr.Init()
// Retry for standalone mode
for attempt := 0; attempt < 10; attempt++ {
if err := a.register(); err == nil {
break
} else if attempt == 9 {
return fmt.Errorf("register after 10 attempts: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}
heartbeat := time.NewTicker(10 * time.Second)
reconcile := time.NewTicker(3 * time.Second)
for {
select {
case <-stopCh: return nil
case <-heartbeat.C: a.sendHeartbeat()
case <-reconcile.C: a.reconcilePods()
}
}
}
Retry is needed for standalone mode, where the API Server and Agent start at the same time. The API Server may not be ready yet.
Node Registration¶
func (a *Agent) register() error {
hostname, _ := os.Hostname()
node := Node{
Kind: "Node",
Metadata: ObjectMeta{
Name: a.nodeName,
Labels: map[string]string{
"hostname": hostname,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
},
},
Spec: NodeSpec{Address: a.nodeName},
Status: NodeStatus{
Condition: NodeReady,
Capacity: a.capacity,
Allocatable: a.capacity,
},
}
return a.post("/api/v1/nodes", node)
}
Labels automatically include hostname, OS, and architecture. A pod's nodeSelector can use these labels.
Resource Detection¶
func detectCapacity() NodeResources {
var memTotal int64
data, err := os.ReadFile("/proc/meminfo")
if err == nil {
fmt.Sscanf(string(data), "MemTotal: %d kB", &memTotal)
memTotal *= 1024
}
if memTotal == 0 {
memTotal = 2 * 1024 * 1024 * 1024 // 2GB default
}
return NodeResources{
CPU: int64(runtime.NumCPU()) * 1000, // millicores
Memory: memTotal,
Pods: 110,
}
}
Reads memory from /proc/meminfo, CPU from runtime.NumCPU(). 110 pods per node is the default Kubernetes limit.
Pod Reconciliation¶
func (a *Agent) reconcilePods() {
pods, _ := a.listMyPods()
for _, pod := range pods {
if pod.Spec.NodeName != a.nodeName { continue }
switch pod.Status.Phase {
case PodPending:
a.startPod(pod)
case PodRunning:
a.checkPod(pod)
}
}
}
Every 3 seconds, the Agent checks its pods. Pending ones get started. Running ones get health-checked.
Starting a Pod¶
func (a *Agent) startPod(pod *Pod) {
var statuses []ContainerStatus
for _, cs := range pod.Spec.Containers {
var env []string
for k, v := range cs.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
opts := container.RunOpts{
Name: fmt.Sprintf("%s-%s",
pod.Metadata.Name, cs.Name),
Image: cs.Image,
Command: cs.Command,
Config: container.Config{
Hostname: pod.Metadata.Name,
Env: env,
Memory: cs.Resources.Memory,
},
}
c, err := a.mgr.Create(opts)
if err != nil {
statuses = append(statuses, ContainerStatus{
Name: cs.Name, State: "failed"})
continue
}
a.mgr.Start(c.ID)
statuses = append(statuses, ContainerStatus{
Name: cs.Name, ContainerID: c.ID,
Ready: true, State: "running"})
}
// Update IP
if pod.Status.PodIP == "" {
for _, cs := range statuses {
if cs.ContainerID != "" {
c, _ := a.mgr.Get(cs.ContainerID)
if c != nil && c.Network != nil {
pod.Status.PodIP = c.Network.IPAddress
break
}
}
}
if pod.Status.PodIP == "" {
pod.Status.PodIP = "127.0.0.1"
}
}
allReady := true
for _, s := range statuses {
if !s.Ready { allReady = false; break }
}
if allReady {
pod.Status.Phase = PodRunning
} else {
pod.Status.Phase = PodFailed
}
a.updatePodStatus(pod)
}
graph TD
A["Pod Pending<br/>NodeName = this node"] --> B["For each container in spec"]
B --> C["Create container"]
C --> D["Start container"]
D --> E["Get IP from network"]
E --> F{"All Ready?"}
F -->|Yes| G["Pod = Running"]
F -->|No| H["Pod = Failed"]
The Agent takes the IP address of the first container and sets it as PodIP. This is a simplification -- in Kubernetes, all containers in a pod share one network namespace.
Limitations¶
The Agent creates a separate container for each container spec in the pod. In Kubernetes, all containers in a pod share one network namespace (via the pause container). In Shepherd, each container gets its own IP, which breaks the pod networking model.
A few more sharp edges:
detectCapacity()reads/proc/meminfo-- that's Linux-specific. On macOS the file doesn't exist, so the code silently falls back to the 2 GB default. No scheduler will ever learn that the number is made up.- The Agent only reconciles
PendingandRunning. If a pod is deleted from the API while the Agent wasn't looking, the container keeps running -- an orphan. There's no reconciliation of "what's actually running" against "what should be running."
💡 Fun facts¶
- The name kubelet is "kubernetes" + "-let" (like "applet", "servlet"): a small agent. And despite the name, it's one of the largest and most complex components of Kubernetes -- far from 350 lines.
- The real kubelet hasn't run containers itself for a long time. It talks over CRI (Container Runtime Interface) -- a gRPC protocol -- to a runtime like containerd or CRI-O. Our Agent, calling
mgr.Create()directly, is closer to how kubelet worked before ~1.5, when Docker was wired in internally. - The pause container in Kubernetes (also "infra container") is a tiny program that does nothing but sleep and hold the network namespace. Its sole job is to outlive restarts of the application containers so the pod IP doesn't change. The
pauseimage is one of the most frequently run containers in the world -- roughly 700 KB on disk and about 250 KB of RAM. - 110 pods per node isn't a physical limit -- it's a conservative kubelet default (
--max-pods). It's tied more to pressure on the control plane and network than to the node's own resources. On large clusters, the scheduler and API Server feel every new pod object long before the node runs out of CPU. - The real kubelet's main loop is
syncLoop: it watches the API for pod changes and periodically re-syncs everything anyway. That combination of watch + reconcile is why missed events rarely matter -- the same insight as our 3-second ticker, just at production scale. - Inside
syncLoop, the Pod Lifecycle Event Generator (PLEG) watches container runtime events and maps them back to pods. Without PLEG, kubelet wouldn't know a container died until the next full sync -- a dedicated bridge between "runtime said something" and "pod status should change." - Kubernetes also supports static pods: manifest files dropped into
/etc/kubernetes/manifests/that the kubelet runs without the scheduler. The control plane sees them as mirror pods. Shepherd has no equivalent -- every pod must come through the API. - The kubelet concept traces back to Borg at Google: the
borgleton each machine was the ancestor of today's node agent. Kubernetes kept the pattern -- one daemon per node that makes local reality match cluster intent. - When a node joins a real cluster, the kubelet goes through TLS bootstrap: it gets a client certificate signed by the control plane so only authorized agents can register and update node state. Shepherd skips that entirely -- any process that knows the API address can pretend to be a node.
What I figured out while digging into this¶
The biggest insight: the Agent doesn't "manage" pods, it reconciles them. There's no "start this pod" command -- just a loop that every three seconds asks "which pods are assigned to me?" and brings reality up to the desired state. At first the polling-instead-of-push bugged me. But that's exactly what makes the Agent self-healing: miss an event, and the next tick fixes everything anyway. Push without reconcile would be more fragile.
I also understood why the retry on registration is needed. The first standalone run kept failing because the Agent knocked on an API Server that hadn't come up yet. It was a reminder that in distributed systems "the other component isn't ready yet" is the norm, not an error.
What could be improved¶
- Add a pause container per pod and share one network namespace across containers -- then PodIP becomes real instead of the first container's IP.
- Move to a CRI-like interface instead of direct
container.Managercalls -- then the runtime could be swapped out. - Implement real state reconciliation: find orphan containers (running, but with no pod in the API) and clean them up.
- Cross-platform
detectCapacity(): a separate branch for macOS (sysctl hw.memsize) instead of silently falling back to 2 GB.
Try It Yourself¶
# In multi-node mode:
# Terminal 1 (control plane):
shepherd --mode server --addr :9876
# Terminal 2 (worker):
shepherd --mode agent --node-name worker-1 --api-addr localhost:9876
sheepctl nodes # you'll see worker-1
The Agent works. Next up -- the full journey of a pod from creation to Running.
Resources¶
- kubelet reference — node agent CLI and flags
- Nodes — node and kubelet behaviour
- Pod lifecycle — what the agent enforces
- Container Runtime Interface (CRI) — how the real kubelet talks to containerd or CRI-O
- Node authorizer — what a kubelet is allowed to read and write
- Static Pods — pods the kubelet runs from local manifests
- Large-scale Borg cluster management (EuroSys 2015) — where the borglet/kubelet pattern originated
- Kubernetes pause image source — the infra container that holds the pod network namespace
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Node Health | Next: Pod Lifecycle
