Не знаю чому, але я прокинувся і в світі немає 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

👍ПодобаєтьсяСподобалось45
До обраногоВ обраному28
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 технології на другому поверсі, третій ряд, четверта полка

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

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

Не знаю чому, але я прокинувся і в світі нема 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.

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