Skip to main content

The Runtime Data a LangGraph Graph Reads, Dropped by .dockerignore

· 5 min read
Vadim Nicolai
Senior Software Engineer

A file a graph reads at run time is a deployment contract, not a repository detail. The container is the only place that contract gets tested, and nobody runs the tests there.

LangGraph in production, a 10-part series. ← Part 8 · Part 10 →

Your tests pass because they run in the checkout, not in the image​

A loader that resolves tables/carriers.tsv relative to the process root succeeds in every local test and every CI job that runs from the repository tree. The image is built from the build context, after .dockerignore has filtered it — the only environment where the file can be absent. Run one graph against the checkout and it returns {'rows': 2}; run the identical graph against the computed build context and it raises. The only variable is which directory is the root.

Shadow mode makes this worse. A shadow failure arrives as a returned value rather than a crash, so a missing table can be swallowed every period and only surface at promotion.

The ignore rule is right about the build and wrong about the run​

The build asks whether any build step reads tables/; it does not. The process asks whether any node opens something under tables/; it does, on every run. The ignore file has room for the first answer only.

The build context documentation specifies that patterns are evaluated in order, that a later !pattern re-includes a path an earlier rule excluded, and that the last matching rule wins; a real build also shows that a pattern matching a directory covers everything beneath it. So tables/ followed by !tables/carton_sizes.tsv ships exactly one of the two files. The boot check reports missing at boot: ['tables/carriers.tsv'], and the graph raises FileNotFoundError: carriers.tsv.

The narrow exception is the trap. It makes the ignore file look maintained while the requirement was two files, not one, and nothing in the repository says so.

If you take the cheap verification route, know which way it errs. The example's matcher uses fnmatch, and Python's fnmatch documentation is explicit that the filename separator is not special to the module — * crosses /. Docker's does not. An fnmatch-based approximation rejects some contexts Docker would accept — false alarms, the safe direction — though ** patterns can err the other way, so it is still an approximation. Applying the ignore rules to the tree is cheap; building the image and testing for each declared file inside it is exact.

A declaration nobody's loader obeys is a comment​

Make "what must ship" a value the code can read:

from pathlib import Path

runtime_data = ("tables/carton_sizes.tsv", "tables/carriers.tsv")

def missing_runtime_data(root: Path) -> list[str]:
return [p for p in runtime_data if not (root / p).is_file()]

Call it at boot and refuse to start when it returns anything, naming the paths. A bad image then fails its deploy instead of failing on the first scheduled run hours later.

The tuple looks like the fix. It is not: a loader that opens a path by string literal works whether or not that path is declared, so the declaration drifts one file behind the code exactly as the ignore file's exception list does.

Patch file opening during one run of each graph and compare the paths it saw with the tuple. Then plant the defect on purpose: declare a new path, add no ignore exception, and require the context test to fail and name it. A test that has never failed is not known to do anything — and this one stands between a green pipeline and an image that raises on its first scheduled run.

The best fix removes the file from the argument entirely​

Stop treating repository layout as a runtime dependency. Put the tables next to the module and read them as package data with importlib.resources: files() to read resources directly, as_file() for APIs that need a real filesystem path. Package data travels with the code into any image that installs the package, so the ignore file stops mattering.

The cost is real: every data change becomes a code release, and a carton-size correction from the floor cannot be applied in place without rebuilding and redeploying.

The mount argument does not escape the problem. A mount never provisioned in one environment produces the same FileNotFoundError, at the same moment, as a file the ignore rules removed. The boot check is about whether the path resolves, not where the bytes came from, so it survives the mount argument intact.

What to ship​

  • Changes at code speed and belongs to the code: package data through importlib.resources. No ignore-file coupling, no CI check, no boot check needed.
  • Must stay in the repository but must ship: one declared tuple, loaders reading through it, a build-context assertion in CI — exact costs a full image build, cheap costs a matcher you now have to trust — and a boot check that refuses to start.
  • Owned by people who do not cut releases: a mount, plus the same boot check.
  • Small repository, small image: invert the ignore file. * first, re-include what ships. The friction lands at build time, which is the point.

The graph does not care whether the file was excluded by a rule, missed by a mount, or never existed. It cares that the path resolves when the node runs. missing_runtime_data is a couple of lines at the entrypoint, and it converts a silent substitution of empty data into a failed deploy.