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. Контейнеры окупаются, когда приложений несколько, у них разные зависимости и их надо переносить между машинами. Про то, когда рано брать оркестратор, есть отдельный разбор.