Рантайм Docker: изоляция, лимиты, код 137
Запустил официальный образ nginx командой «nginx», как её обычно пишут на самой машине. Через секунду смотрю статус: «Exited (0)». Контейнер завершился сам, без ошибок, код выхода нулевой. Тот же образ с добавкой «-g daemon off;» работает и держится: внутри 4 процесса, первый из них - мастер-процесс nginx.
Стержень: контейнер живёт ровно столько, сколько живёт его первый процесс; коды выхода читаются арифметикой; привилегии и сокет демона снимают изоляцию целиком.
// Формулировки: «контейнер сразу Exited (0), почему?», «что означает код 137?», «чем опасен проброс сокета демона?»
Контейнер жив, пока жив его первый процесс
Контейнер существует ровно столько, сколько работает процесс, запущенный при старте. Классическая ошибка новичка - запустить внутри службу, которая по традиции уходит в фон. Родительский процесс при этом честно завершается с нулевым кодом, а вместе с ним завершается и контейнер. Именно это я и получил с nginx: «Exited (0)» через секунду, никакой ошибки в логах нет, и искать нечего.
Отсюда правило: внутри контейнера службу запускают в переднем плане, то есть запрещают ей уходить в фон. У nginx это добавка «daemon off», у большинства демонов есть свой такой ключ. Проверка простая: посмотреть, что стало первым процессом. У меня после исправления это «nginx: master process nginx -g daemon off;», а всего процессов внутри 4 - мастер и рабочие.
// Второе правило той же природы: логи пишут на экран, а не в файлы внутри контейнера. Всё, что программа печатает на экран (это называют стандартным выводом), забирает демон - постоянно работающая служба docker на машине, - и отдаёт по команде docker logs. Файл внутри контейнера умрёт вместе с ним, и читать его будет неоткуда. И третье: один контейнер - одна служба. Диспетчер служб внутри контейнера ломает и перезапуск, и сбор логов, потому что демон видит только первый процесс.
- передний план
- режим, в котором служба не уходит в фон и держит свой процесс живым
- драйвер логов
- механизм демона, который забирает стандартный вывод контейнера
Коды выхода: арифметика и два разных 137
Код выхода - число, которое процесс оставляет после себя. Ноль означает «отработал штатно», остальное разбирается по таблице. Я получил каждое значение вживую: обычный выход с кодом 3 даёт 3; запуск несуществующей команды даёт 127; запуск файла, который есть, но не помечен исполняемым, даёт 126. Числа больше 128 означают гибель от сигнала: 128 плюс номер сигнала. Сигнал - это короткое уведомление от ядра процессу; девятый называется SIGKILL и не перехватывается, пятнадцатый SIGTERM означает вежливую просьбу завершиться.
Значит, 137 = 128 + 9: процесс добили принудительно. Дальше начинается самое полезное, потому что причин ровно две и выглядят они одинаково. Первая: контейнеру не хватило памяти по лимиту. Запустил программу, которая ест по 20 мегабайт, с потолком 128 мегабайт - код выхода 137, флаг OOMKilled (out of memory, нехватка памяти) стоит в положении true. Вторая: приложение не отреагировало на просьбу завершиться. Запустил программу, которая ловит SIGTERM и остаётся жить, и дал команду остановки с ожиданием 5 секунд - остановка заняла 5163 миллисекунды, код выхода тот же 137, а флаг OOMKilled стоит в положении false.
// Различает эти два случая только флаг, само число ничего не говорит. Первый случай лечат лимитом и поиском утечки, второй - обработчиком сигнала в приложении. И держите в голове ещё одну мелочь: лимит памяти контейнера жёсткий, а счётчики внутри показывают память всей машины, поэтому программы, которые подбирают себе размер буферов «от объёма памяти машины», регулярно нарываются на это самое 137.
- код выхода
- число, которое процесс оставляет после себя; 0 означает штатное завершение
- сигнал
- короткое уведомление процессу от ядра: завершись, остановись, перечитай настройки
- OOMKilled
- флаг в описании контейнера: убит за превышение лимита памяти
Root внутри - это root снаружи
По умолчанию процесс внутри контейнера работает от root. И это тот же самый root, что и на машине, просто с ограниченным набором прав. Проверка на пальцах: пробросил каталог машины внутрь контейнера (это называют монтированием) и создал в нём файл из контейнера. На самой машине файл принадлежит root:root, хотя мой обычный пользователь тут - prod с идентификатором 1000. С флагом --user 1000:1000 контейнер работает от того же 1000, и созданный файл принадлежит уже prod:prod. Отсюда и вечная беда с правами на примонтированных каталогах, и правило запускать приложение не от root.
Дальше про два флага, которые снимают остатки изоляции. Обычный контейнер не может смонтировать себе файловую систему: «mount: permission denied (are you root?)». С флагом --privileged та же команда отрабатывает и монтирует. Этот флаг выдаёт контейнеру полный набор прав root и доступ к устройствам машины. Второе: проброшенный внутрь корневой каталог машины. Я запустил обычный контейнер, отдав ему весь диск машины на чтение, и оттуда прочитал её имя и увидел, что файл с хешами паролей системы тоже читается. Только на чтение, зато целиком.
// Третий и самый недооценённый случай - сокет демона. Это его интерфейс управления без всякой проверки пароля: кто дотянулся до сокета, тот управляет демоном. Я запустил обычный контейнер с проброшенным сокетом и списком в четыре строки кода получил перечень всех 6 контейнеров узла с их именами и образами, включая боевые. А раз можно перечислить - можно и запустить новый, с корневым каталогом машины внутри и полными правами. То есть контейнер с сокетом равен root на машине. В сборочных задачах это особенно больно: чужой код в задаче получает права на весь сборочный сервер.
- root
- пользователь системы с максимальными правами; идентификатор 0
- --privileged
- флаг, выдающий контейнеру полный набор прав root и устройства машины
- сокет демона
- интерфейс управления демоном без пароля; доступ к нему равен root на узле
Как отвечать: «Контейнер завершился с кодом 137. Что это значит?»
Числа больше 128 означают гибель от сигнала: 137 это 128 плюс 9, то есть процесс добили принудительно. Дальше надо понять, кто именно. Причин ровно две, и внешне они не отличаются. Первая - нехватка памяти по лимиту, тогда в описании контейнера стоит флаг OOMKilled в положении true, а в журнале ядра есть запись. Вторая - приложение не среагировало на просьбу завершиться, демон подождал и добил, флаг при этом false. Я проверял оба случая: с потолком в 128 мегабайт программа умирает с флагом true, а программа, которая игнорирует сигнал, умирает через ровно столько миллисекунд, сколько было отведено на ожидание, с флагом false. Так что первым делом смотрю флаг, а дальше это либо лимит и утечка, либо необработанный сигнал в коде.
Ответ показывает арифметику, называет две неразличимые снаружи причины и даёт способ их развести. Это ровно то, что делают на дежурстве.
На чём валятся
- −Запускают внутри контейнера службу, которая уходит в фон, и не понимают, почему контейнер сразу Exited (0).
- −Считают 137 синонимом нехватки памяти, не посмотрев флаг OOMKilled.
- −Пишут логи в файл внутри контейнера, а потом ищут их после перезапуска.
- −Пробрасывают сокет демона в сборочную задачу ради удобства и раздают права на весь сервер.
- −Ставят --privileged, чтобы «наконец заработало», и оставляют это в проде.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.
- Разработчик просит запустить контейнер с --privileged и монтированием /var/run/docker.sock. Чем это грозит?A)Это равносильно root на самом хостеB)Контейнер получит доступ к сети хоста и обойдёт фаерволC)Образ перестанет запускаться на нодах без нужных модулей ядраD)Демон начнёт писать логи контейнера в системный журнал хоста
показать ответ и разбор
+A)Это равносильно root на самом хосте// разбор: Сокет демона — это API без аутентификации: имея его, контейнер запускает новый контейнер с монтированием корня хоста и получает полный доступ к файловой системе и процессам. Флаг --privileged добавляет к этому все capabilities и доступ к устройствам. Такой контейнер границей безопасности считать нельзя: он равен root на узле. Обходят через прокси с ограниченным API или сборщик без демона.
- Приложение в контейнере точно работает и что-то пишет в свой файл-лог, но docker logs пуст. Почему?A)Логи не появятся, пока контейнер не будет остановленB)docker logs берёт stdout/stderr, а приложение пишет в файлC)Драйвер логирования в этом контейнере оказался отключёнD)docker logs требует флаг --follow, без него он пуст
показать ответ и разбор
+B)docker logs берёт stdout/stderr, а приложение пишет в файл// разбор: docker logs отдаёт то, что контейнер написал в свои stdout/stderr (их подхватывает драйвер логирования). Если приложение пишет в файл внутри контейнера, эти строки мимо docker logs — их видно только внутри (docker exec … tail). Отсюда 12-факторное правило: в контейнере логируй в stdout/stderr, а сбором и ротацией занимается платформа.
- Контейнер по умолчанию запускает процесс от root. namespaces ведь изолируют — чем это всё же плохо?A)root в контейнере полностью отделён от хоста и рисков не несётB)Проблема лишь в том, что процесс root занимает больше памятиC)root в контейнере = root на хосте при дыре в изоляцииD)root внутри мешает пробросу томов и публикации портов
показать ответ и разбор
+C)root в контейнере = root на хосте при дыре в изоляции// разбор: По умолчанию user namespace не включён, поэтому uid 0 внутри контейнера — это тот же uid 0 на хосте. При выходе за пределы изоляции (баг рантайма, смонтированный docker.sock, --privileged, host-путь во writable-томе) процесс действует правами root хоста. Хардненинг: инструкция USER с непривилегированным uid, read-only rootfs, drop capabilities, а где нужно — включённый userns-remap.
- В контейнере копятся зомби-процессы (defunct). Приложение — PID 1 и порождает дочерние. Что упустили?A)PID 1 не пожинает мёртвых детей; нужен init (--init/tini)B)Зомби появляются из-за нехватки памяти под таблицу процессовC)Их создаёт сам docker при каждом exec-заходе в контейнерD)Дочерние процессы надо было запускать с флагом --detach
показать ответ и разбор
+A)PID 1 не пожинает мёртвых детей; нужен init (--init/tini)// разбор: Когда дочерний процесс завершается, кто-то должен сделать wait() и забрать его код — этим занимается PID 1. Обычное приложение на месте PID 1 сирот не пожинает, поэтому завершившиеся внуки застревают зомби (defunct). Решение — лёгкий init как PID 1: docker run --init (встроенный tini) или tini/dumb-init в образе; он и reaping делает, и сигналы пробрасывает.
- Какой командой запускают контейнер из готового образа?A)docker pullB)docker start-imageC)docker execD)docker run
показать ответ и разбор
+D)docker run// разбор: run создаёт контейнер из образа и запускает его; если образа нет локально, он сам подтянется из реестра. Команда exec выполняет процесс в уже работающем контейнере, а pull только скачивает образ.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.