Local Kubernetes dev environments: k3d, Tilt, and the inner loop that finally stuck
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:
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:
Output:
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 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.
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.
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:
# 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 will spin up its interactive terminal UI and start a web console at http://localhost:10350.
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:
-H “Content-Type: application/json” \
-d ‘{“symbol”: “BTCUSD”, “qty”: 1.45, “price”: 42500.0, “side”: “BUY”}’
Output response:
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:
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:
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:
-H “Content-Type: application/json” \
-d ‘{“symbol”: “BTCUSD”, “qty”: -5.0, “price”: 42500.0, “side”: “BUY”}’
The response is instantaneous:
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:
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_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:
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:
sync(‘./app.py’, ‘/app/app.py’),
run(‘kill -HUP 1’) # Signals your parent entrypoint to clean reload
]