A Docker CLI in 500 Lines of Go¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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)¶
- Linux Namespaces
- Re-Exec Pattern
- pivot_root
- Cgroups v2
- OverlayFS
- Bridge Networking
- NAT and iptables
- Image Management
- Container Lifecycle
- Docker CLI (this article)
Resources¶
- Docker CLI reference — official command reference
- Docker CLI source code — Docker's actual CLI implementation in Go
- spf13/cobra — popular Go CLI framework used by Docker/kubectl
- urfave/cli — another popular Go CLI framework
- flag package — Go's built-in flag parser
- text/tabwriter — Go's built-in column-aligned text formatting
- os.Args — raw access to process arguments in Go
- 12 Factor CLI Apps — principles for building good CLIs
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Container Lifecycle
