Вступление
20 марта 2013 года на конференции PyCon 2013, Соломон Хайкс (CEO компании dotCloud) выступил с пятиминутной презентацией The future of Linux Containers. В ней широкой общественности впервые была представлена внутренняя разработка компании dotCloud под названием Docker, а спустя несколько дней ее исходный код был выложен в открытый доступ. Хотя технологии LXC и Aufs, на которых была основана первая версия Docker существовали и активно использовались уже порядка пяти лет, но именно появление Docker послужило началом стремительного роста и эволюции систем контейнеризации, что кардинально преобразило многие процессы разработки и деплоймента программного обеспечения.
Этой статьей я собираюсь начать небольшой цикл посвященный изучению развития исходного кода Docker на протяжении нескольких лет. В первой части мы посмотрим на то, что представлял собой код на момент создания git репозитория датированного январем 2013 года. Тогда исходный код Docker (за исключением тестов) состоял всего из шести файлов общим объемом ~600 строк кода, написанных на языке Go. Это больше походило на библиотеку/api, функционал которой состоял лишь в создании, удалении, запуске и остановке контейнеров. Мы разберем принцип работы и даже сможем запустить с ее помощью подготовленный контейнер.
Во второй части мы перенесемся на несколько месяцев вперед - в март 2013, когда докер был представлен на конференции PyCon. На тот момент он уже обладал практически всеми знакомыми нам функциями. А в третьей части я постараюсь рассмотреть переход Docker с LXC на собственную разработку, произошедший годом позже.
Исходный код
Для запуска я буду использовать Windows 10 c Vagrant и Ubuntu 20.04. Сразу хочу предупредить, что WSL2 не подойдет, так как используемое ядро не поддерживает aufs, требуемую в ранних версиях.
В первую очередь нам нужно скачать исходный код https://github.com/docker/engine.git или https://github.com/moby/moby.git. Склонируем репозиторий и вернемся к началу истории (хеш первого коммита можно получить при помощи git log --reverse).
cd /home/vagrant && git clone https://github.com/docker/engine.git && cd ./engine
git checkout -f a27b4b8cb8e838d03a99b6d2b30f76bdaf2f9e5d && wc -l $(ls -1 | grep -v test)
HEAD is now at a27b4b8cb8 Initial commit
203 container.go
112 docker.go
52 filesystem.go
94 lxc_template.go
48 state.go
115 utils.go
624 total
На момент создания репозитория (18 января 2013 года), код Docker состоял всего из 6 файлов общим объемом в 624 строки и как мы увидим далее, представлял собой лишь обертку и удобное api для утилит lxc и mount.
Итак, приступим к изучению самого кода, отправной точкой будет служить файл docker.go, в котором объявлены функции New и NewFromDirectory:
type Docker struct {
root string
repository string
containers *list.List
}
func New() (*Docker, error) {
return NewFromDirectory("/var/lib/docker")
}
func NewFromDirectory(root string) (*Docker, error) {
docker := &Docker{
root: root,
repository: path.Join(root, "containers"),
containers: list.New(),
}
if err := os.Mkdir(docker.repository, 0700); err != nil && !os.IsExist(err) {
return nil, err
}
if err := docker.restore(); err != nil {
return nil, err
}
return docker, nil
}
New является фасадом, который просто вызывает NewFromDirectory с дефолтным путем /var/lib/docker. Функция NewFromDirectory производит инициализацию структуры Docker, создает директорию для контейнеров (если она не существует) и возвращает структуру Docker (перед чем вызывается метод restore):
func (docker *Docker) restore() error {
dir, err := ioutil.ReadDir(docker.repository)
if err != nil {
return err
}
for _, v := range dir {
container, err := loadContainer(path.Join(docker.repository, v.Name()))
if err != nil {
fmt.Errorf("Failed to load %v: %v", v.Name(), err)
continue
}
docker.containers.PushBack(container)
}
return nil
}
В методе restore происходит попытка загрузки и инициализации существующих контейнеров из папки containers вызовом функции loadContainer. При первом запуске данная папка будет пуста, так что ничего не произойдет. Мы рассмотрим данный функционал немного позже, когда перейдем к файлу container.go.
Следующие интересующие нас методы из файла docker.go это Create и Destroy:
func (docker *Docker) Create(name string, command string, args []string, layers []string, config *Config) (*Container, error) {
if docker.Exists(name) {
return nil, fmt.Errorf("Container %v already exists", name)
}
root := path.Join(docker.repository, name)
container, err := createContainer(name, root, command, args, layers, config)
if err != nil {
return nil, err
}
docker.containers.PushBack(container)
return container, nil
}
func (docker *Docker) Destroy(container *Container) error {
element := docker.getContainerElement(container.Name)
if element == nil {
return fmt.Errorf("Container %v not found - maybe it was already destroyed?", container.Name)
}
if err := container.Stop(); err != nil {
return err
}
if err := os.RemoveAll(container.Root); err != nil {
return err
}
docker.containers.Remove(element)
return nil
}
Здесь все предельно просто - метод Create проверяет существование контейнера по переданному имени, делегирует создание методу createContainer (находится в файле container.go) и сохраняет созданный контейнер в список.
Аналогично ему метод Destroy останавливает контейнер, удаляет корневую директорию контейнера, а затем удаляет его из списка containers.
Остальное содержимое файла docker.go составляют лишь несколько хелпер методов:
-
Get - возвращает контейнер по имени
-
List - возвращает список контейнеров
-
getContainerElement - возвращает элемент списка контейнеров
-
Exists - проверяет существование контейнера в списке по имени
Следующей точкой будет файл container.go и метод createContainer, который собственно и занимается реальным созданием контейнеров:
func createContainer(name string, root string, command string, args []string, layers []string, config *Config) (*Container, error) {
container := &Container{
Name: name,
Root: root,
Path: command,
Args: args,
Config: config,
Filesystem: newFilesystem(path.Join(root, "rootfs"), path.Join(root, "rw"), layers),
State: newState(),
lxcConfigPath: path.Join(root, "config.lxc"),
stdout: newWriteBroadcaster(),
stderr: newWriteBroadcaster(),
}
if err := os.Mkdir(root, 0700); err != nil {
return nil, err
}
if err := container.save(); err != nil {
return nil, err
}
if err := container.generateLXCConfig(); err != nil {
return nil, err
}
return container, nil
}
В начале метода происходит инициализация структуры Container, создание директории и сохранение в ней экспортированного файла конфигурации контейнера, а также конфигурационного файла для утилиты lxc-start. В функции newFilesystem из файла filesystem.go просто инициализируется и возвращается структура Filesystem. Чуть позже мы рассмотрим filesystem.go подробнее.
type Filesystem struct {
RootFS string
RWPath string
Layers []string
}
func newFilesystem(rootfs string, rwpath string, layers []string) *Filesystem {
return &Filesystem{
RootFS: rootfs,
RWPath: rwpath,
Layers: layers,
}
}
Функция newState находится в state.go и инициализирует структуру содержащую pid, exitcode, а так же мьютекс для коммуникации.
type State struct {
Running bool
Pid int
ExitCode int
stateChangeLock *sync.Mutex
stateChangeCond *sync.Cond
}
func newState() *State {
lock := new(sync.Mutex)
return &State{
stateChangeLock: lock,
stateChangeCond: sync.NewCond(lock),
}
}
Функция newWriteBroadcaster() из utils.go возвращают структуру writeBroadcaster (broadcast pattern) использующуюся для работы с потоками stdout и stderr.
type writeBroadcaster struct {
writers *list.List
}
func (w *writeBroadcaster) AddWriter(writer io.WriteCloser) {
w.writers.PushBack(writer)
}
func (w *writeBroadcaster) RemoveWriter(writer io.WriteCloser) {
for e := w.writers.Front(); e != nil; e = e.Next() {
v := e.Value.(io.Writer)
if v == writer {
w.writers.Remove(e)
return
}
}
}
func (w *writeBroadcaster) Write(p []byte) (n int, err error) {
failed := []*list.Element{}
for e := w.writers.Front(); e != nil; e = e.Next() {
writer := e.Value.(io.Writer)
if n, err := writer.Write(p); err != nil || n != len(p) {
// On error, evict the writer
failed = append(failed, e)
}
}
// We cannot remove while iterating, so it has to be done in
// a separate step
for _, e := range failed {
w.writers.Remove(e)
}
return len(p), nil
}
func (w *writeBroadcaster) Close() error {
for e := w.writers.Front(); e != nil; e = e.Next() {
writer := e.Value.(io.WriteCloser)
writer.Close()
}
return nil
}
func newWriteBroadcaster() *writeBroadcaster {
return &writeBroadcaster{list.New()}
}
Метод save экспортирует структуру Container в json формат и сохраняет в файл, по которому в дальнейшем происходит загрузка контейнеров в методе restore и loadContainer, о чем было указано ранее.
func (container *Container) save() error {
configPath := path.Join(container.Root, "config.json")
fo, err := os.Create(configPath)
if err != nil {
return err
}
defer fo.Close()
enc := json.NewEncoder(fo)
if err := enc.Encode(container); err != nil {
return err
}
return nil
}
func loadContainer(containerPath string) (*Container, error) {
configPath := path.Join(containerPath, "config.json")
fi, err := os.Open(configPath)
if err != nil {
return nil, err
}
defer fi.Close()
enc := json.NewDecoder(fi)
container := &Container{}
if err := enc.Decode(container); err != nil {
return nil, err
}
return container, nil
}
Перейдем к последнему методу вызываемому в функции createContainer - generateLXCConfig:
func (container *Container) generateLXCConfig() error {
fo, err := os.Create(container.lxcConfigPath)
if err != nil {
return err
}
defer fo.Close()
if err := LxcTemplateCompiled.Execute(fo, container); err != nil {
return err
}
return nil
}
Как видим, в нем используется переменная LxcTemplateCompiled. Чтобы разобраться с ней, нам нужно взглянуть на файл lxc_template.go, в котором находится шаблон для конфигурационного файла lxc:
lxc_template.go
const LxcTemplate = `
# hostname
{{if .Config.Hostname}}
lxc.utsname = {{.Config.Hostname}}
{{else}}
lxc.utsname = {{.Name}}
{{end}}
#lxc.aa_profile = unconfined
# network configuration
#lxc.network.type = veth
#lxc.network.flags = up
#lxc.network.link = br0
#lxc.network.name = eth0 # Internal container network interface name
#lxc.network.mtu = 1500
#lxc.network.ipv4 = {ip_address}/{ip_prefix_len}
# root filesystem
lxc.rootfs = {{.Filesystem.RootFS}}
# use a dedicated pts for the container (and limit the number of pseudo terminal
# available)
lxc.pts = 1024
# disable the main console
lxc.console = none
# no controlling tty at all
lxc.tty = 1
# no implicit access to devices
lxc.cgroup.devices.deny = a
# /dev/null and zero
lxc.cgroup.devices.allow = c 1:3 rwm
lxc.cgroup.devices.allow = c 1:5 rwm
# consoles
lxc.cgroup.devices.allow = c 5:1 rwm
lxc.cgroup.devices.allow = c 5:0 rwm
lxc.cgroup.devices.allow = c 4:0 rwm
lxc.cgroup.devices.allow = c 4:1 rwm
# /dev/urandom,/dev/random
lxc.cgroup.devices.allow = c 1:9 rwm
lxc.cgroup.devices.allow = c 1:8 rwm
# /dev/pts/* - pts namespaces are "coming soon"
lxc.cgroup.devices.allow = c 136:* rwm
lxc.cgroup.devices.allow = c 5:2 rwm
# tuntap
lxc.cgroup.devices.allow = c 10:200 rwm
# fuse
#lxc.cgroup.devices.allow = c 10:229 rwm
# rtc
#lxc.cgroup.devices.allow = c 254:0 rwm
# standard mount point
lxc.mount.entry = proc {{.Filesystem.RootFS}}/proc proc nosuid,nodev,noexec 0 0
lxc.mount.entry = sysfs {{.Filesystem.RootFS}}/sys sysfs nosuid,nodev,noexec 0 0
lxc.mount.entry = devpts {{.Filesystem.RootFS}}/dev/pts devpts newinstance,ptmxmode=0666,nosuid,noexec 0 0
#lxc.mount.entry = varrun {{.Filesystem.RootFS}}/var/run tmpfs mode=755,size=4096k,nosuid,nodev,noexec 0 0
#lxc.mount.entry = varlock {{.Filesystem.RootFS}}/var/lock tmpfs size=1024k,nosuid,nodev,noexec 0 0
#lxc.mount.entry = shm {{.Filesystem.RootFS}}/dev/shm tmpfs size=65536k,nosuid,nodev,noexec 0 0
# drop linux capabilities (apply mainly to the user root in the container)
lxc.cap.drop = audit_control audit_write mac_admin mac_override mknod net_raw setfcap setpcap sys_admin sys_boot sys_module sys_nice sys_pacct sys_rawio sys_resource sys_time sys_tty_config
# limits
{{if .Config.Ram}}
lxc.cgroup.memory.limit_in_bytes = {{.Config.Ram}}
{{end}}
`
var LxcTemplateCompiled *template.Template
func init() {
var err error
LxcTemplateCompiled, err = template.New("lxc").Parse(LxcTemplate)
if err != nil {
panic(err)
}
}
Он компилируется на этапе инициализации модуля, а в методе generateLXCConfig после подстановки значений сохраняется на диск в файле config.lxc.
Подведем итог: в методе createContainer выполняется инициализация структуры Container и вспомогательных структур Filesystem, State и writeBroadcaster. Далее в папке containers создается директория с именем контейнера, в которую сохраняется структура Container после экспорта в json. На последнем этапе на основе параметров контейнера генерируется и сохраняется конфигурационный файл для lxc-start.
Далее перейдем к запуску контейнера, который происходит в методе Start из файла container.go
func (container *Container) Start() error {
if err := container.Filesystem.Mount(); err != nil {
return err
}
params := []string{
"-n", container.Name,
"-f", container.lxcConfigPath,
"--",
container.Path,
}
params = append(params, container.Args...)
container.cmd = exec.Command("/usr/bin/lxc-start", params...)
container.cmd.Stdout = container.stdout
container.cmd.Stderr = container.stderr
if err := container.cmd.Start(); err != nil {
return err
}
container.State.setRunning(container.cmd.Process.Pid)
go container.monitor()
// Wait until we are out of the STARTING state before returning
//
// Even though lxc-wait blocks until the container reaches a given state,
// sometimes it returns an error code, which is why we have to retry.
//
// This is a rare race condition that happens for short lived programs
for retries := 0; retries < 3; retries++ {
err := exec.Command("/usr/bin/lxc-wait", "-n", container.Name, "-s", "RUNNING|STOPPED").Run()
if err == nil {
return nil
}
}
return errors.New("Container failed to start")
}
В первой же строке происходит монтирование файловой системы контейнера. Весь функционал по работе с fs находится в filesystem.go, который содержит всего 50 строк кода, так что имеет смысл разобрать его оставшуюся часть, чтобы в дальнейшем больше не возвращаться к нему.
func (fs *Filesystem) createMountPoints() error {
if err := os.Mkdir(fs.RootFS, 0700); err != nil && !os.IsExist(err) {
return err
}
if err := os.Mkdir(fs.RWPath, 0700); err != nil && !os.IsExist(err) {
return err
}
return nil
}
func (fs *Filesystem) Mount() error {
if err := fs.createMountPoints(); err != nil {
return err
}
rwBranch := fmt.Sprintf("%v=rw", fs.RWPath)
roBranches := ""
for _, layer := range fs.Layers {
roBranches += fmt.Sprintf("%v=ro:", layer)
}
branches := fmt.Sprintf("br:%v:%v", rwBranch, roBranches)
cmd := exec.Command("mount", "-t", "aufs", "-o", branches, "none", fs.RootFS)
if err := cmd.Run(); err != nil {
return err
}
return nil
}
func (fs *Filesystem) Umount() error {
return exec.Command("umount", fs.RootFS).Run()
}
Как видим, это всего лишь обертка над утилитой mount. Метод createMountPoints создает две директории:
-
Rootfs - будет точкой монтирования файловой системы контейнера.
-
RWPath - директория с возможностью записи, в которой будут сохраняться все изменения файловой системы происходящие во время работы контейнера.
Метод Mount формирует строку параметров и вызывает утилиту mount с типом aufs. Метод Umount соответственно размонтирует файловую систему.
Все слои образа (Layers) представляют собой пути к директориям, которые монтируются с правами read only в объединенную файловую систему в точке RootFS, а все изменения происходят в директории RWPath и сохраняются даже после размонтирования. Файловая система Aufs имеет механизм copy-on-write, это означает, что когда происходит попытка изменения файла находящегося в read-only слое, он будет автоматически скопирован в RWPath.
Теперь вернемся к функции Start из container.go. После монтирования файловой системы она подготавливает параметры и запускает утилиту lxc-start, перед этим перенаправляя потоки stdout и stderr. В качестве параметров ей передается имя запускаемого контейнера и путь к конфигурационному файлу config.lxc, который был сгенерирован ранее на этапе создания контейнера. После запуска процесса его pid сохраняется в структуре State:
func (s *State) setRunning(pid int) {
s.Running = true
s.ExitCode = 0
s.Pid = pid
s.broadcast()
}
Далее запускается горутина monitor, которая ждет завершения процесса, закрывает потоки stdout и stderr и сохраняет exitcode контейнера через метод setStopped:
func (container *Container) monitor() {
container.cmd.Wait()
container.stdout.Close()
container.stderr.Close()
container.State.setStopped(container.cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus())
}
func (s *State) setStopped(exitCode int) {
s.Running = false
s.Pid = 0
s.ExitCode = exitCode
s.broadcast()
}
Осталось разобрать метод Stop, который предназначен для остановки контейнеров:
func (container *Container) Stop() error {
if container.State.Running {
if err := exec.Command("/usr/bin/lxc-stop", "-n", container.Name).Run(); err != nil {
return err
}
//FIXME: We should lxc-wait for the container to stop
}
if err := container.Filesystem.Umount(); err != nil {
// FIXME: Do not abort, probably already umounted?
return nil
}
return nil
}
Здесь тоже все предельно просто - если контейнер еще работает, то пытаемся остановить его с помощью вызова утилиты lxc-stop, после чего размонтируем файловую систему.
Остальные методы и функции представляют собой разнообразные хелперы, которые я не вижу большого смысла разбирать.
Запуск контейнеров
Теперь мы можем попробовать запустить контейнер. Как я указывал в начале, все операции выполняются на чистой виртуалке с Ubuntu 20.04.
Перед продолжением нужно установить golang и lxc, а так же скачать rootfs для образа (я буду использовать alpine linux).
sudo apt-get update && sudo apt-get install -y golang lxc
mkdir -p /home/vagrant/docker/images/alpine
curl https://dl-cdn.alpinelinux.org/alpine/v3.14/releases/x86_64/alpine-minirootfs-3.14.1-x86_64.tar.gz | tar -xz -C /home/vagrant/docker/images/alpine
Проверим что rootfs на месте:
vagrant@ubuntu-focal:~/engine$ ls /home/vagrant/docker/images/alpine/
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
Теперь нужно сделать небольшие изменения, так как некоторые опции lxc поменялись в новой версии, а так же поправим метод restore в docker.go. Ниже приведен патч:
docker.patch
diff --git a/container.go b/container.go
index 77fd7c0fec..3787ed0278 100644
--- a/container.go
+++ b/container.go
@@ -110,6 +110,7 @@ func (container *Container) Start() error {
params := []string{
"-n", container.Name,
"-f", container.lxcConfigPath,
+ "-F",
"--",
container.Path,
}
diff --git a/docker.go b/docker.go
index 3bdf694664..929fd4cf53 100644
--- a/docker.go
+++ b/docker.go
@@ -81,6 +81,10 @@ func (docker *Docker) restore() error {
}
for _, v := range dir {
container, err := loadContainer(path.Join(docker.repository, v.Name()))
+ container.State = newState()
+ container.lxcConfigPath = path.Join(container.Root, "config.lxc")
+ container.stdout = newWriteBroadcaster()
+ container.stderr = newWriteBroadcaster()
if err != nil {
fmt.Errorf("Failed to load %v: %v", v.Name(), err)
continue
diff --git a/lxc_template.go b/lxc_template.go
index af9855c663..d13d4cbba5 100644
--- a/lxc_template.go
+++ b/lxc_template.go
@@ -7,9 +7,9 @@ import (
const LxcTemplate = `
# hostname
{{if .Config.Hostname}}
-lxc.utsname = {{.Config.Hostname}}
+lxc.uts.name = {{.Config.Hostname}}
{{else}}
-lxc.utsname = {{.Name}}
+lxc.uts.name = {{.Name}}
{{end}}
#lxc.aa_profile = unconfined
@@ -22,17 +22,17 @@ lxc.utsname = {{.Name}}
#lxc.network.ipv4 = {ip_address}/{ip_prefix_len}
# root filesystem
-lxc.rootfs = {{.Filesystem.RootFS}}
+lxc.rootfs.path = {{.Filesystem.RootFS}}
# use a dedicated pts for the container (and limit the number of pseudo terminal
# available)
-lxc.pts = 1024
+#lxc.pts = 1024
# disable the main console
-lxc.console = none
+#lxc.console = none
# no controlling tty at all
-lxc.tty = 1
+#lxc.tty = 1
# no implicit access to devices
lxc.cgroup.devices.deny = a
Далее выполним go mod init docker и остается лишь написать простую cli программу для управления контейнерами. Создадим папку cli c файлом docker.go и подобным контентом:
cli/docker.go
package main
import (
"docker"
"fmt"
"os"
)
func main() {
if len(os.Args) < 3 || (os.Args[1] == "run" && len(os.Args) < 4) {
fmt.Println("Usage: docker run|destroy container_name [cmd] [args]")
os.Exit(1)
}
Docker, err := docker.NewFromDirectory("/home/vagrant/docker")
if err != nil {
panic(err)
}
name := os.Args[2]
container := Docker.Get(name)
switch os.Args[1] {
case "destroy":
if container != nil {
err := Docker.Destroy(container)
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("Container %s has been destroyedn", name)
}
} else {
fmt.Printf("Container %s does not existn", name)
}
case "run":
cmd := os.Args[3]
args := os.Args[4:]
if container == nil {
container, err = Docker.Create(
name,
cmd,
args,
[]string{"/home/vagrant/docker/images/alpine"},
&docker.Config{},
)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
} else {
container.Path = cmd
container.Args = args
}
defer container.Stop()
output, err := container.Output()
if err != nil {
fmt.Println(err)
} else {
fmt.Println(string(output))
}
default:
fmt.Printf("Invalid command %s. Use (run|destroy)n", os.Args[1])
}
}
Теперь скомпилируем программу и запустим контейнер:
vagrant@ubuntu-focal:~/engine/cli$ go build docker.go
vagrant@ubuntu-focal:~/engine/cli$ sudo ./docker run test cat /etc/alpine-release
3.14.1
vagrant@ubuntu-focal:~/engine/cli$ sudo tree /home/vagrant/docker/containers/
/home/vagrant/docker/containers/
└── test
├── config.json
├── config.lxc
├── rootfs
└── rw
3 directories, 2 files
Как видим после запуска была создана папка с именем контейнера, в которой были сохранены конфигурационные файлы и созданы директории для файловой системы.
Запустим еще один контейнер и создадим в нем файл:
vagrant@ubuntu-focal:~/engine/cli$ sudo ./docker run test2 touch /file.txt
vagrant@ubuntu-focal:~/engine/cli$ sudo tree /home/vagrant/docker/containers/
/home/vagrant/docker/containers/
├── test
│ ├── config.json
│ ├── config.lxc
│ ├── rootfs
│ └── rw
└── test2
├── config.json
├── config.lxc
├── rootfs
└── rw
└── file.txt
6 directories, 5 files
Запустим /bin/sh в созданном контейнере:
vagrant@ubuntu-focal:~/engine/cli$ sudo ./docker run test2 /bin/sh
/ # ls
bin dev etc file.txt home lib media mnt opt proc root run sbin srv sys tmp usr var
При каждом запуске происходит монтирование файловой системы, так что можем убедиться, что файл доступен при повторном запуске контейнера.
Теперь попробуем удалить контейнер:
vagrant@ubuntu-focal:~/engine/cli$ sudo ./docker destroy test2
Container test2 has been destroyed
vagrant@ubuntu-focal:~/engine/cli$ sudo tree /home/vagrant/docker/containers/
/home/vagrant/docker/containers/
└── test
├── config.json
├── config.lxc
├── rootfs
└── rw
3 directories, 2 files
Как видим, все файлы контейнера test2 были успешно удалены.
Заключение
Вот приблизительно так выглядел весь функционал Docker на момент его зарождения.
В следующей статье мы перенесемся в март 2013, когда код докера впервые был выложен в публичный доступ, и посмотрим какие изменения произошли за это время.
Автор:
nick1612