Skip to main content

README.md

# FLAMEDockerBackend

A [FLAME](https://github.com/phoenixframework/flame) backend that runs runners
as Docker containers on the host machine via the Docker Engine API (Docker-out-of-Docker) —
no cloud account, no Kubernetes, no external infrastructure required.

The parent app runs inside a container and provisions runners by talking to the host Docker daemon
through the mounted socket.
Runners are ordinary containers started from the same image, connected to the same user-defined network,
and shut down automatically when idle.

## Features

- **Zero-infrastructure local scaling / resource usage control** — provision FLAME runners on your local machine
or any host with Docker. No cloud provider setup or k8s needed. Allows limiting resources per specific task.
- **Docker-out-of-Docker** — the parent runs inside a container and talks to the host daemon through
a mounted socket (`/var/run/docker.sock`). No privileged containers or sidecars required.
- **Cross-platform socket detection** — automatically finds the Docker socket on Linux, macOS (Docker Desktop),
and WSL2, or accepts an explicit path.
- **Minimal dependencies** — depends only on FLAME and Jason libraries. Docker API calls via UNIX socket
are done with httpc, so no additional HTTP client libraries are used.
- **Image pull on demand** — if the configured image is not present locally,
the backend pulls it before booting the runner.
- **Environment propagation** — `ERL_AFLAGS` and `ERL_ZFLAGS` are forwarded from the parent to runners automatically,
additional environment variables are configurable.

## Installation

```elixir
def deps do
  [
    {:flame_docker_backend, "~> 0.1.0"}
  ]
end
```

## Configuration

Add to your `config/runtime.exs`:

```elixir
config :flame, :backend, FLAMEDockerBackend

config :flame, FLAMEDockerBackend,
  image: "my-app:latest",
  network: "my_network"
```

Then add a `FLAME.Pool` to your application supervisor:

```elixir
{FLAME.Pool,
 name: MyApp.Runner,
 backend: FLAMEDockerBackend,
 min: 0,
 max: 4,
 idle_shutdown_after: 15_000}
```

**Required options:**
- `:image` — Docker image to use for runner containers
- `:network` — User-defined Docker network (required for DNS resolution between parent and runners)

**Optional options:**
- `:boot_timeout` — Milliseconds to wait for a runner to connect back (default: `30_000`)
- `:docker_socket_path` — Path to the Docker socket (auto-detected if omitted)
- `:env` — Additional environment variables to set on runner containers (`PHX_SERVER` may be overridden; `FLAME_PARENT` may not)
- `:host_config` — Docker `HostConfig` map (resource limits, binds, etc.)
- `:mounts` — Docker `Mounts` list (bind mounts, volumes, tmpfs)
- `:cmd` — Docker `Cmd` override (list of strings)
- `:keep_runners` — When `true`, leave exited runner containers for log inspection (default: `false`, containers are removed)

See [Available configurations](#available-configurations) for examples.

## Parent deployment

The parent must run as a **distributed release** on a **user-defined Docker network** with:

1. **Docker socket mounted** — `-v /var/run/docker.sock:/var/run/docker.sock` (or your platform path)
2. **Stable container name** — `--name my-app-parent` so Docker DNS matches the Erlang node hostname
3. **Same network as runners** — `--network my_network` (must match `:network` config)
4. **Shared cookie** — set `RELEASE_COOKIE` on the parent; it is forwarded to runners automatically

Example:

```bash
docker run --rm \
  --name my-app-parent \
  --network my_network \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e RELEASE_COOKIE=my-secret-cookie \
  my-app:latest
```

**Docker socket paths by platform:**

| Platform | Socket path |
|----------|-------------|
| Linux | `/var/run/docker.sock` |
| WSL2 | `/mnt/wsl/shared-docker/docker.sock` |
| macOS (Docker Desktop) | `~/.docker/run/docker.sock` |

Mount it into the parent container with `-v <host-socket>:/var/run/docker.sock`.

## Integration Examples

**Basic Elixir Applications**

See integration steps in
[minimal test app's README](test_apps/minimal/README.md#flame--flamedockerbackend-integration-steps).

**Phoenix Projects**

See integration steps in
[phx_minimal test app's README](test_apps/phx_minimal/README.md#flame--flamedockerbackend-integration-steps).

## Available configurations

Runner container options use Docker Engine API field names. Set them globally in
`config :flame, FLAMEDockerBackend`, per pool via the `backend` tuple, or at
runtime when starting a pool or `FLAME.Runner`.

**Application config** (`config/runtime.exs`):

```elixir
config :flame, FLAMEDockerBackend,
  image: "my-app:latest",
  network: "my_network",
  host_config: %{
    "Memory" => 2_147_483_648,
    "NanoCpus" => 2_000_000_000,
    "Ulimits" => [%{"Name" => "nofile", "Soft" => 65_536, "Hard" => 65_536}]
  },
  mounts: [
    %{"Type" => "bind", "Source" => "/data/models", "Target" => "/models", "ReadOnly" => true}
  ]
```

**Per-pool overrides** (static or dynamic values at startup):

```elixir
{FLAME.Pool,
 name: MyApp.GpuRunner,
 backend:
   {FLAMEDockerBackend,
    image: "my-app:gpu",
    network: System.fetch_env!("DOCKER_NETWORK"),
    host_config: %{"Memory" => 4_294_967_296, "NanoCpus" => 4_000_000_000},
    mounts: [
      %{
        "Type" => "bind",
        "Source" => "/var/run/docker.sock",
        "Target" => "/var/run/docker.sock",
        "ReadOnly" => true
      }
    ]},
 min: 0,
 max: 2,
 idle_shutdown_after: 15_000}
```

**One-off runner** with custom container settings:

```elixir
{:ok, runner} =
  FLAME.Runner.start_link(
    backend:
      {FLAMEDockerBackend,
       image: "my-app:latest",
       network: "my_network",
       cmd: ["bin/my_app", "start"]}
  )
```

Pool-level `backend` options override application config. Wiring fields
(`Hostname`, `NetworkingConfig`, `FLAME_PARENT`, etc.) are always set by the
backend and cannot be overridden.

## Testing

**Unit tests** (default):

```bash
mix test
```

**Docker integration tests** — build the test app images, start parent containers, and
run `FLAME.call` via release RPC. Requires a running Docker daemon and the socket path
detected by `DockerAPI.default_socket_path/0`:

```bash
mix test.docker
```

### `test_apps/minimal`

A minimal test application for integration testing.

**Run everything** (from the project root):

```bash
./scripts/minimal/01_run.sh
```

The script builds the image, recreates the `minimal_flame_docker_backend_test` network,
picks the Docker socket for your platform (Linux, WSL2, or macOS), and starts the parent container with IEx.

Optional arguments:

```bash
# custom command (FLAGS default omitted when CMD is provided)
./scripts/minimal/01_run.sh "bin/minimal remote"

# custom docker run flags and command
./scripts/minimal/01_run.sh "bin/minimal start_iex" "-it"
```

**Test the FLAME backend in IEx:**

```elixir
# Spawns a runner container and executes the function there
Minimal.test_flame_backend_lambda()

# Or test manually:
FLAME.call(Minimal.Runner, fn ->
  IO.puts("hey from remote")
  System.get_env() |> dbg
  {node(), self()}
end)

# Execute many tasks — observe that only max containers from the FLAME.Pool child spec are spawned:
(for _ <- 1..10, do: Task.async(fn -> Minimal.test_flame_backend_lambda() end)) |> Task.await_many(120_000)
# Or even more tasks:
(for _ <- 1..10_000, do: Task.async(fn -> Minimal.test_flame_backend_lambda(:infinity) end)) |> Task.await_many(:infinity)
```

**Connect to the FLAME runner node:**

```bash
# find CONTAINER_ID of the node you want to connect to remotely:
docker ps

docker exec -it $CONTAINER_ID bin/minimal remote
```

**Watch Docker activity** (in another terminal):

```bash
docker ps -a --filter "name=minimal"
```

**Cleanup:**

```bash
./scripts/minimal/02_cleanup.sh
```

Removes all containers matching `minimal` (parent and FLAME runners) and the test network.

### `test_apps/phx_minimal`

A Phoenix test application with a LiveView UI for integration testing.

**Run everything** (from the project root):

```bash
./scripts/phx_minimal/01_run.sh
```

The script builds the image, recreates the `phx_minimal_flame_docker_backend_test` network,
picks the Docker socket for your platform (Linux, WSL2, or macOS),
and starts the parent container with IEx on port 4000.

Optional arguments:

```bash
# custom command (FLAGS default omitted when CMD is provided)
./scripts/phx_minimal/01_run.sh "bin/phx_minimal remote"

# custom command and docker run flags
./scripts/phx_minimal/01_run.sh "bin/phx_minimal start_iex" "-d"
```

**Try the FLAME backend in the browser:**

Open [http://localhost:4000](http://localhost:4000) and click **Spawn FLAME task**.
Each click runs `FLAME.call` on a remote runner via `start_async`;
completed colors are saved to the database and shown in the UI.
Click rapidly to queue multiple tasks — the pool runs up to `max` concurrent runners.

**Connect to the FLAME runner node:**

```bash
# find CONTAINER_ID of the node you want to connect to remotely:
docker ps

docker exec -it $CONTAINER_ID bin/phx_minimal remote
```

**Watch Docker activity** (in another terminal):

```bash
docker ps -a --filter "name=phx_minimal"
```

**Cleanup:**

```bash
./scripts/phx_minimal/02_cleanup.sh
```

Removes all containers matching `phx_minimal` (parent and FLAME runners) and the test network.