В настоящее время резко возрос спрос на контейнеры, используемые в продакшене для эксплуатации больших энтерпрайз-приложений. Как правило, они развёртываются в Docker. Docker де-факто стал основной технологией для работы с контейнеризованными приложениями. Но на основе чего он построен? Как он контейнеризует приложения? В данной статье постараемся ответить на эти вопросы.
❯ Зачем нужны контейнеры
Прежде, чем поговорить о контейнерах, давайте разберёмся, что такое процесс.
Процесс создаётся, когда программа идёт в выполнение.

Как выполнить программу? Какие для этого существуют требования?
Программе требуются библиотеки, окружения и ресурсы. Располагая всем этим, можно приступить к её выполнению и для этого создать процесс. Например, чтобы выполнить скрипт на Python, нам нужен двоичный файл с кодом Python, те или иные модули Python, рабочие окружения для Python и такие ресурсы, как процессор (ЦП), память, диск.

Рассмотрим веб-приложение, состоящее из множества микросервисов, написанных на разных версиях разных языков. Эти микросервисы – просто процессы на бэкенде.

Теперь, учитывая всё вышесказанное, представим, как эти сервисы будут выполняться на реальной машине. Это не так просто, вернее, ЕДВА ВОЗМОЖНО.
Но почему? В чём же проблема эксплуатировать все эти сервисы на одной физической машине, ведь они относятся к одному и тому же веб-приложению?
Проблема такова: допустим, в приложении два сервиса на Java, причём, они написаны на разных версиях Java. Каково тогда будет значение JAVA_HOME? Ведь на одной физической машине не могут быть установлены две разные JAVA_HOME.
Если нам требуется эксплуатировать N сервисов на одной машине, то каждому сервису мы должны присвоить отдельный порт, так, чтобы между портами не возникало коллизий. Допустим, у нас в системе работает две версии postgres: pg10 на порту 5432 и pg9.6 на порту 5433. Так что сервис должен знать, на каком порту он работает:

Если подытожить описанную выше проблему в двух словах, то у нас НЕТ ИЗОЛЯЦИИ.
Поскольку у нас используются разные версии библиотек и, следовательно, им требуются разные окружения, все сервисы до единого должны быть изолированы друг от друга.
Чтобы изолировать процессы, мы научились пользоваться виртуальными машинами. Рассмотрим, как эта проблема решается на виртуальных машинах.

Виртуальная машина — это отдельная гостевая операционная система. Её создаёт гипервизор, работающий на хост-машине. Именно в такой отдельной гостевой системе изолируются библиотеки, окружения и ресурсы.
Но в такой ситуации возникают некоторые проблемы с использованием виртуальных машин.
Представьте, каково будет выполнять все вышеупомянутые сервисы (процессы) на физической машине, создавая при этом виртуальные машины по одной на каждый сервис.
Естественно, если на одной физической машине будет работать 10+ виртуальных, вы увидите, как это сказывается на производительности. Дело в том, что на гостевой операционной системе для каждой виртуальной машины отдельно выполняется управление памятью, управление сетью и т.д.
Более того, при использовании виртуальных машин оказывается очень сложно как следует организовать использование ресурсов.
Основной источник издержек, возникающих при применении виртуальных машин — гипервизор. Именно он виртуализует железо, создавая гостевую операционную систему для каждой виртуальной машины.
Нам всего лишь нужна такая среда, в которой можно было бы обособить библиотеки, окружения и ресурсы без необходимости создавать отдельную ОС. Почему не передать всё управление ресурсами в операционную систему хоста, а не виртуализовывать железо — и тем самым избежать издержек?
Да, есть такая штука, «КОНТЕЙНЕР», при помощи которой именно это и можно сделать.
Контейнер — не что иное, как изолированный процесс, реализованный с использованием некоторых технологий Linux, в частности, пространств имён и групп cgroup.
Теперь давайте подробно разберём, что такое контейнеры, как они обеспечивают изоляцию процесса, что такое пространства имён и cgroup, и как всё это используется.
Контейнер — это группа процессов, работающих на хост-машине. Причём, отдельные процессы изолированы в своих пространствах имён.
Он обеспечивает виртуализацию на уровне операционной системы. Поэтому контейнер иногда называют «легковесной виртуальной машиной».
Это базовые моменты. Давайте теперь разберёмся, как создавать контейнеры. Многие привыкли создавать контейнеры при помощи Docker командой docker run. Но разве это единственный вариант? Нет, есть и другие инструменты, в частности, lxc, podman, т.д. Как при помощи этих инструментов создаются контейнеры? Что при этом происходит на бэкенде?
Чтобы ответить на эти вопросы, давайте создадим контейнер с нуля, воспользовавшись вышеупомянутыми технологиями Linux.
❯ Простой контейнер на Golang
Давайте напишем на Go простую программу, которая принимает в качестве аргумента команду и выполняет эту команду, создавая новый процесс. Допустим, эта программа на Go — Docker. Чтобы выполнить действие в Docker, применяется команда “docker run”. Аналогично, в данном случае выполним “go run container.go run”
package main
import (
"fmt"
"os"
"os/exec"
)
// go run container.go run <cmd> <args>
// docker run <cmd> <args>
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("invalid command!!")
}
}
func run() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}
Вышеприведённая программа выполняет полученный аргумент как команду. Как показано далее, “go run container run echo hello container” выполняет команду “echo hello container”. При этом создаётся новый процесс, который можно считать контейнером.

Аналогично, давайте создадим процесс при помощи /bin/bash
и присвоим этому контейнеру выделенное хост-имя. Меняя хост-имя внутри контейнера, мы в то же время меняем и имя хост-машины.

Дело в том, что в этом контейнере не изолируется хост-имя. Итак, чтобы изолировать хост-имя, мы должны присвоить этому контейнеру новое пространство имён UTS. В golang это можно сделать при помощи пакета syscall
.
func run() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Run()
}
Теперь, если вы измените хост-имя контейнера, это не повлияет на хост-имя хост-машины, поскольку у контейнера своё собственное пространство имён UTS.

Но я хочу автоматически присваивать контейнеру хост-имя прямо из программы на golang, это я делаю при помощи системного вызова syscall.Sethostname([]byte("container-demo"))
. Но там, где я поставлю эту функцию в вышеприведённой программе, в функции cmd.Run
()
будет создаваться процесс, который будет выходить в той же строке. Соответственно, давайте ответвим дочерний процесс, и уже внутри него установим хост-имя.
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
// go run container.go run <cmd> <args>
// docker run <cmd> <args>
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("invalid command!!")
}
}
func run() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
args := append([]string{"child"}, os.Args[2:]...)
cmd := exec.Command("/proc/self/exe", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Run()
}
func child() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
syscall.Sethostname([]byte("container-demo"))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
}

Здесь есть ещё одна тонкость: сейчас контейнеру видны все процессы, выполняемые на хост-машине.

Правильно, чтобы контейнеру были видны лишь процессы, выполняемые в этом контейнере. Этого можно добиться, используя пространство имён PID.
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
}

Даже в таком случае контейнер по-прежнему в состоянии видеть процессы на хосте. Всё дело в /proc
; контейнер использует ровно ту же корневую файловую систему, что и хост-машина. Это неправильно, так как для контейнера должна использоваться другая корневая файловая система, к которой требуется монтировать /proc
. В каталоге /containerfs
содержатся файлы операционной системы, в частности, некоторые двоичные файлы — например, утилиты python и core linux. Таким образом, когда этот каталог монтируется как корневая файловая система для контейнера, такая система становится самодостаточной для работы утилит linux и не зависит от хост-машины с её двоичными файлами. Также она предоставляет для данного контейнера отдельное окружение.
func child() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
syscall.Sethostname([]byte("container-demo"))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
syscall.Chroot("/containerfs")
os.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")
cmd.Run()
}

Итак, при помощи пространства имён PID мы добились изоляции id всех процессов. Аналогично, можно обеспечить изоляцию сети и пользователей, опираясь на сетевые и пользовательские пространства имён.
В принципе, пространство имён – и есть то, что вы увидите в контейнере. При помощи пространств имён можно создавать ограниченные представления систем. Например, можно показать только дерево процессов, только сетевые интерфейсы, показать пользователей или какие каталоги монтированы в систему:
Пространство имён UTS (Unix с разделением времени): хост-имя и доменное имя
Пространство имён PID: номер процесса
Пространство имён монтирования: точки монтирования
Пространство имён IPC: ресурсы для межпроцессной коммуникации
Сетевое пространство имён: сетевые ресурсы
Пользовательское пространство имён: ID пользователей и групп
Теперь давайте рассмотрим, как в контейнере устроено управление ресурсами. У меня есть скрипт на python hungry.py
, потребляющий 10 МБ памяти каждые 0,5 секунд. Если выполнить этот скрипт при помощи программы container.go, то процесс контейнера сможет постепенно потребить всю память, доступную на хост-машине.
Для управления ресурсами — в частности, памятью, ЦП, блоками диска — можно воспользоваться cgroups (контрольными группами). В каждой системе предусмотрен каталог для контрольных групп /sys/fs/cgroup/
, а память по умолчанию использует значения, указанные в /sys/fs/cgroup/memory. Как вы можете убедиться, в /sys/fs/cgroup/memory/memory.limit_in_bytes
задано огромное значение, поэтому процесс может потребить столько памяти, сколько есть на хосте.
В рассматриваемой здесь программе на go я создаю контрольную группу prabhu
, задаю максимальный предел памяти 100 МБ, а также отключаю подкачку памяти. Ещё я присваиваю задачам контрольной группы prabhu
id процесса, соответствующего контейнеру.
func child() {
fmt.Printf("Running %v as PID %d \n", os.Args[2:], os.Getpid())
syscall.Sethostname([]byte("container-demo"))
controlgroup()
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
syscall.Chroot("/containerfs")
os.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")
cmd.Run()
}
func controlgroup() {
cgPath := filepath.Join("/sys/fs/cgroup/memory", "prabhu")
os.Mkdir(cgPath, 0755)
ioutil.WriteFile(filepath.Join(cgPath, "memory.limit_in_bytes"), []byte("100000000"), 0700)
ioutil.WriteFile(filepath.Join(cgPath, "memory.swappiness"), []byte("0"), 0700)
ioutil.WriteFile(filepath.Join(cgPath, "tasks"), []byte(strconv.Itoa(os.Getpid())), 0700)
}

Поскольку контейнерному процессу присвоена контрольная группа prabhu
, этот процесс может потребить не более 100 МБ памяти. Если он пытается потребить больше, то уничтожается.
Системный администратор, пользуясь контрольными группами, может с высокой детализацией управлять ресурсами системы — их выделением, приоритезацией, отклонением и мониторингом. Аппаратные ресурсы можно соответствующим образом распределять между задачами и пользователями, повышая общую эффективность работы. Следовательно, при помощи cgroups удобно управлять ресурсами в контейнерной экосистеме.
В этом посте мы создали простой контейнер, в котором изолировали хост-имя, смонтировали (/proc) и дерево процессов при помощи соответствующих пространств имён, а управление памятью в контейнере организовали при помощи контрольных групп.
❯ Заключение
Контейнеры — это просто обособленные группы процессов, работающие на одной хост-машине. Описанный здесь подход к изоляции позволяет задействовать несколько базовых технологий, встроенных в ядро Linux, в частности, пространства имён, cgroups и chroots.
Именно так Docker контейнеризует приложения, и при этом предоставляет множество других возможностей — в частности, хранит и передаёт файлы в форме образов docker.
Docker — не единственная технология для работы с контейнерами. Существующие альтернативы — Podman от RedHat, LXC Linux Containers.
Статья написана под впечатлением от Building a container from scratch in Go - Liz Rice (Microscaling Systems)
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

slonopotamus
Windows Containers: ну да, ну да, пошли мы нахер...