Skip to content

A Docker CLI in 500 Lines of Go

A Docker CLI in 500 Lines of Go

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Sheep has a CLI that works like Docker: sheep run, sheep ps, sheep stop. All in a single file of 500 lines. No cobra, no urfave/cli -- just the standard library.

Command routing

func main() {
    if len(os.Args) < 2 {
        printUsage()
        os.Exit(1)
    }

    switch os.Args[1] {
    case "init":
        handleInit()   // re-exec for the container
        return
    case "version":
        fmt.Println("sheep v0.1.0")
        return
    }

    mgr := container.NewManager(dataDir)
    mgr.Init()

    switch os.Args[1] {
    case "run":     cmdRun(mgr)
    case "create":  cmdCreate(mgr)
    case "start":   cmdStart(mgr)
    case "stop":    cmdStop(mgr)
    case "rm":      cmdRemove(mgr)
    case "ps":      cmdPs(mgr)
    case "inspect": cmdInspect(mgr)
    case "images":  cmdImages(mgr)
    case "pull":    cmdPull(mgr)
    case "push":    cmdPush(mgr)
    case "import":  cmdImport(mgr)
    case "bootstrap": cmdBootstrap(mgr)
    default:
        fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
        os.Exit(1)
    }
}

The first switch intercepts init -- that's the re-exec from the child container process, so the Manager isn't initialized. The second switch handles normal commands.

Flag parsing for run

func parseRunFlags(args []string) container.RunOpts {
    opts := container.RunOpts{}
    for i := 0; i < len(args); i++ {
        switch args[i] {
        case "--name":
            i++; opts.Name = args[i]
        case "-d", "--detach":
            opts.Detach = true
        case "-m", "--memory":
            i++; opts.Config.Memory = parseMemory(args[i])
        case "--cpu-shares":
            i++; v, _ := strconv.ParseInt(args[i], 10, 64)
            opts.Config.CPUShares = v
        case "--pids-limit":
            i++; v, _ := strconv.ParseInt(args[i], 10, 64)
            opts.Config.PidsLimit = v
        case "-e", "--env":
            i++; opts.Config.Env = append(opts.Config.Env, args[i])
        case "-v", "--volume":
            i++
            parts := strings.SplitN(args[i], ":", 3)
            if len(parts) >= 2 {
                m := container.Mount{
                    Source: parts[0], Target: parts[1],
                }
                if len(parts) == 3 && parts[2] == "ro" {
                    m.ReadOnly = true
                }
                opts.Mounts = append(opts.Mounts, m)
            }
        default:
            if opts.Image == "" {
                opts.Image = args[i]
            } else {
                opts.Command = append(opts.Command, args[i])
            }
        }
    }
    if len(opts.Command) == 0 {
        opts.Command = []string{"/bin/sh"}
    }
    return opts
}

Manual parsing without libraries. The first argument without a flag is the image, the rest is the command. Just like Docker.

The run command

func cmdRun(mgr *container.Manager) {
    opts := parseRunFlags(os.Args[2:])

    c, err := mgr.Create(opts)
    if err != nil { fatal("create: %v", err) }

    if err := mgr.Start(c.ID); err != nil {
        fatal("start: %v", err)
    }

    if opts.Detach {
        fmt.Println(c.ID)
    } else {
        fmt.Printf("container %s started (pid %d)\n",
            container.ShortID(c.ID), c.Pid)
    }
}

run = create + start. With the -d flag it prints only the ID (like Docker).

The ps command

func cmdPs(mgr *container.Manager) {
    all := false
    for _, arg := range os.Args[2:] {
        if arg == "-a" || arg == "--all" { all = true }
    }

    containers := mgr.List(all)
    tbl := cli.NewTable("CONTAINER ID", "IMAGE",
        "COMMAND", "CREATED", "STATUS", "NAME")

    for _, c := range containers {
        tbl.AddRow(
            container.ShortID(c.ID),
            c.Image,
            cli.Truncate(strings.Join(c.Command, " "), 30),
            timeAgo(c.CreatedAt),
            formatStatus(c),
            c.Name,
        )
    }
    tbl.Render(os.Stdout)
}

Output is formatted as a table with aligned columns.

Status formatting

func formatStatus(c *container.Container) string {
    switch c.State {
    case container.StateRunning:
        return fmt.Sprintf("Up %s", timeAgo(c.StartedAt))
    case container.StateStopped:
        return fmt.Sprintf("Exited (%d) %s",
            c.ExitCode, timeAgo(c.StoppedAt))
    default:
        return string(c.State)
    }
}

func timeAgo(t time.Time) string {
    d := time.Since(t)
    switch {
    case d < time.Minute:
        return fmt.Sprintf("%ds ago", int(d.Seconds()))
    case d < time.Hour:
        return fmt.Sprintf("%dm ago", int(d.Minutes()))
    case d < 24*time.Hour:
        return fmt.Sprintf("%dh ago", int(d.Hours()))
    default:
        return fmt.Sprintf("%dd ago", int(d.Hours()/24))
    }
}

Full command set

graph LR
    subgraph "Container"
        RUN["run"] --> CREATE["create"] & START["start"]
        STOP["stop"]
        RM["rm"]
        PS["ps"]
        INSPECT["inspect"]
        LOGS["logs"]
    end

    subgraph "Images"
        PULL["pull"]
        PUSH["push"]
        IMAGES["images"]
        IMPORT["import"]
        BOOTSTRAP["bootstrap"]
        TAG["tag"]
        RMI["rmi"]
    end

All commands are straightforward wrappers around Manager and ImageManager. None exceeds 30 lines.

Why no cobra?

Cobra gives you autocompletion, nested commands, doc generation. For a large project that's valuable. But for 15 simple commands, switch/case with manual flag parsing gives you: - Zero dependencies - Faster compilation - Easy to read and understand - Easy to add a new command

The downside -- no autocompletion, and flag help is generated by hand.

Things to keep in mind

Manual parsing doesn't validate unknown flags. sheep run --typo value nginx silently ignores --typo. Cobra would show an error. For a production CLI, that's critical.

Try it yourself

# Full help:
./sheep help
# Run with all options:
sudo ./sheep run --name web -m 256m -d \
  -e PORT=8080 -v /data:/app:ro nginx /bin/sh
sudo ./sheep ps
sudo ./sheep inspect web
sudo ./sheep logs web

This wraps up the Container Runtime series. On to building the orchestrator.

Series: Container Runtime (Sheep = Docker)

  1. Linux Namespaces
  2. Re-Exec Pattern
  3. pivot_root
  4. Cgroups v2
  5. OverlayFS
  6. Bridge Networking
  7. NAT and iptables
  8. Image Management
  9. Container Lifecycle
  10. Docker CLI (this article)

Resources

Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow

Previous: Container Lifecycle