
Docker за последнее время стал очень популярен за счет своей производительности, отказоустойчивости и, главное, простоты.
Сегодня можно найти тысячи образов в hub.docker.com. За счет своей простоты в создании образов, буквально за пол часа можно начать вносить свой вклад.
Но многие забывают о best practices, и за счет этого docker hub наполнился огромным количеством не самых лучших образов.
В этой статье я хочу описать на сколько просто и полезно создавать образы используя Best Practices на примере.
В качестве примера я выбрал нетривиальный образ с oracle 11g xe GitHub docker-hub.
В исходном проекте можно определить слабые места и недоработки, отсортированные по основным пунктам с best practices:
Использование .dockerignore
Очень полезный функционал, но, к сожалению, многие о нем не знают и не пользуются.
В итоге
oracle-xe_11.2.0-1.0_amd64.debaa
oracle-xe_11.2.0-1.0_amd64.debab
oracle-xe_11.2.0-1.0_amd64.debac
.git
.gitignore
Запускать только один процесс на контейнер
Это довольно распространенная ошибка, и допускается за счет того, что люди не до конца понимают принципы работы и риски.
В первую очередь в глаза кидается SSHD и не очень правильная инструкция CMD
CMD sed -i -E "s/HOST = [^)]+/HOST = $HOSTNAME/g" /u01/app/oracle/product/11.2.0/xe/network/admin/listener.ora; service oracle-xe start; /usr/sbin/sshd -D
Минусы использования подобного подхода можно обсуждать очень долго, особенно если пользователь захочет «кастомизировать» входящую команду.
В первую очередь удаляем SSHD так как он нам не нужен, даже если нам будет необходимо выполнить debug или просто подключится к консоле контейнера лучше использовать docker exec -it ${CONTAINER_ID} /bin/bash
Также очевидно, что при остановке контейнера Gracefully останавливается только SSHD, в то время, как сама база останавливается по TERM сигналу как процесс без паррента, что не есть хорошо, особенно для базы данных, особенно для Oracle DB.
по «sed» и «service start» уже можно предположить, что просто не будет, и разумно будет перенести ввесь описанный функционал в entrypoint.sh
При подготовке ENTRYPOINT был вынужден использовать несколько
Минимизация количества слоев
Этот пункт очень прост, но в то же время очень важен, так как Docker работает по наслоению инкрементальных изменений в ФС по одной на каждую инструкцию, вот пример рационального использования, главное стараться оставлять код читабельным и вместить все изменения в одну RUN инструкцию
# Prepare to install Oracle
RUN apt-get update && apt-get install -y -q libaio1 net-tools bc curl && apt-get clean && rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/* &&ln -s /usr/bin/awk /bin/awk &&mkdir /var/lock/subsys &&chmod 755 /sbin/chkconfig &&/oracle-install.sh
Функционал по установке oracle перенесен в sh скрипт в пользу читабельности.
Избегать установки лишних не самых необходимых пакетов
Помимо отказа от установки лишних пакетов так же крайне важно очищать после себя установочные файлы, кеши и прочее в одной инструкции, чтобы исключить ненужные наслоения инкрементальных слоев, иначе образ будет вдвойне тяжелей.
apt-get clean && rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/* /download/directory
Контейнер должен быть эфемерный
Это один из самых сложных и важных моментов. Под понятием «Эфемерный» подразумевается, что при старте контейнера, а затем его остановки с удалением, следующий запуск должен быть способным продолжать работу предыдущего с минимальной конфигурацией.
В нашем случае это файлы базы данных (принцип работы как холодный бэкап) с возможностью его использования при старте нового контейнера.
Данный подход и Docker позволяет нам легко и быстро создавать резервную копию, моментально откатится и клонировать всю базу.
Также не мало важно вынести базовые параметры как конфигурирование через ENV переменные.
В итоге у меня получился вот такой ENTRYPOINT
#!/bin/bash
# Prevent owner issues on mounted folders
chown -R oracle:dba /u01/app/oracle
rm -f /u01/app/oracle/product
ln -s /u01/app/oracle-product /u01/app/oracle/product
# Update hostname
sed -i -E "s/HOST = [^)]+/HOST = $HOSTNAME/g" /u01/app/oracle/product/11.2.0/xe/network/admin/listener.ora
sed -i -E "s/PORT = [^)]+/PORT = 1521/g" /u01/app/oracle/product/11.2.0/xe/network/admin/listener.ora
echo "export ORACLE_HOME=/u01/app/oracle/product/11.2.0/xe" > /etc/profile.d/oracle-xe.sh
echo "export PATH=\$ORACLE_HOME/bin:\$PATH" >> /etc/profile.d/oracle-xe.sh
echo "export ORACLE_SID=XE" >> /etc/profile.d/oracle-xe.sh
. /etc/profile
case "$1" in
'')
#Check for mounted database files
if [ "$(ls -A /u01/app/oracle/oradata)" ]; then
echo "found files in /u01/app/oracle/oradata Using them instead of initial database"
echo "XE:$ORACLE_HOME:N" >> /etc/oratab
chown oracle:dba /etc/oratab
chown 664 /etc/oratab
printf "ORACLE_DBENABLED=false\nLISTENER_PORT=1521\nHTTP_PORT=8080\nCONFIGURE_RUN=true\n" > /etc/default/oracle-xe
rm -rf /u01/app/oracle-product/11.2.0/xe/dbs
ln -s /u01/app/oracle/dbs /u01/app/oracle-product/11.2.0/xe/dbs
else
echo "Database not initialized. Initializing database."
printf "Setting up:\nprocesses=$processes\nsessions=$sessions\ntransactions=$transactions\n"
echo "If you want to use different parameters set processes, sessions, transactions env variables and consider this formula:"
printf "processes=x\nsessions=x*1.1+5\ntransactions=sessions*1.1\n"
mv /u01/app/oracle-product/11.2.0/xe/dbs /u01/app/oracle/dbs
ln -s /u01/app/oracle/dbs /u01/app/oracle-product/11.2.0/xe/dbs
#Setting up processes, sessions, transactions.
sed -i -E "s/processes=[^)]+/processes=$processes/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/init.ora
sed -i -E "s/processes=[^)]+/processes=$processes/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/initXETemp.ora
sed -i -E "s/sessions=[^)]+/sessions=$sessions/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/init.ora
sed -i -E "s/sessions=[^)]+/sessions=$sessions/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/initXETemp.ora
sed -i -E "s/transactions=[^)]+/transactions=$transactions/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/init.ora
sed -i -E "s/transactions=[^)]+/transactions=$transactions/g" /u01/app/oracle/product/11.2.0/xe/config/scripts/initXETemp.ora
printf 8080\\n1521\\noracle\\noracle\\ny\\n | /etc/init.d/oracle-xe configure
echo "Database initialized. Please visit http://#containeer:8080/apex to proceed with configuration"
fi
/etc/init.d/oracle-xe start
echo "Database ready to use. Enjoy! ;)"
##
## Workaround for graceful shutdown. oracle... ?( ? ? _-`)?
##
while [ "$END" == '' ]; do
sleep 1
trap "/etc/init.d/oracle-xe stop && END=1" INT TERM
done
;;
*)
echo "Database is not configured. Please run /etc/init.d/oracle-xe configure if needed."
$1
;;
esac
Резюме
В итоге, следуя Best Practices, мы получили целый ряд преимуществ:
- Размер образа уменьшился на 3GB (с 3.8Gb до 825Mb)
- Поддержка монтирования и повторного использования дата-файлов
- Graceful остановка сервиса
- Возможности для более тонкой настройке базы через параметры при старте контейнера
Результаты работы и детали решения проблем вы можете найти на github и hub.docker.com
Спасибо за внимание.
Комментарии (37)
frol
16.06.2015 08:54+1Я так и не понял как вы связали
.dockerignore
с уменьшением веса финального образа. Этот файл влияет только на процесс сборки, то есть что будет доступно для копирования в Dockerfile. Однако, если не копировать, то образ и не будет увеличиваться. Так что на вес образа влияла команда видаCOPY * /tmp
(или ADD).
Вот тут можно наглядно сравнить два образа: ImageLayers.
В остальном, спасибо, что несёте добро в массы, а то неоправданно огромные образы буквально завалили DockerHub.Sath Автор
16.06.2015 11:20По поводу .dockerignore — в процессе сборки каждая инструкция это инкрементальный слой.
В начале сборки загружаются все файлы которые есть в папке рядом с Dockerfile, этот первый этап также является слоемfrol
16.06.2015 11:32+2Это неправда. Я даже провёл только что эксперимент:
$ du -sh . 413M . $ cat Dockerfile FROM alpine COPY serial.txt /tmp/serial.txt $ cat .dockerignore cat: .dockerignore: No such file or directory $ ls -lah serial.txt -rw-r--r-- 1 frol frol 30 May 25 15:43 serial.txt $ docker build -t qq . Sending build context to Docker daemon 432.5 MB Sending build context to Docker daemon Step 0 : FROM alpine ---> 8697b6cc1f48 Step 1 : COPY serial.txt /tmp/serial.txt ---> 2209f356a4ea Removing intermediate container 9d055644cb5b Successfully built 2209f356a4ea $ docker images | grep qq qq latest 2209f356a4ea 5 seconds ago 5.238 MB
У меня в папке файлов на 413МБ, никакого.dockerignore
нет,docker build
запаковал в tar все файлы и получил 435МБ, которые «отправил на сборку» (так работает build) и в образ я добавил только файлserial.txt
, весящий 30 байт, но финальный образ весит 5.2МБ!
Таким образом.dockerignore
может сократить этап архивирования для build, но файлы из текущего каталога не попадут в образ если вы их туда не скопируете командами COPY или ADD, что видно из моего эксперимента.Sath Автор
16.06.2015 11:42-1Интересно, возможно я шибаюсь. Похоже на то что доработали это начиная с версии 1.4
frol
16.06.2015 12:01Убедитесь сами и поправьте статью дабы не вводить людей в заблуждение, пожалуйста.
Sath Автор
16.06.2015 12:09Как минимум вот тут все описано
docs.docker.com/articles/dockerfile_best-practices
Use a .dockerignore file
For faster uploading and efficiency during docker build, you should use a .dockerignore file to exclude files or directories from the build context and final image. For example, unless.git is needed by your build process or scripts, you should add it to .dockerignore, which can save many megabytes worth of upload time.frol
16.06.2015 12:11Предпочитаете верить написанной глупости (неточности/устаревшей информации) вместо своих собственных глаз?
ctrlok
16.06.2015 16:57возможно в вашем случае dockerignore сработал потому что вы сделали
?ADD .
Sath Автор
16.06.2015 17:03В статье есть все ссылки как на результат так и на исходник
Я уже признал что дело было не в этом,
вот причина разростания образа в несколько раз:
ADD chkconfig /sbin/chkconfig ADD init.ora / ADD initXETemp.ora / ADD oracle-xe_11.2.0-1.0_amd64.debaa / ADD oracle-xe_11.2.0-1.0_amd64.debab / ADD oracle-xe_11.2.0-1.0_amd64.debac / # ADD oracle-xe_11.2.0-1.0_amd64.deb / RUN cat /oracle-xe_11.2.0-1.0_amd64.deba* > /oracle-xe_11.2.0-1.0_amd64.deb ... # Remove installation files RUN rm /oracle-xe_11.2.0-1.0_amd64.deb*
ctrlok
16.06.2015 17:07ну да, так оно и есть. Я не увидел обновление статьи. Тут скорее надо просто базово понимать как работают слои в докере, я ниже описывал. Если мы добавляем файл создается слой, если удаляем — еще один слой поверх него.
Если слои нафиг не нужны можно вообще компактить итоговые имеджи, но зачем это можеть быть надо представить сложно.Sath Автор
16.06.2015 17:10Если слои нафиг не нужны можно вообще компактить итоговые имеджи, но зачем это можеть быть надо представить сложно.
Разве что для кеширования при будущих сборкахctrlok
16.06.2015 17:15ну смысл как раз в том, чтобы запихивать в один шаг все те действия, на которые не желательно создавать один слой. Если имадж скомпактить, тогда апдейт по сети будет не на размер дифа, а на размер имеджа.
vintage
16.06.2015 12:20+3Нет, всегда так и было. Если вы добавляете не конкретные файлы, а целые директории, то .dockerignore позволяет указать какие типы дочерних файлов/директорий добавлять всё же не стоит.
ctrlok
16.06.2015 16:18Очень похоже на вредные советы от Остера :)
самое главное, размер образа стал меньше более чем на 2Gb
Мне кажется, что самое главное тут не то, что образ стал меньше, а то, что ты не тащишь гит, который не нужен, на прод. Вообще best practices от докера это предварительная подготовка артифактов, возможно тоже в отдельном докер контейнере и экспорт их в новый имедж.
К тому же большой плюс dockerignore, что если репо изменилось, например появился новый коммит, или что-то еще — совсем не обязательно снова запускать шаги, которые уже были закешированы. И с этим связан второй «вредный совет»:
главное стараться оставлять код читабельным и вместить все изменения в одну RUN инструкцию
Вмещать все в одну инструкцию не просто тупо, а очень тупо именно из-за кеширования. Рассматривайте любой RUN как транзакцию, потому что так она и работает — если говорить немного упрощенно, run выполняется как отдельный запуск докер контейнера, на основании предыдущего имеджа, после чего diff по файловой системе сохраняется в новый имадж. Пытаться забить все в один ран актуально только тогда, когда необходимо действия выполнять одной группой, то есть изменение последнего шага должно влиять на повторение выполнения первого шага. Иначе вы будете постоянно повторять одно и то же действие.
То есть, например, правильный пример выглядит так:
RUN apt-get update && apt-get install ruby-2.1 RUN gem install bundler ADD Gemfile /project/Gemfile ADD Gemfile.lock /project/Gemfile.lock RUN bundle install ADD . /project
Потому что мы сначала устанавливаем руби нужной версии, потом устанавливаем bundler, потом добавляем жемфайл и жемфайл.лок, после чего инсталим депенды, если жемфайл изменится депенды переустановятся, но все остальное останется как было. Это достаточно сильно ускоряет разработку и обновление, то есть если что-то изменилось можно конкретно отследить на каком шаге были изменения и быть однозначно уверенным что все что до изменения осталось тем же, что и на предыдущем, рабочем имадже.
Запускать только один процесс на контейнер
Это довольно распространенная ошибка, и допускается за счет того, что люди не до конца понимают принципы работы и риски.
ШТА? докер сам по себе не умеет менеджить процессы. Очень желателен какой-то менеджер процессов, подробнее можете прочитать например тут
В общем непонятно как автор вообще может с серьзным лицом говорить что это все бест практис. Это скорее антипаттерны.Sath Автор
16.06.2015 16:23Под одним процессом подразумевается что не нужно лепить в 1 контейнер кучу сервисов, и SSHD на дисерт, к примеру.
ctrlok
16.06.2015 16:27ну init это один процес, и процес который init мэнеджит — еще один, уже два.
sshd лепили потому что docker exec был жопный. Начиная с 1.5 помойму, уже все стало ок и его не лепят. Ну и разделить доступ к контейнерам, чтоб не давать рута сразу на все контейнеры можно только через sshd, но это уже отдельная песня.Sath Автор
16.06.2015 16:35ну init это один процес, и процес который init мэнеджит — еще один, уже два.
зачем процесс который менеджит init?
И под процессом я закладывал смысл не 1 pid а 1 сервис, надеюсь о child процессах не будем прододжать…ctrlok
16.06.2015 16:50зачем процесс который менеджит init?
процесс который менеджит init это и есть ваш сервис.
И под процессом я закладывал смысл не 1 pid а 1 сервис
В линукс терминологии есть вполне себе определение процесса.
Естественно, навешивать в один имедж и php-fpm и nginx и mysql не надо, но описание один контейнер – один процесс это четкий антипаттерн
Если у вас не будет инита, который менеджит запущенные процессы, то будет много всякой фигни. Я выше ссылку дал, там про это подробнее расписано. Яркий пример это как раз поведение child процесса, когда умирает родитель — он должен присвоиться иниту, как происходит во всем линуксе, без инита это просто анменедж процесс, который принесет много головной боли.Sath Автор
16.06.2015 16:58Антипаттерн и тут? docs.docker.com/articles/dockerfile_best-practices
Run only one process per container
In almost all cases, you should only run a single process in a single container. Decoupling applications into multiple containers makes it much easier to scale horizontally and reuse containers. If that service depends on another service, make use of container linking.
Это официальная дока, и я с ней полностью согласен.
В данном случае(с Oracle DB) по другому нормально создать контейнер не получилось, если вы имеете свое мнение по этому поводу — пишите предложенияctrlok
16.06.2015 17:13Я уже более мение аргументировал на тему необходимости инита. Да и вообще на эту тему очень много всего уже было написано. Одну из базовых ссылок с инфой я приводил выше. Мне просто не понятно вы полностью согласны с один процесс на контейнер потому что это официальная дока или потому что имеете сильную позицию?
Например я знаю и минусы использования инита + процесс — это невозможность пробрасывать сигналы. Но мне кажется что неуправляемые процессы внутри контейнера и как следствие неубиваемые контейнеры это немного сильнее неприятнее, чем определенные нюансы при пробросе сигнала.Sath Автор
16.06.2015 17:17Я согласен с вашей точкой зрения.
Я против супервизоров с несколькими сервисами. Я за то чтобы инфраструктуру разбивать на мельчайшие детали(microservices). Это дает больше отказоустойчивости, в случае если какой-либо компонент отваливается — все остальное продолжает работать
vintage
17.06.2015 16:05Установка пакетов с нуля — довольно медленная операция, пусть и не частая. Мы сначала обновляем зависимости, а потом уже запускаем билд контейнера. То есть для докера, обновление пакетов выглядит как обновление всех остальных исходников. Такая схема отрабатывает гораздо быстрее и без лишних промежуточных коммитов.
ctrlok
17.06.2015 17:48Ну то есть есть некий базовый имедж с зависимостями который вы и обновляете? А потом в имедже с артефактом просто от него наследуетесь? Ну это вполне стандартный способ разделения мух и котлет. Но с точки зрения докера нет никакой разницы между таким имеджем и слоем, кроме как удобство обновления и управления.
vintage
18.06.2015 14:11Нет, примерно таким скриптом:
git pull npm install docker build --rm -t my/app .
А в Dockerfile уже все сбилженные файлы просто добавляются в образ.ctrlok
18.06.2015 15:06а, ну следующий уровень билдить в отдельном докер имадже и версионировать билд окружение и билд скрипт, это достаточно удобно, потому что можно тестировать билд скрипт прямо на машинах разработчиков и не зависить от окружения на билд сервере.
vintage
19.06.2015 18:32Не очень понял о чём вы.
ctrlok
19.06.2015 18:33Я имею ввиду саму процедуру, когда ты билдишь артефакт проводить в отдельном контейнере, из которого уже экспортить сам артефакт.
vintage
19.06.2015 20:07А, ну у нас сначала собирается образ с билдером, потом он билдит исходники приложения в хостовой директории, а потом билдится образ из получившихся файлов :-)
amarao
У докера нет лучших практик. У докера есть «быстро» и «какой такой продакшен?».
Sath Автор
А что вы скажите на Containerizing the Cloud with Docker on Google Cloud Platform и kubernetes который открыл гугл?
И с личного опыта хочу сказать что продакшн на Docker есть.
amarao
Kubernetes не щупал.
Насчёт второго — в этом и ужас. Оно в таком виде потом на продакшен, а потом вопросы: «ну что за подстава, мы его запускали как wget h ttp://...;docker run, а оно у нас все пароли из базы спёрло».
У докера в том виде, как его сопровождает комьюнити, нет ни безопасности, ни best practice (в контексте «best of production»). Оно задумывалось как rapid development (мне надо _СЕЙЧАС_ это запустить и насрать на мнение aptitude о зависимостях), а оказалось в продакшене у многих хипстерско-вевбдванольных конторах без нормальных сисадминов (птички-облака-бигдата-а-что-такое-oom-killer).
При правильной позиции сисадминов и вменяемых (с позиции devops) девелоперов его можно готовить и использовать как любую другую программу. Но в таких средах обычно docker ничего существенно не меняет, потому что у людей и CI нормальный, и пакеты в свой репозиторий (rpm/deb) выкладываются (сами).
ctrlok
ну кое что у докера все-таки есть, а именно имутабельность и повторяемость сетапа на всех этапах. То есть ты сразу пакуешь и енв и все вообще и это все будет повторяться раз за разом.
К тому же в отличии от deb\rpm, паковать и тестить которые надо как на установку на чистую машину, так и на обновление, docker относительно прост в этом плане. И не надо учиться паковать дебки, и не надо писать сложные chef рецепты, которые по разному работают на разных операционках.
Ну то есть как я понимаю docker очень сильно позволяет секономить нервов на внедрении devops практик в компании.
Про секюрность — есть vault, conjur да и kerberos никто не отменял :) Вопросы сохранения и доставки секретов актуальны вообще везде, вне зависимости от докера.
Конечно докер не серебрянная пуля, но много хорошего и полезного все же есть.
rhamdeew
Но все же, согласитесь, всегда приятно если «быстро» будет чуточку легче и еще быстрей.
Насчет продакшена, что скажете про www.iron.io?
amarao
Эм, я не до конца понял как оно у них внутри устроено. А поддержку «для клиентов» вам все впилят куда угодно. Будете самолёты с ПО в докере покупать — будут вам самолёты с докером. Захотите атомные реакторы и сможете за это платить — будут вам атомные реакторы с деплоем через curl.
frol
Если уметь его готовить — это очень мощный инструмент, а если образ всегда начинается с
FROM ubuntu
, то это уже личные проблемы каждого, такие люди иcurl ... | bash
иsudo make install
делают. Инструмент не виноват в глупости того, кто пользуется инструментом. Молотком можно людей калечить, а можно гвозди забивать.