Difficulty: intermediate
Estimated Time: 30-45 minutes

In this lesson you'll get an introduction to what a container really is, by writing a basic container in Go. You'll see how a container is really just a Linux process, with a restricted view.

Congratulations, you built your own container!

You now know that a container is really just a Linux process that has a restricted view.

  • Namespaces limit what the container process can see - for example, giving the container an isolated set of process IDs.
  • Changing the root limits the set of files and directories that the container can see.

There is a third construct that limits the resources that a container has access to: control groups or cgroups. There are different types of cgroup for different types of resources: memory, CPU, process, I/O and more. The cgroup controls how much of that resource is available to its members. A process is assigned to a cgroup so that it can only have access to that limited resource.

Notes and further reading

Write a container from scratch

Step 1 of 7

Execute an arbitrary command

We're going to create a program that can run an arbitrary command in a containerized process. This will be an illustration of what happens when you do a docker run command.

This isn't going to be production-quality code!

First let's start by writing some code that will execute an arbitrary command.

package main

import (
  "fmt"
  "os"
  "os/exec"
)

func main() {
    // We expect "run" as the first argument
    switch os.Args[1] {
        case "run":
            run()
        default:
            panic("Bad argument")
    }

    fmt.Println("== Finished ==")
}

func run() {
    // Arguments 2 onwards are the arbitrary command we're going to run
    fmt.Printf("Running %v\n", os.Args[2:])

    // Set up a struct that describes the command we want to run
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // This is where we run the command
    err := cmd.Run()
    if err != nil {
      panic(fmt.Sprintf("running: %v\n", err))
    }
}

Build and run this command:

go run main.go run echo hello

  • go run main.go builds and runs the program
  • run is argument 1 - you'll see why we want this in a later step
  • echo hello is the command we want to run

At this stage there is no containerization - your program runs the command in a regular Linux process. The command echo hello gets executed, then the program completes.

Note that you can also run a shell as your arbitrary command:

go run main.go run bash

You'll see a command prompt, but notice that the == Finished == message doesn't get output straight way. The program is blocked while the shell process in running. You'll only see the == Finished == message when you exit that shell:

exit

Use ps to check what is running

You can use a second terminal window to confirm what's running. First check what processes are running under your user ID:

ps -af

If nothing else is running, you should only see output for the ps command you were just running.

Run your program again (this will run in the first terminal window):

go run main.go run bash

While your program is still running, check the processes again:

ps -af

You should see four processes running:

  • go run main.go run bash is the command you executed to compile and then run your program
  • go compiled and wrote the executable to the /tmp directory. You should be able to see that executable in the process list
  • Your program cloned a new process to run bash
  • You should also see the process for ps that you just ran

Finally, quit out of your program:

exit

Want to check what's still running?

ps -af

Next step

In the next step you will start to containerize the process by giving it its own namespace for its host name.

main.go