Linux и сети: Глубокое погружение для DevOps

Курс формирует системное понимание работы ядра Linux и сетевого стека, необходимое для траблшутинга высоконагруженных систем. Вы пройдете путь от управления ресурсами и правами доступа до настройки сложных сетевых топологий и фильтрации трафика.

1. Архитектура ядра Linux, системные вызовы и управление жизненным циклом процессов

Архитектура ядра Linux, системные вызовы и управление жизненным циклом процессов

Когда вы запускаете команду ls или разворачиваете высоконагруженный микросервис в контейнере, вы взаимодействуете с самой сложной программой, когда-либо созданной человечеством — ядром Linux. Для DevOps-инженера понимание того, что происходит «под капотом» при создании процесса, — это не академическое упражнение, а критический навык для отладки утечек ресурсов, зомби-процессов и проблем с производительностью, которые невозможно решить простым перезапуском сервиса.

Фундамент: Монолитная архитектура и кольца защиты

Ядро Linux часто называют монолитным, но это определение требует уточнения. В отличие от микроядерных архитектур (например, Minix или L4), где большинство сервисов (драйверы, файловые системы) работают в пользовательском пространстве, Linux исполняет все ключевые компоненты в едином адресном пространстве с максимальными привилегиями. Однако современное ядро является «модульным монолитом»: оно позволяет динамически загружать и выгружать код (модули ядра) без перезагрузки системы.

Центральная концепция безопасности и стабильности Linux строится на разделении уровней привилегий процессора, известных как кольца защиты (Protection Rings). В архитектуре x86_64 выделяют четыре кольца, но Linux использует только два:

  • Ring 0 (Kernel Mode): Здесь работает ядро. Оно имеет неограниченный доступ к аппаратному обеспечению, памяти и портам ввода-вывода. Любая ошибка в коде на этом уровне (например, в драйвере) может привести к Kernel Panic и полной остановке системы.
  • Ring 3 (User Mode): Здесь работают ваши приложения, базы данных и оболочка bash. Процессы в Ring 3 изолированы друг от друга и не могут напрямую обращаться к железу. Если программа в Ring 3 попытается выполнить привилегированную инструкцию, процессор сгенерирует исключение, и ядро немедленно завершит этот процесс (Segmentation Fault).
  • Это разделение создает фундаментальную проблему: если приложению нужно записать данные на диск или отправить пакет в сеть, оно не может сделать это само, так как диск и сетевая карта находятся в ведении Ring 0. Решением является механизм системных вызовов (System Calls).

    Системные вызовы: Интерфейс между мирами

    Системный вызов — это единственный легальный способ для программы в User Mode попросить ядро выполнить действие от её имени. Это своего рода «контрольно-пропускной пункт» на границе колец защиты.

    Когда приложение вызывает функцию, например open() или read(), происходит следующая последовательность действий:

  • Библиотека C (glibc) подготавливает аргументы и помещает номер системного вызова в специальный регистр процессора (например, %rax в x86_64).
  • Выполняется специальная инструкция процессора (syscall), которая инициирует программное прерывание.
  • Процессор переключается из Ring 3 в Ring 0, сохраняет состояние (контекст) пользовательского процесса и передает управление обработчику системных вызовов в ядре.
  • Ядро проверяет права доступа: имеет ли этот пользователь право читать данный файл?
  • Ядро выполняет работу и возвращает результат в регистр, после чего возвращает управление в User Mode.
  • Количество системных вызовов в Linux относительно невелико (около 350-450 в зависимости от версии ядра). Это делает интерфейс стабильным. Для DevOps-инженера важно понимать, что каждый системный вызов — это «дорогостоящая» операция с точки зрения производительности из-за переключения контекста и очистки кэшей процессора. Именно поэтому высокопроизводительные приложения стараются минимизировать их количество (например, используя буферизацию ввода-вывода).

    Структура процесса в памяти: task_struct

    С точки зрения ядра, процесс — это не просто запущенный бинарный файл, а сложная структура данных, называемая дескриптором процесса. В исходном коде ядра она представлена структурой struct task_struct.

    Ядро хранит все task_struct в циклическом двусвязном списке. Каждый дескриптор содержит полную информацию о состоянии процесса: * PID (Process ID): Уникальный идентификатор. * Состояние процесса: (Running, Interruptible, Uninterruptible, Stopped, Zombie). * Учетные данные (Credentials): UID, GID, возможности (capabilities). * Дерево процессов: Указатели на родительский процесс и список дочерних. * Файловая система: Ссылки на открытые файлы и текущую рабочую директорию. * Адресное пространство: Описание виртуальной памяти процесса.

    Важно понимать разницу между процессом и потоком (thread) в Linux. Для ядра Linux нет принципиальной разницы между ними. Поток — это просто процесс, который разделяет адресное пространство и файловые дескрипторы со своим родителем. Оба они представляются структурой task_struct и планируются планировщиком задач одинаково.

    Жизненный цикл процесса: От рождения до превращения в зомби

    Процессы в Linux не появляются «из ниоткуда». Каждый процесс (кроме самого первого — init или systemd с PID 1) создается другим процессом. Этот механизм реализуется через два основных системных вызова: fork() и execve().

    Механизм fork() и магия Copy-on-Write

    Когда процесс вызывает fork(), ядро создает практически точную копию родительского процесса. Новый процесс (child) получает копию дескриптора родителя, те же открытые файлы и ту же память.

    Однако полное копирование всей оперативной памяти процесса при каждом fork() было бы катастрофически медленным. Для оптимизации Linux использует механизм Copy-on-Write (CoW).

  • При fork() ядро не копирует страницы памяти. Вместо этого оно помечает страницы памяти родителя как «только для чтения» и разделяет их между родителем и ребенком.
  • Оба процесса читают из одних и тех же физических ячеек памяти.
  • Как только один из процессов пытается записать данные в страницу, процессор генерирует исключение (Page Fault).
  • Ядро видит, что это страница CoW, создает физическую копию этой конкретной страницы для пишущего процесса и меняет права доступа на «чтение-запись».
  • Это позволяет мгновенно создавать процессы, даже если они занимают гигабайты памяти.

    Замена образа: execve()

    Обычно после fork() дочерний процесс хочет запустить другую программу. Для этого используется системный вызов execve(). Он полностью заменяет текущий образ процесса (код, данные, стек, кучу) новой программой из исполняемого файла. PID при этом остается прежним.

    Завершение и состояние Zombie

    Когда процесс завершает работу (вызывает exit() или получает фатальный сигнал), он не исчезает из системы мгновенно. Он переходит в состояние Zombie (Z). В этом состоянии процесс уже не занимает память и не исполняется, но его запись в таблице процессов (task_struct) сохраняется. Это нужно для того, чтобы родительский процесс мог прочитать код возврата своего «ребенка» с помощью системного вызова wait().

    Если родитель не вызывает wait() (например, из-за ошибки в коде), процесс остается зомби навсегда. Зомби не потребляют ресурсы CPU или RAM, но они занимают место в таблице PID. Если таблица PID переполнится (лимит cat /proc/sys/kernel/pid_max), система не сможет создавать новые процессы.

    Если родитель умирает раньше ребенка, ребенок становится «сиротой» (orphan) и усыновляется процессом PID 1, который гарантированно вызывает wait() для всех своих детей.

    Состояния процессов и планировщик

    Процесс в течение жизни постоянно меняет свои состояния. Понимание этих состояний критично для диагностики «зависших» систем.

    | Состояние | Описание | Обозначение в ps / top | | :--- | :--- | :--- | | Running / Runnable | Процесс либо исполняется на CPU прямо сейчас, либо готов к исполнению и ждет своей очереди. | R | | Interruptible Sleep | Процесс ждет события (ввода пользователя, сетевого пакета). Его можно прервать сигналом. | S | | Uninterruptible Sleep | Процесс ждет завершения ввода-вывода (например, чтения с диска). Его нельзя убить даже командой kill -9. | D | | Stopped | Процесс остановлен (например, по нажатию Ctrl+Z или сигналу SIGSTOP). | T | | Zombie | Процесс завершен, но не «вычищен» родителем. | Z |

    Состояние D (Uninterruptible Sleep) часто является индикатором проблем с дисковой подсистемой или сетевыми файловыми системами (NFS). Если процесс завис в D, это означает, что он застрял внутри системного вызова в ядре, ожидая ответа от железа, который никогда не придет.

    Планировщик задач: CFS (Completely Fair Scheduler)

    Поскольку процессов в системе обычно сотни, а ядер процессора — десятки, ядро должно постоянно решать, кому дать право на исполнение. В современном Linux за это отвечает Completely Fair Scheduler (CFS).

    В отличие от старых планировщиков, CFS не использует фиксированные «кванты времени». Вместо этого он старается распределить процессорное время максимально справедливо. Ключевое понятие здесь — vruntime (virtual runtime). Чем меньше процесс провел времени на CPU, тем меньше его vruntime, и тем выше его приоритет на получение следующего такта процессора.

    DevOps-инженер может влиять на планировщик через:

  • Nice value: Значение от -20 (наивысший приоритет) до 19 (наинизший). По умолчанию процесс создается с nice = 0.
  • CPU Affinity: Привязка процесса к конкретным ядрам (используется для минимизации промахов кэша L1/L2 в высоконагруженных БД).
  • Cgroups: Ограничение доли CPU, которую может потреблять группа процессов (фундамент лимитов в Docker/Kubernetes).
  • Сигналы: Механизм уведомлений

    Сигналы — это ограниченная форма межпроцессного взаимодействия (IPC), позволяющая ядру или одному процессу уведомить другой процесс о событии.

    Когда процесс получает сигнал, у него есть три пути:

  • Игнорировать сигнал (кроме SIGKILL и SIGSTOP).
  • Перехватить сигнал и выполнить свою функцию-обработчик (например, плавно закрыть соединения при SIGTERM).
  • Выполнить действие по умолчанию (обычно это завершение процесса).
  • Основные сигналы для DevOps: * SIGTERM (15): Просьба вежливо завершиться. Приложение должно очистить ресурсы и выйти. * SIGKILL (9): Немедленное завершение ядром. Процесс не может его перехватить. Ресурсы могут остаться в несогласованном состоянии. * SIGHUP (1): Традиционно используется для просьбы перечитать конфигурационные файлы без перезапуска. * SIGCHLD: Отправляется родителю, когда его ребенок завершился (сигнал к вызову wait()).

    Контекст прерываний и Softirqs

    Работа ядра не ограничивается только обслуживанием системных вызовов. Огромная часть работы происходит в ответ на внешние события от железа — прерывания (Interrupts).

    Когда сетевая карта получает пакет, она посылает электрический сигнал процессору. Процессор бросает все дела и переключается в Interrupt Context. Важное правило: в контексте прерывания ядро не может «спать» (блокироваться), так как прерывание не привязано к конкретному процессу.

    Чтобы не блокировать систему надолго, обработка прерывания делится на две части:

  • Top Half (Hard IRQ): Быстрая работа — подтвердить получение прерывания, забрать данные из буфера устройства и запланировать дальнейшую обработку.
  • Bottom Half (Softirq / Tasklets): Отложенная, более тяжелая работа (например, разбор стека TCP/IP). Выполняется сразу после Hard IRQ, но позволяет другим прерываниям вклиниваться.
  • Если вы видите в top, что процесс ksoftirqd потребляет много CPU, это верный признак того, что система завалена сетевыми пакетами или дисковыми операциями, и ядро тратит все силы на их первичную обработку.

    Практическое применение: Исследование через /proc

    Ядро Linux предоставляет уникальное окно в свой внутренний мир через виртуальную файловую систему /proc. Это не настоящие файлы на диске, а интерфейс к структурам данных ядра.

    Для каждого процесса с PID N существует директория /proc/N/: * /proc/N/status: Человекочитаемая информация о состоянии, PID, PPID, использовании памяти и сигналах. * /proc/N/stack: Показывает текущий стек вызовов в пространстве ядра. Если процесс завис в состоянии D, именно здесь вы увидите, на какой функции ядра он остановился. * /proc/N/fd/: Список всех открытых файловых дескрипторов (сокеты, файлы, пайпы). * /proc/N/maps: Карта виртуальной памяти процесса.

    Понимание иерархии процессов и их связей с ядром позволяет DevOps-инженеру отвечать на сложные вопросы. Почему контейнер не запускается? Возможно, он не может выполнить clone() (современный аналог fork()) из-за лимита pids.max в cgroups. Почему приложение тормозит? Возможно, оно генерирует тысячи лишних системных вызовов stat(), что видно через strace.

    Архитектура ядра Linux построена на балансе между защищенностью (кольца защиты), гибкостью (модульность) и производительностью (CoW, CFS). Владение этими концепциями превращает «магию» работы операционной системы в предсказуемую и управляемую среду.

    2. Углубленное управление памятью, виртуальная память и механизмы кэширования в Linux

    Углубленное управление памятью, виртуальная память и механизмы кэширования в Linux

    Начинающий системный администратор, впервые запустив утилиту free -h на нагруженном сервере, часто испытывает панику: колонка free (свободная память) стремится к нулю, даже если на сервере 128 ГБ оперативной памяти. Возникает инстинктивное желание перезапустить сервисы или добавить еще планок RAM. Однако для ядра Linux пустая оперативная память — это потраченный впустую ресурс. Система намеренно утилизирует каждый свободный байт для ускорения операций ввода-вывода, скрывая реальную физическую память за слоем абстракций.

    Понимание того, как ядро распределяет, кэширует и отбирает память, отличает инженера, способного настроить сервер под highload-базу данных, от того, кто просто перезапускает контейнеры по факту их падения от нехватки ресурсов.

    Анатомия виртуальной памяти

    Процессы в Linux никогда не работают с физической оперативной памятью напрямую. Каждому процессу выдается иллюзия того, что он единолично владеет огромным, непрерывным адресным пространством. Эта абстракция называется виртуальной памятью.

    В 64-битных системах теоретическое адресное пространство огромно, но ядро делит его на две строгие зоны: нижняя часть отдается под пользовательское пространство (User Space), а верхняя зарезервирована под память ядра (Kernel Space). Когда процесс обращается к переменной по адресу в памяти, процессор получает виртуальный адрес. Чтобы превратить его в физический (конкретную ячейку в чипе RAM), используется аппаратный компонент процессора — MMU (Memory Management Unit).

    Память не выделяется побайтово. Она разбита на блоки фиксированного размера — страницы (Pages). Стандартный размер страницы в архитектуре x86_64 составляет 4 КБ.

    !Схема трансляции виртуальной памяти в физическую

    Чтобы MMU знал, какая виртуальная страница соответствует какой физической, ядро поддерживает структуру данных, называемую таблицей страниц (Page Table). У каждого процесса она своя. Поскольку обращение к таблице страниц при каждой операции чтения/записи сильно замедлило бы работу, процессоры используют TLB (Translation Lookaside Buffer) — сверхбыстрый аппаратный кэш для недавно использованных трансляций адресов. Если трансляция найдена в TLB (TLB Hit), обращение к памяти происходит мгновенно. Если нет (TLB Miss) — процессору приходится обходить иерархию таблиц страниц в оперативной памяти, что стоит десятков тактов.

    Demand Paging и Page Faults

    Когда приложение запрашивает память через системный вызов (например, стандартная библиотека C вызывает brk или mmap при использовании malloc), ядро не выделяет физические страницы немедленно. Оно лишь обновляет таблицу страниц процесса, помечая выделенный виртуальный диапазон как валидный, но не привязанный к физической RAM. Это механизм отложенного выделения (Demand Paging).

    Реальное выделение происходит в момент первого обращения к этой памяти. Процессор пытается записать данные по виртуальному адресу, MMU не находит физической страницы и генерирует аппаратное прерывание — Page Fault. Ядро перехватывает его, находит свободную физическую страницу, обновляет таблицу страниц процесса и возвращает управление приложению. Приложение даже не замечает этой паузы.

    Page Fault бывает двух типов, и их различие критически важно для диагностики производительности:

  • Minor Page Fault (Мягкий): Страница уже находится в оперативной памяти, но еще не привязана к процессу. Например, при первом обращении к выделенной через malloc памяти ядро просто выдает чистую страницу. Это быстрая операция.
  • Major Page Fault (Жесткий): Данных в оперативной памяти нет, и ядру необходимо прочитать их с диска. Это происходит, например, когда процесс обращается к части исполняемого файла или к памяти, которая была выгружена в Swap.
  • Major Page Fault — главный враг производительности. Чтение с диска (даже NVMe) на порядки медленнее обращения к RAM. Если система мониторинга показывает всплеск Major Page Faults у процесса базы данных, это верный признак того, что рабочему набору данных не хватает оперативной памяти и система начала активно читать с диска.

    Как ядро управляет физическими страницами

    Для управления миллионами физических страниц (на сервере с 64 ГБ RAM их более 16 миллионов) ядру требуются высокоэффективные алгоритмы. Основным механизмом распределения физических страниц в Linux является Buddy Allocator (система двойников).

    Алгоритм группирует свободные страницы в блоки, размер которых равен степени двойки: 1 страница, 2, 4, 8, 16 и так далее, вплоть до 1024 страниц (4 МБ).

    Когда ядру требуется непрерывный кусок памяти, оно ищет блок подходящего размера. Если нужного блока нет, берется блок большего размера и делится пополам (на двух "близнецов" — buddies). Одна половина используется, вторая остается свободной. Когда память освобождается, ядро проверяет, свободен ли соседний "близнец". Если да, они немедленно сливаются в более крупный блок, предотвращая фрагментацию физической памяти.

    !Визуализация работы Buddy Allocator

    Однако Buddy Allocator работает только с целыми страницами (минимум 4 КБ). Ядру часто нужны гораздо меньшие структуры данных: дескрипторы процессов, сетевые сокеты, элементы файловой системы. Выделять под каждый сокет 4 КБ было бы катастрофическим расточительством.

    Для мелких объектов поверх Buddy Allocator работает Slab Allocator. Он запрашивает у Buddy крупные блоки страниц и нарезает их на мелкие кусочки фиксированного размера под конкретные нужды ядра. Slab кэширует часто используемые объекты. Если процесс завершился, его структура не уничтожается полностью, а возвращается в Slab-кэш, чтобы при запуске нового процесса ядро могло мгновенно выдать готовую структуру без затрат на инициализацию.

    Кэширование: Page Cache и Dirty Pages

    Возвращаемся к пугающему выводу free -h. Вся физическая память, не занятая ядром и анонимной памятью процессов, отдается под Page Cache (страничный кэш).

    В Linux нет прямого чтения файлов с диска. Когда процесс читает файл, ядро сначала копирует блоки диска в Page Cache в оперативной памяти, а затем передает их процессу. При повторном чтении ядро отдаст данные из памяти со скоростью RAM. Именно поэтому колонка buff/cache в выводе free всегда стремится занять все свободное пространство.

    С записью ситуация еще интереснее. Когда процесс пишет данные в файл, ядро не отправляет их на диск немедленно. Оно записывает их в Page Cache и помечает эти страницы как "грязные" (Dirty Pages). Процесс получает ответ, что запись прошла успешно, и продолжает работу без блокировки на медленный I/O.

    Асинхронные потоки ядра (ранее pdflush, сейчас рабочие потоки flush) периодически просыпаются и сбрасывают грязные страницы на диск. Поведение этого механизма регулируется параметрами sysctl, которые DevOps-инженер обязан уметь настраивать:

  • vm.dirty_background_ratio: Процент от общей памяти, при достижении которого ядро начинает фоновую запись грязных страниц на диск, не блокируя процессы. По умолчанию обычно 10%.
  • vm.dirty_ratio: Жесткий лимит. Если процент грязных страниц достигает этого значения, ядро блокирует все процессы, пытающиеся писать на диск, заставляя их ждать физической записи. По умолчанию часто 20%.
  • Математика расчета абсолютного значения проста:

    Где — объем памяти в байтах, — общий объем доступной оперативной памяти, а — значение параметра (например, vm.dirty_ratio).

    Рассмотрим пример. На сервере базы данных с 256 ГБ RAM дефолтный vm.dirty_ratio в 20% означает, что ядро может накопить более 50 ГБ грязных страниц. Если дисковая подсистема не справляется, и лимит достигается, база данных внезапно "зависнет", ожидая сброса десятков гигабайт на диск. Для серверов с большим объемом памяти эти параметры часто переводят из процентов в абсолютные значения (vm.dirty_bytes и vm.dirty_background_bytes), устанавливая их на уровне нескольких сотен мегабайт, чтобы обеспечить равномерную, предсказуемую нагрузку на I/O без долгих блокировок.

    Swap и параметр Swappiness

    Swap (пространство подкачки) — это область на диске, куда ядро вытесняет редко используемые страницы памяти, чтобы освободить RAM для более важных задач.

    Память делится на два основных типа:

  • File-backed memory: Память, связанная с файлами на диске (Page Cache, бинарные файлы). Если ядру нужна RAM, оно может просто удалить эти страницы. Если они понадобятся снова, их можно прочитать с диска. Если страницы грязные, их нужно сначала записать на диск.
  • Anonymous memory: Память, не имеющая файла на диске (куча, стек процессов, данные, выделенные через malloc). Если ядру нужно освободить эту память, ее некуда сбросить, кроме как в специально отведенный Swap.
  • Параметр /proc/sys/vm/swappiness (от 0 до 100) определяет баланс при нехватке памяти: что ядру вытеснять охотнее — страничный кэш или анонимную память в Swap? Значение по умолчанию — 60. Это означает, что ядро будет довольно активно использовать Swap, даже если есть свободный Page Cache.

    Среди начинающих администраторов существует миф, что для баз данных нужно ставить vm.swappiness = 0, чтобы полностью отключить Swap. На современных ядрах Linux значение 0 означает "не использовать Swap до тех пор, пока не наступит критическая нехватка памяти (OOM)". Это может привести к тому, что ядро вытеснит весь Page Cache (сильно замедлив файловые операции), но так и не тронет Swap, а затем резко убьет процесс базы данных. Правильным подходом для баз данных часто является установка vm.swappiness = 1 (или небольшого значения вроде 10), что говорит ядру: "максимально избегай Swap, но используй его, чтобы предотвратить OOM".

    OOM Killer: Судья последней инстанции

    Когда физическая память и Swap полностью исчерпаны, а ядро не может освободить Page Cache, система оказывается в состоянии Out Of Memory (OOM). Чтобы ядро не упало в Kernel Panic (полный отказ системы), активируется механизм OOM Killer.

    Его задача — найти и убить один или несколько процессов, чтобы освободить память и спасти систему. Выбор жертвы не случаен. Ядро вычисляет для каждого процесса оценку oom_score. Чем выше оценка, тем больше шансов, что процесс будет убит (максимум 1000).

    Оценка формируется на основе эвристики:

  • Процессы, потребляющие больше всего памяти, получают высокие баллы.
  • Процессы, работающие от имени пользователя root, получают небольшую скидку.
  • Длительность работы процесса (старые процессы убивают реже) в современных ядрах учитывается меньше, чем объем занимаемой памяти.
  • DevOps-инженер может влиять на этот выбор. У каждого процесса в файловой системе /proc есть файл /proc/<pid>/oom_score_adj (диапазон от -1000 до 1000). Значение из этого файла прибавляется к базовому oom_score.

    Например, на сервере работает СУБД PostgreSQL и вспомогательный агент сбора метрик. Если агент из-за утечки памяти начнет потреблять всю RAM, OOM Killer может ошибочно убить PostgreSQL, так как база данных в абсолютных значениях занимает больше памяти. Чтобы защитить критичный процесс, мы можем установить для PostgreSQL oom_score_adj = -800. Теперь его итоговый балл будет искусственно занижен, и OOM Killer гарантированно убьет агента метрик, спасая базу данных. Значение -1000 делает процесс полностью неуязвимым для OOM Killer, но использовать его нужно с крайней осторожностью, так как это может привести к зависанию ядра, если этот процесс сам исчерпает всю память.

    Понимание того, как виртуальные адреса превращаются в физические через таблицы страниц, как Buddy Allocator борется с фрагментацией, и почему грязные страницы могут стать причиной внезапных задержек, дает инженеру полный контроль над поведением системы. Настройка параметров ядра перестает быть магией со StackOverflow и превращается в точную инженерную задачу по балансировке ресурсов между кэшем и приложениями.