skip to content

A script piped into grafana/k6 with `run -` fails on a relative import - why, and what fixes it?

level: middleimportance: must knowfreq 54%

answer

  1. only one file crossed the boundary
  2. a stdin script has no folder
  3. the base becomes the working directory
  4. mount the tree, pass a real path

basics

~20 s

Piping delivers one file and nothing else. k6 records a stdin script as /-, so relative imports resolve against the container's working directory, not the script's folder. Mount the directory and pass an in-container path instead.

solid answer

~40 s

`run -` moves exactly one file across the container boundary, as bytes on standard input. Everything the script imports relatively is a *second* file, and it never arrived. Worse, k6 gives a stdin script the internal path `/-`, which has no directory of its own, so the base for resolving `./modules/module.js` falls back to the process working directory - `/home/k6` in this image - and k6 reports the specifier could not be found on local disk. The fix is to stop piping: mount the tree with `-v /host/src:/src` and run `grafana/k6 run /src/index.js`, so the script's own in-container directory becomes the resolution base and its neighbours are really there. Built-in modules and `https://` imports are unaffected either way.

code

bash · 7 lines
bash
# Fails: only index.js crossed the boundary, and its base is /home/k6
docker run --rm -i grafana/k6 run - < src/index.js

# Works: the whole tree is inside, and the base is /src
docker run --rm \
  -v "$PWD/src:/src:ro" \
  grafana/k6 run /src/index.js

go deeper

for a junior

Take away the simple rule: run - delivers one file. If the script imports a neighbouring file, mount the directory and pass the script's in-container path instead of piping it.

for a middle

Be able to say where the base comes from. A file-loaded script resolves against its own directory; a stdin script is recorded as /- and resolves against the process working directory, which is /home/k6 in this image.

for a senior

Diagnose it from the error alone: a not-found module specifier under a piped run means the resolution base is the working directory, not the script's folder. Then show the mount plus in-container path that fixes it for good.

for a principal

Decide the team's default. Piping is fine for one-file smoke checks, but a shared suite with common modules should standardise on a mounted subtree and a fixed mount point, so the same command runs unchanged on every runner.

## What `run -` actually delivers `docker run --rm -i grafana/k6 run - < index.js` moves a single stream of bytes into the container through standard input. There is no volume, no copy, and no directory - just the contents of one file. That is the entire reason the form is convenient, and it is also its only real limitation. A test script that imports nothing local runs perfectly this way. A script that begins with `import { hello } from './modules/module.js';` does not, because `./modules/module.js` is a second file that was never delivered. ## Where a relative specifier is resolved from k6 resolves a relative import against the base of the module doing the importing, and the two ways of loading a script produce two different bases: - **Loaded from a path** - `run /src/index.js` - the loader returns the script with its real URL and no explicit working directory, so k6 derives the base from the script's own location: `/src/`. A sibling import lands on `/src/modules/module.js`. - **Loaded from stdin** - `run -` - the loader caches the bytes under the internal path `/-` and attaches the *process* working directory as the base instead. In this image that is `WORKDIR /home/k6` unless you override it, so the same import lands on `/home/k6/modules/module.js`. Nothing was ever mounted at `/home/k6/modules`, so the load fails and k6 says the module specifier could not be found on local disk. The message is accurate and still misleading: the file exists on your machine, and from inside the container it genuinely does not. ## The fix is a mount and a real path Put the whole directory into the container and hand `run` an in-container path: 1. Mount the tree the script lives in, for example `-v "$PWD/tests:/tests:ro"`. 2. Pass the entry script by its in-container location: `run /tests/index.js`. 3. Leave the imports exactly as they are - they now resolve against `/tests/`, which is populated. Read-only is usually right for the script tree: k6 only needs to read it, and the image runs as UID 12345, so the mounted files must be readable by that user. ## What survives piping, and what does not | the script does this | under `run -` | under a mounted path | |---|---|---| | imports a module compiled into k6, such as `k6/http` | works | works | | imports over `https://` | works | works | | imports `./helpers.js` beside itself | fails | works | | imports `../shared/auth.js` from a parent folder | fails | works if that folder is inside the mount | | opens a data file at run time | fails | works | | is a single self-contained file | works | works | Built-in modules are compiled into the binary and remote modules are fetched during init, which is why both are indifferent to how the script arrived. Only files that must exist on the container's filesystem care. ## In a CI job This is the failure that turns a green local run red the first time a suite is containerised. The script that was developed as `k6 run index.js` on a laptop resolved its imports against its own folder; the CI step that pipes it in resolves them against `/home/k6`. Two habits keep it from recurring: - Mount the repository subtree rather than piping, as soon as the suite is more than one file, and make the mount point stable (`/tests`) so the command is identical on every runner. - Keep the entry script's imports relative and inside the mounted subtree, so the same command works whether the mount comes from a checkout or from a scratch directory. ## Symptoms that look like something else - **"It worked before we containerised it."** The script did not change; the resolution base did. - **A mount that exists but resolves nothing.** If you mount at `/tests` and still pipe with `run -`, the base is still `/home/k6`; the mount is present but nothing points at it. - **An import that resolves only because of `-w`.** Overriding the working directory can make a piped script's imports work by accident, which hides the real dependency on process cwd. - **Blaming the module system.** k6 resolves the specifier exactly as asked; the file is missing.

  • Why do `https://` imports still work under `run -`?
    Remote specifiers are fetched over the network during init rather than read from the container's filesystem, so how the entry script arrived is irrelevant. The same holds for modules compiled into the k6 binary. Only specifiers that must resolve to a real file on the container's disk are affected.
  • Can `-w` make a piped script's relative imports resolve?
    Yes, and that is a trap. A stdin script's base is the process working directory, so setting the container's working directory to a mounted path makes some imports resolve. It works by coincidence and breaks whenever the layout shifts; passing the script by its in-container path is the honest fix.
  • Should the script mount be read-only?
    Usually yes. k6 only reads the script tree, and the image runs as UID 12345, so a read-only mount is enough as long as those files are readable by that user. Keep any directory k6 has to write into as a separate, writable mount.

Piping a script in with run - is like faxing someone a single page. A relative import is a note saying 'see the sheet next to this one in the folder' - and the folder never came.

saying these in an interview costs you the question

  • Thinks piping a script also brings its imported files
  • Blames k6's module resolution rather than the missing file
  • Believes a mount helps while the script still comes from stdin
  • Assumes relative imports resolve against the script for stdin too
  • Says built-in modules break in containers as well