DEV Community

Jamiu Tijani
Jamiu Tijani

Posted on

I turned an Android phone into a Kubernetes worker node

A few months ago I had a thought: modern Android phones are carrying 8-core ARM SoCs, gigabytes of RAM, and fast storage sitting idle on a desk. What if a standard Kubernetes cluster could just... use them?

That question became DroidNode.

$ kubectl get nodes
NAME                        STATUS   ROLES    AGE
kali                        Ready    master   4d
droidnode-b96a49db497af1eb  Ready    <none>   2h   ← Android phone
Enter fullscreen mode Exit fullscreen mode

standard kubectl apply works and the pod runs on the phone.


The constraints that made it interesting

The hardest constraint was no root. Most container runtimes (Docker, containerd, even podman in its default mode) lean on kernel namespaces and cgroups and both requires elevated privileges on Android.

The second constraint: no modifications to k3s or Kubernetes. The control plane had to stay completely unaware that it was talking to an Android device. From the scheduler's perspective it needed to look like an ordinary Linux worker node.

So the problem became: how do you run arbitrary Linux container workloads on Android, without root, while speaking fluent Kubernetes?


The key insight: proot

proot is a userspace implementation of chroot built on ptrace. Instead of asking the kernel to change the root filesystem (which requires root), proot intercepts every syscall the guest process makes and translates the paths in software.

It's slower than a real namespace-based container runtime, but it works on Android with developer mode enabled.

Basically the flow is :

kubectl apply -f pod.yaml
    → k3s schedules pod to droidnode node
    → DroidNode agent receives pod spec
    → pulls OCI image layers from registry
    → unpacks layers into a rootfs directory
    → spawns: proot -r /path/to/rootfs -w /app /bin/sh ...
    → pod runs, logs stream back, status reported to k3s
Enter fullscreen mode Exit fullscreen mode

Implementing the kubelet API

Kubernetes doesn't care what's running the pods , it just talks HTTP to whatever is registered as the kubelet for a node. So I implemented the parts of the kubelet API that k3s actually uses:

  • GET /pods — return current pod list
  • GET /containerLogs/{namespace}/{pod}/{container} — stream logs
  • POST /run/{namespace}/{pod}/{container} — exec (not yet implemented)

The node registers itself with k3s by creating a Node object in the API server and then sending periodic heartbeat patches to the node's status. As long as the Ready condition stays True and lastHeartbeatTime updates every 30 seconds, k3s treats it like any other node.

Because k3s requires the kubelet endpoint to serve HTTPS with a certificate the control plane trusts. The agent generates its own CA on first run, writes it to disk, and you copy it to k3s once:

adb shell run-as com.droidnode cat /data/data/com.droidnode/files/kubelet-ca.crt \
    > /tmp/android-kubelet-ca.crt
sudo cp /tmp/android-kubelet-ca.crt /etc/rancher/k3s/
Enter fullscreen mode Exit fullscreen mode

After that, every restart reuses the same CA so the trust relationship persists.


OCI image pulling from scratch

I couldn't use any existing container runtime library because they all assume Linux namespaces. So I wrote the OCI registry client from scratch in Rust.

The flow is:

  1. Fetch the image manifest from the registry (Docker Hub, GHCR, etc.)
  2. If it's a manifest list, pick the linux/arm64 platform entry
  3. Fetch each layer blob (gzipped tar)
  4. Unpack layers in order, applying whiteout files (deletions) as we go
  5. Write a sentinel file so we know the rootfs is ready next time

The whiteout handling was surprisingly fiddly. OCI layers express file deletions as special .wh. prefixed files (you have to process them in layer order or you end up with files that should have been deleted still present in the rootfs).

Another notable issue is hardlinks across layer boundaries. Android's internal storage filesystem doesn't support cross-directory hardlinks in some configurations, so I had to fall back to a copy when link() fails:

match std::fs::hard_link(&src, &dst) {
    Ok(_) => {}
    Err(_) => { std::fs::copy(&src, &dst)?; }
}
Enter fullscreen mode Exit fullscreen mode

The architecture: The Standard

The Rust agent follows Hassan Habib's The Standard strictly:

Brokers → Foundation Services → Orchestration Services → Exposers
Enter fullscreen mode Exit fullscreen mode
  • Brokers wrap exactly one external system (OCI registry, filesystem, proot binary, k8s API). No logic, no conditionals.
  • Foundation services each do one thing (pull an image, unpack a layer, run a workload).
  • Orchestration services coordinate foundation services (reconciliation loop, image pipeline).
  • Exposers are thin entry points (the kubelet HTTPS server, the Android foreground service).

Enforcing this made the codebase much easier to test in pieces — I could validate the OCI registry broker against Docker Hub independently, then layer the image pull service on top of it, and so on.


The Android side: Kotlin foreground service

On Android, a foreground service is the only reliable way to keep a process alive without root. The Kotlin layer is intentionally thin — it just:

  • Starts the Rust agent as a native subprocess
  • Holds a wake lock so Android doesn't kill it under memory pressure
  • Monitors battery and network state
  • Provides a debug UI (tap the notification to see live logs, node status, and pod events)

The Rust agent writes to stdout; Kotlin tails it and feeds lines into a ring buffer that the debug UI reads. No JNI — clean process boundary.


What actually works today

kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: hello-android
spec:
  nodeName: droidnode-<your-device-id>
  restartPolicy: Never
  containers:
    - name: hello
      image: alpine:latest
      command: ["/bin/sh", "-c"]
      args: ["echo hello from DroidNode; uname -m"]
EOF

kubectl logs hello-android
# hello from DroidNode
# aarch64
Enter fullscreen mode Exit fullscreen mode

I've tested with Alpine, Python HTTP servers, and stefanprodan/podinfo. Images with a WORKDIR set work correctly — the agent reads the WorkingDir field from the image config and passes it to proot's -w flag.


Known limitations

This is a working proof of concept, not a production runtime. The main gaps:

Area Status
Multi-container pods Only first container runs
restartPolicy: Always Not implemented
Volume mounts Source path mapping is broken
Network isolation Containers share host network
kubectl exec Not implemented
Load balancer pods (svclb) iptables fails inside proot

All of these are tracked as open issues if you want to pick one up.


What's next

The most interesting unsolved problem is network isolation. Right now every container shares the Android device's network interface. Implementing proper pod networking without root is genuinely hard, the closest viable approach is probably a userspace TCP proxy similar to what k3s does for services.


Try it

The repo is at github.com/c0d3g3n13/droidnode. You need a device with developer mode enabled, an ARM64 Android phone (API 26+), and a k3s cluster to connect it to.

If you find it interesting and want to contribute, the open issues are a good place to start. The architecture is strictly layered so most features can be added without touching much existing code.

Top comments (0)