Docker: не вредные советы

В комментариях к моей статье Docker: вредные советы было много просьб объяснить, чем так ужасен описанный в ней Dockerfile.

Краткое содержание предыдущей серии: два разработчика в жестком дедлайне составляют Dockerfile. В процессе к ним заходит Ops Игорь Иванович. Итоговый Dockerfile плох настолько, что ИИ оказывается на грани инфаркта.

Docker: не вредные советы

Сейчас разберемся, что не так с этим Dockerfile.

Итак, прошла неделя.

Dev Петя встречается в столовой за чашкой кофе с Ops Игорем Ивановичем.

П: Игорь Иванович, вы сильно заняты? Хотелось бы разобраться, где мы напортачили.

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

  1. Идеология Docker: один контейнер — один процесс.
  2. Чем меньше контейнер, тем лучше.
  3. Чем больше берется из кэша, тем лучше.

П: А почему в одном контейнере должен быть один процесс?

ИИ: Docker при запуске контейнера отслеживает состояние процесса с pid 1. Если процесс умирает, Docker пытается перезапустить контейнер. Допустим, у вас в контейнере запущено несколько приложений или основное приложение запущено не с pid 1. Если процесс умрет, Docker об этом не узнает.

Если больше нет вопросов, показывай ваш Dockerfile.

И Петя показал:

FROM ubuntu:latest

# Копируем исходный код
COPY ./ /app
WORKDIR /app

# Обновляем список пакетов
RUN apt-get update 

# Обновляем пакеты
RUN apt-get upgrade

# Устанавливаем нужные пакеты
RUN apt-get -y install libpq-dev imagemagick gsfonts ruby-full ssh supervisor

# Устанавливаем bundler
RUN gem install bundler

# Устанавливаем nodejs используется для сборки статики
RUN curl -sL https://deb.nodesource.com/setup_9.x | sudo bash -
RUN apt-get install -y nodejs

# Устанавливаем зависимости
RUN bundle install --without development test --path vendor/bundle

# Чистим за собой кэши
RUN rm -rf /usr/local/bundle/cache/*.gem 
RUN apt-get clean 
RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 
RUN rake assets:precompile
# Запускаем скрипт, при старте контейнера, который запустит все остальное.
CMD ["/app/init.sh"]

ИИ: Ох, давай разбираться по порядку. Начнем с первой строчки:

FROM ubuntu:latest

Вы берете тэг latest. Использование тэга latest приводит к непредсказуемым последствиям. Представьте, мейнтейнер образа собирает новую версию образа с другим списком ПО, этот образ получает тэг latest. И ваш контейнер в лучшем случае перестает собираться, а в худшем вы ловите баги, которых ранее не было.

Вы берете образ с полноценной ОС с большим количеством ненужного ПО, что раздувает объем контейнера. И чем больше ПО, тем больше дырок и уязвимостей.

Вдобавок чем больше образ, тем больше он занимает места на хосте и в registry (ты же где-то хранишь образы)?

П: Да, конечно, у нас registry, вы же его и настраивали.

ИИ: Так, о чем это я?.. Ах да, объемы… Так же растет нагрузка на сеть. Для единичного образа это незаметно, но когда идет непрерывная сборка, тесты и деплой, это ощутимо. А если у тебя нет God’s mode на AWS, тебе еще и космический счет прилетит.

Поэтому нужно выбирать наиболее подходящий образ, с точной версией и минимумом ПО. Например, возьми: FROM ruby:2.5.5-stretch

П: О, понятно. А как и где посмотреть имеющиеся образы? Как понять, какой мне нужен?

ИИ: Обычно образы берутся с докерхаба, не путай с порнхабом :). Для образа обычно существует несколько сборок:
Alpine: образы собраны на минималистичном образе Linux, всего 5 Мб. Его минус: он собран с собственной реализацией libc, стандартные пакеты в нем не работают. На поиск и установку нужного пакета уйдет немало времени.
Scratch: базовый образ, не используется для сборки других образов. Он предназначен исключительно для запуска бинарных, подготовленных данных. Идеально подойдет для запуска бинарных приложений, которые включают в себя все необходимое, например go-приложения.
На базе какой-либо ОС, например Ubuntu или Debian. Ну тут, думаю, пояснять не надо.

ИИ: Теперь нам нужно поставить все доп. пакеты и почистить за собой кэши. И сразу можно выкинуть apt-get upgrade. Иначе при каждой сборке, несмотря на фиксированный тэг базового имиджа, будут получаться разные образы. Обновление пакетов в образе — это задача мейнтейнера, она сопровождается изменением тэга.

П: Да, я пробовал это сделать, у меня получилось так:

WORKDIR /app
COPY ./ /app

RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - 
    && apt-get -y install libpq-dev imagemagick gsfonts ruby-full ssh supervisor nodejs 
    && gem install bundler 
    && bundle install --without development test --path vendor/bundle

RUN rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

ИИ: Неплохо, но тут тоже есть, над чем поработать. Смотри, вот эта команда:

RUN rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*  

… не удаляет данные из итогового образа, а лишь создает дополнительный слой без этих данных. Правильно так:

RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - 
    && apt-get -y install libpq-dev imagemagick gsfonts nodejs 
    && gem install bundler 
    && bundle install --without development test --path vendor/bundle   
    && rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 

Но это еще не все. Что у вас там, Ruby? Тогда не надо в начале копировать весь проект. Достаточно скопировать Gemfile и Gemfile.lock.

При таком подходе bundle install не будет выполняться на каждое изменение исходников, а только если изменился Gemfile или Gemfile.lock.

Те же методы работают и для других языков с менеджером зависимостей, таких как npm, pip, composer и других базирующихся на файле со списком зависимостей.

Ну и наконец, помнишь, в начале я говорил про идеологию Docker «один контейнер — один процесс»? Это означает, что supervisor не нужен. Так же не стоит устанавливать systemd, по тем же причинам. По сути, Docker сам является supervisor. И когда ты пытаешься запускать в нем несколько процессов, это как в одном процессе supervisor запускать несколько приложений.
При сборке вы сделаете единый образ, а потом запустите нужное количество контейнеров, чтобы в каждом работал один процесс.

Но об этом позже.

П: Кажется, понял. Смотрите, что получается:

FROM ruby:2.5.5-stretch

WORKDIR /app
COPY Gemfile* /app

RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - 
    && apt-get -y install libpq-dev imagemagick gsfonts nodejs 
    && gem install bundler 
    && bundle install --without development test --path vendor/bundle   
    && rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 

COPY . /app
RUN rake assets:precompile

CMD ["bundle”, “exec”, “passenger”, “start"]

А запуск демонов переопределим при запуске контейнера?

ИИ: Да, все верно. Кстати, можно использовать как CMD так и ENTRYPOINT. А разобраться, в чем отличие, это тебе домашнее задание. На эту тему на Хабре есть хорошая статья.

Так, давай дальше. Ты качаешь файл для установки node, но при этом нет никакой гарантии, что в нем будет то, что тебе нужно. Надо добавить валидацию. Например, так:

RUN curl -sL https://deb.nodesource.com/setup_9.x > setup_9.x 
    && echo "958c9a95c4974c918dca773edf6d18b1d1a41434  setup_9.x" | sha1sum -c - 
    &&  bash  setup_9.x 
    && rm -rf setup_9.x 
    && apt-get -y install libpq-dev imagemagick gsfonts nodejs 
    && gem install bundler 
    && bundle install --without development test --path vendor/bundle   
    && rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 

По контрольной сумме ты сможешь проверить, что скачал верный файл.

П: Но если файл изменится, то сборка не пройдет.

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

П: Спасибо. Получается, итоговый Dockerfile будет выглядеть так:

FROM ruby:2.5.5-stretch

WORKDIR /app
COPY Gemfile* /app

RUN curl -sL https://deb.nodesource.com/setup_9.x > setup_9.x 
    && echo "958c9a95c4974c918dca773edf6d18b1d1a41434  setup_9.x" | sha1sum -c - 
    &&  bash  setup_9.x 
    && rm -rf setup_9.x 
    && apt-get -y install libpq-dev imagemagick gsfonts nodejs 
    && gem install bundler 
    && bundle install --without development test --path vendor/bundle   
    && rm -rf /usr/local/bundle/cache/*.gem 
    && apt-get clean  
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 

COPY . /app
RUN rake assets:precompile

CMD ["bundle”, “exec”, “passenger”, “start"]

П: Игорь Иванович, спасибо за помощь. Мне уже пора бежать, надо еще 10 коммитов за сегодня сделать.

Игорь Иванович, взглядом остановив торопливого коллегу, делает глоток крепкого кофе. Поразмыслив несколько секунд об SLA 99.9% и коде без багов, он задает вопрос.

ИИ: А где вы логи храните?

П: Конечно, в production.log. Кстати да, а как мы без ssh получим к ним доступ?

ИИ: Если вы их оставите в файлах, решение для вас уже придумали. Команда docker exec позволяет исполнить любую команду в контейнере. Например, вы можете сделать cat для логов. А использовав ключ -it и запустив bash (если он установлен в контейнере), вы получите интерактивный доступ к контейнеру.

Но хранить логи в файлах не стоит. Как минимум это приводит к бесконтрольному росту контейнера, логи же никто не ротирует. Все логи нужно кидать в stdout. Там их уже можно посмотреть с помощью команды docker logs.

П: Игорь Иванович, а может вынести логи в смонтированную директорию, на физическую ноду, как данные пользователей?

ИИ: Хорошо, что вы не забыли вынести данные, загруженные на диск ноды. С логами так тоже можно, только не забудь настроить ротирование.
Все, можешь бежать.

П: Игорь Иванович, а посоветуйте, что почитать?

ИИ: Для начала прочитай рекомендации от разработчиков Docker, вряд ли кто-то знает Docker лучше них.

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

Источник: habr.com