Алексей Меленчук
вся записка
дата
объём 5 мин чтения

Docker: что он изолирует на самом деле

Чем контейнер отличается от виртуальной машины, какие механизмы ядра его изолируют и почему контейнер не является границей безопасности. Разбор устройства без метафор.

Коротко. Контейнер — это обычный процесс хоста с ограниченным обзором. Ядро у него общее с хостом, и это главное отличие от виртуальной машины: изоляция ресурсов есть, границы безопасности уровня гипервизора нет.

Расхожая метафора «контейнер — это лёгкая виртуальная машина» удобна, пока не начинает вредить. Из неё следуют неверные выводы о безопасности, и следуют они у людей, которые принимают решения.

Разберём, что происходит на самом деле.

Контейнер — это процесс

Запустите контейнер и посмотрите список процессов на хосте. Вы увидите там процесс контейнера — обычный, с обычным идентификатором. Никакой отдельной операционной системы под ним нет.

Разница с обычным процессом только в том, что ему ограничили обзор и ресурсы. Делают это два механизма ядра Linux.

Пространства имён ограничивают, что процесс видит:

  • pid — свои процессы, чужих не видно;
  • mnt — своя файловая система;
  • net — свой сетевой стек, свои интерфейсы и порты;
  • uts — своё имя хоста;
  • ipc — своя межпроцессная связь;
  • user — своё отображение пользователей, позволяющее быть root внутри и непривилегированным снаружи.

Контрольные группы ограничивают, сколько процесс потребляет: процессорное время, память, ввод-вывод. Именно они убивают контейнер по превышению лимита памяти.

Сверху добавляются ограничение возможностей и фильтрация системных вызовов.

Всё. Никакой эмуляции оборудования, никакой второй операционной системы. Отсюда и скорость запуска: запустить контейнер — значит запустить процесс, а не загрузить систему.

Что из этого следует про безопасность

Ядро общее. Процесс в контейнере делает системные вызовы к тому же ядру, что и хост. Уязвимость в ядре, доступная через системный вызов, доступна из контейнера.

У виртуальной машины между гостем и хостом стоит гипервизор, и гостевое ядро своё. Поверхность атаки принципиально уже.

Отсюда вывод, который стоит произнести прямо: контейнер — не граница безопасности того же класса, что виртуальная машина. Для своего кода этого достаточно. Для чужого — нет.

Если вы запускаете код, которому не доверяете, — сборки клиентов, пользовательские скрипты, — нужна граница уровня виртуализации: gVisor, Firecracker, Kata. Docker в этой роли даёт ложное спокойствие.

Три ошибки, растущие из метафоры

root внутри контейнера. По умолчанию процесс в контейнере работает от root, и без пространства имён пользователей это тот же root хоста с урезанными возможностями. Смонтировали каталог хоста — получили запись в него от суперпользователя.

Лечится: USER в образе, --user при запуске, включённое отображение пользователей.

Монтирование сокета Docker внутрь контейнера. Приём из инструкций по непрерывной интеграции: «чтобы собирать образы изнутри». Доступ к сокету равен правам root на хосте — через него можно запустить контейнер с примонтированным корнем.

«Контейнер упал — данные потеряны, но это же изолировано». Изолирована файловая система, а не хост. Записанное в примонтированный том остаётся на хосте, включая то, что писать не собирались.

Образ — это слои

Вторая половина Docker, которая к изоляции отношения не имеет.

Образ собран из слоёв, каждый слой — разница файловой системы. Контейнер добавляет к ним записываемый слой поверх.

Практическое следствие, о котором забывают: слой помнит всё, что в нём было. Скопировали ключ, удалили его следующей инструкцией — ключ остался в предыдущем слое и достанется любому, у кого есть образ. Подробнее — в разборе про секреты и воспроизводимость.

Второе следствие — порядок инструкций определяет скорость пересборки. Копирование исходников до установки зависимостей означает, что любая правка кода пересобирает зависимости заново.

Первый процесс и почему контейнер не останавливается

Практическая ловушка, которая ловит всех по одному разу.

Процесс с идентификатором 1 в пространстве имён получает особую роль: он обязан пожинать осиротевшие дочерние процессы и он не получает обработчиков сигналов по умолчанию. Обычный процесс, не назначавший обработчик SIGTERM, завершается; процесс с номером 1 — нет.

Отсюда два следствия.

Контейнер не останавливается по docker stop и через десять секунд убивается жёстко. Приложение не успевает закрыть соединения и доиграть запросы — вместо корректного завершения получается обрыв.

Причина обычно в том, что точка входа — оболочка: CMD npm start в форме строки запускает /bin/sh -c npm start, оболочка становится первым процессом, сигнал приходит ей и дальше не идёт.

Лечится формой списка — CMD ["node", "server.js"], — тогда первым процессом становится приложение. И обработчик SIGTERM в приложении надо написать: закрыть приём новых запросов, дождаться текущих, выйти.

Зомби-процессы накапливаются, если приложение порождает дочерние и не пожинает их. Флаг --init подкладывает минимальный первый процесс, который это делает.

Слои и порядок инструкций

Правило, которое сокращает время сборки в разы.

Слой пересобирается, если изменилось его содержимое или любой слой до него. Отсюда порядок: сначала то, что меняется редко, потом то, что меняется часто.

COPY package.json package-lock.json ./
RUN npm ci          # пересоберётся только при смене зависимостей
COPY . .            # меняется при каждой правке кода
RUN npm run build

Обратный порядок — COPY . . перед установкой зависимостей — означает полную переустановку зависимостей на каждую правку одной строки.

Многоэтапная сборка решает вторую задачу: инструменты сборки не должны попасть в итоговый образ. Компилятор, заголовки, кэш пакетного менеджера нужны на этапе сборки и вредны в проде — они увеличивают образ и расширяют поверхность атаки.

Что я держу в голове при работе с контейнерами

Контейнер — единица упаковки и ограничения ресурсов, а не единица безопасности. Свой код — да. Чужой — только с настоящей границей.

Лимиты памяти задаются всегда, и рантайм о них знает. JVM без явной настройки под контейнер планирует кучу по памяти хоста и убивается по превышению лимита, не оставив ничего в журнале приложения: его убили снаружи.

Образ пинится по дайджесту, а не по тегу. Тег указывает на разные образы в разные дни, и сборка перестаёт быть воспроизводимой.

Когда контейнеры вообще не нужны. Одно приложение на одной машине, которое ставится пакетом и обновляется системным менеджером, прекрасно живёт без Docker. Контейнеры окупаются, когда приложений несколько, у них разные зависимости и их надо переносить между машинами. Про то, когда рано брать оркестратор, есть отдельный разбор.