Dynamic sandboxes (beta)

In addition to the function interface, Modal has a direct interface for defining containers at runtime and running arbitrary code inside them.

This can be useful if, for example, you want to:

  • Run code generated by a language model with a list of dynamically generated requirements.
  • Check out a git repository and run a command against it, like a test suite, or npm lint.
  • Use Modal to orchestrate containers that don’t have or use Python.

Each individual job is called a Sandbox, and can be created using the spawn_sandbox function on a running app:

@app.local_entrypoint()
def main():
    sb = app.spawn_sandbox(
        "bash",
        "-c",
        "cd /repo && pytest .",
        image=modal.Image.debian_slim().pip_install("pandas"),
        mounts=[modal.Mount.from_local_dir("./my_repo", remote_path="/repo")],
        timeout=600, # 10 minutes
    )

    sb.wait()

    if sb.returncode != 0:
        print(f"Tests failed with code {sb.returncode}")
        print(sb.stderr.read())

It’s useful to note that the Sandbox object returned above has an interface similar to Python’s asyncio.subprocess.Process API, and can be used in a similar way.

Parameters

spawn_sandbox currently supports the following parameters:

  • workdir: Working directory for the sandbox. Defaults to /.
  • image: The container image to use for the sandbox.
  • mounts: List of read-only mounts to mount into the sandbox.
  • secrets: List of secrets to make available to the sandbox. Be careful with this option, as it can expose secrets to untrusted code.
  • network_file_systems: Dictionary mapping mount paths to Network File Systems to be attached.
  • gpu: Optional configuration for GPU accelerator to attach.
  • cpu: Minimum number of cores the sandbox should be allocated.
  • memory: Minimum amount of memory the sandbox should be allocated.
  • timeout: Timeout in seconds before the sandbox is killed. Defaults to 300 seconds (5 minutes).
  • block_network: Whether the sandbox’s network access should be blocked. Defaults to False.

Dynamically defined environments

Note that any valid Image or Mount can be used with a sandbox, even if those images or mounts have not previously been defined. This also means that images and mounts can be built from requirements at runtime. For example, you could use a language model to write some code and define your image, and then spawn a sandbox with it. Check out devlooper for a concrete example of this.

Returning or persisting data

Modal Volumes or NetworkFileSystems can be attached to sandboxes. If you want to give the caller access to files written by the sandbox, you could create an ephemeral NetworkFileSystem that will be garbage collected when the app finishes:

@app.local_entrypoint()
def main():
    with modal.NetworkFileSystem.ephemeral() as nfs:
        sb = app.spawn_sandbox(
            "bash",
            "-c",
            "echo foo > /cache/a.txt",
            network_file_systems={"/cache": nfs},
        )
        sb.wait()
        for data in nfs.read_file("/a.txt"):
            print(data)

Alternatively, if you want to persist files between sandbox invocations (useful if you’re building a stateful code interpreter, for example), you can use create a persisted Volume or NetworkFileSystem with a dynamically assigned label:

nfs = modal.NetworkFileSystem.from_name(f"vol-{session_id}", create_if_missing=True)
sb = app.spawn_sandbox(
    "bash",
    "-c",
    "echo foo > /cache/a.txt",
    network_file_systems={"/cache": nfs},
)

Isolation and security

Sandboxes can be used to run untrusted code, such as code from third parties or generated by a language model. Unlike regular Function runners, Sandbox runners do not have the ability to spawn new containers, or otherwise perform operations in your workspace. These runners are also torn down after execution, and never reused across calls. A sandbox’s network access can also be blocked with block_network=True.

Input

Sandboxes support accepting input via the stdin attribute on the sandbox object. The stdin handle is a StreamWriter object.

sandbox = app.spawn_sandbox(
    "bash",
    "-c",
    "while read line; do echo $line; done",
)

with open("data.txt", "rb") as f:
    for line in f.readlines():
        sandbox.stdin.write(line)
        sandbox.stdin.drain()  # flush line
    sandbox.stdin.write_eof()
    sandbox.stdin.drain()  # flush EOF
sandbox.wait()

Output

The interface for accessing sandbox output is via the stdout and stderr attributes on the sandbox object. These are LogsReader objects, and they support streaming output or reading it all in one call.

The read method fetches all logs until EOF and returns the entire output stream.

sandbox = app.spawn_sandbox("echo", "hello")
sandbox.wait()

print(sandbox.stdout.read())

To stream output take advantage of the fact that stdout and stderr are iterable. Note that this snippet uses an asynchronous iteration.

async for line in sandbox.stdout:
    print(line)