MuonTrap

repository·main·Indexed 42 days ago

https://github.com/fhunleth/muontrap

A lightweight Elixir library for safely running and supervising external OS processes. It prevents zombie processes by ensuring OS processes and their children are terminated if the Elixir process crashes. Features include a safer replacement for System.cmd/3 via MuonTrap.cmd/3, a supervised MuonTrap.Daemon for long-running processes, and Linux cgroup v2 integration for enforcing resource limits on CPU, memory, and PIDs.

Tokens
5.9K
Snippets
22
Records
28
Agent score
55%

What's inside MuonTrap

  1. Control stdio flow to prevent OOM

    main

    Because MuonTrap captures stdout/stderr, a fast-producing process can overwhelm the Elixir process mailbox. MuonTrap implements flow control by stopping reads when a threshold is reached, which eventually blocks the child process's writes.

    Use the :stdio_window option to set the maximum number of unacknowledged bytes. The default is 10 KB.

  2. Supervise MuonTrap.Daemon in a supervision tree

    main

    To restart long-running programs automatically, use MuonTrap.Daemon as a child in an Elixir Supervisor. You can control the restart behavior using the :restart strategy:

    • :permanent (Default): Always restart the command if it exits or crashes.
    • :transient: Do not restart if the command exits with status 0 (success). Restart on any other exit status.
    • :temporary: Never restart the command.

    If running multiple daemons under the same supervisor, assign each a unique :id using Supervisor.child_spec/2.

    # In your application supervisor
    children = [
      {MuonTrap.Daemon, ["command", ["arg1"], options]}
    ]
    
    # Using child_spec for custom restart behavior
    child_spec = Supervisor.child_spec(
      {MuonTrap.Daemon, ["command", ["arg1"], options]},
      id: :my_daemon,
      restart: :transient
    )
  3. Set up cgroup v2 for MuonTrap

    main

    MuonTrap requires cgroup v2 (the unified hierarchy at /sys/fs/cgroup). To use it, you must perform a one-time setup:

    1. Enable controllers at the root: A controller must be enabled in the root's cgroup.subtree_control before it is available to child cgroups. You can do this via CLI or Elixir:

      • CLI: echo +cpu +memory +pids | sudo tee /sys/fs/cgroup/cgroup.subtree_control
      • Elixir: File.write!("/sys/fs/cgroup/cgroup.subtree_control", "+cpu +memory +pids") Note: On systemd hosts, it is often easier to use a slice with Delegate=yes and point cgroup_base to that slice's directory.
    2. Create a writable parent directory: Create a directory under /sys/fs/cgroup that MuonTrap can use as a base for creating sub-cgroups, and ensure your user has permissions:

      sudo mkdir -p /sys/fs/cgroup/mycgroup
      sudo chown -R $(whoami) /sys/fs/cgroup/mycgroup

    On Nerves, ensure the kernel has CONFIG_CGROUPS, CONFIG_MEMCG, CONFIG_CFS_BANDWIDTH, and CONFIG_CGROUP_PIDS enabled.

    echo +cpu +memory +pids | sudo tee /sys/fs/cgroup/cgroup.subtree_control
    
    sudo mkdir -p /sys/fs/cgroup/mycgroup
    sudo chown -R $(whoami) /sys/fs/cgroup/mycgroup
  4. Configure cgroups for resource limiting on Linux

    main

    If running on Linux with cgroup v2 support, you can use MuonTrap to enforce resource limits (like memory or CPU) using cgroups.

    1. Enable controllers: Write to /sys/fs/cgroup/cgroup.subtree_control to enable the desired controllers (e.g., +cpu +memory +pids).
    2. Create a base directory: Create a parent directory in /sys/fs/cgroup/ that MuonTrap can use as a base.
    3. Set permissions: Ensure the directory is writable (you may need to chown it).
    4. Configure the Daemon: Pass cgroup_base and a cgroup map in the options.

    MuonTrap will create a unique sub-cgroup under your base directory for the process.

    # 1. Enable controllers
    File.write("/sys/fs/cgroup/cgroup.subtree_control", "+cpu +memory +pids")
    
    # 2. Create parent directory
    File.mkdir_p("/sys/fs/cgroup/mycgroup")
    
    # 3. Use in Daemon
    {MuonTrap.Daemon, [
       "long_running_command",
       ["arg1", "arg2"],
       [cgroup_base: "mycgroup", cgroup: %{memory_max: 500_000_000}]
     ]}
  5. Start an OS process as a supervised MuonTrap.Daemon

    main

    You can wrap an OS process in a MuonTrap.Daemon GenServer to allow it to be supervised by an Elixir Supervisor. This is typically done by adding the daemon to a children list in a supervisor specification.

    When using MuonTrap.Daemon in a child_spec tuple, the second element is a list containing:

    1. The program to run (binary).
    2. A list of command-line arguments.
    3. A list of options (keyword list).

    If you need to run multiple daemons under the same supervisor, ensure they have unique IDs by using Supervisor.child_spec/2.

    children = [
      {MuonTrap.Daemon, ["my_server", ["--options", "foo"], [cd: "/some_directory"]]}
    ]
    
    opts = [strategy: :one_for_one, name: MyApplication.Supervisor]
    Supervisor.start_link(children, opts)

    To run multiple daemons with unique IDs:

    Supervisor.child_spec({MuonTrap.Daemon, ["my_server", []]}, id: :server1)
  6. Sandbox processes using MuonTrap and bubblewrap

    main

    MuonTrap provides resource limits (cgroups), but not filesystem or network isolation. To achieve true sandboxing (e.g., for browsers or untrusted code), layer bubblewrap (bwrap) under MuonTrap. MuonTrap manages the lifecycle and resource caps, while bwrap manages namespaces and isolation.

    Recommended flags for bwrap:

    • --unshare-all: Provides fresh namespaces (PID, network, etc.).
    • --die-with-parent: Ensures the sandbox dies if MuonTrap dies.
    • --ro-bind / --bind: Controls filesystem visibility.
    {MuonTrap.Daemon,
     ["bwrap",
      [
        "--ro-bind", "/usr", "/usr",
        "--ro-bind", "/lib", "/lib",
        "--ro-bind", "/lib64", "/lib64",
        "--proc", "/proc",
        "--dev", "/dev",
        "--bind", "/tmp/browser-home", "/home/browser",
        "--unshare-all",
        "--die-with-parent",
        "--",
        "/usr/bin/my-browser"
      ],
      [
        cgroup_base: "mycgroup",
        cgroup: %{
          memory_max: 536_870_912,
          memory_oom_group: true,
          cpu_max: {50_000, 100_000},
          pids_max: 200
        }
      ]
     ]}
  7. Delay a MuonTrap.Daemon until a dependency is ready

    main

    Use the :wait_for option in a MuonTrap.Daemon to run a blocking function before the OS process is launched. This function runs in a linked Task, so it can poll for a TCP port or a file without blocking the supervisor. If the function raises, the Daemon is torn down and the supervisor's restart policy applies.

    {MuonTrap.Daemon, [
      "my_server", 
      [],
      [wait_for: fn -> wait_for_tcp_port("db", 5432) end]
    ]}
  8. Monitor daemon statistics and cgroup usage

    main

    Use MuonTrap.Daemon functions to inspect a running daemon:

    • MuonTrap.Daemon.statistics(daemon_pid): Returns output byte counters and a snapshot of all readable cgroup stat files (e.g., memory.current, cpu.stat, pids.current).
    • MuonTrap.Daemon.cgroup_config(daemon_pid): Returns the current writable :cgroup configuration map.
    • MuonTrap.Daemon.cgroup_path(daemon_pid): Returns the current cgroup path.
    • MuonTrap.Daemon.cgget(daemon_pid, "file_name"): Reads a specific raw cgroup interface file.
    • MuonTrap.Daemon.cgset(daemon_pid, "file_name", "value"): Writes to a specific cgroup interface file.
    # Get all stats
    stats = MuonTrap.Daemon.statistics(daemon_pid)
    
    # Get current config
    config = MuonTrap.Daemon.cgroup_config(daemon_pid)
    
    # Generic interface access
    {:ok, raw} = MuonTrap.Daemon.cgget(daemon_pid, "memory.peak")
    :ok = MuonTrap.Daemon.cgset(daemon_pid, "memory.max", "536870912")
  9. Run a command with MuonTrap.cmd/3

    main

    Use MuonTrap.cmd/3 as a safer replacement for System.cmd/3. Unlike System.cmd/3, if the Elixir process running the command crashes, MuonTrap ensures the OS process and all its children are killed.

    To capture output as it is printed, use the :into option along with stderr_to_stdout: true.

    # Basic usage
    MuonTrap.cmd("echo", ["hello"])
    
    # Streaming output
    MuonTrap.cmd("my_program", [], stderr_to_stdout: true, into: IO.binstream(:stdio, :line))
  10. Run a command in a cgroup with MuonTrap.cmd/2

    main

    Use MuonTrap.cmd/2 to run a program within a cgroup. MuonTrap creates a temporary sub-cgroup under the specified cgroup_base and automatically enables controllers based on the keys provided in the :cgroup map.

    Important: If you use :cgroup_path instead of a base/map combo, ensure no other processes are using that path, as MuonTrap kills everything in that cgroup upon cleanup.

    MuonTrap.cmd("spawning_program", [], cgroup_base: "mycgroup", cgroup: %{cpu_weight: 100})