Re-inventing bicycle (docker)

Не знаю чому, але я прокинувся, і в світі нема Docker.
А я знаю, що таке контейнери, k8s, docker CLI, containerd тощо - і вже не можу без них жити!!!
Тому давайте разом з вами створимо щось схоже на контейнер у звичному для нас вигляді.
Що нам для цього потрібно?
- Тестовий аплікейшн, а точніше - executable entrypoint
- Набір бібліотек, які йому потрібні як залежності
- Базова ОС із системними компонентами
- 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
super mega app
Але якось малувато інформації воно виводить, тому давайте ще додамо трохи виводу про 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.
Namespaces
PID namespace isolation
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 🔧
Це складніше, бо network namespace включає:
- інтерфейси (lo, eth0)
- IP-адреси
- маршрути
- правила iptables
- сокети та порти
Коли процес переміщується в network namespace, він бачить тільки ті мережеві інтерфейси, які існують у цьому netns.
network namespace
root network namespace vs app_ns
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?

Посилання
- https://wiki.debian.org/Debootstrap
- https://linux.die.net/man/1/chroot
- https://man7.org/linux/man-pages/man7/namespaces.7.html
- https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h
- https://man7.org/linux/man-pages/man1/unshare.1.html
- https://man7.org/linux/man-pages/man1/nsenter.1.html
- https://linux.die.net/man/1/socat
- https://alex-xjk.github.io/post/taskstruct/
Вперше опубліковано на LinkedIn 6 квітня 2025 р.: оригінал.