Isolating Per-Tenant Geospatial Processing Jobs in MicroVMs
You are building something with a map in it. Logistics routing, agtech field boundaries, insurance wildfire and flood risk, real estate parcel analytics, a drone survey platform. Customers upload spatial data — shapefiles, GeoJSON, GeoTIFF rasters, GeoPackages, KML from somebody's Google Earth session, LAS/LAZ point clouds straight off a lidar rig — and your product buffers it, reprojects it, clips it to an area of interest, builds a tile pyramid, or renders a PNG. The upload endpoint says "drag your data here." The worker behind it says, quietly, "I will now decide which of a hundred-plus C parsers to invoke based on bytes a stranger chose."
I'm Ajay; I built PandaStack. This post is about treating per-tenant geoprocessing as what it is — untrusted-input processing that happens to have a coordinate reference system attached — and giving each job its own disposable Firecracker microVM. We'll go through GDAL as a dependency surface, the virtual-filesystem prefixes that turn a path string inside a dataset into an SSRF primitive, why raster work is memory-shaped in a way that makes pooled workers a liability, the determinism problem that PROJ datum grids create for your actual answers, and the hybrid design for interactive tiling where you don't want to pay setup per request.
The upload doesn't feed the parser — it picks the parser
Every other endpoint in your product takes a typed body with a schema and a size cap. The spatial upload takes a file, and "spatial file" is not a format — it's a category with dozens of members, several of which are containers for other formats. Your code calls something like `gdal.Open()` or `ogr.Open()`, GDAL walks its driver list, each driver sniffs the bytes, and the first one that claims the file wins. That dispatch is a feature: it's why one code path can read a 1990s shapefile, a modern Cloud-Optimized GeoTIFF, and a NetCDF climate cube. It is also a dispatch table an attacker gets to index into.
And GDAL is enormous. It is the universal adapter of the geospatial world, which means it carries the transitive weight of the whole ecosystem: PROJ for coordinate transforms, GEOS for geometry predicates, libtiff and libgeotiff for rasters, libpng/libjpeg/libwebp for compressed tiles and embedded imagery, expat for the XML in KML and GML, SQLite for GeoPackage, HDF5 and NetCDF for scientific grids, zlib and its friends underneath all of it. That's not a criticism of GDAL — it's a monumentally good piece of software and the reason your product exists at all. It is an observation about where the bytes end up.
- One entry point, a hundred-plus back ends — you audited your Python or Node wrapper. The code that actually touches the customer's bytes is a C or C++ driver you have never opened, selected at runtime by those same bytes.
- Containers inside containers — a GeoPackage is a SQLite database. An .shz or zipped shapefile is an archive. KMZ is a ZIP. A GeoTIFF can embed a JPEG-compressed tile stream, so a "raster" job is also an image-decoder job.
- Memory-unsafe by construction — decompressors, XML parsers and image decoders are the three classic homes of heap overflows, and a single upload can exercise all three before your code sees a geometry.
- Transitively invisible patching — a CVE in an image codec four levels down arrives in your fleet through a base image rebuild, which is a schedule, not a control.
- It doesn't need to be exploited to hurt you — a driver that segfaults halfway through a tile pyramid, while holding a PostGIS transaction open, ruins the same afternoon that an exploit would.
A shapefile is a bundle that can disagree with itself
The shapefile is the format that will not die, and it's structurally interesting for exactly the reason it's fragile. It isn't one file — it's a set: `.shp` holds geometry records, `.shx` is the index into those records, `.dbf` is a dBASE table of attributes, `.prj` states the coordinate system in WKT, `.cpg` claims an attribute encoding. Each part declares its own lengths and counts, independently. The `.shx` can say there are 40,000 records while the `.shp` contains 12. Record content lengths are expressed in 16-bit words and can point past the end of the file. The `.dbf` field descriptors declare widths that need not match the data that follows.
Independent declarations plus permissive readers is the oldest malformed-input pattern there is, and the readers are permissive on purpose, because half the shapefiles in the world came out of software that was already legacy when GPS was a novelty. The `.prj` deserves a special mention too: it is attacker-supplied WKT that goes into a CRS parser, and the `.cpg` is an attacker-supplied encoding label that decides how a million attribute strings get decoded — the same silent-corruption family I went through for spreadsheets in /blog/microvm-per-tenant-csv-import-pipeline-isolation.
A GeoPackage is a database you were handed by a stranger
GeoPackage is the modern, sensible replacement for the shapefile, and it is a SQLite database file. When you open one, you are handing an untrusted, fully attacker-authored database file to a SQL engine and asking it to please read the tables. SQLite is unusually well-tested — it is one of the most heavily fuzzed codebases in existence — but the shape of the trust relationship is still worth saying out loud: the file defines its own schema, its own page structure, its own indexes, and its own triggers, and your process opens it with the same call it would use for a file it wrote itself.
The concrete controls are small and worth setting anyway: don't let SQLite load extensions inside the geoprocessing environment, don't run the file through anything that will execute triggers you didn't write, and treat "open the GeoPackage" as the same category of act as "execute the uploaded script." Because in a fairly literal sense, it is.
/vsicurl and friends: a great feature with a sharp edge
This one deserves care, because it's easy to write it up as a vulnerability and it isn't one — it's one of GDAL's best ideas. GDAL exposes virtual filesystem prefixes: `/vsicurl/` reads a remote file over HTTP with range requests, `/vsis3/` and `/vsigs/` do the same against object storage, `/vsizip/` reads inside an archive without extracting it, `/vsigzip/`, `/vsitar/`, `/vsimem/` for in-memory. They compose, too — `/vsizip//vsicurl/https://host/data.zip/inner.shp` is a legal path. This is precisely what makes Cloud-Optimized GeoTIFF work: stream 2 MB of overview out of a 40 GB raster sitting in a bucket, no download.
The sharp edge is that a path is just a string, and spatial formats are full of paths that reference other data. A VRT (GDAL's XML virtual-raster format) names its source datasets by path. A tile index, a `.gpkg` external link, a GML schema location, an OGR datasource string in a job spec your customer filled in — all of these are places where a path can arrive from outside. If any component of a path GDAL is about to open is attacker-controlled, the attacker chooses what your worker fetches. That is SSRF wearing a `.vrt` extension, and inside a cloud VM the interesting URLs are the link-local metadata endpoints. `/vsizip/` has the parallel problem: a nested archive is a decompression bomb whose expansion your upload size cap said nothing about.
Egress-off by default is the single highest-value control in this whole design, and it composes with everything else: no unexpected fetches, no exfiltration path for whatever the job did manage to read, no surprise dependency on a remote grid server mid-job. If a tenant's job genuinely needs to read a bucket, the orchestrator fetches those bytes and writes them into the guest, so the allowlist lives in trusted code rather than in a string inside a customer's file. I went through the mechanics of that boundary in /blog/controlling-network-egress-untrusted-code.
#!/usr/bin/env bash
# fences.sh -- runs INSIDE the geoprocessing guest, before any driver sees a byte.
# Assumption baked into every line: the uploaded file chose which parser runs.
set -euo pipefail
# 1) Virtual filesystems. Egress is already off at the netns level; these stop
# GDAL from even trying, and make the failure a clean error instead of a
# hanging connect. A path string INSIDE a .vrt is not YOUR path string.
export CPL_VSIL_CURL_ALLOWED_EXTENSIONS="" # nothing is fetchable over HTTP
export GDAL_HTTP_MAX_RETRY=0
export GDAL_HTTP_TIMEOUT=5
export PROJ_NETWORK=OFF # no on-demand datum-grid downloads
export OGR_SQLITE_LOAD_EXTENSIONS=NO # a GeoPackage IS a SQLite database
# 2) Only the drivers this job needs. GDAL_SKIP removes drivers globally; the
# surgical version is allowed_drivers=[...] on gdal.OpenEx() per open call.
# Every driver you skip is a parser that cannot be reached by a rename.
export GDAL_SKIP="HDF4,HDF5,netCDF,PDF,WCS,WMS,WMTS,OGCAPI,MRSID"
# 3) Bound memory before the guest's OOM killer has to have an opinion.
export GDAL_CACHEMAX=512 # MB of raster block cache (default scales w/ RAM)
export GDAL_NUM_THREADS=2 # match the BAKED vCPU count, never ALL_CPUS
export CPL_TMPDIR=/work/tmp # temp files land on the disk we throw away
export OGR_GEOMETRY_ACCEPT_UNCLOSED_RING=NO
ulimit -v 6291456 # 6 GiB address space: the driver dies, the host does not
ulimit -f 20971520 # 20 GiB written -- an overview pyramid is allowed to be big
ulimit -t 900 # 900s CPU: a GEOS buffer on a pathological polygon gets reaped
ulimit -c 0 # no core dumps of data we are about to destroy
# 4) Ask the file what it CLAIMS to be before decoding it. gdalinfo reads
# headers only, so this is cheap -- and the declared dimensions are what
# tell you the 20 MB upload wants 60 GB of RAM.
mkdir -p /work/report /work/tmp /work/out
gdalinfo -json /work/in/upload.tif > /work/report/gdalinfo.json
python3 - <<'PY'
import json, sys
BYTES = {"Byte": 1, "UInt16": 2, "Int16": 2, "UInt32": 4, "Int32": 4,
"Float32": 4, "Float64": 8}
info = json.load(open("/work/report/gdalinfo.json"))
w, h = info["size"]
bands = info["bands"]
footprint = sum(w * h * BYTES.get(b.get("type", "Byte"), 8) for b in bands)
# The upload endpoint bounded the COMPRESSED bytes. This bounds the work.
if footprint > 8 * 1024**3:
sys.exit("refusing: %d x %d x %d bands = %.1f GiB uncompressed"
% (w, h, len(bands), footprint / 1024**3))
print("ok: %.2f GiB uncompressed footprint" % (footprint / 1024**3))
PY
Raster work is memory-shaped, and memory is what pools share
Text pipelines fail on CPU. Geospatial pipelines fail on RAM, and they do it suddenly. The compression ratios in this domain are extreme by design: elevation models, land cover classifications and NDVI rasters are enormously self-similar, so a 20 MB deflate-compressed GeoTIFF can be a perfectly ordinary 40,000 by 40,000 pixel product that occupies tens of gigabytes decompressed. Nobody uploaded that maliciously. That's just what a county-scale raster looks like after LZW gets through with it.
- Compression ratio is the whole problem — your upload cap bounds the wrapper. The work is set by width times height times bands times bytes-per-sample, and that number is in the header, not in the file size.
- Pyramids materialise — building overviews or a tile pyramid walks the full resolution and writes every level. A warp with a resampling kernel wants source and destination windows resident simultaneously.
- Striped rasters defeat windowing — a Cloud-Optimized GeoTIFF is tiled and can be read a window at a time. A striped TIFF with full-width strips forces the driver to pull a whole stripe, and a stripe can be enormous.
- Point clouds allocate on a declared number — a LAS/LAZ header states the point count. A hundred million points is a real drone survey, not an attack, and a reader that allocates the array up front from that header allocates the world. Stream in chunks, and cross-check the declared count against the file size.
- Geometry engines do unbudgeted work — a self-intersecting polygon with a few hundred thousand vertices, a buffer with a large distance and fine quadrant segmentation, a union across a layer with pathological topology. GEOS will try, and it does not consult your SLA first.
- Reprojection multiplies everything — warping a large raster between projections is a resample of the entire grid, and the destination extent can be much larger than the source if the projection stretches at that latitude.
Now put that on a pooled worker fleet. Tenant A's drone survey drives RSS up until the kernel's OOM killer wakes and reaps the process with the fattest score — which may be tenant A's job, or may just as easily be tenant B's tidy little parcel clip, dying two-thirds of the way through with rows already written to PostGIS. The OOM killer has no opinion about whose job was reasonable. There is no code review that prevents this; it's a property of sharing an address space with people who upload lidar.
A pooled geoprocessing worker is a shared memory budget that your customers get to spend on each other's behalf.
One microVM per job puts a real fence there. The guest's vCPU and RAM are fixed by the template snapshot, so a pathological raster exhausts its own machine and the guest's own OOM killer reaps the guest's own GDAL process. Add a hard TTL so a wedged `ST_Buffer` is reclaimed rather than lingering until Monday, and add the `ulimit` fences above so the failure is predictable and legible inside the guest instead of an abrupt kernel-level execution recorded in someone else's dmesg.
Determinism: your PROJ version is part of the answer
This is the section I'd most like you to take away, because it isn't a security argument and it bites teams who never have a security incident at all. In geospatial work, the software version is load-bearing on the *answer*.
Transforming coordinates between datums — NAD27 to NAD83, NAD83 to WGS84 realizations, OSGB36 to ETRS89, any of the national grids — is not a formula. It's a formula *plus a grid file* of empirically measured local shifts (NTv2, NADCON, and friends). PROJ ships a small core and expects the larger grid sets to be present on disk or fetched from a CDN. Here's the part that gets people: if the grid you need is missing, PROJ generally does not fail. It falls back to a coarser operation — a ballpark transformation, a Helmert with published parameters, sometimes effectively a null shift — and returns coordinates. Coordinates that are wrong by metres. No exception, no warning in your logs, no red anything. Your parcel boundary just moved across a fence line, and you'll find out when a customer's surveyor does.
Change the PROJ version and results move too, for good reasons: the EPSG registry gets updated, operations get added, preferred transformation paths change between releases. Same input, same code, different container base image, different answer. On a pooled fleet the environment is whatever the last base-image rebuild shipped, which means "why did the same job produce different geometry in March and in July" is a question with no recoverable answer.
So pin all three: the PROJ version, the GDAL version, and the grid data itself. Then record them in the job report, next to the output, so an answer can always be traced back to the machine that produced it. This is where snapshot-restore stops being purely a latency story: a PandaStack template is baked once (that first cold boot is around 3 seconds) and every create afterwards restores that same snapshot — roughly 49ms for the restore step, 179ms p50 end to end, about 203ms p99. Every job therefore starts from a byte-identical guest: same PROJ, same GDAL, same GEOS, same grid files. Reproducibility becomes structural rather than aspirational, which is the same argument I made for build environments in /blog/reproducible-build-sandboxes.
# Runs INSIDE the guest. The point of this module is that a coordinate is not
# a number, it is a number PLUS the software and grid data that produced it.
# So we refuse to transform when the right grid is absent, and we write the
# provenance next to the output every single time.
import hashlib
import json
import pathlib
import pyproj
from pyproj.transformer import TransformerGroup
from osgeo import gdal
GRID_DIR = "/opt/proj-data" # baked into the template, never downloaded
pyproj.datadir.set_data_dir(GRID_DIR)
class MissingGrid(Exception):
pass
def _grid_digest() -> str:
"""Hash the grid DATA, not just the library version.
Two hosts running identical PROJ builds can still disagree if one of them
is missing an NTv2 file. This digest is what makes 'same environment'
a checkable claim rather than a hopeful one.
"""
h = hashlib.sha256()
for p in sorted(pathlib.Path(GRID_DIR).rglob("*")):
if p.is_file():
h.update(p.name.encode())
h.update(p.read_bytes())
return h.hexdigest()
def build_transformer(src_epsg: int, dst_epsg: int):
src = pyproj.CRS.from_epsg(src_epsg)
dst = pyproj.CRS.from_epsg(dst_epsg)
# THE check almost nobody runs. TransformerGroup enumerates every candidate
# operation PROJ knows about and tells you which ones need grids you do not
# have. Without this, PROJ silently degrades to a coarser transform and
# your coordinates move by metres with no error anywhere.
group = TransformerGroup(src, dst)
if not group.best_available:
missing = "; ".join(op.name for op in group.unavailable_operations)
raise MissingGrid(
f"EPSG:{src_epsg} -> EPSG:{dst_epsg}: best operation needs grids "
f"this template does not ship ({missing}). Refusing to guess."
)
tr = group.transformers[0]
provenance = {
"src_epsg": src_epsg,
"dst_epsg": dst_epsg,
"operation": tr.description,
"definition": tr.definition, # the exact pipeline that ran
"accuracy_m": tr.accuracy,
"proj_version": pyproj.proj_version_str,
"pyproj_version": pyproj.__version__,
"gdal_version": gdal.__version__,
"grid_digest": _grid_digest(),
"network_enabled": pyproj.network.is_network_enabled(), # must be False
}
return tr, provenance
def reproject(points, src_epsg: int, dst_epsg: int, report_path: str):
tr, provenance = build_transformer(src_epsg, dst_epsg)
# The provenance file is written BEFORE the numbers are handed on, so an
# output can never exist without a record of what produced it.
pathlib.Path(report_path).write_text(json.dumps(provenance, indent=2))
return [tr.transform(x, y) for (x, y) in points]
The design: one guest per job, staged output, orchestrator commits
The shape is the same one that works for every hostile-input pipeline, and geospatial just supplies unusually good reasons for each rule.
- One microVM per job, created on demand. The unit of isolation matches the unit of work, which is what makes attribution, resource accounting and cleanup all fall out for free.
- No egress by default. This kills the `/vsicurl` SSRF class outright and removes the metadata service from the map. If the job needs remote data, the trusted orchestrator fetches it and writes it in.
- No database credential in the guest. The guest holds one tenant's file and produces artifacts. It structurally cannot write half a parcel layer into PostGIS, because it cannot write to PostGIS at all.
- Fixed vCPU and RAM from the baked template. A blowup lands on the tenant who caused it. Nobody else's job is a candidate for the OOM killer's attention.
- Hard TTL on every job. A wedged geometry operation is reclaimed automatically rather than discovered during a capacity incident three days later.
- Staged output plus a machine-readable report. Geometries written to a GeoPackage in `/work/out`, and a report carrying feature counts, invalid-geometry counts, the CRS in and out, the transform provenance from above, and a digest of the artifact.
- The orchestrator validates the whole report and commits once, transactionally, keyed on an idempotency key derived from the tenant, a hash of the input bytes, and the job-spec version. Retries collapse instead of duplicating a layer.
from pandastack import Sandbox
import json
class GeoJobFailed(Exception):
pass
def run_geo_job(tenant_id: str, job_id: str, upload: bytes, spec: dict) -> dict:
"""Run ONE tenant's geoprocessing job in a machine that exists only for it.
What is deliberately NOT in this guest: the PostGIS DSN, the object-store
key, another tenant's parcels, and a route to the internet. What IS in it:
a pinned GDAL/PROJ/GEOS stack and one hostile file.
"""
sbx = Sandbox.create(
template="code-interpreter",
ttl_seconds=1800, # a runaway GEOS buffer cannot run all night
metadata={
"tenant": tenant_id,
"job": job_id,
"kind": "geoprocessing",
},
)
try:
# The uploaded bundle goes in. It may be a zipped shapefile, a GeoTIFF,
# a GeoPackage, or a LAZ point cloud -- we do not pretend to know yet,
# and we do not let a filename convince us either.
sbx.filesystem.write("/work/in/upload.bin", upload)
sbx.filesystem.write("/work/spec.json", json.dumps(spec, sort_keys=True))
# fences.sh sets the GDAL config + ulimits and does the header-only
# size check BEFORE any driver decodes anything.
out = sbx.exec(
"cd /work && bash fences.sh && python3 -m geo.run "
"--spec spec.json --input in/upload.bin "
"--out out/result.gpkg --report report/job.json",
timeout_seconds=1500,
)
# FAIL CLOSED. A segfaulting driver, an OOM, a decompression-bomb
# rejection, a missing datum grid and a timeout all land here, and all
# of them produce exactly zero committed features.
if out.exit_code != 0:
raise GeoJobFailed(job_id, out.exit_code, out.stderr[-4000:])
report = json.loads(sbx.filesystem.read("/work/report/job.json"))
artifact = sbx.filesystem.read("/work/out/result.gpkg")
return {"report": report, "artifact": artifact}
finally:
# The guest, the customer's upload, every extracted temp file, and the
# 30 GiB of scratch a warp left behind all cease to exist together.
sbx.kill()
The report is the interesting deliverable, not an afterthought. Feature counts in and out, how many geometries were invalid and what you did about them, the source and destination CRS, the exact PROJ operation and its stated accuracy, the library versions, the grid digest, and a hash of the artifact. That object is what lets you answer "why does this boundary look different from last quarter's run" without archaeology. The same purity discipline applies to per-tenant ETL generally — I went through the staging-and-commit pattern in /blog/microvm-per-tenant-etl-pipeline-isolation.
The hybrid: batch jobs are ephemeral, tiling is not
One guest per job is exactly right for a batch shape: upload, process, produce artifact, done. It is the wrong shape for the other half of a geospatial product, which is interactive. A user pans and zooms a map and your backend serves tiles. A drone platform shows a live preview while an orthomosaic is being clipped. Somebody drags a polygon and expects the area statistics to update while they're still holding the mouse.
At that point the cost you care about isn't create latency, it's *warm state*: the raster's overviews resident in the block cache, an index built, a dataset handle open. Recreating that per request means paying setup on every tile, and 179ms of create is irrelevant next to re-opening a 40 GB dataset and re-warming its cache from cold. So use a different lifecycle for the interactive path: a persistent per-tenant sandbox that stays around for the session, with hibernate/wake instead of create/destroy.
- Batch jobs get an ephemeral guest — created per job, TTL-bounded, killed on completion. Isolation is per job, which is what you want when the input is a stranger's file.
- Interactive sessions get a persistent per-tenant guest — one machine per tenant session, holding warm dataset handles and a hot block cache. Isolation is still per tenant, which is the boundary that actually matters for a map viewer.
- Hibernate on idle, wake on the next request — a session that goes quiet gets snapshotted and paused rather than destroyed, so you stop paying for an idle machine without throwing away the warm state. Waking is a restore, not a rebuild.
- Keep the trust boundaries different — the interactive guest reads data your orchestrator staged into it. It does not open arbitrary uploads. Fresh uploads always go through the ephemeral batch path first, where the fences live.
- Fork for what-if analysis — a scenario workflow ("what does the risk score look like if I move this boundary?") is a copy-on-write branch of a warm session rather than a fresh setup. Same-host fork on PandaStack is 400–750ms; cross-host is 1.2–3.5s.
- Cap concurrent sessions per tenant — persistent guests are a resource commitment. The ephemeral path is self-limiting because jobs end; the interactive path needs an explicit ceiling or a busy Monday becomes a capacity incident.
The general tradeoff between these two lifecycles — and when a long-lived sandbox is the right call rather than a smell — is something I wrote up separately in /blog/long-running-sandboxes-for-ai-agents; the reasoning transfers directly, because a map session and an agent session have the same warm-state economics.
Shared pool vs container per job vs microVM per job
- Memory blowups — Shared worker pool: a 20 MB GeoTIFF that decompresses to 60 GB drives the pool into OOM and the kernel reaps whichever process looks tastiest, including an unrelated tenant's small parcel clip mid-write. Container per job: cgroup memory limits contain it properly, which is a real improvement, though the limit applies to a process tree sharing the host kernel's page cache and reclaim behaviour. MicroVM per job: vCPU and RAM are fixed by the baked snapshot, the guest's own OOM killer reaps the guest's own GDAL, and a hard TTL reclaims anything wedged.
- Driver and parser exploitation — Shared worker pool: a heap overflow in an image codec or XML parser yields code execution in a process holding credentials that reach every tenant's data. Container per job: much better blast containment, but the kernel is shared, so a kernel-reachable bug in the parsing path is a host problem and a container escape is a fleet problem. MicroVM per job: a hardware-virtualized guest with its own kernel, so a successful exploit owns a throwaway machine containing exactly one tenant's shapefile.
- SSRF via /vsi paths — Shared worker pool: the worker typically has full network egress because it needs to talk to your database and object store, so a `/vsicurl/` path interpolated from an uploaded VRT can reach internal services and the instance metadata endpoint. Container per job: egress policy is possible but is usually inherited from the host network namespace and is easy to leave permissive. MicroVM per job: each guest gets its own network namespace and TAP device, so egress-off is the default posture and the whole SSRF class is unreachable rather than merely discouraged.
- Reprojection determinism — Shared worker pool: PROJ, GDAL and the datum grids are whatever the current base image shipped, so a rebuild can silently change coordinates by metres with no error. Container per job: the image digest pins the libraries, which handles most of it, provided nothing fetches grids at runtime and the image is genuinely pinned rather than tagged `latest`. MicroVM per job: every job restores the same baked snapshot generation, byte-identical down to the grid files, and the job report records the versions and a grid digest alongside the output.
- Credential blast radius — Shared worker pool: the worker needs write access to the spatial tables, which in practice means write access to every tenant's rows, sitting in the same process as the parser. Container per job: usually the same credential model, just in a smaller box. MicroVM per job: the guest holds no credential at all — it stages a GeoPackage and a report, and the trusted orchestrator does the writing.
- Attribution — Shared worker pool: logs, CPU seconds and peak RSS interleave across tenants, so "why was this job slow" means grepping by tenant ID and hoping. Container per job: per-container metrics get you most of the way. MicroVM per job: one job, one machine, one stdout stream, one honest resource profile — and when a customer asks why their orthomosaic took eleven minutes, you can show them instead of telling them.
- Cost — Shared worker pool: effectively free per job, which is why everyone starts here and why every geoprocessing service eventually acquires an incident report. Container per job: an image pull and a cold start, usually seconds, which pushes teams back toward pooling. MicroVM per job: on PandaStack a create is 179ms p50 and about 203ms p99 because every create restores a baked snapshot (roughly 49ms for the restore step) rather than cold-booting — the ~3s cold boot happens once, at bake time.
That last line is what makes the design practical rather than merely correct. "A VM per geoprocessing job" sounds extravagant right up until the create is a fifth of a second in front of a job that runs for seconds to minutes. If provisioning cost thirty seconds you would pool workers and accept shared fate, the way everyone does. At 179ms, the isolation costs less than the JSON encoding of the report it produces. Capacity holds up too: each PandaStack agent pre-allocates 16,384 /30 network subnets, so concurrency is bounded by host CPU and memory rather than by network slots — which matters on the morning a customer bulk-uploads a season's worth of survey flights.
Where this leaves you
Geospatial processing looks like a data problem and is filed as one. It is an untrusted-code-execution problem with unusually good excuses. The uploaded file selects which of a hundred-plus C and C++ drivers runs; those drivers sit on a transitive tree of decompressors, XML parsers, image codecs and a SQL engine; a path string embedded in a dataset can make the parser fetch a URL on your behalf; and the resource cost of the work is declared in a header rather than bounded by the bytes you accepted.
One disposable microVM per job answers the family together. Memory blowups exhaust a guest with a fixed budget and a hard TTL instead of taking a pool with them. Driver exploitation lands in a hardware-virtualized guest with its own kernel and one tenant's file in it. Egress-off removes the `/vsicurl` SSRF class rather than mitigating it. A byte-identical snapshot pins PROJ, GDAL and the datum grids so a reprojection is reproducible in July and in the following March, and the job report says which versions produced the answer. And because the guest stages artifacts rather than holding a database credential, a crash produces nothing at all — the orchestrator commits once, transactionally, keyed on idempotency, so a retry cannot duplicate a layer. For the interactive half of the product, keep a persistent per-tenant sandbox and hibernate it instead of paying setup on every tile.
The unglamorous version of all this: you are already running a hundred parsers you have never read, on bytes you did not choose, on a machine that can reach your database. The fix isn't to read the parsers. It's to make the room they run in empty, bounded, offline, and cheap to burn down.
For adjacent patterns: document and PDF parsers have the same C-dependency shape in /blog/microvm-ai-agent-pdf-document-processing, the mechanics behind the sub-second create are in /blog/snapshot-restore-boot-path, and keeping tenant datastores separate underneath all of this is in /blog/per-tenant-database-isolation.
Frequently asked questions
Is it safe to let GDAL open files uploaded by customers?
Not in a process you care about. GDAL is excellent software, but opening an uploaded file means GDAL walks its driver list, each driver sniffs the bytes, and the first one that claims the file wins — so the uploader is choosing which of a hundred-plus C and C++ parsers runs. Behind those drivers sit decompressors, an XML parser, image codecs, SQLite for GeoPackage, and HDF5/NetCDF for scientific grids, which is a lot of memory-unsafe surface reached from one call. The practical answer is to keep using GDAL, because nothing else reads the formats your customers actually have, and to run it somewhere disposable: a per-job guest with fixed RAM, a hard TTL, no network egress, no database credential, and only the drivers that job needs enabled via GDAL_SKIP or allowed_drivers on OpenEx. Then a driver bug costs you a throwaway machine holding one tenant's file.
What is the risk with GDAL's /vsicurl and /vsizip virtual filesystems?
They are genuinely useful features — /vsicurl reads a remote file with HTTP range requests, which is exactly how Cloud-Optimized GeoTIFF streams a small window out of a huge raster, and /vsizip reads inside an archive without extracting it. The risk appears when any part of a path GDAL is about to open comes from outside your code. Spatial formats reference other data by path: a VRT names its source datasets, a tile index names files, job specs carry datasource strings. If an attacker controls that string, they choose what your worker fetches, which is server-side request forgery — and inside a cloud VM the attractive targets are internal services and the link-local metadata endpoint. Nested /vsizip paths add decompression bombs on top. Configure GDAL to disable or allowlist these prefixes, but make the durable control the network: a guest with no egress cannot fetch anything regardless of what path string got interpolated where.
Why do reprojected coordinates change between runs or between servers?
Because a datum transformation is a formula plus empirically measured grid files, and both the grids and the PROJ version are inputs to the answer. If the NTv2 or NADCON grid for the best available operation is missing, PROJ generally does not raise an error — it falls back to a coarser operation and returns coordinates that are wrong by metres, silently. Separately, PROJ releases update the EPSG registry and can change which transformation path is preferred, so the same input and the same code on a newer base image can produce different output. The defenses are to pin the PROJ and GDAL versions and the grid data together, disable PROJ network fetching so nothing is downloaded at runtime, check pyproj's TransformerGroup for best_available before transforming so a missing grid is a loud failure, and record the operation name, accuracy, library versions and a digest of the grid directory in the job report. A snapshot-restored guest makes that pinning structural, since every job starts from a byte-identical machine.
How do you stop one tenant's huge raster from taking down other tenants' jobs?
Stop sharing the memory. Geospatial workloads fail on RAM and they do it abruptly: a 20 MB compressed GeoTIFF can be a completely ordinary county-scale product that occupies tens of gigabytes decompressed, and a hundred-million-point LAZ file is a real drone survey rather than an attack. On a pooled worker fleet that means the kernel's OOM killer picks a victim by score, not by fairness, so an unrelated tenant's small clip can die mid-write. Give each job its own microVM with vCPU and RAM fixed by the template snapshot, add ulimits inside the guest so the failure is legible rather than abrupt, set GDAL_CACHEMAX explicitly rather than letting it scale with host RAM, and read the raster's declared dimensions from the header with gdalinfo before decoding anything so you can reject an eight-gigabyte-plus uncompressed footprint up front. Then a blowup lands entirely on the tenant who caused it.
Should interactive map tiling also use one microVM per request?
No — that is the one place the per-job pattern is the wrong shape. Batch geoprocessing is upload, process, produce artifact, done, which suits an ephemeral guest perfectly. Interactive tiling is different: what costs you is warm state, not create latency. Open dataset handles, resident overviews, a hot raster block cache and built indexes take real time to establish, and a 179ms create is irrelevant next to re-warming a 40 GB dataset from cold on every tile request. Use a persistent per-tenant sandbox for the session instead, and hibernate it on idle so you stop paying for an idle machine without discarding the warm state; the next request wakes it via a restore rather than a rebuild. Keep the trust boundaries distinct: the interactive guest reads data your orchestrator staged into it, while fresh customer uploads always go through the ephemeral batch path where the fences and the driver restrictions live.
49ms p50 cold start. Fork, snapshot, and scale to zero.