Part 1: Getting Started

Executing Processes from Go

At its core, a container runtime is a way to use one process (the container manager) to launch another process. That's a massive oversimplification, but launching processes is as good a place to start as any for building boxr.

Let's make a basic CLI for launching other commands through Go. There won't be any flash to it, simply using os/exec package to take the user's input and pass it to the OS as a new command to run. Using the Cobra CLI package, lets make a a command, boxr, and a subcommand, run, that takes in the remaining CLI args and treats them as a command and arguments to run.

package main

import (
  "os"
  "os/exec"
  "fmt"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "boxr",
    Short: "Boxr is a simple container runtime",
    Long:  `A simple container runtime implementation written in Go.`,
}


var runCmd = &cobra.Command{
    Use:   "run [command]",
    Short: "Run a container",
    Long: `Run a container with the specified command.
Examples:
  boxr run /bin/bash           # Run interactively
  boxr run -d sleep 1000       # Run in background
  boxr run --detach sleep 1000 # Run in background`,
    Args: cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {

    command := exec.Command(args[0], args[1:]...)
    command.Stdin = os.Stdin
    command.Stdout = os.Stdout
    command.Stderr = os.Stderr
    command.Run()
    },
}

func init() {
    rootCmd.AddCommand(runCmd)
}

func main() {

    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Now execute go run step1.go run -- ls and you should see the contents of your directory printed out. There's no file system isolation yet, no process isolation, nothing like that. The ls that you are executing is the same ls binary that you could run directly, and it has the same privileges on your machine as you do.

Play around with the command a bit-- even try launching a shell (zsh/bash/fish, whatever you like). Long running processes should give you enough time to poke around at the process list while the command is still being executed to see what is happening. Tools like pgrep and ps can be used to find the PIDs of the processes, and you can find out more by looking at /proc/<pid>

Structuring the Project

To make our lives a little easier as the project expands, let's rename this file cmd/main.go and move the logic inside the Run field of runCmd struct into its own file inside pkg/container/main.go and import it into the cmd/main.go file.

package container

import (
  "os/exec"
  "fmt"
)

func run(args []string) {

    command := exec.Command(args[0], args[1:]...)
    command.Stdin = os.Stdin
    command.Stdout = os.Stdout
    command.Stderr = os.Stderr
    command.Run()
}
package main

import (
  "os"
  "os/exec"
  "fmt"
  "github.com/spf13/cobra"
  "github.com/gruejay3/container-runtime/pkg/container"
)

var rootCmd = &cobra.Command{
    Use:   "boxr",
    Short: "Boxr is a simple container runtime",
    Long:  `A simple container runtime implementation written in Go.`,
}


var runCmd = &cobra.Command{
    Use:   "run [command]",
    Short: "Run a container",
    Long: `Run a container with the specified command.
Examples:
  boxr run /bin/bash           # Run interactively
  boxr run -d sleep 1000       # Run in background
  boxr run --detach sleep 1000 # Run in background`,
    Args: cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
    container.Run(args)
    },
}

func init() {
    rootCmd.AddCommand(runCmd)
}

func main() {

    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

This gives us a good platform to add functionality to the run command. Namespaces, chroot, and cgroups can be applied to the execution of the command, and that logic can be kept separate from the CLI logic. In the future, we will split out the logic even further, but the container package will be the "entrypoint" into running a container, meaning we can modify a lot "under the hood" without needing to make drastic changes to the cmd/ directory.

Adding flags

To understand a bit more about Cobra CLI, and to add an important feature of containers, let's allow the caller to spawn the process in "detached" mode, meaning the boxr command will return but the spawned process may still be running.

We can do this using a new variable detached that will be true if the user passes the -d/--detached flag.

cmd/main.go

package main

import (
    "fmt"
    "os"

    "github.com/gruejay/container-runtime/pkg/container"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "boxr",
    Short: "Boxr is a simple container runtime",
    Long:  `A simple container runtime implementation written in Go.`,
}

var detach bool

var runCmd = &cobra.Command{
    Use:   "run [command]",
    Short: "Run a container",
    Long: `Run a container with the specified command.
Examples:
  boxr run /bin/bash           # Run interactively
  boxr run -d sleep 1000       # Run in background
  boxr run --detach sleep 1000 # Run in background`,
    Args: cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        // Initialize a new container with default settings
        if err := container.Run(args, detach); err != nil {
            fmt.Printf("Error running container: %v\n", err)
            os.Exit(1)
        }
    },
}

func init() {
    runCmd.Flags().BoolVarP(&detach, "detach", "d", false, "Run container in background")
    rootCmd.AddCommand(runCmd)
}

func main() {

    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

pkg/container/container.go

package container

import (
  "os/exec"
  "fmt"
)

func run(args []string, detach bool) error {

  command := exec.Command(args[0], args[1:]...)
  if !detach {
    command.Stdin = os.Stdin
    command.Stdout = os.Stdout
    command.Stderr = os.Stderr
    if err := cmd.Run(); err != nil {
      return fmt.Errorf("command failed with %v", err)
    }
  } else {
    command.Start()
    pid := cmd.Process.Pid
    fmt.Printf("Spawned process: %d", pid)
    return nil
  }
  return nil
}

Now try running go run cmd/main.go run -d -- sleep 100, then afterwards pgrep sleep and you should get a PID matching the PID printed by go run.

Adding some Structure

Running a container will require knowing a lot of information: - Namespace config (which namespaces to create, which to attach to) - FS information (which directory is the root directory?) - Mount information (which devices/filesystems to mount into the container, and where) - Command information (which command to run, with what args) - whether to attach stdinstdoutstderr - And more

It would be hard and messy to track all of that separately by passing arguments one-by-one. Each change we make to the container (adding a feature, flag, argument) would require updating the function signatures for everything. We can make our lives a lot easier by creating a Container type that will hold all of the configuration we care about.

Adding the following to pkg/container/container.go gives us a container type that has two fields: Detach, to tell us whether to attach to the running process, and Args to hold the info the user passed in about what to execute. Now, instead of container.Run() being a standalone function, it will be a Method on the Container type. We will need to rewrite the logic of the function to use the new container type, and rewrite cmd/main.go to use it, as well.

In addition to adding the type declaration, let's add a function NewContainer() that returns a pointer to an instance of Container with some defaults. Those can, of course, be overridden, but it will be nice in the near future when we start adding namespace configuration, but don't yet have config files.

type Container struct {
    Detach     bool            `json:"detach"`
    Args       []string
}

func NewContainer() *Container {
  return &Container{
    Detach: false
  }
}

func (*Container) Run() {
    cmd := exec.Command(c.Args[0], c.Args[1:]...)

    if !c.Detach {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
        if err := cmd.Run(); err != nil {
            return fmt.Errorf("command failed with %v", err)
        }
        slog.Info("started container process",
            "command", c.Args[0],
            "args", c.Args[1:],
        )
    } else {
        cmd.Start()
        pid := cmd.Process.Pid
        slog.Info("started detached container process",
            "command", c.Args[0],
            "args", c.Args[1:],
            "pid", pid,
        )
    }
    return nil

}

cmd/main.go (snippet)

var runCmd = &cobra.Command{
    Use:   "run [command]",
    Short: "Run a container",
    Long: `Run a container with the specified command.
Examples:
  boxr run /bin/bash           # Run interactively
  boxr run -d sleep 1000       # Run in background
  boxr run --detach sleep 1000 # Run in background`,
    Args: cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        // Initialize a new container with default settings
        c := container.NewContainer()

        // Set the command and arguments
        c.Args = args

        // Set detach mode from flag
        c.Detach = detach

        // Run the container
        if err := c.Run(); err != nil {
            fmt.Printf("Error running container: %v\n", err)
            os.Exit(1)
        }
    },
}

Now, when boxr run is called, first a new instance of Container is created, and the user's desired command and detach behavior are set in the Container's fields. Then the Run method is called, which behaves pretty much the same as before, except gets the necessary info from the Container instance its called on, instead of via arguments. If we wanted to add, say, namespace changes, we could add that to the Container type and not have to change the method signature. Let's do that in part 3!

Appendix: Turning boxr into a real command

If you've used Go before, you know how to compile into an executable. But in case you don't:

go build -o boxr cmd/main.go && chmod +x ./boxr will give you a binary executable, ./boxr, that you can now use. ./boxr run -- ls or ./boxr run -d -- sleep 100