Skip to content
Cloud Native

Local Kubernetes dev environments: k3d, Tilt, and the inner loop that finally stuck

k3d — a bunch of cargo containers stacked on top of each other

The worst part of building distributed execution systems is the time wasted waiting for Docker images to compile, push, pull, and deploy. When you are writing latency-sensitive algorithmic trading adapters or real-time event-driven engines, you cannot rely solely on unit tests. You need to verify how your service behaves when interacting with an in-cluster message broker, a mock exchange API, and a database.

For years, my loop looked like this:
1. Make a one-line change in a Python or Go service.
2. Run docker build -t myregistry.local/execution-service:latest ..
3. Wait 90 seconds for layers to rebuild.
4. Run docker push myregistry.local/execution-service:latest.
5. Run kubectl rollout restart deployment execution-service.
6. Wait 45 seconds for Kubernetes to pull the image, fail its liveness probe, terminate the old pod, and start the new one.
7. Realize I made a syntax error. Repeat.

A three-minute feedback loop is a productivity killer. It forces you to batch your work, lose focus, and spend half your day browsing hacker news while waiting for bash scripts to finish.

I tried Minikube, but it chewed through my MacBook’s battery and felt sluggish. I tried raw Docker Compose, but it failed to replicate Kubernetes-specific patterns like init-containers, ingress rules, and configmap mounts.

The combination that finally fixed this is k3d combined with Tilt. My inner loop went from 180 seconds to under 2 seconds. This post goes through the exact configuration, code, and architecture I use to run high-throughput services locally.


The local Kubernetes architecture

To make local kubernetes development fast, you must avoid rebuilding whole container layers and bypass the remote registry entirely.

k3d runs lightweight k3s (a minimal Kubernetes distribution by Rancher) inside Docker containers. It is fast to spin up, uses very little memory, and supports local registries out of the box. Tilt monitors your local workspace, detects code modifications, and live-injects those changes directly into the running containers without restarting them, unless a dependency change requires a full rebuild.

flowchart TD
 dev["Developer Machine"]
 tilt["Tilt Engine"]
 k3d["k3d Cluster"]
 reg["Local Registry"]
 app["App Container"]
 tilt -->|"Watches File System"| dev
 tilt -->|"Builds & Pushes"| reg
 reg -->|"Pulls Image"| k3d
 tilt -->|"Live Syncs Code"| app
 k3d -->|"Hosts"| app

Setting up the infrastructure with k3d

First, we need a reproducible way to spin up a local Kubernetes cluster with a built-in registry and correct port mapping. Avoid doing this via imperitive CLI commands; use a configuration file so anyone on your team can spin up the identical environment with a single command.

Here is the k3d-config.yaml I use:

apiVersion: k3d.io/v1alpha5
kind: Simple
metadata:
name: dev-cluster
servers: 1
agents: 1
image: rancher/k3s:v1.27.4-k3s1
ports:
– port: 8080:80
nodeFilters:
– loadbalancer
registries:
create:
name: k3d-dev-registry.localhost
host: 127.0.0.1
port: “5005”
options:
k3d:
wait: true
timeout: “360s”
k3s:
extraArgs:
– arg: –disable=traefik
nodeFilters:
– server:*

This configuration achieves three things:
1. It disables the default Traefik ingress controller because I prefer to use standard NGINX or let Tilt manage ingress paths directly.
2. It sets up a dedicated local container registry running at 127.0.0.1:5005.
3. It maps port 8080 on your host machine to port 80 on the k3d load balancer, allowing you to access cluster services locally without running kubectl port-forward manually every time.

To spin up this cluster, run:

k3d cluster create –config k3d-config.yaml

Output:

INFO[0000] Configured neighbors list…
INFO[0000] Created network ‘k3d-dev-cluster’
INFO[0000] Created image volume k3d-dev-cluster-images
INFO[0000] Creating node ‘k3d-dev-registry.localhost’
INFO[0001] Creating services LB for cluster ‘dev-cluster’
INFO[0001] Creating node ‘k3d-dev-cluster-server-0’
INFO[0001] Creating node ‘k3d-dev-cluster-agent-0’
INFO[0002] Starting new tools loop…
INFO[0002] Starting node ‘k3d-dev-registry.localhost’
INFO[0002] Starting node ‘k3d-dev-cluster-server-0’
INFO[0010] Starting node ‘k3d-dev-cluster-agent-0’
INFO[0015] Starting node ‘k3d-dev-cluster-tools’
INFO[0016] Cluster ‘dev-cluster’ created successfully!
INFO[0016] You can now use it with kubectl: `kubectl cluster-info`

The Target Application

Let’s build a fast, lightweight mock trading execution engine in Python using FastAPI. The service will ingest buy/sell orders, simulate latency, and log the executions.

Here is the app.py:

import os
import time
import uuid
import logging
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

logging.basicConfig(level=logging.INFO, format=“%(asctime)s [%(levelname)s] %(message)s”)
logger = logging.getLogger(“execution-engine”)

app = FastAPI(title=“MockExecutionEngine”, version=“1.0.0”)

# Simulate a memory-based book
order_book = {}

class Order(BaseModel):
symbol: str
qty: float
price: float
side: str # BUY or SELL

@app.get(“/healthz”)
def health_check():
return {“status”: “healthy”, “timestamp”: time.time()}

@app.post(“/v1/orders”)
def place_order(order: Order):
if order.side.upper() not in [“BUY”, “SELL”]:
raise HTTPException(status_code=400, detail=“Invalid side. Must be BUY or SELL.”)

order_id = str(uuid.uuid4())
logger.info(f”Received order: {order.side} {order.qty} {order.symbol} @ {order.price}”)

# Simulate exchange match latency
time.sleep(0.05)

execution_report = {
“order_id”: order_id,
“symbol”: order.symbol,
“qty”: order.qty,
“price”: order.price,
“side”: order.side,
“status”: “FILLED”,
“transact_time”: time.time()
}

order_book[order_id] = execution_report
logger.info(f”Order {order_id} filled successfully”)
return execution_report

@app.get(“/v1/orders/{order_id}”)
def get_order(order_id: str):
order = order_book.get(order_id)
if not order:
raise HTTPException(status_code=404, detail=“Order not found”)
return order

To make Tilt’s live update engine work smoothly, write a standard Dockerfile that sets up a clean environment but does not compile production-only optimizations that prevent on-the-fly execution changes.

FROM python:3.11-slim

WORKDIR /app

RUN pip install –no-cache-dir uvicorn fastapi pydantic

COPY . .

EXPOSE 8000

CMD [“uvicorn”, “app:app”, “–host”, “0.0.0.0”, “–port”, “8000”]

Next, write the standard Kubernetes deployment manifest (deployment.yaml). This manifest keeps things simple, but notice that we will use a specific tag image that our local registry supports.

apiVersion: apps/v1
kind: Deployment
metadata:
name: execution-service
labels:
app: execution-service
spec:
replicas: 1
selector:
matchLabels:
app: execution-service
template:
metadata:
labels:
app: execution-service
spec:
containers:
– name: execution-service
image: k3d-dev-registry.localhost:5005/execution-service:latest
ports:
– containerPort: 8000
imagePullPolicy: IfNotPresent
resources:
limits:
cpu: “500m”
memory: “256Mi”
requests:
cpu: “100m”
memory: “128Mi”
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 2
periodSeconds: 5
—
apiVersion: v1
kind: Service
metadata:
name: execution-service
spec:
type: ClusterIP
ports:
– port: 80
targetPort: 8000
selector:
app: execution-service

Configuring the Tiltfile

The Tiltfile is written in Starlark, a dialect of Python. It tells Tilt how to build your images, track changes on your local filesystem, and update the cluster.

If you modify app.py, we do not want Tilt to rebuild the entire container image. Instead, we want Tilt to copy the changed file straight into the running container container filesystem and signal the application process (or let uvicorn auto-reload catch it).

Here is the Tiltfile:

# Tiltfile

# Declare the registry we configured in k3d
default_registry(‘k3d-dev-registry.localhost:5005’)

# Configure docker build with live update sync
docker_build(
ref = ‘k3d-dev-registry.localhost:5005/execution-service’,
context = ‘.’,
dockerfile = ‘./Dockerfile’,
only = [
‘./app.py’,
‘./Dockerfile’
],
live_update = [
# Sync the local app.py file directly into the container’s app directory
sync(‘./app.py’, ‘/app/app.py’),
]
)

# Deploy the kubernetes configurations
k8s_yaml(‘deployment.yaml’)

# Port-forward the service to the host environment
k8s_resource(‘execution-service’, port_forwards=‘8000:80’)


Running the setup

With the k3d cluster running, boot up your Tilt environment:

tilt up

Tilt will spin up its interactive terminal UI and start a web console at http://localhost:10350.

Tilt social distancing mode: active
Starting Tilt (v0.32.4)…
Configured templates: 1
Checking for Kubernetes cluster…
Connected to Kubernetes cluster: k3d-dev-cluster
Applying Yaml…
Building image: k3d-dev-registry.localhost:5005/execution-service
[1/2] Copying files
[2/2] Building image
Image built and pushed to registry: k3d-dev-registry.localhost:5005/execution-service:tilt-87da96dfa23b
Tracking file modifications…
Port-forwarding execution-service:8000 -> 80
[execution-service] INFO: Started server process [1] [execution-service] INFO: Waiting for application startup.
[execution-service] INFO: Application startup complete.
[execution-service] INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

Now send an execution request to verify it is working:

curl -X POST http://localhost:8000/v1/orders \
-H “Content-Type: application/json” \
-d ‘{“symbol”: “BTCUSD”, “qty”: 1.45, “price”: 42500.0, “side”: “BUY”}’

Output response:

{“order_id”:“d16a6988-75be-4da0-901b-90fbe3d2207a”,“symbol”:“BTCUSD”,“qty”:1.45,“price”:42500.0,“side”:“BUY”,“status”:“FILLED”,“transact_time”:1700000000.52}

Test the Live Update Loop

Now let’s test how fast Tilt synchronizes changes. Let’s modify our execution pipeline logic inside app.py to add validation constraints (e.g., rejecting negative values or quantities) without rebuilding the container.

In your local IDE, modify app.py directly by inserting these lines at the beginning of place_order:

if order.qty <= 0 or order.price <= 0:
logger.error(f”Validation failed: Invalid size or price”)
raise HTTPException(status_code=422, detail=“Quantity and price must be greater than zero”)

The moment you save the file, Tilt’s filesystem listener captures the change. Look at your terminal output or Tilt web UI:

1 file changed: app.py
Copying files to container: execution-service-58bd5767b4-dfjns
Copying /Users/dev/project/app.py to /app/app.py inside container…
Live update executed successfully in 0.23 seconds!

Verify that the runtime changes were instantly adopted without restarting the pod:

curl -i -X POST http://localhost:8000/v1/orders \
-H “Content-Type: application/json” \
-d ‘{“symbol”: “BTCUSD”, “qty”: -5.0, “price”: 42500.0, “side”: “BUY”}’

The response is instantaneous:

HTTP/1.1 422 Unprocessable Entity
date: Mon, 11 Dec 2023 14:10:05 GMT
server: uvicorn
content-length: 53
content-type: application/json

{“detail”:”Quantity and price must be greater than zero”}

No image compilation, no docker push, no kubernetes scheduling delays. The change deployed in less than half a second.


Lessons learned from running k3d + Tilt at scale

This setup is fast, but things can break if you don’t scale the configurations properly. Here are the three most common failures I ran into while tuning local environments with hundreds of microservices.

1. Inotify limits on Linux

If you are developing on Linux (or running docker inside a Linux VM), you might get file-watching errors when your workspace gets large:

FATAL: System limit for number of file watchers reached

This happens because the system’s fs.inotify.max_user_watches value is set too low. Fix this by adding the following to your host’s /etc/sysctl.conf:

fs.inotify.max_user_watches=524288
fs.inotify.max_user_queued_events=16384
fs.inotify.max_user_instances=8192

Apply the changes immediately with sudo sysctl -p.

2. Live Update vs Pip / Dependency updates

If you modify your requirements.txt or go.mod file, a simple hot reload of the code file is not enough; you need to trigger a full build because a dependency is missing. You can configure Tilt to handle this dynamically:

docker_build(
ref = ‘k3d-dev-registry.localhost:5005/execution-service’,
context = ‘.’,
dockerfile = ‘./Dockerfile’,
trigger = [
‘./requirements.txt’
],
live_update = [
sync(‘./app.py’, ‘/app/app.py’),
]
)

Adding trigger targets lets Tilt know that modifying dependencies should bypass live updates and execute a full, clean Docker build.

3. Avoiding State Desynchronization

If your local application holds persistent connections (e.g. gRPC streams, WebSocket subscriptions to tick feeds), updating files via live update can leave stale connections hanging or cause unexpected edge-case errors inside the engine. If your application does not robustly handle dynamic file hot-reloading at runtime, add a fallback trigger to restart the process inside the container:

live_update = [
sync(‘./app.py’, ‘/app/app.py’),
run(‘kill -HUP 1’) # Signals your parent entrypoint to clean reload
]

Join the conversation

Your email address will not be published. Required fields are marked *