Делаем свой контейнер в 100 строчек кода

Перевод статьи "Build Your Own Container Using Less than 100 Lines of Go"

Docker зарелизился в марте 2013 и произвел довольно громкий переворот в индустрии програмного обеспечения, изменив подход к упаковке и деплою современных приложений. После Docker появилось много различных приложений, которые используют или дополняют Docker, а затем настал момент понимания, что контейнеризация все еще не серебрянная пуля. Цель этой статьи - избавиться от налета таинственности и рассказать как все работает на самом деле.

В этой серии статей мы поговорим о технологиях, которые лежат в основе контейнеров, что сейчас пользуется популярностью у разработчиков, рассмотрим частые проблемы, возникающие при развертывании контейнеров(например, интеграция с CI и СD) и поговорим о мониторинге при переменной нагрузке и частой смене потенциалов. В заключении мы помечтаем о будущем контейнеризации и обсудим, какой популярностью пользуется unikernels в самых передовых организациях.

Эта статья одна из серии "Containers in the Real World - Stepping Off the Hype Curve". Вы можете подписаться на обновления через RSS.

Проблема всех аналогий в том, что они, как правило, выключают ваш мозг, когда вы их слышите. Некоторые считают, что архитектура програмного обеспечения это "просто как" архитектура зданий. Но на самом деле, это не так. А очень удачное звучание такой аналогии приносит еще больше вреда. Продолжая тему аналогий, вспомним, что контейнеры часто ассоциируют с транспортными контейнерами. Можно сказать, что контейнеры можно перемещать "просто как" обычные грузовые контейнеры. Но это не совсем так. И такая аналогия скрывает множество деталей.

Чтобы реально понимать что такое контейнеры и какое место они занимают в мире разработки ПО, необходимо понимать как они работают. И в этой статье мы как раз об этом поговорим. Узнаем отличия между контейнерами и контейнеризацией, обсудим контейнеры в Linux (включая namespace, cgroup и слоенные файловые системы). Напишем немного кода, чтобы создать простой контейнер с нуля, и, наконец, сделаем выводы и посмотрим куда нас все это приведет.

Что такое контейнеры на самом деле?

Я люблю играть в игры. Давайте сыграем в одну прямо сейчас. Представте, что вам нужно ответить на вопрос "что такое контейнер?". Ответили? Попытаюсь угадать ваши ответы:

  • Способ совместного использования ресурсов.
  • Изоляция процессов.
  • Один из способов легкой виртуализации.
  • Упаковка корневой файловой системы и метаданных вместе.
  • Своеобразная chroot тюрьма.
  • Что-то вроде транспортного контейнера.
  • Все что делает докер.

Посмотрите, одно слово означает довольно много вещей. Так сложилось, что слово "контейнер" стали использовать для обозначения большого количества различных концепций. Оно используется как для определения аналогии с контейерезацией, так и для определения технологий для реализации контейнеров. Если рассматривать их по отдельности, то все становится прозрачнее. И так, давайте поговорим, зачем нам нужны контейнеры. А затем узнаем, как нам этого добиться(и потом снова вернемся к первому вопросу).

В начале

В начале была программа. Представим, что у нас есть некоторая программа run.sh, которую мы собираемся скопировать на сервер и там запустить. Но вот такой бездумный запуск программ на удаленном сервере - это дело не благородное, а порой даже не безопасное. Именно поэтому люди изобрели виртуальные серверы и права доступа. И все было хорошо.

Но простой запуск run.sh не возможен без наличия всех необходимых ему зависимостей. Нужны определенные библиотеки, установленные на этом хосте. Кроме того, программы, как правило, не хотят работать одинаково на локальном хосте и на удаленном(и не пробуйте меня переубеждать). В результате мы изобрели AMI (Amazon Machine Images), и VMware образы, Vagrantfile, и многое другое. И все было хорошо.

Почти хорошо. Все эти пачки образов сложно было доставлять конечному потребителю, они были неоправданно большими и очень плохо стандартизированными. Поэтому, мы придумали кеширование.

И все опять стало хорошо.

Кеширование - это именно то, что очень выгодно отличает Docker контейнеры от VMDK или Vagrantfile. Теперь мы можем работать только с дельтами базовых образов, а не с полными огромными образами. Это означает, что мы можем запаковать рабочую среду и перенести ее на другую машину. Вот почему, когда вы запускаете docker run whatever, то Docker поднимается очень быстро, даже если это выглядит как запуск нового образа системы. Мы поговорим о том, как это работает чуть позже.

И вот теперь мы можем сказать, что такое контейнеры на самом деле. Это инструмент для сборки зависимостей таким образом, чтобы мы могли запускать код где угодно воспроизводимо и безопасно. Но это все определения на очень высоком уровне. Давайте поговорим о реализации.

Создание контейнеров

И так, что такое контейнер в более практическом смысле? Бы ло бы очень замечательно, если контейнер создавался с помощью системного вызова create_container. Но, к сожалению, это не так. Хотя, если откровенно, то не многим сложнее.

Чтобы говорить о контейнерах на более низком уровне, нам нужно разбираться в трех вещах: пространствах имен(namespaces), cgroups, и слоенных файловых системах(layered filesystems). Конечно, нужно еще много всего, но именно эти три вещи делают всю магию.

Пространства имен

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

Пространства имен бывают:

  • PID: Это пространство имен предоставляет процессу и его потомкам собственное видение набора процессов в системе. Это можно представить себе как таблицу соответствия. Когда процесс в пространстве PID запрашивает у системы список процессов, то ядро сначало смотрит в эту таблицу соответствия. Если процесс существует в этой таблице, то используется соответствующий ID вместо реального ID. Если же процесса нет в таблице, то ядро делает вид, что процесса вообще не существует. Пространство PID задает первому процессу ID 1, таким образом, мы можем изолировать дерево процессов для каждого контейнера.
  • MNT: Это одно из самых важных пространств имен. Оно предоставляет процессу отдельные таблицы монтирования. Это значит, что процесс может монтировать и отмонтировать необходимые директории не затрагивая других пространств имен(в том числе и основное). И еще один важный момент: в сочетании с использованием системного вызова pivot_root мы можем позволить процессу иметь свою собственную файловую систему. Именно благодаря этой фиче, просто переключая файловые системы, которые видит контейнер, мы можем сделать вид, что процесс запущен на ubuntu, busybox или alpine.
  • NET: Сетевое пространство имен предоставляет процессу возможность использовать свой собственный сетевой стек. Конечно, только процессы из главного сетевого пространства имен (те, которые запускаются, когда вы включаете компьютер) имеют доступ к реальным сетевым картам. Но мы можем создавать виртуальные изернет пары - виртуально связанные сетевые карты в разных пространствах имен. Это выглядит как наличие нескольких ip стеков на одной машине, которые могут общаться друг с другом. Если добавить немного магии роутинга, то можно предоставить контейнерам доступ в реальный мир, несмотря на изоляцию в своем собственно ip стеке.
  • UTS: Пространство имен предоставляет процессу возможность иметь свои собственные имена для хоста и домена. После настройки пространства имен UTS, любые изменения имени хоста или домена не затронут другие процессы.
  • IPC: Это пространство имен изолирует механизм межпроцессорного взаимодействия, таких как очередь сообщений. Для более глубокого понимания, загляните в документацию.
  • USER: Это пространство имен было добавленно совсем недавно и именно настройка этого пространства оказывает наибольшее влияние на безопасность работы контейнера. Пространство USER выполняет мапинг между UID'ами в рамках процесса к UID'ами (и GID'ами) самого хоста. Это очень полезно. Используя это пространство имен, мы можем привязать ID пользователя root в контейнере (например 0) к произвольному и непривилегированному UID на реальном хосте. Таким образом, мы можем предоставить root доступ к ресурсам внутри контейнера, но, на самом деле, не предоставляя root права в рамках всего хоста. Контейнер может запускать процессы с UID 0 (что означает root пользователя) но в ядре будет происходить мапинг этого UID с некоторым непривилегированном реальным UID. Большинство систем контейнеризации не мапят UID внутри контейнера на нулевой UID в вызывающем пространстве.

Почти все современные системы контейнеризации размещают пользовательский процесс во всех указанных пространствах имен для создания специального окружения. Это необходимо, как пример, для создания некоторой исходной сети внутри изолированного сетевого пространства имен с возможностью подключения к реальной сети.

Cgroups

Cgroups это тема для отдельной статьи и не одной (думаю, я когда ни будь этим займусь). В рамках этого туториала я опишу их совсем кратко. Если есть желание или проблемы с пониманием основной концепции, то всегда можете обратиться к официальной документации.

Если рассматривать cgroups поверхностно, то это такой механизм, с помощью которого можно собрать некоторый набор идентификаторов задач и применить к этому набору определенные ограничения. В то время, как пространства имен позволяют изолировать процесс, cgroups позволяют справедливо(или не справедливо) распределить ресурсы между процессами.

Cgroups предоставляются ядром как специальная файловая система, которую вы можете примонтировать. Вы можете добавить процесс или поток в cgroup просто указав идентификатор в специальном файле задач, а затем выполнять тонкую настройку просто редактируя этот файл.

Слоенный файловые системы

Пространства имен и cgroups это инструменты отвечающие за изоляцию и распределения ресурсов для процессов. Это большие стальные ящики и суровые охранники в порту. Слоенные файловые системы это инструмент, который позволяет перемещать целые образы машин. Если продолжить сравнение с портом, то это механизм, который позволяет кораблю плыть с контейнерами на борту.

На базовом уровне, слоенные файловые системы оптимизируют создание копий основных файловых систем для каждого контейнера. Это можно сделать различными способами. Btrfs использует копирование при записи на уровне файловой системы. Aufs использует "объединенное монтирование". Способов действительно очень много. В рамках этой статьи мы будем использовать самый простой: просто делать копию. Это очень медленный способ но он работает и прост для понимания.

Создание контейнера по шагам

Шаг первый. Создаем шаблон приложения

Для начала создадим базовый шаблон. Я предполагаю, что у вас установленна последняя версия языка Go. Все начинается с вот такого простого кода:

package main

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

func main() {
    switch os.Args[1] {
    case "run":
        parent()
    case "child":
        child()
    default:
        panic("wat should I do")
    }
}

func parent() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        fmt.Println("ERROR", err)
        os.Exit(1)
    }
}

func child() { 
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        fmt.Println("ERROR", err)
        os.Exit(1)
    }
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

Что делает этот код? Мы читаем первый аргумент, если это строка "run", то запускается метод parent(), а если это строка "child", то запускаем метод child(). В методе parent() запускается специальный файл /proc/self/exe, который содержит образ памяти для текущего исполняемого файла. Другими словами, мы заново запускаем себя же, но с указанием параметра "child".

Что это за сумашествие? Ну, на самом деле, это еще не очень большое сумашествие. Просто, теперь мы можем запускать другу программу, которая, в свою очередь, запускает программу указанную пользователем(параметр os.Args[2:]). Теперь у нас есть каркас, перейдем к созданию контейнера.

Шаг второй. Используем пространства имен

Чтобы начать использовать пространства имен, достаточно добавить одну строчку кода. Просто вставляем указанный ниже коду на вторую строку метода parent(). Таким образом, мы добавим специальные флаги, которые будут влиять на запуск дочернего процесса.

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}

Теперь ваша программа будет запускаться внутри пространств имен UTS, PID и MNT.

Шаг третий. Корневая файловая система.

Теперь наш процесс запускается изолированно, с установленным набором пространств имен (можете поэксперементировать, добавляя флаги для использования других пространств). Но файловая система такая же, что и на хостовой машине. Так и должно быть, несмотря на то, что указан флаг для использования пространства имен MNT, но начальные точки монтирования унаследованы от родительского пространства имен.

Давайте изменим это. Нам нужно добавить несколько строчек кода, чтобы переключится в корневую файловую систему. Добавим код в функцию child().

must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
must(os.MkdirAll("rootfs/oldrootfs", 0700))
must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
must(os.Chdir("/"))

Две последние строчки самые важные, они сообщают файловой системе, сменить текущую директорию с / на rootfs/oldrootfs, и переключить новую rootfs директорию на /. После вызова pivotroot, директория / внутри контейнера указывает на rootfs (биндинг при монтировании необходим, чтобы удовлетворить требованиям команды pivotroot - OS требует использовать pivotroot для переключения файловых систем, котрые не являются частями одного дерева)

Шаг четвертый. Инициализация мира контейнера

На текущий момент, у нас есть процесс, запущенный с набором изолированных пространств имен, с своей собственной корневой файловой системой. Мы не стали рассматривать настройку cgroups, хоть это и достаточно просто. Также, мы не занимались настройкой, которая позволила бы эффективно скачивать и кешировать корневую файловую систему.

И мы пропустили этап настройки самого контейнера. Сейчас у нас есть сырой контейнер, запущенный с использованием изолированных пространствах имен. Мы настроили MNT пространство, но все остальное осталось по умолчанию. В реальной жизни, нам нужно было бы настроить "мир" контейнера перед запуском пользовательского процесса. К примеру, мы могли бы настроить сеть, переключится на необходимый UID до запуска процесса, установить необходимые лимиты (например, rlimits) и так далее. Но это уже больше чем 100 строчек кода.

Шаг пятый. Собираем все вместе

И вот, все готово. Очень-очень простой контейнер, менее чем на 100 строк кода. Конечно же, мы специально делали его таким простым. Если вы вздумаете использовать его в продакшене, то вас сразу заберут в дурку. Но, тем не менее, это наше творчество дает хорошее представление о том, как устроены контейнеры.

package main

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

func main() {
    switch os.Args[1] {
    case "run":
        parent()
    case "child":
        child()
    default:
        panic("wat should I do")
    }
}

func parent() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        fmt.Println("ERROR", err)
        os.Exit(1)
    }
}

func child() {
    must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
    must(os.MkdirAll("rootfs/oldrootfs", 0700))
    must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
    must(os.Chdir("/"))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        fmt.Println("ERROR", err)
        os.Exit(1)
    }
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

К чему все это?

Тут я буду немного противоречивым. Для меня, контейнеры это способ доставки кода куда угодно и инструмент для дешевого(в плане ресурсов) изолированного запуска процесса, но это еще не конец истории. Контейнеры - это набор технологий, а не пользовательский опыт и умения.

Как пользователь, я хочу пользоваться контейнерами не больше чем покупатель использует amazon.com для приобретения нового телефона - ему не приходиться договариваться о отгрузке товара в порту. Контейнеры - это фантастическая технология, которая построена поверх многих инструментов и нам не приходиться отвлекаться на способы разворачивания образов вместо действительно важных дел.

Платформы как Сервис(PaaS), такие как Cloud Foundry, построенные поверх контейнеров, начинали именно с управления кодом, а не с контейнеризации. Большинства разработчиков просто хочет запустить свое приложение нажатием одной кнопки. За кулисами таких сервисов происходит много всего: запускаются контейнеры, как-то настраиваются и т.д. В случае с Cloud Foundry вы можете вообще пропустить этот шаг и использовать подготовленные Docker файлы.

Тем не менее, используя PaaS вся гибкость и мощь контейнеров никуда не девается. Это и управление ресурсами, создание сред и т.д. При этом добавляется возможность использовать простой пользовательский интерфейс, который не требует от разработчика большого опыта и познания в области контейнеров. Кроме того, всегда можно быстро и незаметно добавить быстрые заплатки уязвимости. Базы данных, очереди сообщение и различные сторонние сервисы могут быть представленны как услуги, которые можно привязать к вашему приложению и, таким образом, вообще не замечать, что все построено на контейнерах.

updatedupdated2021-03-062021-03-06