Привет, ребята, продолжая серию статей «Глубоко в контейнер», мы уже знаем, что контейнеры создаются на основе пространств имен Linux и групп Cgroups, и чтобы узнать об этом более глубоко, мы собираемся научиться создавать свой собственный контейнер с помощью Golang.
В этой статье я использовал ссылки на Build Your Own Container Using Less than 100 Lines of Go Джулиана Фридмана и Building a container from scratch in Go Лиз Райс.
Это четвертая часть цикла «Глубоко в контейнер»:
- Пространства имен Linux и группы Cgroups: Из чего состоят контейнеры?
- Глубоко в Container Runtime.
- Как Kubernetes работает с Container Runtime.
- 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
Контейнер запустится и к нему присоединится оболочка.
/ #
Если мы сейчас введем команду, она будет запущена в контейнере.
/ # 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.
Пространства имен
Пространства имен обеспечивают среду изоляции, которая помогает нам запускать процесс независимо от других процессов на том же сервере. На момент написания статьи существует шесть следующих пространств имен,
- PID: пространство имен PID предоставляет процессам независимый набор идентификаторов процессов (PID) от других пространств имен. В пространстве имен PID первому созданному в нем процессу присваивается PID 1.
- MNT: Пространства имен Mount управляют точками монтирования и позволяют монтировать и размонтировать папки, не затрагивая другие пространства имен.
- NET: Сетевые пространства имен создают для процесса свой сетевой стек.
- UTS: Пространства имен UNIX Time-Sharing позволяют процессу иметь отдельное имя хоста и доменное имя.
- USER: пользовательские пространства имен создают для процесса свой собственный набор UIDS и GIDS.
- 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. Если у вас есть вопросы или нужны дополнительные разъяснения, вы можете задать их в разделе комментариев ниже.