Не знаю чому, але я прокинувся і в світі немає 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
Але якось малувато інформації воно виводить, тому давайте ще додамо трохи виводу інформації про 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
18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів