Linux изнутри: архитектура, которую не объясняют новичкам.
Мы часто говорим слово «Linux», имея в виду Ubuntu, CentOS или Debian. Но с инженерной точки зрения это неверно. Linux — это ядро. Кусок кода, который управляет железом. А всё, что мы видим на экране, всё, с чем взаимодействуем — это просто набор программ, которые крутятся поверх этого ядра.
В этой статье разберёмся, как всё это устроено. Начнём с самого низа.
Шаг 1. Нажимаем кнопку питания
Пошёл ток. Процессор просыпается — но в этот момент он тупой кусок кремния. Он не знает, что такое Linux. Он не знает, что такое файлы. Он даже не знает, сколько у него оперативной памяти. Он просто ищет первую инструкцию по жёстко заданному адресу.
Первой стартует прошивка материнской платы — BIOS или UEFI. Её задача — привести железо в чувство. Она запускает POST (Power-On Self-Test): проверяет, есть ли память, работает ли видеокарта, инициализировался ли процессор. Потом начинает опрашивать устройства и ищет, с чего можно загрузиться.
Шаг 2. Загрузчик — GRUB
Допустим, BIOS нашёл жёсткий диск. Но вот незадача — BIOS сам не умеет читать файловые системы. Он не знает, что такое ext4 или XFS. Он не может просто взять и открыть файл. Поэтому он считывает самые первые 512 байт диска (это MBR) или заглядывает в специальный EFI-раздел. А там живёт загрузчик.
В мире Linux это почти всегда GRUB 2.
GRUB — маленькая, но умная программа. Её единственная суперспособность — она умеет читать файловую систему. Заглядывает в раздел /boot, а там лежат два критически важных файла, без которых никакой магии не случится.
vmlinuz — это и есть тот самый Linux. Само ядро, сжатое в бинарный файл. Буква z в конце — это zip, то есть сжатый.
initramfs — маленький временный архив. О нём чуть ниже.
GRUB берёт ядро, распаковывает его прямо в оперативную память и передаёт ему управление. Всё. С этого момента BIOS уходит курить в сторонку, загрузчик умирает. Теперь в системе только один босс — ядро.
Кнопка питания
|
v
BIOS/UEFI
(POST, поиск диска)
|
v
GRUB 2
(читает /boot, загружает ядро)
|
v
vmlinuz (ядро)
|
v
initramfs
(временный диск с драйверами)
|
v
switch_root
(переключение на настоящий диск)
|
v
systemd (PID 1)
|
v
SSH, Nginx, Postgres...
Шаг 3. Проблема курицы и яйца
И вот тут ядро сталкивается с классической инженерной проблемой.
Ядро загрузилось в память. Его задача — смонтировать основной диск, чтобы запустить систему. Но чтобы смонтировать диск, нужны драйверы файловой системы и контроллера диска. А где лежат драйверы? Правильно — на том самом диске, который мы ещё не можем прочитать, потому что нет драйверов.
Замкнутый круг.
Именно для этого GRUB загрузил второй файл — initramfs. Это маленький временный архив, который разворачивается в оперативной памяти как виртуальный диск. Внутри — мини-версия Linux с минимальным набором драйверов. Ядро монтирует initramfs как временный корень, подгружает оттуда нужные драйверы для реального железа (RAID-контроллеры, NVMe, LVM), находит настоящий диск — и делает трюк под названием switch_root, то есть смена корня. Временный диск выбрасывается из памяти, вместо него подключается настоящий.
Уточнение: иногда встречается термин pivot_root — это более старый механизм. В современных системах с initramfs используется именно switch_root.
Важно для практики: если сервер виснет на этапе загрузки с ошибкой kernel panic — значит, ядро застряло именно здесь. Оно либо не нашло initramfs, либо внутри него не оказалось нужного драйвера для диска.
Шаг 4. Ядро — три главные задачи
Загрузчик отработал, драйверы подгрузились — мы попадаем в kernel space. С точки зрения архитектуры процессора мы находимся в кольце защиты Ring 0. Здесь разрешено всё: любая инструкция процессора, любой адрес памяти.
Linux — это монолитное ядро, а не набор разрозненных сервисов. Драйвер видеокарты, сетевой стек TCP/IP, драйвер файловой системы ext4 — всё это варится в одном гигантском котле, в едином адресном пространстве. Это даёт дикую производительность, потому что нет накладных расходов на общение между компонентами. Но есть риск: если кривой драйвер упадёт — он утащит за собой весь сервер в kernel panic.
У ядра три основные задачи.
1. Управление памятью
Главная задача ядра здесь — врать. Оно врёт нашим программам.
Когда Nginx или Python просит память, он думает, что единственный в системе. Для него всё выглядит как красивая непрерывная лента адресов. Хотя на самом деле физическая память фрагментирована и куски данных разбросаны хаотично.
Ядро ведёт гигантскую таблицу виртуальной памяти: «виртуальный адрес X для процесса Nginx — это на самом деле физическая ячейка Y». Это даёт две вещи: изоляцию (один процесс физически не может залезть в память другого) и своп (если памяти не хватает, ядро незаметно выгружает часть данных на диск).
Но если память кончилась совсем и своп тоже забит — приходит OOM Killer. И он неспроста так назван. Это буквально киллер, которого нанимает ядро. Он просыпается, сканирует таблицу процессов, находит того, кто ест больше всего при низком приоритете — и пускает ему пулю в лоб. В Kubernetes это происходит постоянно при неправильно настроенных лимитах подов.
2. Планировщик процессов
Допустим, у нас на сервере 4 ядра, а запущено 500 процессов. Как они работают одновременно?
Никак. Это иллюзия.
Ядро использует вытесняющую многозадачность. Оно даёт процессу проработать несколько миллисекунд — так называемый квант времени — а потом жёстко его останавливает. Происходит переключение контекста: ядро сохраняет состояние регистров процессора для текущей задачи, загружает состояние для следующей и нажимает play. И так тысячи раз в секунду.
Если видите в мониторинге высокий load average, но процессор не загружен на 100% — скорее всего, дело не в процессоре вообще. Load average считает не только активно работающие процессы, но и те, что заблокированы в ожидании I/O — зависли, пока диск или сеть не ответят. Это состояние D в выводе ps (uninterruptible sleep). Процессор в это время простаивает, а сервер «тормозит». Проверить можно через iostat или vmstat — если высокий wa (I/O wait), причина именно там.
Переключение контекста — отдельная история. Оно видно в vmstat как высокое значение cs. Если система действительно тратит ресурсы на переключение между тысячами процессов — CPU будет загружен, а cs зашкаливать.
3. Абстракция оборудования
Ядро выступает в роли переводчика. Программы в пользовательском пространстве не знают железа. Вот есть программа на Python, которая записывает слово в файл. Но Python не знает, куда он записывает — на самсунговский SSD, на старый жёсткий диск или вообще на сетевую шару. У каждого диска свой протокол, свои вольтажи, свои команды контроллера.
Ядро предоставляет универсальный интерфейс. Программа говорит: «пиши в этот файл». А драйвер ядра переводит это в электрические сигналы. Благодаря этому наш код работает одинаково на любом железе.
Шаг 5. Системные вызовы — единственная дверь в ядро
Между режимом пользователя, где живут наши программы, и режимом ядра стоит бетонная стена. Процессор физически запрещает коду из пользовательского пространства обращаться к оборудованию. Если программа попытается напрямую обратиться к железу — процессор немедленно её остановит. Никакого доступа не будет.
Но в этой стене есть одна-единственная бронированная дверь. Она называется системный вызов (syscall). Это строго регламентированный API. Ядро говорит: «я не пущу тебя к диску напрямую, но если вежливо попросишь через специальную функцию — сделаю это за тебя».
USER SPACE (Ring 3)
┌─────────────────────────────┐
│ Python Nginx SSH Docker │
└──────────────┬──────────────┘
│ syscall
═══════════════╪═══════════════ ← бетонная стена
│
┌──────────────▼──────────────┐
│ KERNEL SPACE │
│ (Ring 0 — разрешено всё) │
│ память / процессы / диски │
└─────────────────────────────┘
Давайте на примере. Пишем
print("Hello World")
в Python — и происходит целая цепочка событий:
- Интерпретатор Python вызывает стандартную библиотеку C (glibc).
- Библиотека формирует системный вызов write.
- Она кладёт аргументы в регистры процессора и генерирует программное прерывание.
- Процессор останавливает программу, переключает режим в Ring 0 и передаёт управление ядру.
- Ядро проверяет права и выводит текст на экран.
- Управление возвращается программе.
Всего в Linux около 300–400 системных вызовов. Основные, на которых держится всё: fork, exec, open, close, read, write, socket, connect.
Зачем это знать? Потому что программы часто врут. Логи могут быть пустыми, ошибки — неинформативными. Но программа не может врать ядру. Она обязана делать syscall’ы, чтобы хоть что-то сделать.
И здесь на сцену выходит strace — главный инструмент отладки. Это прослушка для syscall’ов. Запускаете его на процесс и видите всю подноготную: что он пытался сделать, что получил, на чём завис. Strace — это рентген. Если умеете читать его вывод, для вас не существует непонятных багов.
Шаг 6. Первый процесс — systemd
Ядро загрузилось, инициализировало память и драйверы. Но сервер всё ещё пустой — в нём нет ни SSH, ни консоли, ни сети. Чтобы система ожила, ядро должно запустить самый первый процесс в пространстве пользователя. Для этого оно ищет исполняемый файл по нескольким путям:
/sbin/init
/etc/init
/bin/init
/bin/sh
И запускает первый найденный. Этот процесс получает PID 1 — его уникальный идентификатор. Все остальные процессы в системе — PID 2, 3, 4, 10000 — будут потомками этого процесса. Если PID 1 умрёт — ядро решит, что система сломалась, вывалится в kernel panic и сервер встанет.
В современном Linux роль первого процесса выполняет systemd.
Раньше, во времена SysV init, процессы запускались тупо по очереди: сначала сеть, потом диск, потом база. Это было долго. systemd работает как менеджер зависимостей — строит направленный граф и запускает всё параллельно, максимально утилизируя процессор при загрузке. SSH ни от чего не зависит? Запускаем сразу. Postgres зависит от диска? Подождёт.
Когда мы пишем
systemctl start nginx
— мы отправляем команду systemd через сокет. Systemd проверяет права, смотрит зависимости и выполняет системный вызов fork (создаёт копию себя) + exec (заменяет копию на бинарник Nginx). Так рождается процесс веб-сервера. Он — дочерний по отношению к systemd.
Шаг 7. Файловая система — «всё есть файл»
Процессы запущены, но им нужно читать конфиги, писать логи, сохранять данные. Поднимаемся на следующий уровень абстракции — файловая система.
Главная философия Unix: всё есть файл. Но давайте разберём это не как лозунг, а как инженерное решение.
В Windows есть буквы дисков C:, D:, E: — физическое разделение устройств. В Linux подход другой. Здесь существует единое дерево каталогов, которое всегда начинается с корня — символа /. Процессу плевать, сколько у нас дисков. Он видит только дерево.
/ ← корень
├── boot/ ← SSD с ядром
├── etc/ ← конфиги
├── home/ ← домашние папки
│ └── user/
├── proc/ ← виртуальный (данные ядра)
├── sys/ ← виртуальный (настройки ядра)
├── tmp/ ← оперативная память
├── usr/
│ ├── bin/ ← бинарники программ
│ └── lib/ ← общие библиотеки
├── var/
│ └── log/ ← логи
└── mnt/
└── share/ ← сетевой диск
Внутри ядра есть прослойка VFS (Virtual File System) — универсальный интерфейс. Берём SSD и монтируем его в /boot. Берём сетевой диск и монтируем в /mnt/share. Берём оперативную память и монтируем в /tmp. Для программы всё это выглядит одинаково — она просто пишет по путям, а VFS на лету решает, куда отправить байты: по кабелю SATA или по сети на другой сервер.
Что такое файл физически?
Для нас файл — это photo.jpg. Но для ядра имя — пустой звук. Просто набор байт для удобства человека. Для ядра файл — это inode (индексный дескриптор). Каждый файл в системе — это просто номер.
В inode хранится вся метаинформация кроме имени: кто владелец, какие права доступа, размер файла, временны́е метки, и самое главное — указатели на секторы диска, где физически лежат данные.
А что тогда такое имя файла? Имя живёт в каталоге. Технически каталог — это тоже файл, но внутри него простая таблица из двух колонок: имя файла → номер inode. Когда мы открываем файл, ядро читает каталог, находит номер inode, проверяет права — и только после этого начинает читать данные.
Каталог /home/user/
┌─────────────┬──────────┐
│ Имя │ inode # │
├─────────────┼──────────┤
│ photo.jpg │ 42017 │
│ notes.txt │ 42031 │
│ script.sh │ 42089 │
└─────────────┴──────────┘
│
▼
inode #42017
┌─────────────────────┐
│ владелец: uid 1000 │
│ права: 644 │
│ размер: 3.2 MB │
│ изменён: 2024-03-01│
│ данные → [секторы] │
└─────────────────────┘
/proc и /sys — ядро как файлы
Разработчики Linux подумали: а зачем ограничиваться дисками? Давайте представим само ядро в виде файлов.
Появились /proc и /sys. Размер файлов там — 0 байт. На диске ничего нет. Это иллюзия — но прямой интерфейс к структурам данных ядра в оперативной памяти. Читаете /proc/cpuinfo — ядро опрашивает процессор и генерирует текст ответа на лету.
И самое крутое — это работает в обе стороны. Хотите включить маршрутизацию пакетов и превратить сервер в роутер? Не нужно искать никакую галочку ни в какой панели управления. Просто пишете цифру 1 в нужный файл:
echo 1 > /proc/sys/net/ipv4/ip_forward
Ядро перехватывает эту запись и меняет переменную в своём коде. Вот что значит «всё есть файл» — это универсальный API для управления системой.
Шаг 8. Процессы, stdin/stdout и пайпы
Процесс — это изолированный контейнер в памяти с собственным PID, переменными и ресурсами. Но процесс в вакууме бесполезен. Ему нужно получать данные и отдавать результат.
Когда ядро запускает любой процесс, оно автоматически открывает для него три канала связи — три файла:
Дескриптор Название По умолчанию привязан к
0 stdin (стандартный ввод) клавиатура
1 stdout (стандартный вывод) экран/терминал
2 stderr (стандартный вывод ошибок) экран/терминал
Зачем разделены stdout и stderr? Чтобы отличать мух от котлет. Если программа работает и сыплет ошибками, мы можем сказать оболочке: полезные данные (дескриптор 1) пиши в файл, а ошибки (дескриптор 2) выброси в /dev/null — чёрную дыру:
command > data.txt 2>/dev/null
Пайп | — главная магия Linux
Символ | — это механизм межпроцессного взаимодействия. Когда мы пишем:
cat access.log | grep "ERROR"
Ядро запускает cat и grep одновременно. Для cat подменяет stdout: вместо экрана — специальный буфер в памяти (пайп). Для grep подменяет stdin: вместо клавиатуры — тот же буфер.
В чём гениальность? cat думает, что пишет на экран. grep думает, что читает с клавиатуры. Они не знают о существовании друг друга. Ядро просто переткнуло провода.
cat access.log grep "ERROR"
┌───────────┐ ┌───────────┐
│ │ stdout │ │
│ cat ├──────────►│ grep │──► экран
│ │ пайп │ │
└───────────┘ (буфер) └───────────┘
думает что думает что
пишет на экран читает с клавиатуры
Это позволяет строить конвейеры любой длины. Пять маленьких программ, соединённых пайпами, превращаются в мощный инструмент аналитики. Это и есть подход Unix: писать программы, которые делают одну вещь, но хорошо — и научить их работать вместе через текстовые потоки.
Шаг 9. Сигналы — как процессы получают пинок
Процессы не живут в полной изоляции. Ядро и другие процессы могут им кое-что послать. Это называется сигнал — короткое асинхронное уведомление: «эй, тебе пора умереть» или «перечитай конфиг».
Когда вы нажимаете Ctrl+C в терминале — вы не «закрываете» программу. Вы отправляете ей сигнал SIGINT. Программа может его поймать и корректно завершиться. Или проигнорировать.
Основные сигналы, которые стоит знать:
SIGTERM — вежливая просьба завершиться. Программа может поймать, сохранить состояние и выйти.
SIGKILL — приказ, который нельзя игнорировать. Ядро убивает процесс немедленно, без права на сохранение. Именно этот сигнал использует OOM Killer.
SIGHUP — исторически означал «потерян терминал». Сегодня демоны используют его как сигнал перечитать конфиг без перезапуска.
SIGINT — прерывание с клавиатуры (Ctrl+C).
SIGSTOP — заморозить процесс. Тоже нельзя игнорировать.
Отправить сигнал вручную можно командой kill — несмотря на название, она умеет слать любой сигнал:
kill -SIGTERM 1234 # вежливо попросить процесс 1234 завершиться
kill -SIGKILL 1234 # убить без разговоров
kill -SIGHUP 1234 # попросить перечитать конфиг
Важно понимать: SIGKILL — это крайняя мера. Процесс не успевает ничего сохранить, закрыть соединения, записать данные. Поэтому сначала всегда SIGTERM, и только если не помогло — SIGKILL.
Шаг 10. Права доступа
В Linux безопасность — это не магия антивируса. Это проверка битов в inode.
Ядро не знает слов. Оно знает только цифры. Наше имя пользователя транслируется в UID (User ID), группа — в GID (Group ID). База данных соответствий лежит в простых текстовых файлах /etc/passwd и /etc/group.
В каждой inode записано: «этим файлом владеет UID такой-то, группа такая-то». И там же три набора прав: user (владелец), group (группа), others (все остальные). Каждому набору — три действия:
read (4) — читать
write (2) — изменять
execute (1) — запускать как процесс
Это не случайные числа — это битовая маска: read = 100, write = 010, execute = 001.
Когда пишем
chmod 755 file
:
7 = 4+2+1 — владелец может всё
5 = 4+0+1 — группа может читать и запускать, но не писать
5 = 4+0+1 — все остальные тоже
Важный нюанс: флаг x на каталоге означает не запуск, а право войти в него. Есть права на чтение папки, но нет права на вход — увидим список файлов, но не сможем прочитать их содержимое.
Root и sudo
В Linux есть один пользователь, для которого ядро отключает проверку прав — это root (UID 0). Когда процесс с UID 0 открывает файл, ядро не смотрит в inode — просто открывает. Root может читать /etc/shadow с хэшами паролей, убить любой процесс, отформатировать диск на живой системе.
Поэтому сидеть под root — дурной тон и риск. Для администрирования используется sudo — утилита с битом SUID, которая временно повышает UID до 0, выполняет одну команду и, что критически важно, записывает всё в лог аудита. Так мы получаем безопасность без потери контроля.
Шаг 11. Пакетный менеджер и библиотеки
В Windows все привыкли: идёшь на сайт, качаешь .exe, кликаешь Next, и каждая программа тащит с собой свои собственные библиотеки. В Linux принят другой подход — централизованные репозитории. Это проверенные склады бинарных пакетов, которые поддерживаются разработчиками дистрибутива.
Когда пишем
apt install nginx
— происходит не просто скачивание. Пакетный менеджер распаковывает пакет строго по стандарту FHS: бинарники в /usr/bin или /usr/sbin, конфиги в /etc, сервисный файл в /lib/systemd/system. И самое главное — проверяет зависимости.
Почему это так важно? Потому что программы в Linux почти всегда динамически скомпонованы. Бинарник Nginx весит копейки — в нём нет кода шифрования и сжатия. При запуске он просто говорит ядру: «мне нужна такая-то библиотека». Эти библиотеки общие — лежат в системе в одном экземпляре. Ядро загружает библиотеку в память один раз, а Nginx, SSH-клиент и Python-скрипт просто получают ссылку на тот же участок памяти. Это колоссально экономит оперативку.
Но версии библиотек должны совпадать. Если этот карточный домик рушится — это называется Dependency Hell (ад зависимостей). Именно поэтому нужен пакетный менеджер, а не ручное копирование файлов.
Шаг 12. Namespaces и cgroups — из чего сделан Docker
В итоге мы скажем, что «никакого Docker нет». Но давайте разберёмся, что за этим стоит.
Ядро Linux предоставляет два механизма, которые вместе и дают нам контейнеры.
Namespaces — изоляция окружения
Namespace — это механизм, который говорит процессу: «ты видишь не весь мир, а только свою часть».
Есть несколько типов namespaces:
PID namespace — процесс думает, что он PID 1 в системе. Не видит процессы снаружи.
NET namespace — у процесса своя сетевая карта, свой IP, свои правила файрвола.
MNT namespace — процесс видит своё собственное дерево файловой системы.
UTS namespace — процесс думает, что у него своё имя хоста.
USER namespace — процесс думает, что он root — но только внутри своего namespace.
Когда Docker создаёт контейнер — он просто создаёт набор namespaces и запускает процесс внутри них. Процесс думает, что он один на сервере. На самом деле рядом крутятся сотни таких же «одиноких» процессов.
Хост (Linux kernel)
┌─────────────────────────────────────┐
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Контейнер 1 │ │ Контейнер 2 │ │
│ │ (namespaces)│ │ (namespaces)│ │
│ │ PID: 1 │ │ PID: 1 │ │
│ │ IP: 10.0.1 │ │ IP: 10.0.2 │ │
│ │ cgroups: │ │ cgroups: │ │
│ │ 512MB RAM │ │ 1GB RAM │ │
│ │ 1 CPU │ │ 2 CPU │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────┘
Cgroups — ограничение ресурсов
Namespaces дают изоляцию. Но что если процесс внутри своего namespace решит сожрать всю память сервера?
Для этого существуют cgroups (control groups). Это механизм ядра, который позволяет ограничить, сколько ресурсов может использовать процесс или группа процессов:
memory.limit_in_bytes — максимум оперативной памяти
cpu.shares — доля процессорного времени
blkio.weight — приоритет дискового ввода-вывода
Именно cgroups — это то, что вы настраиваете в Kubernetes, когда пишете resources.limits в манифесте пода. Kubernetes говорит ядру: «этот процесс может использовать не более 512MB памяти». Если превысил — приходит OOM Killer.
Так что Docker — это просто удобная обёртка над namespaces и cgroups. Никакой магии. Всё это умеет делать любой процесс в Linux — Docker просто автоматизирует и упаковывает это в понятный интерфейс.
Итог: смотрите на Linux как инженер
Мы прошли путь от холодного кремния до работающего веб-сервера. Теперь в голове должна быть не каша из команд, а чёткая вертикальная структура:
- Железо — тупой кремний, который разбудил BIOS
- GRUB — загрузчик нашёл и распаковал ядро
- Ядро — диктатор, который врёт программам про память, нарезает время процессора, управляет драйверами
- Системные вызовы — единственная дверь между программами и ядром
- systemd (PID 1) — строит всё окружение пользовательского пространства
- Файловая система — единое дерево, где каждый файл — это номер с правами доступа
- Процессы и пайпы — изолированные контейнеры, соединённые потоками данных
- Сигналы — как ядро и процессы общаются друг с другом
- Права доступа — биты в inode, которые ядро проверяет на каждый системный вызов
- Пакеты и библиотеки — управляемая экосистема зависимостей
- Namespaces и cgroups — изоляция и ограничение ресурсов, из которых состоит любой контейнер
Если держать в голове эту схему — магия исчезает. Остаётся чистая, сухая логика.
Видите ошибку Permission Denied? Это не «Linux ругается». Это ядро при вызове open() сравнило ваш UID с битами в inode и вернуло код ошибки.
Сервер тормозит, высокий load average? Это не «процессор устал». Это очередь в планировщике стала слишком длинной, и система тратит ресурсы на переключение контекста.
Спрашивают про Docker? В его основе — namespaces и cgroups, которые ядро предоставляет любому процессу. Docker — это удобная обёртка над этими механизмами. Понимаете ядро — понимаете, как работают контейнеры.
Новичок учит команды наизусть. Инженер понимает, как текут байты. Команду можно нагуглить за 5 секунд. Понимание архитектуры — только наработать.
