Не знаю чому, але я прокинувся і в світі немає Docker

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Не знаю чому, але я прокинувся і в світі нема Docker.

А я знаю, що таке контейнери, k8s, docker CLI, containerd тощо — і вже не можу без них жити!!!

Тому давайте разом з вами створимо щось схоже на контейнер у звичному для нас вигляді.

Що нам для цього потрібно?

  1. Тестовий аплікейшн, а точніше — executable entrypoint.
  2. Набір бібліотек, які йому потрібні як залежності.
  3. Базова ОС з системними компонентами.
  4. CLI, завдяки якому ми зможемо піднімати наш контейнер.

Також я не повинен забувати, що контейнер — це про ізоляцію процесу на всіх рівнях від хостової машини:

  • PID — я не хочу, щоб процес в нашому контейнері міг бачити інші;
  • User namespace — я не хочу, щоб база користувачів всередині контейнера перетиналась з іншими;
  • Network stack — я хочу повністю ізольований нетворкінг всередині контейнера, але хочу, щоб була можливість достукатись до порту, на якому буде слухати наш сервіс;
  • Mnt and chroot — файлова система та структура каталогів.
  • І найважливіше — я хочу, щоб була можливість обмежити кількість обчислювальних ресурсів, які контейнер може брати в роботу (CPU, memory).

Наче все на стіл поклали з основних речей. Давайте розбиратись.

Перше, що нам потрібно — це application

Для тестування нашої ідеї контейнерів нам не потрібна суперскладна річ — достатньо взяти web server. Він якраз буде слухати HTTP requests на якомусь порту.

#Структура каталогу

myapp/
├── app.py
└── templates/
    └── index.html
#app.py
from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def home():
    return render_template("index.html")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)
#templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Привіт з контейнера</title>
</head>
<body>
    <h1>Це наш Flask-сервер без Docker 🐍</h1>
</body>
</html>

Оскільки, слава богам, в нас є virtualenv — можемо собі тихесенько це протестувати:

devops01@devops01:~$ which python3
/usr/bin/python3

#virtualenv test
#source test/bin/activate
#(test) DevOps01/docker_without_docker > which python
 /Users/.../docker_without_docker/test/bin/python

Тепер ми маємо Python binaries and libraries from virtualenv.

Далі нам потрібно поставити flask з pip і спробувати запустити нашу мега апку :)

#(test) DevOps01/docker_without_docker > pip3 install flask
#(test) DevOps01/docker_without_docker >  python app.py 
 * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:8080
 * Running on http://192.168.0.31:8080

Але якось малувато інформації воно виводить, тому давайте ще додамо трохи виводу інформації про hostname, ip’s of interfaces та кількість запущених процесів.

Для цього ми використаємо наступні бібліотеки:

  • socket;
  • os;
  • subprocess;
  • psutil.
#templates/index.html
!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Інформація про хост</title>
</head>
<body>
    <h1>Контейнер працює ✅</h1>
    <ul>
        <li><strong>Hostname:</strong> {{ hostname }}</li>
        <li><strong>IP Addresses:</strong>
            <ul>
                {% for ip in ip_addresses %}
                <li>{{ ip }}</li>
                {% endfor %}
            </ul>
        </li>
        <li><strong>Кількість процесів:</strong> {{ process_count }}</li>
    </ul>
</body>
</html>

#app.py

from flask import Flask, render_template
import socket
import os
import subprocess
import psutil  

app = Flask(__name__)

def get_ip_addresses():
    ip_list = []
    for interface, snics in psutil.net_if_addrs().items():
        for snic in snics:
            if snic.family == socket.AF_INET:
                ip_list.append(f"{interface}: {snic.address}")
    return ip_list

@app.route("/")
def home():
    hostname = socket.gethostname()
    ip_addresses = get_ip_addresses()

    if os.path.exists("/proc"):
        try:
            process_count = len([pid for pid in os.listdir("/proc") if pid.isdigit()])
        except Exception as e:
            process_count = f"Error: {e}"
    else:
        try:
            output = subprocess.check_output(["ps", "-e"])
            process_count = len(output.decode().splitlines()) - 1
        except Exception as e:
            process_count = f"Error: {e}"

    return render_template("index.html",
                           hostname=hostname,
                           ip_addresses=ip_addresses,
                           process_count=process_count)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Тепер залишається тільки доставити psutil з того з PYPI, бо socket, os, subprocess є в стандартній бібліотеці.

#(test) DevOps01/docker_without_docker > pip3 install psutil
#(test) DevOps01/docker_without_docker  > python app.py      
 * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:8080
 * Running on http://192.168.0.31:8080

🔥 Супер. Для початку нам цього достатньо. Тепер починаємо створювати наш контейнер. Почнемо з PoC — все інше будемо додавати по ходу.

Створюємо контейнер

Нам потрібна альтернатива Docker image, тобто мінімальна root OS з усіма системними бібліотеками та встановленими: python3, python3-pip, python3-dev.

Але ж ми цього не маємо! Чи маємо?

1. Альтернатива Docker image — створюємо minimal root OS

# sudo debootstrap --arch=arm64 --variant=minbase jammy ./ubuntu-arm64-rootfs http://ports.ubuntu.com/

На виході маємо мінімальний білд ubuntu 22.04. Такий собі FROM ubuntu:jammy.

2. Монтуємо /proc та /dev у наш «контейнер»:

sudo mount -t proc proc ./ubuntu-arm64-rootfs/proc
sudo mount --bind /dev ./ubuntu-arm64-rootfs/dev

3. Провалюємось в chroot

sudo chroot ./ubuntu-arm64-rootfs /bin/bash
root@devops01:/# which python
root@devops01:/# which python3
root@devops01:/# python3
bash: python3: command not found
root@devops01:/# python
bash: python: command not found
root@devops01:/#

Але тут у нас нема python. Тому доставимо все:

cat <<EOF > /etc/apt/sources.list
deb http://ports.ubuntu.com/ubuntu-ports jammy main universe multiverse restricted
deb http://ports.ubuntu.com/ubuntu-ports jammy-updates main universe multiverse restricted
deb http://ports.ubuntu.com/ubuntu-ports jammy-security main universe multiverse restricted
EOF

#apt update
#apt install -y python3 python3-pip python3-dev curl virtualenv
root@devops01:/# python3
Python 3.10.12 (main, Feb  4 2025, 14:57:36) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()

4. Додаємо virtualenv

virtualenv /tmp/docker_without_docker/container
source container/bin/activate
(container) root@devops01:/tmp/docker_without_docker# which python3
/tmp/docker_without_docker/container/bin/python3
  • Тепер ми маємо якусь ізоляцію (тільки на рівні chroot).
  • Pid у нас той самий.
#out of chroot
devops01@devops01:~$ ip a | grep enp0s1 
2: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    inet 192.168.105.9/24 metric 100 brd 192.168.105.255 scope global dynamic enp0s1
#in chroot
root@devops01:/# ip a | grep enp0s1
2: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    inet 192.168.105.9/24 metric 100 brd 192.168.105.255 scope global dynamic enp0s1

#out of chroot
devops01@devops01:~$ sudo ps -aux | grep app.py
root       13674  0.2  0.7  35448 28248 pts/1    S+   21:05   0:00 python3 app.py 
devops01   13935  0.4  0.0   6676  1920 pts/2    S+   21:05   0:00 grep --color=auto app.py

#in chroot
root@devops01:/# ps -aux  | grep app.py
- root       13674  0.1  0.7  35448 28248 ?        S+   21:05   0:00 python3 app.py
- root       14427  0.0  0.0   3436  1792 ?        S+   21:06   0:00 grep --color=auto app.py

Робимо ізоляцію простору процесів або PID

Я хочу, щоб процес всередині контейнеру мав pid == 1 та не бачів інші процеси. У цьому нам допоможуть Linux namespaces та unshare.

devops01@devops01:~$ sudo unshare --fork --pid --mount-proc chroot ./ubuntu-arm64-rootfs /bin/bash -c cd /tmp/docker_without_docker && source container/bin/activate && exec python3 app.py\ * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:8080
 * Running on http://192.168.105.9:8080

Тепер, як подивитися PID усередині цього PID namespace? Спочатку нам потрібно знайти PID нашого процесу на рівні хостової системи (бо саме там ми бачимо все 🙂).

sudo ps -aux | grep app.py
root       14941  0.1  0.1  17696  5760 pts/0    S+   21:15   0:00 sudo unshare --fork --pid --mount-proc chroot ./ubuntu-arm64-rootfs /bin/bash -c cd /tmp/docker_without_docker && source container/bin/activate && exec python3 app.py
root       14942  0.0  0.0  17696  2368 pts/1    Ss   21:15   0:00 sudo unshare --fork --pid --mount-proc chroot ./ubuntu-arm64-rootfs /bin/bash -c cd /tmp/docker_without_docker && source container/bin/activate && exec python3 app.py
root       14943  0.0  0.0   5696  1664 pts/1    S+   21:15   0:00 unshare --fork --pid --mount-proc chroot ./ubuntu-arm64-rootfs /bin/bash -c cd /tmp/docker_without_docker && source container/bin/activate && exec python3 app.py
root       14944  1.1  0.7  35448 28316 pts/1    S+   21:15   0:00 python3 app.py
devops01   15004  0.0  0.0   6676  1792 pts/2    S+   21:15   0:00 grep --color=auto app.py


Наш процес це 14944, останній в ланцюгу форків!

Провалюємось, команда nsenter нам в допомогу:

sudo nsenter --target 14944 --pid --mount bash
root@devops01:/# ps -aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.1  0.7  35448 28316 pts/1    S+   21:15   0:00 python3 app.py
root          19  0.0  0.0   7712  3840 pts/5    S    21:21   0:00 bash
root          26 50.0  0.1  12248  4352 pts/5    R+   21:21   0:00 ps -aux

→ бачимо python3 як PID 1 ✅

Як це взагалі працює?

Кожен процес у Linux має структуру task_struct, яка зберігає:

  • PID.
  • батьківський процес.
  • namespace-інформацію.

Namespace-інформація описується структурою nsproxy:

struct nsproxy {
    struct uts_namespace *uts_ns;
    struct ipc_namespace *ipc_ns;
    struct mnt_namespace *mnt_ns;
    struct pid_namespace *pid_ns;
    struct net_namespace *net_ns;
    struct user_namespace *user_ns;
};

Створення namespace відбувається через системні виклики

  • clone() - створює новий процес з новими namespaces.
  • unshare() - від’єднує поточний процес від існуючого namespace.
  • setns() - приєднує процес до вже існуючого namespace (через файл /proc/PID/ns/*).

sudo unshare --pid --fork --mount-proc bash

🧠 Що робить ядро:

  • unshare(CLONE_NEWPID) — створює новий PID namespace.
  • clone() — створює дочірній процес.
  • новий task_struct отримує посилання на новий pid_namespace.
  • монтується новий /proc, який показує тільки процеси з нового PID namespace.

Кожен процес має свої namespace-дескриптори

ls -l /proc/$$/ns

$$ — це спеціальна змінна, яка повертає PID (Process ID) поточного shell-процесу.

Отримаєш щось типу:

ipc  -> ipc:[4026531839]
mnt  -> mnt:[4026531840]
net  -> net:[4026532008]
pid  -> pid:[4026531836]
user -> user:[4026531837]
uts  -> uts:[4026531838]

Так само можна перевірити дескриптори PID=1 у нашому namespace (наприклад, це буде python3 app.py).

ls -l /proc/1/ns
cgroup             -> 'cgroup:[4026531835]'
ipc                -> 'ipc:[4026531839]'
mnt                -> 'mnt:[4026532384]'
net                -> 'net:[4026531840]'
pid                -> 'pid:[4026532385]'
pid_for_children   -> 'pid:[4026532385]'
time               -> 'time:[4026531834]'
time_for_children  -> 'time:[4026531834]'
user               -> 'user:[4026531837]'
uts                -> 'uts:[4026531838]'

💡 Коли процес створює новий namespace:

  • ядро створює нову структуру (pid_namespace, mnt_namespace, тощо);
  • процес отримує посилання на них;
  • усі подальші системні виклики (fork, exec, mount, ps, ip) працюють у контексті цього namespace.

Фух, рухаємось далі)

UTS namespace

🛠️ Додаємо uts namespace для роботи з hostname та domain name:

sudo unshare --fork --pid --uts --mount-proc \
  chroot ./ubuntu-arm64-rootfs /bin/bash -c \
  "cd /tmp/docker_without_docker && source container/bin/activate && exec python3 app.py"

root@devops01:/# hostname container
root@devops01:/# hostname
container

#Тим часом

devops01@devops01:~$ hostname
devops01

Таким чином ми створюємо окремий uts namespace, у якому зможемо змінити hostname — і ця зміна не вплине на хост-систему.

❗ Але є проблема: ми бачимо всі процеси у виводі нашого аплікейшену.

Це тому, що /proc змонтований усередину нашого chroot. Параметр —mount-proc працює тільки якщо /proc вже існує перед запуском chroot, а ми це попередньо якраз робили :(

Тому ми можемо це обійти наступним чином:

sudo unshare --fork --pid --uts chroot ./ubuntu-arm64-rootfs /bin/bash -c \
  "mount -t proc proc /proc && cd /tmp/docker_without_docker && hostname container && exec /tmp/docker_without_docker/container/bin/python app.py"

Чудово! Продовжуємо ізоляцію — тепер повноцінно зануримось у network namespace 🔧

Це складніше, бо містить:

  • інтерфейси (lo, eth0);
  • IP-адреси;
  • маршрути;
  • правила iptables;
  • сокети та порти;
  • коли процес переміщується в network namespace, він бачить тільки ті мережеві інтерфейси, які існують у цьому netns.

sudo ip netns add app_ns
sudo ip link add veth-host type veth peer name veth-ns
sudo ip link set veth-ns netns app_ns
sudo ip addr add 192.168.100.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec app_ns ip addr add 192.168.100.2/24 dev veth-ns
sudo ip netns exec app_ns ip link set veth-ns up
sudo ip netns exec app_ns ip link set lo up
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 192.168.100.2/24 -o enp0s1 -j MASQUERADE
  • Тут ми створюємо свою ізольований network stack для «контейнера».
  • Через ip netns add додаємо новий network namespace (app_ns) — тобто повністю окремий мережевий стек.
  • Далі робимо veth-пару — два інтерфейси, які з’єднані як дріт: один (veth-host) лишається на хості, другий (veth-ns) закидаємо в наш app_ns.
  • Роздаємо IP-адреси з однієї підмережі (192.168.100.1 хосту, 192.168.100.2 контейнеру), піднімаємо інтерфейси, вмикаємо ip_forward, і додаємо iptables-правило MASQUERADE, щоб трафік з контейнера міг виходити в інтернет через хост.
  • По факту — ми своїми руками зробили те, що Docker робить під капотом.

Далі запускаємо наш процес

sudo ip netns exec app_ns \
  unshare --fork --pid --uts \
  chroot ./ubuntu-arm64-rootfs /bin/bash -c \
  "mount -t proc proc /proc && hostname container && cd /tmp/docker_without_docker && exec /tmp/docker_without_docker/container/bin/python app.py"

sudo ip netns exec app_ns ss -tuln | grep 8080
tcp   LISTEN 0      128          0.0.0.0:8080      0.0.0.0:*


busted!!!!

Ну тут все просто — оскільки усе це в мене запущено на віртуалці, то я маю доступ лише всередині самої віртуалки. Бо veth-інтерфейси, по суті, існують тільки всередині хоста (віртуалки в моєму випадку) — назовні воно не пробивається.

Тобто з хостової машини (не з віртуалки) до 192.168.100.2 я не достукаюсь. Але всередині VM все працює як треба.

devops01@devops01:~$ curl 192.168.100.2:8080
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Інформація про хост</title>
</head>
<body>
    <h1>Контейнер працює ✅</h1>
    <ul>
        <li><strong>Hostname:</strong> container</li>
        <li><strong>IP Addresses:</strong>
            <ul>
                
                <li>lo: 127.0.0.1</li>
                
                <li>veth-ns: 192.168.100.2</li>
                
            </ul>
        </li>
        <li><strong>Кількість процесів:</strong> 1</li>
    </ul>
</body>

Для цього ми можемо використати SOCAT або iptables.


sudo socat TCP-LISTEN:50800,fork TCP:192.168.100.2:8080

Трафік перехоплюється і передається всередину контейнера.

🧪 Фінальний entrypoint.sh

#!/bin/bash

set -e

ROOTFS="./ubuntu-arm64-rootfs"
NETNS="app_ns"
WORKDIR="/tmp/docker_without_docker"
PYTHON="$WORKDIR/container/bin/python"
FLASK_ENTRY="$WORKDIR/app.py"
FLASK_PORT=8080
EXTERNAL_PORT=50800
CONTAINER_IP="192.168.100.2"

echo "Запускаємо socat на $EXTERNAL_PORT → $CONTAINER_IP:$FLASK_PORT..."
sudo socat TCP-LISTEN:$EXTERNAL_PORT,fork TCP:$CONTAINER_IP:$FLASK_PORT &
SOCAT_PID=$!

echo "Запускаємо Flask у netns $NETNS + chroot + unshare..."
sudo ip netns exec "$NETNS" \
  unshare --fork --pid --uts \
  chroot "$ROOTFS" /bin/bash -c "
    mount -t proc proc /proc &&
    hostname container &&
    cd $WORKDIR &&
    exec $PYTHON $FLASK_ENTRY
  "

echo "Зупиняємо socat (PID: $SOCAT_PID)..."
kill $SOCAT_PID

Коли я майже завершив створення власного runtime і залишалось лише додати cgroups — я прокинувся.

Хоча ж... я вже прокидався?

А може це я вже сплю в середині namespace, і десь там у реальному світі все ще є Docker?

Посилання

— devops01.xyz
— www.linkedin.com/...​-hrechanychenko-9221aa66
— t.me/devops_01_ua
— wiki.debian.org/Debootstrap
— linux.die.net/man/1/chroot
— man7.org/...​es/man7/namespaces.7.html
— github.com/...​r/include/linux/nsproxy.h
— man7.org/...​pages/man1/unshare.1.html
— man7.org/...​pages/man1/nsenter.1.html
— linux.die.net/man/1/socat
— alex-xjk.github.io/post/taskstruct

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось47
До обраногоВ обраному30
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую всім за коментарі!

Радію, що стаття вам сподобалась. Майже дописав наступну — вона буде не менш цікава й так само велосипедна.

Буду вдячний за підтримку мого збору: dou.ua/forums/topic/53591

www.linkedin.com/...​4BOERzqgdTQHc0tLKoVYZg6Os

За донат — ваучери на сертифікації CKA, AWS і Terraform.

Якось робив дуже схожу річ з метою мати кілька користувачів в системі залогінених одночасно і щоб кожен з них мав свій нетворк. Ну тобто щоб коли один користувач запускає ВПН це не афектило інших. Дивно, але готового рішення на той момент не знайшов, хоча юзкейс ніби доволі тривіальний. В моєму випадку я звісно не робив chroot, натомість додавав ще mount namespace в якому ізолював /etc/resolv.conf. Загалом все працює, більше того, в створеному руками неймспейсі я запускав докер в якому запускав k8s через kind. Ну тобто ця ізоляція може бути вкладеною, і не один раз. Дякую за статтю.

Я б подивився цей isekai 😆😆😆

А цей девопс щось таки шарить в цій темі:)

что люди только не сделают, лишь бы не заюзать FreeBsd Jails

freebsd технології на другому поверсі, третій ряд, четверта полка

Ну це ж ще в 2009 було, вже всі забули :)

Ще Solaris Zones з попереднього століття пригадай :) там, навіть ZFS працював без костилів :)

Дякую, дуже крутий матеріал!

Найкраща стаття за останній час.

Не знаю чому, але я прокинувся і в світі нема Docker.

Та багато хто прокинувся в цьому світі після того як докер став просити гроші за ліцензію на його комерційне використання. І нічого, викручуються люди.

Артеме, дякую за матеріал!

Мотивує вчити глибше механізми Unix-like ОС.

На мій смак, побажання до наступних публікацій:

— присвятити хоча б по одному реченню природномовного описового тексту кожній приведній системній утиліті (можна вже після сніпетів, щоб не руйнувати інтригу)
— приділити увагу пошукам свіжості у кіноілюстраціях

— приділити увагу пошукам свіжості у кіноілюстраціях

Сучасне кіно не варте часу на його перегляд. )

Дякую за статтю. Колись навіть такий проект був.

залишалось лише додати cgroups

— це саме той момент де народився docker :) адже dataplain (фіча ядра) то ок, а от control plain це саме те, що робить docker та будь який високорівневий інструмент. Доречі, runc може бути достатньо для запуску всього, що треба, якщо порівнювати інструменти. Дякую за матеріал!

А може це я вже сплю в середині namespace, і десь там у реальному світі все ще є Docker?

nap versus snap)

The snap daemon uses privilege isolation mechanisms rooted in the Linux kernel through Cgroups and Namespaces, AppArmor and Seccomp.

Підписатись на коментарі