A from-scratch reimplementation of Kubernetes' core APIs in pure Node.js — compatible with the real
kubectlCLI and standard Kubernetes YAML.
An attempt to recreate the core functionality of
Kubernetes v1.29 in Node.js. It speaks the
same HTTP/JSON/protobuf surface as a real kube-apiserver, schedules pods onto
a fleet of simulated nodes, orders its writes through a Raft log behind an
etcd-compatible store, keeps objects in MongoDB, and runs "pods" as sibling
Docker containers on the host.
What it's for. Learning kubectl, testing operators or controllers
against a lightweight fake apiserver, and quick demos without a real cluster
or kind/minikube boot time. Runs on a laptop in seconds.
What it is not. A production Kubernetes replacement. See Scope & limitations for the honest list of gaps.
Prerequisites: Node.js ≥ 20,
Docker Engine ≥ 25.0.3 (Docker
Desktop, or any reachable docker daemon), and
kubectl — recommended, though the
API is plain HTTP and works from anything.
npm run setup # installs deps, brings up MongoDB, builds helper images, writes ./kubeconfig
npm start # API server on http://127.0.0.1:8080In another shell:
export KUBECONFIG=$PWD/kubeconfig
kubectl get ns
kubectl apply -f examples/hello-world
kubectl get pods -Anpm run setup writes ./kubeconfig; it isn't checked in, since it is
generated.
npm run setup also brings up MongoDB in Docker. To point at your own instance
instead, set DB_URL in a .env file (see .env.example) and skip that part.
The server logs one line per request — method, path, status, duration. When you
need to see what a client actually sent, DEBUG_BODIES=1 npm start adds the
headers and both bodies.
To stop everything:
# Ctrl-C the server, then:
docker compose down
docker rm -f $(docker ps -aq --filter "label=k8s-sim") 2>/dev/null || true┌──────────────┐ HTTP (kube API) ┌───────────────────────────┐
│ kubectl │ ────────────────────────▶│ Express on :8080 │
│ (your CLI) │ │ routes/ middleware/ │
└──────────────┘ │ objects/ controllers/ │
└──┬───────────┬──────────┬─┘
│ │ │
Mongoose │ │ │ docker CLI
▼ ▼ ▼
┌───────────┐ ┌──────────┐ ┌─────────────┐
│ Mongo │ │ store/ │ │ Docker (host│
│ (objects) │ │ MVCC + │ │ daemon) │
│ │ │ Raft log │ │ — spawns │
│ │ │ :2379 │ │ "pods" │
└───────────┘ └──────────┘ └─────────────┘
▲
│ etcd v3 JSON API
curl / your tooling
- Every API resource has an object class in
objects/, a Mongoose schema indatabase/models.js, and an Express route inroutes/. The middleware inmiddleware/general.jswires up CRUD + watch + table + patch (JSON Patch, JSON Merge Patch, and Strategic Merge Patch are all supported). objects/bus.jsis a process-wide event bus that feeds watch streams so clients seeADDED/MODIFIED/DELETEDevents in real time.controllers/nodes.jscreates the simulated fleet at boot;sim-node-1..Ncarry capacity, allocatable and the usual topology labels.controllers/endpoints.jskeeps each Service'sEndpointsandEndpointSlicein step with the pods its selector matches. Level-triggered: it recomputes the set rather than patching it incrementally.controllers/scheduler.jsplaces pods on it: filter (readiness, cordons, taints,nodeSelector, node affinity, cpu/memory/pod fit) then score (least-allocated, plus preferred-affinity weight). It's the only controller loop. Other controllers (Deployment → ReplicaSet, Job, DaemonSet, CronJob) run inline on create.store/is the MVCC keyspace and the Raft log that orders writes to it.resourceVersionis that store's revision, the way a real API server's is etcd's. It also serves etcd's v3 JSON API on:2379.- Pods are real Docker containers spawned as siblings on the host Docker
daemon, named
<generateName>-<containerName>, with ConfigMap volume mounts bind-mounted from./volumes/<ns>_<name>/. They start only once the pod is bound to a node, so a pod the scheduler refused doesn't quietly run anyway.
Writes are ordered by a Raft log rather than by whichever database transaction committed first, and the keyspace underneath has etcd's semantics: a global revision, reads at a past revision, compaction that refuses stale reads with code 11 instead of serving the nearest surviving one, leases, and watches that replay from a revision before going live.
It speaks etcd's v3 JSON API, so you can check it from outside the process:
curl 127.0.0.1:2379/v3/kv/range \
-d "{\"key\":\"$(printf '/registry/' | base64)\",\"range_end\":\"$(printf '/registry0' | base64)\"}"Keys and values are base64 and revisions are strings — protobuf's JSON mapping, which the real gateway follows for a reason: a revision parsed as a JSON number stops being the revision past 2^53.
Defaults need no configuration; state lives in .store/.
| Variable | Default | Meaning |
|---|---|---|
STORE |
on |
off falls back to the old database counter for resourceVersion |
STORE_ID |
default |
This node's name |
STORE_DIR |
.store/<id> |
Raft log, snapshots and term state |
STORE_PEERS |
(single node) | id=address,id=address, listing every member including this one |
RAFT_ADDRESS |
http://127.0.0.1:2380 |
Where peers reach this node |
ETCD_ADDRESS |
http://127.0.0.1:2379 |
The etcd v3 endpoint |
API-level (verified against kubetest2 Conformance):
- Namespace / ConfigMap / Secret lifecycle (create / get / list / patch / update / delete / delete-collection)
- ServiceAccount (auto-created per namespace),
kube-root-ca.crtConfigMap - Pod create with
env,envFrom: configMapRef/secretRef,command,args, and ConfigMapvolumeMounts. Phase transitionsPending → Running → Succeeded/Failedviadocker wait. - Init containers: sequential, each with its own
command, reported instatus.initContainerStatuses(waiting → running → terminated with an exit code), and a non-zero exit fails the pod without starting the main containers - Liveness / readiness / startup probes (exec, httpGet, tcpSocket)
- Services with a synthetic ClusterIP (no real routing, see below), and the
Endpointsbehind them: a Service with a selector gets its matching pods, split into ready and not-ready addresses, withtargetPortresolved to the container's port. A Service with no selector is left alone for you to fill in EndpointSlice(discovery.k8s.io/v1) alongside them, which is what anything written since 1.21 actually reads: per-endpointready/serving/terminatingconditions, the node and zone each address is in, atargetRefback to the pod, and thekubernetes.io/service-namelabel a client selects on. One slice per service — sharding exists for a scale this never reaches, and a client can't tell the difference- Watch streams over HTTP with newline-delimited JSON, plus protobuf support
for clients that negotiate it (
client-go's default;kubectlasks for JSON) - Scheduling onto simulated nodes, with the refusals a controller has to
handle: a pod that fits nowhere stays
PendingwithPodScheduled=Falseand aFailedSchedulingevent reading0/3 nodes are available: 1 node(s) had untolerated taint {dedicated: batch}, 2 node(s) didn't match Pod's node affinity/selector - The binding endpoints (
/bindingsandpods/{name}/binding), so an out-of-tree scheduler can place a pod itself. A pod naming anotherschedulerNameis left alone until that scheduler binds it resourceVersionfrom the store's Raft-ordered revision: watches resume from a version instead of replaying the collection, and a write carrying a stale version is rejected with aConflictrather than silently overwriting- Discovery for every routed kind:
/api,/api/v1,/apisand/apis/{group}/{version}, sokubectl api-resourcesandkubectl get <kind>resolve without a warm discovery cache - Label selectors and field selectors on list / delete-collection
/statussubresources on Pod and Namespace- Events auto-emitted on every CRUD for observability
- ~55 resource kinds routed. The "real" ones (Pod, Deployment, ReplicationController, Service, Endpoints, ConfigMap, Secret, Node, Namespace, Event) have lifecycle behavior; the rest are API stubs — they round-trip correctly but don't have a controller.
Everything, in one command. Suites run in their own processes, and the table is the whole output unless something fails:
npm run test:allportability ok 0 fails, 500 passes. (0.2s)
store ok 0 fails, 84 passes. (7.1s)
proto ok 0 fails, 25 passes. (2.5s)
smoke ok 0 fails, 0 warns, out of 41 resources tested. (3.9s)
wire ok 0 fails, out of 41 resources tested. (37.4s)
rv ok 0 fails, 14 passes. (3.6s)
sched ok 0 fails, 38 passes. (34.6s)
pods ok 0 fails, 28 passes. (45.6s)
workload ok 0 fails, 16 passes. (16.7s)
services ok 0 fails, 19 passes. (22.3s)
10/10 suites clean, 0 failing assertions.
Individually, or a subset (npm run test:all -- store rv):
| Suite | Command | Needs | Covers |
|---|---|---|---|
| portability | npm run test:portability |
nothing | that every relative require matches a real file with the exact case — a mismatch loads fine on macOS and throws MODULE_NOT_FOUND on Linux |
| store | npm run test:store |
nothing | MVCC and revision semantics, compare-and-swap, compaction, watches, lease expiry, crash recovery, and a three-node Raft cluster that survives losing its leader |
| proto | npm run test:proto |
server | protobuf round-trips — a wrong field name or an unwrapped Quantity encodes without erroring, so this asserts on what a client decodes |
| smoke | npm run test:smoke |
server | POST/GET/DELETE for every wired resource |
| wire | npm run test:wire |
server | JSON, YAML, protobuf and Table across every resource, plus all four patch types |
| rv | npm run test:rv |
server | resourceVersion allocation, watch resume, optimistic concurrency |
| sched | npm run test:sched |
server | placement, every predicate's refusal message, the events and conditions that report it, and the binding endpoints |
| pods | npm run test:pods |
server + docker | env from ConfigMaps and Secrets, ConfigMap volume mounts, probes, logs, init containers |
| workload | npm run test:workload |
server + docker | the Deployment → ReplicationController → Pod chain |
| services | npm run test:services |
server + docker | Service selectors, and the Endpoints and EndpointSlices behind them |
Assertions are written against what a client ends up with, not against status codes. Almost every bug these have caught returned 200 with the wrong body.
CI runs the same thing on every push and pull request
(.github/workflows/tests.yml): one job for the store and the portability
check, which need neither the server nor a database and answer in seconds, and
one that brings up MongoDB,
starts the API server and runs npm run test:all against the runner's own
Docker daemon — the same arrangement as a laptop, so a failure there is a real
failure rather than an artefact of the environment.
The variable-expansion conformance test ([sig-node] Variable Expansion allow almost all printable ASCII characters as environment variable names [Conformance]) passes end-to-end:
npm test # runs the kubetest2 harness; needs kubetest2 + ginkgoThis is a simulator, not a distribution. The following are intentionally out of scope and won't ever work here:
| Area | Status |
|---|---|
| Multi-node / HA control plane | Partial. The store that orders writes runs as a Raft cluster and survives losing its leader, but the API server is still one Express process and objects still live in one MongoDB. |
| etcd gRPC | No. The store serves etcd's v3 JSON API, so curl works and etcdctl — which speaks gRPC — does not attach. |
| The store as the object store | Not yet. It hands out resourceVersion and is durable and replicated; the ~55 object classes still read and write through Mongoose. |
| Real nodes | No. sim-node-* are simulated: they have capacity, labels and taints that scheduling honours, but no kubelet — every pod's container runs on the host's Docker daemon regardless of which node it was placed on. |
| Pod-to-pod networking (CNI) | No. Pods are sibling containers on Docker's default bridge; no overlay, no kube-proxy, no iptables rules. |
| DNS resolution for services | No. Services get a synthetic ClusterIP but it isn't routed. CoreDNS isn't deployed. |
| Real storage (CSI, PV provisioning) | No. PersistentVolume / PersistentVolumeClaim round-trip as API objects only. |
| Admission webhooks (Mutating/Validating) | No. Config objects round-trip; the webhook call chain isn't invoked. |
| CustomResourceDefinitions | No. CRDs aren't implemented — operators depending on them will fail. |
| Aggregation API | Partial. Discovery covers every kind we route, but aggregated API servers aren't proxied and APIService objects only round-trip. |
| Protobuf for non-core groups | Partial. .proto models are loaded for core/v1, apps/v1, networking.k8s.io/v1, rbac.authorization.k8s.io/v1 and certificates.k8s.io/v1. Other groups (batch, storage.k8s.io, policy, autoscaling, …) negotiate down to JSON rather than failing. |
| RBAC enforcement | No. Role/RoleBinding etc. round-trip but no authz is applied; every request is effectively system:admin. |
| Server-side apply | No. application/apply-patch+yaml is accepted but treated as a strategic merge. |
| HorizontalPodAutoscaler | No. No metrics-server, no autoscaling loop. |
| Kubelet internals (GC, image pull policy, evictions, cgroups) | No. docker run is the extent of it. |
Tests in the [Conformance] suite that rely on any of the above will fail
here no matter how much API-surface work is done.
| Tool | Purpose | Our niche vs. it |
|---|---|---|
kind |
Real Kubernetes in Docker | k8s-sim is ~100× lighter and boots in seconds, but isn't a real cluster. |
kwok |
Simulates kubelet for ≥10k fake nodes | kwok fakes the kubelet against a real apiserver; k8s-sim is the apiserver, and its pods really run. Complementary. |
envtest |
Real kube-apiserver + etcd for Go tests |
envtest is heavier and Go-only; k8s-sim is a standalone HTTP API usable from any language. |
index.js entry point (Express app on :8080 + :6443)
routes/ one file per resource, wires HTTP verbs to middleware
middleware/
general.js find / findOne / save / update / patch / delete / list / watch
protoBuf.js en/decode protobuf bodies (Time, Long, Quantity coercions)
openapi.js OpenAPI v3 schema validation per resource
objects/ domain classes; each extends K8Object in object.js
object.js base: hash(), find(), create(), patch(), update(), delete(), ...
pod.js real lifecycle (runImage, scheduleProbes, exit watcher)
bus.js process-wide event bus for watch streams
database/
models.js Mongoose schemas for every resource
connection.js MongoDB bootstrap
controllers/
scheduler.js filter/score placement, binding, Scheduled events
nodes.js creates the simulated fleet at boot
store/
mvcc.js the keyspace: revisions, history, txn, watch, leases
raft.js election, log replication, apply loop
wal.js durable log, term state and snapshots
transport.js peer RPC over HTTP
gateway.js etcd's v3 JSON API on :2379
node.js one node: store + raft + transport
endpoints.js keeps Endpoints and EndpointSlices in step with pods
functions.js Docker CLI helpers (spawn-based, shell-safe)
proto/ Kubernetes .proto files the server loads at boot
openApiSpecs/ OpenAPI v3 schemas
examples/ sample YAML manifests
test/
test.js integration test via kubetest2 (requires external install)
boot-clean.js smoke test, hits POST/GET/DELETE for every resource type
scheduling.js placement, refusal messages, binding endpoints
store.js MVCC, raft, recovery and the etcd endpoint (no server needed)
services.js Service selectors, Endpoints and EndpointSlices
all.js runs every suite and prints one table (npm run test:all)
scripts/
setup.sh one-command setup (npm run setup)
start.sh start server with mongo up (npm start)
Open an issue or a PR if something is broken. npm run test:all is the fastest
way to find out whether a change holds up — it needs the server and Docker
running for the full set, and nothing at all for portability and store.
MIT — see LICENSE.md.