Mounting Large Datasets Without Copying Them

A containerised reconstruction has been running for forty minutes and the host’s root filesystem is full. The survey is on a separate 20 TB volume with plenty of space; the container’s writable layer, which lives on the root filesystem, is not.

Container storage defaults are designed for application code, not for terabytes of imagery, and a photogrammetry job writes far more than it reads. This page covers getting survey data into a container without copying it, keeping intermediates off the wrong volume, and the storage characteristics that decide whether a job takes two hours or six. It extends containerising photogrammetry workers with Docker.

Four places data can live, and what each costs

Inside the image. Baked in at build time. Appropriate for reference data measured in megabytes — PROJ grids, a camera database — and never for a survey.

The container’s writable layer. Everything a container writes that is not on a mount. It lives on the Docker storage driver’s filesystem, is slow because of copy-on-write, and disappears when the container is removed.

A bind mount. A host directory mapped into the container. No copy, host filesystem performance, and the data outlives the container.

A volume. Docker-managed storage. Useful for state a container owns and awkward for data that other tools also need to reach.

For a photogrammetry worker the answer is almost always bind mounts for input, output and intermediates, with nothing of consequence in the writable layer.

Where a photogrammetry job's data should live Four storage locations compared for a four hundred gigabyte survey. Inside the image is impossible, as the image would have to be that large. The container writable layer is slow because of copy-on-write, sits on the host root filesystem and is discarded with the container. A read-only bind mount of the survey costs nothing and gives host filesystem performance. A read-write bind mount for output and intermediates keeps the large writes off the root filesystem and lets the results outlive the container. A note records that most full-disk failures come from intermediates landing in the writable layer. in the image baked at build time pulled to every node megabytes only PROJ grids, camera db writable layer copy-on-write, slow on the root filesystem fills the host disk and is discarded anyway read-only bind the survey itself no copy at all host performance and cannot be modified read-write bind output and intermediates on the big volume results outlive the run and the root disk stays free Most full-disk failures are intermediates landing in the writable layer. Which is a mount that was never declared, rather than a storage shortage.

Figure 1 — Four locations, and the one that causes the outage.

Minimal reproducible solution

import shutil
from pathlib import Path

import docker


def build_mounts(survey_dir: str, output_dir: str, scratch_dir: str) -> list:
    """Input read-only, output and scratch read-write, nothing in the image.

    Declaring an explicit scratch mount is the part that prevents the
    full-disk failure: a reconstruction writes tens of gigabytes of
    intermediates, and unless a mount claims that path they land in the
    container's writable layer on the host root filesystem.
    """
    for path in (survey_dir, output_dir, scratch_dir):
        Path(path).mkdir(parents=True, exist_ok=True)

    return [
        docker.types.Mount("/data/input", str(Path(survey_dir).resolve()),
                           type="bind", read_only=True),
        docker.types.Mount("/data/output", str(Path(output_dir).resolve()),
                           type="bind", read_only=False),
        docker.types.Mount("/data/scratch", str(Path(scratch_dir).resolve()),
                           type="bind", read_only=False),
    ]


def check_capacity(scratch_dir: str, image_count: int,
                   *, gb_per_thousand_images: float = 45.0) -> dict:
    """Refuse to start a job that will not fit on the scratch volume."""
    need = image_count / 1000.0 * gb_per_thousand_images
    free = shutil.disk_usage(scratch_dir).free / 1e9
    if need > free * 0.9:
        raise OSError(f"this job needs about {need:.0f} GB of scratch and "
                      f"{free:.0f} GB is free on {scratch_dir}")
    return {"needed_gb": round(need), "free_gb": round(free)}

Checking capacity before dispatch rather than discovering it forty minutes in is worth the four lines. A job that fails on a full disk has usually taken a worker out of the pool as well, because the host’s root filesystem filling affects everything on it.

Storage that is fast enough

Bind mounts give host filesystem performance, which raises the question of whether the host’s filesystem is fast enough. Photogrammetry’s access pattern is unusual: a long sequential read of the imagery, then a very large amount of random read and write on intermediates.

The consequence is that network storage behaves very differently for the two phases. Reading a survey over a 10 Gbit link is fine — it is sequential and the link is the limit. Running the reconstruction’s intermediates over the same link is not, because the latency on small random operations dominates and a job can take three times as long.

import time
from pathlib import Path


def measure_scratch(path: str, *, size_mb: int = 512) -> dict:
    """Crude but decisive throughput and latency test on a candidate scratch path.

    A sequential figure alone is misleading: network storage can be fast
    sequentially and unusable for the random small operations a reconstruction
    generates. The small-file test is the one that predicts the job.
    """
    target = Path(path) / ".scratch_probe"
    target.mkdir(parents=True, exist_ok=True)
    block = b"\0" * (1 << 20)

    start = time.perf_counter()
    with (target / "seq.bin").open("wb") as fh:
        for _ in range(size_mb):
            fh.write(block)
        fh.flush()
    seq = size_mb / (time.perf_counter() - start)

    start = time.perf_counter()
    for i in range(2000):
        (target / f"small_{i}").write_bytes(b"x" * 4096)
    small = 2000 / (time.perf_counter() - start)

    for f in target.iterdir():
        f.unlink()
    target.rmdir()

    return {"sequential_mb_s": round(seq), "small_files_per_s": round(small),
            "suitable_for_scratch": small > 800,
            "note": ("adequate for reconstruction intermediates" if small > 800 else
                     "small-file rate is too low — use local storage for scratch")}
Bind mounting compared with copying data into the container Two columns. The bind mount column notes that no data is duplicated regardless of dataset size, that the container starts immediately because nothing is transferred, that host and container see the same bytes so an interrupted run leaves partial output visible on the host, and that ownership and permissions must be reconciled. The copy-in column notes that the dataset is duplicated, that start time scales with dataset size and becomes prohibitive at survey scale, that the container is isolated from the host filesystem, and that output must be explicitly retrieved before the container is removed. bind mount no duplication, whatever the size starts immediately partial output is visible on the host ownership must be reconciled copy into the container the dataset is duplicated start time scales with size isolated from the host filesystem output must be retrieved before removal At survey scale the copy is not a trade-off, it is simply unaffordable.

Figure 3 — For a four-hundred-gigabyte survey only one column is available.

Edge-case matrix

Situation Symptom Handling
No scratch mount declared Host root fills mid-job Declare an explicit scratch mount
Input mounted read-write Intermediates pollute the survey Read-only on input
Network scratch Job three times slower Local scratch, network for input and output
SELinux enforcing Permission denied on a valid mount Add the :z or :Z label option
Mount path does not exist on host Docker creates it as root Create it first, with the right owner
Symlinks in the survey Targets outside the mount are invisible Mount the target directory too
Many small files on network storage Stat calls dominate Copy the input locally if it is small enough
Output volume fills Partial results, no error until later Check capacity before dispatch

The SELinux row is worth knowing because the error is indistinguishable from an ordinary permission problem. On a host with SELinux enforcing, a bind mount is inaccessible to the container until it is relabelled, and the :z suffix does that.

Verification snippet

import subprocess


def verify_mounts(container_id: str, expected: dict[str, bool]) -> dict:
    """Confirm each mount is present and has the expected writability.

    `expected` maps a container path to whether it should be writable. A
    read-only input mount that is actually writable is as much a fault as a
    missing one, because it lets a job modify the survey.
    """
    problems = []
    for path, should_write in expected.items():
        probe = subprocess.run(
            ["docker", "exec", container_id, "sh", "-c",
             f"test -d {path} && touch {path}/.wtest 2>/dev/null && "
             f"rm -f {path}/.wtest && echo rw || echo ro"],
            capture_output=True, text=True)
        state = probe.stdout.strip()
        if state not in {"rw", "ro"}:
            problems.append(f"{path} is not mounted")
        elif (state == "rw") != should_write:
            problems.append(f"{path} is {state} but should be "
                            f"{'rw' if should_write else 'ro'}")
    return {"checked": list(expected), "problems": problems, "ok": not problems}
Job duration by where the intermediates live Three bars showing total job duration for the same reconstruction with intermediates on different storage. Local NVMe completes in one hundred and ten minutes. Local spinning disk takes one hundred and eighty minutes. Network storage over ten gigabit takes three hundred and forty minutes, despite the same link reading the input imagery at full speed. A note states that the difference is latency on small random operations rather than bandwidth, which is why a sequential benchmark does not predict it. intermediates on local NVMe 110 min intermediates on local spinning disk 180 min intermediates on 10 Gbit network storage 340 min Latency on small operations, not bandwidth — the same link reads the imagery at full speed. Which is why a sequential benchmark of the storage predicts none of this.

Figure 2 — Three times the duration from one mount decision.

A layout that works on a fleet

Naming conventions sound trivial and decide how much of a pipeline has to know about storage. One that has held up well is three mounts with fixed container paths and variable host paths.

/data/input is always the survey, always read-only. /data/output is always where the deliverables go. /data/scratch is always node-local and always disposable. The application never sees a host path, so the same image runs on a workstation, a build agent and a cloud node with no change.

The scheduler supplies the host side, and the one rule it must enforce is that scratch is node-local. Everything else can be network storage; scratch cannot, and making that a property of the scheduler rather than of each job definition is what stops it being forgotten.

Cleaning scratch is the scheduler’s job too. A worker that leaves its intermediates behind will fill its own disk within a few jobs, and a cleanup that runs on completion misses the case that matters — the job that failed.

When to escalate

  • There is no local storage on the worker. Either provision some or accept the slowdown explicitly in the scheduling estimates. Pretending the network scratch is equivalent produces timing estimates that are wrong by a factor of three.
  • The survey lives somewhere the container cannot reach. Copy it, or move the worker. An object-store survey needs a fetch step with its own capacity check.
  • Intermediates must be retained for debugging. Mount them to a path with a retention policy rather than leaving them in the writable layer, where they vanish with the container.

Containerising Photogrammetry Workers with Docker