
Дисклеймер
В прошлый раз ты научился создавать контейнеры и даже заставлять их что-то делать. Это похвально, но нет предела совершенству. У нас еще много работы впереди, так что готовься регулярно впитывать вкусную, интересную и полезную информацию по администрированию Docker. И смотреть на котиков.

Алсо, не забывай свои корни:
Напоминалка
В предыдущей статье я рассказывал тебе об основах создания и управления докер-контейнерами. А сегодня речь пойдет про best practices контейнеростроительного дела, среди них:
- Фильтрация файлов на стадии билда
- Теги и лейлбы
- Практики безопасности
- Уменьшение размеров образа и выбор подходящего репозитория для контейнера
Просто игнорируй их
Часто проект, который надо запихнуть в контейнер, бывает совсем не маленьких размеров. И еще чаще, папка с ним содержит несколько модулей и контекстов, длинный список либ, всякую документацию, zip-ы с файлами для прогонки тестов и тому подобные вещи. Большая часть всего этого в контейнере не нужна, поэтому не ленись поддерживать свой .dockerignore в актуальном состоянии.
Если ты думаешь, что ты супер-пупер педант и поддерживаешь папку своего приложения в идеальном порядке, то вот тебе другой пример. Если у тебя есть, например, файл README и ты в нем что-то поменял, а собираешь контейнер ты примерно вот так
COPY ./container_app /usr/src/app
то я тебя поздравляю, при следующей сборке контейнера контекст сборки не поднимется из кеша, потому что ты поменял README и кеш от контекста теперь другой. Наслаждайся очередным часом сборки)

Tag me!
Тег является обязательны условием создания любого промышленного Docker-изображения. Фактически, тег это строка, являющаяся алиасом на ID контейнера. По умолчанию создается тег latest, который, в общем-то, не говорит вообще ни о чем. Например, ты можешь создать новое изображение с другим базовым образом, но тем же функционалом и бум, у тебя два тега latest, два списка уязвимостей, багов и несовместимостей на один и тот же функционал. И пользователь об этом вообще не догадывается. Ты молодец, не надо так.
Думай о тегах для изображения как о способе поддержки списка версий продукта. Вышло обновление образа — запили для него новый тег. Желательно также в теге указывать отличительную особенность новой версии. В общем, смотри пример.
# Вот так НЕ НАДО
docker build -t my_image:latest .
# Пример отражения особенностей изображения: облегченная версия
docker build -t my_image:9.1-slim .
Не пост, а мета
Одним из правил хорошего тона для создания Docker-контейнеров является добавление вспомогательной информации в контейнеры с помощью инструкции LABEL в Dockerfile. Эта практика позволяет пользователям получать следующую, часто необходимую им, информацию:
- Мейл создателя образа, чтобы знать в кого метать фекалии
- Список версий контейнера
- Ссылка на исходный код проекта (если это опенсорс)
- Статистика Jenkins
Еще очень неплохо брать какую-нибудь схему описания лейблов, наполнять мета-информацию контейнера согласно ней, а потом прикреплять ссылку на эту схему в последний из LABEL-ов. Пример стандарта, соответствующего RFC5785, приведен вот тут.
Прятки с конфигами
Я сейчас скажу очевидную вещь, готовься. Хранить пароли и логины в общедоступном Dockerfile — ПЛОХО. Серьезно, создавай конфиг-файлы с чувствительной информацией вне Dockerfile, а потом прокидывай их внутрь контейнера. Как подключать эти конфиги к запускаемому сервису — отдельная история для каждого случая, тут уж разбирайся сам. Я лучше покажу тебе новую фишку Docker (вроде уже вышла из альфы) — секретные команды, которые позволяют автоматически отключить кеширование файлов при их управлении. Пример, как всегда, ниже.
FROM alpine
# Показать секрет в их дефолтном расположении
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecrets
# Показать секрет в их указанном расположении
RUN --mount=type=secret,id=mysecret,dst=/foobar cat /foobar
PID 1, прием
Все процессы в UNIX-системах представлены в виде дерева. Корневым является init(). У него соответственно PID = 1. И этот процесс частично отвечает за то, чтобы управлять ресурсами завершенных дочерних процессов.

Так вот, в Docker каждый процесс запускается в своем пространстве имен. Ииии, у каждого запущенного PID = 1. А никакого функционала, аналогичного функционалу нормального процесса init() ты, конечно, для него не написал.
Поясню на примере. Ты запустил в своем докер-контейнере только один процесс, который, например, запускает веб-сервер. И вот, в один момент, этому процессу понадобилось запустить какой-нибудь bash- скрипт, запускающий perl. Сервер отключает bash-скрипт по тайм-ауту. А процесс perl продолжает работать. После окончания работы, процесс perl перейдет в zombie-состояние, и его родителем станет процесс веб-сервера, который, скорее всего, не знает, как освобождать ресурсы ядра.

Поэтому может случиться такое, что твой код наспамил кучу процессов, они остались в состоянии zombie, заполнили собой таблицу процессов ядра и бум, ты не можешь больше создавать процессы. Хотя случается такое редко.
Для этой проблемы есть несколько решений:
- Запускать команду с помощью bash. Он корректно выводит процессы из zombie-состояния, хотя и не прокидывает сигналы дочерним процессам.
# Опция -e запрещает bash-y запускать скрипт напрямую
CMD ["/bin/bash", "-c", "set -e && /custom_path"]
- Использовать Baseimage — docker. Так ты запустишь полноценную виртуалку.
- Использовать tini. Этот способ наиболее предпочтительный, надежный и проверенный.
Логи мои логи
Контейнеры идеально подходят для приложений без сохранения состояния, которые в идеале должны быть эфемерными. Контейнер запустился, поработал и не оставил никаких следов после. Собственно, все изменения в Docker-контейнере после окончания его работы теряются, если не сделать commit. Но commit создает новый слой в контейнере, тем самым увеличивая размер изображения и создавая ненужную мета-информацию.
Поэтому логи нужно выносить из контейнеров на локальную машину/сервер/бд, где они будут храниться долго и счастливо. Лучше всего выбрать какое-нибудь готовое решение для логирования. Так и картинку красивую можно получить, и разного дополнительного функционала полно. Актуальный список полезностей — тут.
Многоэтапность — твой бро
В Docker с версии 17.05 появилась такая вещь, как многоэтапная сборка. Это когда ты берешь и пишешь в своем Dockerfile несколько инструкций FROM. Зачем это нужно и почему так надо делать — покажу на примере.
Рассмотрим часто встречающуюся ситуацию:
- У тебя есть первый контейнер, в нем ты собираешь свое будущее приложение, чтобы создать нужные артефакты, которые собственно и выполняют бизнес-логику. Часто в этом контейнере есть библиотеки, инструменты разработчика все остальное, что нужно при разработке и тестах, но совершенно не вперлось в продакшне.
FROM golang:1.7.0
WORKDIR /go/src/github.com/me/amasingApp/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN go get -d -v golang.org/x/net/html \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
- Ты хочешь получить второй контейнер, который содержит только скомпилированное приложение и все что необходимо для его запуска, безо всяких лишних деталей.
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/runningApp
COPY app .
CMD ["./runningApp"]
- И ты пишешь скрипт, который собирает первый контейнер, копирует из него все необходимое во второй и запускает его.
#!/bin/sh
echo Building alexellis2/href-counter:build
# Билдим первый контейнер
docker build --build-arg https_proxy=$https_proxy --build-arg http_proxy=$http_proxy \
-t me/amasingApp:build-config . -f Dockerfile.build
# Даем ему имя, чтобы не обращаться по ID или хешу
docker create --name extract me/amasingApp:build-config
# Копируем необходимые артефакты себе на локальную машину в папку /prepared
docker cp extract:/go/src/github.com/alexellis/href-counter/app ./prepared
# Удаляем уже не нужный контейнер
docker rm -f extract
echo Building me/amasingApp:1.0-slim
# Билдим второй контейнер, в котором будет только приложение и все что нужно для запуска
docker build --no-cache -t me/amasingApp:1.0-slim .
# Удаляем папку с временными файлами
rm ./prepared
Так делали все до 17-й версии, потому что на выходе ты получал контейнер с необходимым функционалом и минимальным размером, и все было прекрасно. Кроме того,что приходилось писать скрипт, копировать файлы на локальную машину и создавать два контейнера вместо одного. Теперь, слава богу, можно просто сделать вот так:
# Первое изображение, необходимо для того, чтобы создать артефакты
#Задали имя builder первому изображению, чтобы не обращаться по номеру.
FROM golang:1.7.0 as builder
WORKDIR /go/src/github.com/me/amasingApp/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN go get -d -v golang.org/x/net/html \
&& CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/runningApp
#Копируем нужные файлы из первого изображения
COPY --from=builder /go/src/github.com/me/amasingApp/runningApp .
CMD ["./runningApp"]
Какие плюсы? А вот какие: промежуточные артефакты, необходимые для сборки приложения не сохраняются ни на локальной машине, ни в итоговом контейнере, не надо писать лишних скриптов, создается только 1 контейнер, а результат точно такой же.
На вкус и цвет фломастеры разные
На DockerHub есть сразу несколько поддерживаемых официальных образов: ubuntu, busybox, centos, openuse, alpine ( это если не учитывать уже подготовленных изображений для разработки на каком-то языке). Они отличаются набором базовых утилит, пакетными менеджерами и итоговыми размерами.

Чаще всего, спор идет о выборе между ubuntu и alpine. В первом куча готовых утилит, файловый менеджер apt, поддержка manylinux1 по дефолту. Второй отвечает на это минимальным количеством уязвимостей, крохотными размерами ( важно при запуске большого количества контейнеров) и заточенностью под запуск в RAM.
Alpine — твой выбор, если ты точно знаешь, что тебе нужно, умеешь гуглить названия пакетов для apk (они не такие же как в apt) и хочешь собрать изображение минимального размера, пусть и потратив на поиск необходимых для приложения компонентов больше времени.
Ubuntu — если не особо охота заморачиваться с поиском необходимых пакетов и конфликтами зависимостей, когда нужно запихнуть приложение в контейнер как можно скорее, а желательно вчера, при этом размер изображения тебя не сильно волнует.
Остальные образы являются промежуточными вариантами, со своими плюсами и минусами. Для того, чтобы детальнее в них разобраться, лезь на гитхабы и смотри открытые issue. А в целом, лучшего среди этих образов все равно нет, каждый заточен под что-то свое. Правда с выбором лучше определиться до того, как вы будете выкатывать свою структуру в виде Docker-образов, а иначе гарантированно погрязнете в багах и конфликтах.
Вместо послесловия
Сейчас ты узнал список практик, принятых при работе с Docker, очень краткий и сжатый. Если хочешь разобраться в этом всем получше — нужно много лазить по гиту, практиковаться и собирать изображения по 3 раза в день за 20 минут до еды. Но в любом случае понимание придет со временем и опытом, а для начала и такой список запомнить будет неплохо.
А в следующий раз я дам тебе справку по docker — compose и тому, как настраивать взаимодействие контейнеров между собой. Обязательно оставь свое мнение в комментах внизу, чтобы я понял, надо оно тебе или нет.