Docker за последнее время стал очень популярен за счет своей производительности, отказоустойчивости и, главное, простоты.

Сегодня можно найти тысячи образов в hub.docker.com. За счет своей простоты в создании образов, буквально за пол часа можно начать вносить свой вклад.

Но многие забывают о best practices, и за счет этого docker hub наполнился огромным количеством не самых лучших образов.

В этой статье я хочу описать на сколько просто и полезно создавать образы используя Best Practices на примере.

В качестве примера я выбрал нетривиальный образ с oracle 11g xe GitHub docker-hub.

В исходном проекте можно определить слабые места и недоработки, отсортированные по основным пунктам с best practices:

Использование .dockerignore


Очень полезный функционал, но, к сожалению, многие о нем не знают и не пользуются.
В данном примере за счет добавления исключений в .dockerfile скорость сборки образа сократилась и, что самое главное, размер образа стал меньше более чем на 2Gb

Конечно понятно, что в git хранить тяжелые бинарные файлы совсем не best practice, но пока упустим этот момент, как модно говорить «Работает — не трогай», или как любят говорить в Британии «Так исторически сложилось».

В итоге 3 простые строчки существенно облегчили образ. Также я настоятельно рекомендую .git вносить в .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 был вынужден использовать несколько костылей workarounds (в дальнейшем немного полит-корректней). Подробнее 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)


  1. amarao
    15.06.2015 21:45
    +3

    У докера нет лучших практик. У докера есть «быстро» и «какой такой продакшен?».


    1. Sath Автор
      15.06.2015 23:05
      +2

      А что вы скажите на Containerizing the Cloud with Docker on Google Cloud Platform и kubernetes который открыл гугл?
      И с личного опыта хочу сказать что продакшн на Docker есть.


      1. amarao
        15.06.2015 23:11
        +2

        Kubernetes не щупал.

        Насчёт второго — в этом и ужас. Оно в таком виде потом на продакшен, а потом вопросы: «ну что за подстава, мы его запускали как wget h ttp://...;docker run, а оно у нас все пароли из базы спёрло».

        У докера в том виде, как его сопровождает комьюнити, нет ни безопасности, ни best practice (в контексте «best of production»). Оно задумывалось как rapid development (мне надо _СЕЙЧАС_ это запустить и насрать на мнение aptitude о зависимостях), а оказалось в продакшене у многих хипстерско-вевбдванольных конторах без нормальных сисадминов (птички-облака-бигдата-а-что-такое-oom-killer).

        При правильной позиции сисадминов и вменяемых (с позиции devops) девелоперов его можно готовить и использовать как любую другую программу. Но в таких средах обычно docker ничего существенно не меняет, потому что у людей и CI нормальный, и пакеты в свой репозиторий (rpm/deb) выкладываются (сами).


        1. ctrlok
          16.06.2015 17:05

          ну кое что у докера все-таки есть, а именно имутабельность и повторяемость сетапа на всех этапах. То есть ты сразу пакуешь и енв и все вообще и это все будет повторяться раз за разом.
          К тому же в отличии от deb\rpm, паковать и тестить которые надо как на установку на чистую машину, так и на обновление, docker относительно прост в этом плане. И не надо учиться паковать дебки, и не надо писать сложные chef рецепты, которые по разному работают на разных операционках.

          Ну то есть как я понимаю docker очень сильно позволяет секономить нервов на внедрении devops практик в компании.

          Про секюрность — есть vault, conjur да и kerberos никто не отменял :) Вопросы сохранения и доставки секретов актуальны вообще везде, вне зависимости от докера.

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


    1. rhamdeew
      15.06.2015 23:45

      Но все же, согласитесь, всегда приятно если «быстро» будет чуточку легче и еще быстрей.
      Насчет продакшена, что скажете про www.iron.io?


      1. amarao
        15.06.2015 23:59
        +1

        Эм, я не до конца понял как оно у них внутри устроено. А поддержку «для клиентов» вам все впилят куда угодно. Будете самолёты с ПО в докере покупать — будут вам самолёты с докером. Захотите атомные реакторы и сможете за это платить — будут вам атомные реакторы с деплоем через curl.


    1. frol
      16.06.2015 11:44
      +1

      Если уметь его готовить — это очень мощный инструмент, а если образ всегда начинается с FROM ubuntu, то это уже личные проблемы каждого, такие люди и curl ... | bash и sudo make install делают. Инструмент не виноват в глупости того, кто пользуется инструментом. Молотком можно людей калечить, а можно гвозди забивать.


  1. frol
    16.06.2015 08:54
    +1

    Я так и не понял как вы связали .dockerignore с уменьшением веса финального образа. Этот файл влияет только на процесс сборки, то есть что будет доступно для копирования в Dockerfile. Однако, если не копировать, то образ и не будет увеличиваться. Так что на вес образа влияла команда вида COPY * /tmp (или ADD).

    Вот тут можно наглядно сравнить два образа: ImageLayers.

    В остальном, спасибо, что несёте добро в массы, а то неоправданно огромные образы буквально завалили DockerHub.


    1. Sath Автор
      16.06.2015 11:20

      По поводу .dockerignore — в процессе сборки каждая инструкция это инкрементальный слой.
      В начале сборки загружаются все файлы которые есть в папке рядом с Dockerfile, этот первый этап также является слоем


      1. 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, что видно из моего эксперимента.


        1. Sath Автор
          16.06.2015 11:42
          -1

          Интересно, возможно я шибаюсь. Похоже на то что доработали это начиная с версии 1.4


          1. frol
            16.06.2015 12:01

            Убедитесь сами и поправьте статью дабы не вводить людей в заблуждение, пожалуйста.


            1. 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.


              1. frol
                16.06.2015 12:11

                Предпочитаете верить написанной глупости (неточности/устаревшей информации) вместо своих собственных глаз?


              1. ctrlok
                16.06.2015 16:57

                возможно в вашем случае dockerignore сработал потому что вы сделали

                ADD .
                
                ?


                1. 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*
                  


                  1. ctrlok
                    16.06.2015 17:07

                    ну да, так оно и есть. Я не увидел обновление статьи. Тут скорее надо просто базово понимать как работают слои в докере, я ниже описывал. Если мы добавляем файл создается слой, если удаляем — еще один слой поверх него.
                    Если слои нафиг не нужны можно вообще компактить итоговые имеджи, но зачем это можеть быть надо представить сложно.


                    1. Sath Автор
                      16.06.2015 17:10

                      Если слои нафиг не нужны можно вообще компактить итоговые имеджи, но зачем это можеть быть надо представить сложно.

                      Разве что для кеширования при будущих сборках


                      1. ctrlok
                        16.06.2015 17:15

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


            1. Sath Автор
              16.06.2015 13:09

              Да, вы правы, я ошибся…


              1. Sath Автор
                16.06.2015 13:13

                Существенного уменьшения размера образа я добился за счет оптимизации Dockerfile и объединения RUN инструкций и отказа от COPY/ADD


          1. vintage
            16.06.2015 12:20
            +3

            Нет, всегда так и было. Если вы добавляете не конкретные файлы, а целые директории, то .dockerignore позволяет указать какие типы дочерних файлов/директорий добавлять всё же не стоит.


  1. 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, потом добавляем жемфайл и жемфайл.лок, после чего инсталим депенды, если жемфайл изменится депенды переустановятся, но все остальное останется как было. Это достаточно сильно ускоряет разработку и обновление, то есть если что-то изменилось можно конкретно отследить на каком шаге были изменения и быть однозначно уверенным что все что до изменения осталось тем же, что и на предыдущем, рабочем имадже.

    Запускать только один процесс на контейнер

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


    ШТА? докер сам по себе не умеет менеджить процессы. Очень желателен какой-то менеджер процессов, подробнее можете прочитать например тут

    В общем непонятно как автор вообще может с серьзным лицом говорить что это все бест практис. Это скорее антипаттерны.


    1. Sath Автор
      16.06.2015 16:23

      Под одним процессом подразумевается что не нужно лепить в 1 контейнер кучу сервисов, и SSHD на дисерт, к примеру.


      1. ctrlok
        16.06.2015 16:27

        ну init это один процес, и процес который init мэнеджит — еще один, уже два.
        sshd лепили потому что docker exec был жопный. Начиная с 1.5 помойму, уже все стало ок и его не лепят. Ну и разделить доступ к контейнерам, чтоб не давать рута сразу на все контейнеры можно только через sshd, но это уже отдельная песня.


        1. Sath Автор
          16.06.2015 16:35

          ну init это один процес, и процес который init мэнеджит — еще один, уже два.

          зачем процесс который менеджит init?
          И под процессом я закладывал смысл не 1 pid а 1 сервис, надеюсь о child процессах не будем прододжать…


          1. ctrlok
            16.06.2015 16:50

            зачем процесс который менеджит init?

            процесс который менеджит init это и есть ваш сервис.

            И под процессом я закладывал смысл не 1 pid а 1 сервис

            В линукс терминологии есть вполне себе определение процесса.
            Естественно, навешивать в один имедж и php-fpm и nginx и mysql не надо, но описание один контейнер – один процесс это четкий антипаттерн

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


            1. 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) по другому нормально создать контейнер не получилось, если вы имеете свое мнение по этому поводу — пишите предложения


              1. ctrlok
                16.06.2015 17:13

                Я уже более мение аргументировал на тему необходимости инита. Да и вообще на эту тему очень много всего уже было написано. Одну из базовых ссылок с инфой я приводил выше. Мне просто не понятно вы полностью согласны с один процесс на контейнер потому что это официальная дока или потому что имеете сильную позицию?

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


                1. Sath Автор
                  16.06.2015 17:17

                  Я согласен с вашей точкой зрения.
                  Я против супервизоров с несколькими сервисами. Я за то чтобы инфраструктуру разбивать на мельчайшие детали(microservices). Это дает больше отказоустойчивости, в случае если какой-либо компонент отваливается — все остальное продолжает работать


    1. vintage
      17.06.2015 16:05

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


      1. ctrlok
        17.06.2015 17:48

        Ну то есть есть некий базовый имедж с зависимостями который вы и обновляете? А потом в имедже с артефактом просто от него наследуетесь? Ну это вполне стандартный способ разделения мух и котлет. Но с точки зрения докера нет никакой разницы между таким имеджем и слоем, кроме как удобство обновления и управления.


        1. vintage
          18.06.2015 14:11

          Нет, примерно таким скриптом:

          git pull
          npm install
          docker build --rm -t my/app .
          


          А в Dockerfile уже все сбилженные файлы просто добавляются в образ.


          1. ctrlok
            18.06.2015 15:06

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


            1. vintage
              19.06.2015 18:32

              Не очень понял о чём вы.


              1. ctrlok
                19.06.2015 18:33

                Я имею ввиду саму процедуру, когда ты билдишь артефакт проводить в отдельном контейнере, из которого уже экспортить сам артефакт.


                1. vintage
                  19.06.2015 20:07

                  А, ну у нас сначала собирается образ с билдером, потом он билдит исходники приложения в хостовой директории, а потом билдится образ из получившихся файлов :-)