Build Your Own Docker: Executing a process [1/2]

This is the first article in a series where we’ll build a toy Docker clone in Go.

Previous article: Introduction

Next article: Isolating a process

Sections in this article:

Skeleton

The docker clone we’ll build will be able to pull an image from docker.io and run a command inside it.

It’ll be invoked like this:

$ toy-docker run alpine echo "hello world"

Let’s ignore details about the Docker registry, Docker image etc. for now and focus on just one thing here: How do we execute the echo program and ensure it is sufficiently isolated from the outside world?

package main

import (
	"fmt"
	"os"
)

func main() {
	// Let's ignore the 'run alpine' part for now, and focus on executing the
	// command provided.
	if os.Args[1] != "run" || os.Args[2] != "alpine" {
		fmt.Println("Expected 'run alpine <command>' as the command!")
	}

	// TODO: Execute the command given in os.Args[3]!
}

Basic ‘exec’

On Linux-based systems, the exec syscall (a family of calls, rather) can be used to replace the calling process with a new one.

Let’s use exec to execute the command given:

package main
import (
"fmt"
"os"
+ "syscall"
)
func main() {
// Let's ignore the 'run alpine' part for now, and focus on executing the
// command provided.
if os.Args[1] != "run" || os.Args[2] != "alpine" {
fmt.Println("Expected 'run alpine <command>' as the command!")
}
- // TODO: Execute the command given in os.Args[3]!
+ if err := syscall.Exec("/usr/bin/"+os.Args[3], os.Args[3:], os.Environ()); err != nil {
+ fmt.Printf("Error: %v", err)
+ os.Exit(1)
+ }
}

Let’s test this with toy-docker run alpine echo hey:

➜ toy-docker run alpine echo hello
hello

Seems to work just fine! We can run any arbitrary command, as long as it is available on the host system.

➜  rohitpaulk.com git:(master) ✗ toy-docker run alpine curl httpbin.org/get 
{
  "args": {}, 
  "headers": {
    "Accept": "*/*", 
    "Host": "httpbin.org", 
    "User-Agent": "curl/7.67.0"
  }, 
  "origin": "122.177.35.168, 122.177.35.168", 
  "url": "https://httpbin.org/get"
}


Note: To make the main.go file runnable as toy-docker, I used a quick and dirty hack. I aliased toy-docker to docker run main.go.

alias toy-docker="go run main.go"

The proper way to do this is probarbly to create a package named toy-docker and run go install in it.


The need for isolation

Our version of toy-docker can execute any command given to it. That’s a bit too powerful!

For example, toy-docker can find the directory it was executed from, and list files on the host.

➜ toy-docker run alpine pwd
/home/rohitpaulk/experiments/productions/rohitpaulk.com

➜ toy-docker run alpine ls
404.html  articles  articles.html  _config.yml  css  _data  elsewhere.html  _files  Gemfile  Gemfile.lock  images  index.md  js  _layouts  Makefile  now.md  _plugins  reading.html  _scripts  _site

That’s not good! If I did the same with docker, it’d run in the root folder of what seems like an entirely new machine.

➜ docker run alpine pwd
/
➜ docker run alpine ls 
bin
dev
etc
home
lib
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

toy-docker can also list the processes on the host machine.

➜ toy-docker run alpine ps
    PID TTY          TIME CMD
   9516 pts/1    00:00:00 zsh
  15966 pts/1    00:00:00 go
  16024 pts/1    00:00:00 ps

It can terminate other processes too!

➜ toy-docker run alpine pkill -9 zsh
<poof!> my shell vanishes

Running the same with docker, we can see that it doesn’t have the same privileges.

➜ docker run alpine ps
PID   USER     TIME  COMMAND
    1 root      0:00 ps

The process seems to be running in an isolated environment. A ‘container’, if you will.

its-a-container

In the next article, we’ll look at the isolation mechanisms that Docker uses.