Managed Kubernetes gives you self-healing for free: a node dies, the scheduler notices, a replacement pod comes up elsewhere. If you're running a handful of Vultr instances behind a load balancer — a Postgres read replica, a fleet of API workers, a scraping farm — you don't have a scheduler watching your back. You have a pager going off at 3am because one instance quietly wedged itself and nginx kept routing traffic to it anyway.
You don't need Kubernetes to fix this. You need a reconciler: a small loop that knows what "healthy" means for your app, checks it on a schedule, and replaces anything that fails the check. This is the same pattern k8s uses internally, minus the YAML. Here's how to build one against the Vultr API in about 150 lines of Python.
The shape of the problem
A self-healing pool has four moving parts:
- A health contract — an endpoint each instance exposes that tells you, definitively, whether it's fit to serve traffic.
- A reconciler — a process that polls instance health, decides when "unhealthy" becomes "replace it," and acts.
- Fast replacement — a way to bring a new instance online in under a minute, not the ten-plus minutes a from-scratch cloud-init run usually takes.
- Traffic control — removing a dying instance from rotation before it gets replaced, and adding the new one back in after.
Vultr gives you the primitives for all four: the Instances API, snapshots, tags, and Load Balancers. The gap is the glue code, which is what we're writing.
Step 1: Define health precisely
Don't reuse your load balancer's health check for this. LB checks are usually "does port 443 respond," which misses an app that's up but stuck (e.g., a worker whose event loop is blocked, or a Postgres replica that's fallen behind). Expose a dedicated endpoint that checks the things that actually matter for that instance's role:
# app/healthz.py
from flask import Flask, jsonify
import psutil, time
app = Flask(__name__)
START = time.time()
@app.route("/healthz")
def healthz():
checks = {
"uptime_ok": time.time() - START > 10,
"cpu_ok": psutil.cpu_percent(interval=0.2) < 95,
"disk_ok": psutil.disk_usage("/").percent < 90,
"queue_lag_ok": get_queue_lag_seconds() < 30, # app-specific
}
healthy = all(checks.values())
return jsonify(checks), 200 if healthy else 503
The reconciler treats a non-200 (or a timeout) as a strike. This separation matters: your LB decides routing on a fast, shallow check; the reconciler decides replacement on a slower, deeper one.
Step 2: Tag the pool
Tag every instance in the pool so the reconciler can discover membership without a separate inventory file:
curl -s -X POST "https://api.vultr.com/v2/instances" \
-H "Authorization: Bearer $VULTR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"region": "ewr", "plan": "vc2-2c-4gb",
"snapshot_id": "'"$WEB_SNAPSHOT_ID"'",
"label": "web-pool-1",
"tag": "pool:web"
}'
Note the snapshot_id instead of os_id. Booting from a pre-baked snapshot — your app, dependencies, and systemd units already installed — is the single biggest lever for fast replacement. A snapshot boot is typically running and passing health checks in 30-60 seconds; a fresh OS install plus cloud-init provisioning is 5-15 minutes. Bake a new snapshot as part of your deploy pipeline, not as an afterthought during an incident.
Step 3: The reconciler loop
import requests, time, os
VULTR_API = "https://api.vultr.com/v2"
HEADERS = {"Authorization": f"Bearer {os.environ['VULTR_API_KEY']}"}
STRIKE_THRESHOLD = 3 # consecutive failures before replacement
COOLDOWN_SECONDS = 300 # min gap between replacements per pool
MAX_CONCURRENT_REPLACE = 1 # never replace more than N at once
strikes = {}
last_replacement = 0
def list_pool(tag="pool:web"):
r = requests.get(f"{VULTR_API}/instances", headers=HEADERS,
params={"tag": tag}).json()
return r["instances"]
def is_healthy(instance):
try:
resp = requests.get(f"http://{instance['main_ip']}:8080/healthz", timeout=3)
return resp.status_code == 200
except requests.RequestException:
return False
def replace_instance(instance):
lb_deregister(instance["id"]) # step 4
snap = os.environ["WEB_SNAPSHOT_ID"]
requests.post(f"{VULTR_API}/instances", headers=HEADERS, json={
"region": instance["region"], "plan": instance["plan"],
"snapshot_id": snap, "label": instance["label"], "tag": "pool:web"
})
requests.delete(f"{VULTR_API}/instances/{instance['id']}", headers=HEADERS)
def reconcile():
global last_replacement
if time.time() - last_replacement < COOLDOWN_SECONDS:
return
for inst in list_pool():
iid = inst["id"]
if is_healthy(inst):
strikes[iid] = 0
continue
strikes[iid] = strikes.get(iid, 0) + 1
if strikes[iid] >= STRIKE_THRESHOLD:
replace_instance(inst)
last_replacement = time.time()
strikes[iid] = 0
break # respects MAX_CONCURRENT_REPLACE=1
while True:
reconcile()
time.sleep(20)
Run this as a systemd unit on a small always-on control instance — not inside the pool it's watching, or a bad reconciler bug can strand the whole fleet with no one left to fix it.
Step 4: Deregister before you destroy
The order matters. If you delete the instance before pulling it from the load balancer, in-flight requests get connection resets instead of a clean 503-and-retry. Deregister first, drain briefly, then delete:
def lb_deregister(instance_id):
lb_id = os.environ["VULTR_LB_ID"]
lb = requests.get(f"{VULTR_API}/load-balancers/{lb_id}", headers=HEADERS).json()
forwarding_rules = lb["load_balancer"]["forwarding_rules"]
# Vultr LBs target instances by attachment, not per-instance toggling —
# detach via the instance's load-balancer association endpoint
requests.delete(
f"{VULTR_API}/load-balancers/{lb_id}/instances/{instance_id}",
headers=HEADERS)
time.sleep(5) # let in-flight connections finish draining
Step 5: Guard against flapping and thundering herds
Three failure modes will bite you if you skip them:
-
Flapping: a transient blip (a slow GC pause, a deploy restart) trips the strike counter and you replace a perfectly good instance. The
STRIKE_THRESHOLDof 3 consecutive failures over 20-second polls (~60 seconds sustained) filters this out — tune it against your app's actual worst-case pause time. -
Cascading replacement: if a shared dependency (your database) goes down, every instance fails health checks simultaneously, and a naive reconciler tries to replace the entire pool at once — burning your Vultr API rate limit and your bill.
MAX_CONCURRENT_REPLACEand the globalCOOLDOWN_SECONDScap the blast radius. -
Split-brain reconcilers: if you ever run more than one reconciler instance for redundancy, use a lease (a row in Postgres with
FOR UPDATE SKIP LOCKED, or a Vultr object storage object with a conditional PUT) so only one reconciler acts at a time.
Step 6: Prove it works before you need it
Don't wait for a real outage to find out your reconciler has a bug. Inject failure on purpose:
# SSH into a pool instance and simulate a wedge
ssh web-pool-1 'sudo iptables -A INPUT -p tcp --dport 8080 -j DROP'
Watch the reconciler logs: strikes should accumulate, a replacement instance should appear in the Vultr dashboard within a couple minutes, and the load balancer's target list should update without a gap. Then revert the iptables rule and confirm the old instance — now healthy again but no longer tagged into rotation — gets cleaned up rather than lingering as a zombie you're still paying for.
When to stop doing this yourself
This pattern comfortably handles pools of 2-15 instances with one workload type. Past that, or once you need bin-packing across heterogeneous workloads, rolling deploys with canary percentages, or multi-region failover orchestration, you've outgrown a hand-rolled reconciler and it's time for k3s or full Kubernetes. But for the common case — a solo developer or small team running a handful of Vultr boxes who just wants "if it breaks, it fixes itself" — this gets you there for the cost of one small control-plane instance and an afternoon of writing Python, instead of a cluster you now have to operate.
Top comments (0)