Deep into Container — Создайте свой собственный контейнер с помощью Golang

Привет, ребята, продолжая серию статей «Глубоко в контейнер», мы уже знаем, что контейнеры создаются на основе пространств имен Linux и групп Cgroups, и чтобы узнать об этом более глубоко, мы собираемся научиться создавать свой собственный контейнер с помощью Golang.

В этой статье я использовал ссылки на Build Your Own Container Using Less than 100 Lines of Go Джулиана Фридмана и Building a container from scratch in Go Лиз Райс.


Это четвертая часть цикла «Глубоко в контейнер»:

  1. Пространства имен Linux и группы Cgroups: Из чего состоят контейнеры?
  2. Глубоко в Container Runtime.
  3. Как Kubernetes работает с Container Runtime.
  4. Deep into Container — Build your own container with Golang.

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

Создайте файл с именем container.go и напишите простой код следующим образом.

package main

import (
    "os"
)

func main() {

}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Если вы знакомы с Docker, то знаете, что команда для запуска контейнера — это, например, docker run <container> <command>:

docker run busybox echo "A"
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы увидите, как контейнер запустится и напечатает букву «A», а если вы выполните следующую команду:

docker run -it busybox sh
Enter fullscreen mode Выйти из полноэкранного режима

Контейнер запустится и к нему присоединится оболочка.

/ #
Войти в полноэкранный режим Выйти из полноэкранного режима

Если мы сейчас введем команду, она будет запущена в контейнере.

/ # hostname
d12ccc0e00a0
Войти в полноэкранный режим Выход из полноэкранного режима
/ # ps
PID   USER     TIME  COMMAND
1     root      0:00 sh
9     root      0:00 ps
Войти в полноэкранный режим Выход из полноэкранного режима

Команда hostname не печатает имя хоста сервера, которая печатает имя хоста контейнера, а команда ps печатает только два процесса.

Теперь мы создадим подобный контейнер, используя Golang, обновим container.go следующим образом.

package main

import (
    "os"
)

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

func run() {

}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Мы добавляем функцию run() и в главной функции используем синтаксис switch case, чтобы проверить, что когда мы запускаем программу с флагом run, она выполнит функцию run(). Теперь, когда мы выполним команду go run container.go run, это будет аналогично тому, как если бы мы запустили docker run.

Далее мы обновим функцию run() следующим образом.

package main

import (
    "os"
  "os/exec"
)

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

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

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы используем пакет os/exec для выполнения команд пользовательского ввода, которые хранятся в массиве os.Args, например, когда мы набираем go run container.go run echo "A", то os.Args будет иметь значение:

Args[0] = "container.go"
Args[1] = "run"
Args[2] = "echo"
Args[3] = "A"
Войти в полноэкранный режим Выйти из полноэкранного режима

Значение, которое нам нужно передать в exec.Command(), мы получим из второго индекса os.Args. Синтаксис функции Command() следующий.

exec.Command(name string, arg ...string)
Войти в полноэкранный режим Выйти из полноэкранного режима

Функция принимает первый аргумент, который является командой, которую она будет выполнять, а остальные значения являются аргументами этой команды.

Теперь попробуйте выполнить ту же команду docker run -it busybox sh с вашей программой.

go run container.go run sh
Вход в полноэкранный режим Выйти из полноэкранного режима

Вы увидите, что при выполнении команды docker все происходит в основном так же.

#
Войти в полноэкранный режим Выход из полноэкранного режима

Мы успешно выполнили первый шаг 😁, но при вводе команды hostname будет выведено имя хоста нашего сервера, а не контейнера.

# hostname
LAPTOP-2COB82RG
Вход в полноэкранный режим Выход из полноэкранного режима

Если вы введете команду для изменения имени хоста в нашей программе, это повлияет и на внешнюю часть сервера.

# hostnamectl set-hostname container
Вход в полноэкранный режим Выход из полноэкранного режима

Введите exit и enter, теперь вне сервера, набрав имя хоста, мы увидим, что оно было изменено.

Наша программа в настоящее время просто выполняет команду sh, а не контейнер, далее мы пройдем через каждый шаг для создания контейнера. Как мы знаем, контейнер собирается из пространств имен Linux.

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

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

  1. PID: пространство имен PID предоставляет процессам независимый набор идентификаторов процессов (PID) от других пространств имен. В пространстве имен PID первому созданному в нем процессу присваивается PID 1.
  2. MNT: Пространства имен Mount управляют точками монтирования и позволяют монтировать и размонтировать папки, не затрагивая другие пространства имен.
  3. NET: Сетевые пространства имен создают для процесса свой сетевой стек.
  4. UTS: Пространства имен UNIX Time-Sharing позволяют процессу иметь отдельное имя хоста и доменное имя.
  5. USER: пользовательские пространства имен создают для процесса свой собственный набор UIDS и GIDS.
  6. IPC: пространства имен IPC изолируют процессы от межпроцессного взаимодействия, это предотвращает использование процессами в разных пространствах имен IPC.

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

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

Первое, что нам нужно выделить — это имя хоста, чтобы наша программа имела свое имя хоста. Обновите container.go.

package main

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

// docker run <image> <command>
// go run container.go run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Error")
    }
}

func run() {
    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,
    }

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Чтобы использовать пространства имен Linux в Go, мы просто передаем флаг пространства имен, который хотим использовать, в cmd.SysProcAttr.

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS,
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь попробуем еще раз.

go run container.go run sh
Вход в полноэкранный режим Выход из полноэкранного режима

Выполните команду для изменения имени хоста.

# hostnamectl set-hostname wsl
# hostname
wsl
Войти в полноэкранный режим Выход из полноэкранного режима

Введите exit и enter, теперь вне сервера введите команду hostname и вы увидите, что имя хоста сервера совсем не изменилось. Мы выполнили следующий шаг по созданию контейнера 😁.

Однако, чтобы наша программа была больше похожа на контейнер, нам нужно сделать еще несколько вещей. Как вы можете видеть, когда мы запускаем docker run -it busybox sh и затем вводим hostname, у него будет свое имя хоста, а не так, как если бы мы запустили программу, и нам пришлось бы вручную вводить команду для изменения имени хоста. Обновите container.go.

package main

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

// docker run <image> <command>
// ./container run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("Error")
    }
}

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

    must(cmd.Run())
}

func child() {
    syscall.Sethostname([]byte("container"))

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

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

добавляем еще одну функцию child() и в функции run выполняем дочернюю функцию по команде exec.Command.

exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
Вход в полноэкранный режим Выход из полноэкранного режима

Изменим первый аргумент на /proc/self/exe, эта команда будет самоисполнять программу с дочерним аргументом. Теперь дочерний процесс работает в изолированном пространстве имен UTS, и мы меняем имя хоста с помощью функции syscall.Sethostname([]byte("container")).

go run container.go run sh -> /proc/self/exe child sh -> syscall.Sethostname([]byte("container")) -> exec.Command("sh").

Попробуем еще раз.

go run container.go run sh
Вход в полноэкранный режим Выход из полноэкранного режима

Наберите hostname и вы увидите, что у вашего процесса есть собственное имя хоста.

# hostname
container
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, мы выполнили следующий шаг 😁.

Далее попробуйте ввести команду ps, чтобы вывести список процессов, и посмотрите, совпадает ли он с тем, который мы запускали при запуске docker run?

# ps
PID   TTY      TIME     CMD
11254 pts/3    00:00:00 sudo
11255 pts/3    00:00:00 bash
17530 pts/3    00:00:00 go
17626 pts/3    00:00:00 container
17631 pts/3    00:00:00 exe
17636 pts/3    00:00:00 sh
17637 pts/3    00:00:00 ps
Вход в полноэкранный режим Выход из полноэкранного режима

Совсем не похоже, процессы, которые вы видите, это процессы вне сервера.

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

Мы будем использовать пространство имен PID для создания процесса с независимым набором идентификаторов процессов (PID). Обновите container.go следующим образом.

...
func run() {
 cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
 cmd.Stdin = os.Stdin
 cmd.Stdout = os.Stdout
 cmd.Stderr = os.Stderr
 cmd.SysProcAttr = &syscall.SysProcAttr{
  Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
 }
 must(cmd.Run())
}
...
Вход в полноэкранный режим Выйти из полноэкранного режима

Нам нужно только добавить один флаг syscall.CLONE_NEWPID, теперь запустим снова.

go run container.go run sh
Вход в полноэкранный режим Выход из полноэкранного режима
# ps
PID   TTY      TIME     CMD
11254 pts/3    00:00:00 sudo
11255 pts/3    00:00:00 bash
17530 pts/3    00:00:00 go
17626 pts/3    00:00:00 container
17631 pts/3    00:00:00 exe
17636 pts/3    00:00:00 sh
17637 pts/3    00:00:00 ps
Вход в полноэкранный режим Выход из полноэкранного режима

Что? Ничего не меняется. Почему?

Когда мы запустим программу ps, она получит информацию о процессе в папке /proc в Linux, давайте попробуем.

ls /proc
Войти в полноэкранный режим Выйти из полноэкранного режима

Сейчас файловая система вашего процесса выглядит так же, как и у хоста, потому что его файловая система унаследована от текущего сервера, давайте изменим это.

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

Обновите container.go следующим образом.

package main

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

// docker run <image> <command>
// ./container run <command>
func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("Error")
    }
}

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

    must(cmd.Run())
}

func child() {
    syscall.Sethostname([]byte("container"))
    must(syscall.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

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

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Используем флаг syscall.CLONE_NEWNS для создания процесса с пространством имен MNT и изменяем файловую систему.

syscall.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь запустим снова.

go run container.go run sh
Вход в полноэкранный режим Выход из полноэкранного режима

Вводим команду ps.

# ps
PID TTY      TIME     CMD
1   pts/3    00:00:00 exe
7   pts/3    00:00:00 sh
8   pts/3    00:00:00 ps
Вход в полноэкранный режим Выход из полноэкранного режима

У нас все получилось 😁.

Заключение

Итак, мы знаем, как создать простой контейнер с помощью Golang, но в реальности контейнер будет иметь много других вещей, таких как Cgroups для ограничения ресурсов процесса, создание пространства имен USER, монтирование файлов из контейнера на сервер и т.д..

Но в основном, основной функцией контейнеров для создания изолированной среды являются пространства имен Linux. Если у вас есть вопросы или нужны дополнительные разъяснения, вы можете задать их в разделе комментариев ниже.

Оцените статью
devanswers.ru
Добавить комментарий