Как написать качественный Dockerfile для Ruby приложения?
Это перевод статьи от Florin Lipan, оригинал можно почитать тут.
Простота структуры Dockerfile — это одна из главных причин, почему Docker стал таким популярным. Получить хоть что-то работающее действительно очень легко, тем не менее cобрать чистый и легковесный образ, да еще и чтобы secret'ы не утекли, не так-то просто.
В этой статье будут рассмотрены лучшие практики по написания Dockerfile для приложений на Ruby, но большинство правил подходит и для других языков. В конце я покажу примеры для 3х разных кейсов.
Вот список того, что мы рассмотрим:
- Всегда указывайте версию базового образа
- Используйте образы только из официальных репозиториев
- Всегда указывайте версии зависимостей приложения
- Добавьте
.dockeringore
в репозиторий - Группируйте команды в зависимости от частоты изменений
- Выносите редко меняющиеся команды на самый верх
- Избегайте запуска приложения из-под рута
- Используйте
--chown
при выполненииCOPY
илиADD
- Избегайте утечки secret'ов внутри образа
- Всегда очищайте вставленные secret'ы на одном и том же шаге
- Установка приватных зависимостей с Github через
gitconfig
- Используйте минимальные размеры базовых образов, чтобы уменьшить финальный образ
- Используйте multi-stage билды для уменьшения размера образа
- Используйте multi-stage билды, чтобы secret'ы не попали в историю
- Используйте формат exec, а не shell
- Не устанавливайте development и test зависимости в продакшн билды
- Опционально: один Dockerfile для production, development и test окружений через multi-stage билды
- Бонус: прогон миграций
- Всё вместе
Всегда указывайте версию базового образа
Плохо 💩
FROM ruby
CMD ruby -e "puts 1 + 2"
Хорошо 👍
FROM ruby:2.5.5
CMD ruby -e "puts 1 + 2"
Если вы хотите воспроизводимые билды(поверьте, вы их хотите), то ОБЯЗАТЕЛЬНО нужно указывать версию базового образа. Всегда указывайте версию полностью, учитывая всё, в том числе и патчи.
Если вы хотите обновить базовый образ, то делайте это явно через pull request, чтобы если что можно было откатиться обратно. Это сэкономит кучу времени в будущем, если потребуется отладка.
Используйте образы только из официальных репозиториев
Плохо 💩
FROM random-dude-on-the-internet/ruby:2.5.5
Хорошо 👍
FROM ruby:2.5.5
Тоже хорошо 👍
FROM your-own-registry.com/ruby:2.5.5
Когда юзаете образы с https://hub.docker.com отдавайте предпочтения официальным образам и проверяйте чексумму содержимого. Все официальные образы помечены меткой Docker Official Images возле названия.
Если официального образа для вашей задачи нет, то создайте свой собственный, основанный в идеале на официальном или том, которому вы доверяете.
Помните, что Docker Hub не запрещает делать изменения в образах или тэгах. Именно поэтому не стоит доверять всему оттуда.
Добавьте .dockeringore
в репозиторий
Команда COPY не учитывает содержимое файла .gitignore. Это значит, что при использовании команды типа COPY .
, в образ могут попасть файлы, которые туда попадать не должны.
Вы можете создать файл .dockerignore
, который работает по такому же принципу как и .gitignore
, только для сборки Docker образа. Можете туда добавить например папку .git/
и всё ее содержимое.
Группируйте команды в зависимости от частоты изменений
Плохо 💩
FROM ruby:2.5.5
RUN apt update
RUN apt install -y mysql-client
RUN apt install -y postgresql-client
RUN apt install -y nginx
RUN bundle install
CMD ruby -e "puts 1 + 2"
Хорошо 👍
FROM ruby:2.5.5
# Это обычно нужно сделать только один раз
RUN apt update && \
apt install -y mysql-client postgresql-client nginx
# А это каждый раз, когда добавляем зависимость
RUN bundle install
CMD ruby -e "puts 1 + 2"
Чем меньше шагов в Dockerfile, тем меньше это занимает места на диске. С другой стороны нужно быть осторожным и не группировать шаги, которые могут поменяться одновременно. Таким образом придется выполнять все шаги, если изменение будет только в одном месте. Это достаточно сильно замедляет процесс билда образа.
Выносите редко меняющиеся команды на самый верх
Плохо 💩
FROM ruby:2.5.5
# Исходники
COPY my-code/ /srv/
# Зависимости приложения
COPY Gemfile Gemfile.lock ./
RUN bundle install
# Системные зависимости
RUN apt update && \
apt install -y mysql-client postgresql-client nginx
Хорошо 👍
FROM ruby:2.5.5
# Системные зависимости
RUN apt update && \
apt install -y mysql-client postgresql-client nginx
# Зависимости приложения
COPY Gemfile Gemfile.lock ./
RUN bundle install
# Исходники
COPY my-source-code /srv/
Докер будет пересобирать все шаги сверху вниз начиная с того, где появились изменения. Изменениями считается новая/отредактированная строчка в Dockerfile. В случае с командой COPY
проверяются еще и файлы, которые были переданы в качестве аргументов.
Таким образом лучше писать редко изменяемые шаги в самый верх фала. Это ускорит последующие сборки образа, т.к. Docker будет использовать кэш.
Избегайте запуска приложения из-под рута
Плохо 💩
FROM ruby:2.5.5-alpine
RUN gem install sinatra -v 2.0.5
RUN echo 'require "sinatra"; run Sinatra::Application.run!' > config.ru
# Будет запущено от рута
CMD rackup
Хорошо 👍
FROM ruby:2.5.5-alpine
RUN gem install sinatra -v 2.0.5
# Создаем отдельного юзера под приложение
RUN adduser -D my-sinatra-user
# Комнды RUN, CMD и ENTRYPOINT будут выполнятся теперь от этого юзера
# Но не команды COPY или ADD, для них нужно использовать флаг --chown
USER my-sinatra-user
# Устанавливаем основную директорию
WORKDIR /home/my-sinatra-user
RUN echo 'require "sinatra"; run Sinatra::Application.run!' > config.ru
# Эта команду будет выполнена от пользователя my-sinatra-user
CMD rackup
Работа приложения из-под рута добавляет еще один вектор атак. Если злоумышленник получит доступ к удаленному выполнению кода через уязвимости приложения, то он может легко получить доступ и к самой машине. Будет гораздо больше проблем, если такой человек получит сразу доступ к руту, а не к обычному юзеру.
Используйте --chown
при выполнении COPY
или ADD
Плохо 💩
FROM ruby:2.5.5-alpine
RUN adduser -D my-sinatra-user
USER my-sinatra-user
WORKDIR /home/my-sinatra-user
# Эти файлы будут доступны только от рута
COPY Gemfile Gemfile.lock ./
RUN bundle install
CMD rackup
Хорошо 👍
FROM ruby:2.5.5-alpine
RUN adduser -D my-sinatra-user
USER my-sinatra-user
WORKDIR /home/my-sinatra-user
# А теперь от my-sinatra-user
COPY Gemfile Gemfile.lock ./
RUN bundle install
CMD rackup
Команда USER
из предыдущего шага работает только для RUN
, CMD
или ENTRYPOINT
. Для COPY
и ADD
нужно использовать флаг --chown
.
Избегайте утечки secret'ов внутри образа
FROM ruby:2.5.5
ENV DB_PASSWORD "secret stuff"
Никогда не добавляйте в Dockerfile пароли и другие чувствительные данные в открытом виде. Их можно передать или через команду ARG
и флаг --build-arg
во время сборки образа, или с помощью -e
или --env-file
в качестве переменных окружения.
Но есть один момент: когда передача происходит через --build-arg
или -e
, команда сохраняется в истории Docker'a. Но этого можно тоже избежать, на шаге 14 разберемся.
Плохо 💩
FROM ruby:2.5.5
ARG PRIVATE_SSH_KEY
# Добавление SSH ключа
RUN echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa
# Использование ключа
RUN bundle install
RUN rm /root/.ssh/id_rsa
Хорошо 👍
FROM ruby:2.5.5
ARG PRIVATE_SSH_KEY
# Все манипуляции с ключом в одном шаге
RUN echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa && \
bundle install && \
rm /root/.ssh/id_rsa
В первом примере 2 шага хранят SSH ключ. Если кто-то получит доступ к истории сборки образа, то он также получит доступ и к ключу. В предложенном решении все действия с ключом происходят в одном шаге(в конце удаляем файл). Таким образом в историю ничего не утекает.
Установка приватных зависимостей с Github через gitconfig
Довольно часто нам нужно установить зависимости из приватных репозиториев, будь это Ruby гем или NPM пакет. Есть несколько способов как это сделать, рассмотрим пример с GitHub'ом.
- Настройте machine user на Github.
- Дайте этому пользователю доступ только на чтение к приватным репозиториям.
- Сгенерируйте Github access token для этого пользователя.
- Используйте этот токен для выполнения bundle install ии npm install.
- Удалите токен из билда.
Как только у нас появился Github токен, мы может использовать .gitconfig
URL rewrite для аутентификации через него время деплоя(SSH способ будет доступен во время разработки). В Git есть опция insteadOf, которая может перезаписать URL репозитория, таким образом можно добавить токен.
После установки всех зависимостей важно удалить .gitconfig
файл на том же шаге, чтобы токен не утёк во внутрь образа.
Сам токен будет передаваться через build argument, пример ниже:
FROM ruby:2.5.5
ARG GITHUB_TOKEN
# Приватный гем, для которого создан GITHUB_TOKEN
RUN echo 'source "https://rubygems.org"; gem "some-private-gem", git: "git@github.com:some-user/some-private-gem"' > Gemfile
# Сначала делаем замену в Gemfile, а потом в package.json
# По идее это должно работать и для других менеджеров зависимостей
# Здесь используется ключ --add (чтобы не было перезаписи)
# В самом конце удаляем файл
RUN git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf git@github.com:some-user && \
git config --global --add url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf ssh://git@github && \
bundle install && \
rm ~/.gitconfig
Теперь образ можно собрать через команду docker build --build-arg GITHUB_TOKEN=xxx
.
Тоже самое можно сделать, если добавить во время сборки SSH ключ, тогда не нужно будет переписывать URL'ы в Gemfile, но важно в конце тоже удалить этот ключ.
Используйте минимальные размеры базовых образов, чтобы уменьшить финальный образ
Плохо 💩
FROM ruby:2.5.5
CMD ruby -e "puts 1 + 2"
Хорошо 👍
FROM ruby:2.5.5-alpine
CMD ruby -e "puts 1 + 2"
В чем разница?
> docker images -a | grep base-image normal-base-image 869MB small-base-image 45.3MB
Некоторые базовые достаточно большие. Если использовать изначально образ, которые занимает меньше места, то можно ускорить процесс сборки и деплоя. Плюсом будет экономия дискового пространства.
Alpine Linux обычно удовлетворяет всем условиям, поэтому можно советовать его.
При выборе операционной системы обратите внимание на:
- Пакетный менеджер и доступные пакеты. Собирать пакеты из исходников может быть еще тем геморроем
- Shell. В некоторых образах его нет
- Стабильность и безопасность дистрибутива. Не стоит использовать не проверенные временем образы
Используйте multi-stage билды для уменьшения размера образа
Плохо 💩
FROM ruby:2.5.5-alpine
# Зависимости Nokogiri
RUN apk add --update \
build-base \
libxml2-dev \
libxslt-dev
# Nokogiri
RUN echo 'source "https://rubygems.org"; gem "nokogiri"' > Gemfile
RUN bundle install
CMD /bin/sh
Хорошо 👍
# Образ "builder" собирает Nokogiri
FROM ruby:2.5.5-alpine AS builder
# Зависимости Nokogiri
RUN apk add --update \
build-base \
libxml2-dev \
libxslt-dev
# Nokogiri
RUN echo 'source "https://rubygems.org"; gem "nokogiri"' > Gemfile
RUN bundle install
# Финальный образ, чистый
FROM ruby:2.5.5-alpine
# Мы копируем полностью папку с гемами со всеми артефактами с builder образа
COPY /usr/local/bundle/ /usr/local/bundle/
CMD /bin/sh
Multi-stage билды — это билды, которые собираются из частей других билдов. Эти другие билды потенциально могут собираться вообще из других образов. Используя multi-stage билды можно заметно уменьшить размер финального образа. Чем меньше финальный образ, тем быстрее происходит процесс деплоя/ролбека.
В этом примере выше рассмотрен гем Nokogiri. Чтобы его установить обычно нужно дополнительно поставить тяжеловесные системные зависимости(libxml
и libxslt
). Но они нужны только во время сборки самого гема. Гем также нужно собирать нативно, это требует дополнительного времени.
Размеры mulit-stage/single-stage образов
> docker images | grep nokogiri nokogiri-simple 251MB nokogiri-multi 70.1MB
Как можно заметить, размеры образов отличаются больше чем в 3 раза.
Используйте multi-stage билды, чтобы secret'ы не попали в историю
Плохо 💩
FROM ruby:2.5.5-alpine
# Секрет
ARG PRIVATE_SSH_KEY
# Простой Gemfile, чтобы Bundler не ругался
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
# Используем ключ по назначению
RUN mkdir -p /root/.ssh/ && \
echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa && \
bundle install && \
rm /root/.ssh/id_rsa
CMD ruby -e "puts 1 + 2"
Хорошо 👍
FROM ruby:2.5.5-alpine AS builder
# Секрет
ARG PRIVATE_SSH_KEY
# Простой Gemfile, чтобы Bundler не ругался
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
# Используем ключ по назначению
RUN mkdir -p /root/.ssh/ && \
echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa && \
bundle install && \
rm /root/.ssh/id_rsa
# В финальном образе ключ не нужен
FROM ruby:2.5.5-alpine
COPY /usr/local/bundle/ /usr/local/bundle/
CMD ruby -e "puts 1 + 2"
Чтобы собрать образ, нужно передать PRIVATE_SSH_KEY
в качестве build аргумента:
docker build -t my-fancy-image --build-arg PRIVATE_SSH_KEY=xxx .
Как было сказано в шаге 9, использовать build аргументы нужно для того, чтобы секреты не утекли. По умолчанию же они всё равно попадают в Docker history. Такого нельзя допускать, когда мы не доверяем окружению во время сборки.
Ниже docker history для первого Dockerfile'a:
# На строке 3-4 PRIVATE_SSH_KEY передается в открытом виде > docker history my-fancy-image IMAGE CREATED CREATED BY SIZE 67e60c0853ab 19 seconds ago /bin/sh -c #(nop) CMD ["/bin/sh" "-c" "ruby… 0B 94dd778c4b5d 20 seconds ago |1 PRIVATE_SSH_KEY=xxx /bin/sh -c mkdir -p /… 30.9MB 32a993af7bfb About a minute ago |1 PRIVATE_SSH_KEY=xxx /bin/sh -c echo 'sour… 45B 2be964ad91c7 About a minute ago /bin/sh -c #(nop) ARG PRIVATE_SSH_KEY 0B 44723f3ab2bd 4 months ago /bin/sh -c #(nop) CMD ["irb"] 0B <missing> 4 months ago /bin/sh -c mkdir -p "$GEM_HOME" && chmod 777… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV PATH=/usr/local/bundl… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV BUNDLE_PATH=/usr/loca… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV GEM_HOME=/usr/local/b… 0B <missing> 4 months ago /bin/sh -c set -ex && apk add --no-cache -… 45.5MB <missing> 4 months ago /bin/sh -c #(nop) ENV RUBYGEMS_VERSION=3.0.3 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_DOWNLOAD_SHA256=… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_VERSION=2.5.5 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_MAJOR=2.5 0B <missing> 4 months ago /bin/sh -c mkdir -p /usr/local/etc && { e… 45B <missing> 4 months ago /bin/sh -c apk add --no-cache gmp-dev 3.4MB <missing> 4 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B <missing> 4 months ago /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a… 5.53MB
А это при использование multi-stage билда:
> docker history my-fancy-image IMAGE CREATED CREATED BY SIZE 2706a2f47816 8 seconds ago /bin/sh -c #(nop) CMD ["/bin/sh" "-c" "ruby… 0B 86509dba3bd9 9 seconds ago /bin/sh -c #(nop) COPY dir:e110956912ddb292a… 3.16MB 44723f3ab2bd 4 months ago /bin/sh -c #(nop) CMD ["irb"] 0B <missing> 4 months ago /bin/sh -c mkdir -p "$GEM_HOME" && chmod 777… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV PATH=/usr/local/bundl… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV BUNDLE_PATH=/usr/loca… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV GEM_HOME=/usr/local/b… 0B <missing> 4 months ago /bin/sh -c set -ex && apk add --no-cache -… 45.5MB <missing> 4 months ago /bin/sh -c #(nop) ENV RUBYGEMS_VERSION=3.0.3 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_DOWNLOAD_SHA256=… 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_VERSION=2.5.5 0B <missing> 4 months ago /bin/sh -c #(nop) ENV RUBY_MAJOR=2.5 0B <missing> 4 months ago /bin/sh -c mkdir -p /usr/local/etc && { e… 45B <missing> 4 months ago /bin/sh -c apk add --no-cache gmp-dev 3.4MB <missing> 4 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B <missing> 4 months ago /bin/sh -c #(nop) ADD file:a86aea1f3a7d68f6a… 5.53MB
При muliti-stage сборке в историю пишутся только команды из финального образа. Каеф, наши секреты в секрете.
Используйте формат exec, а не shell в команде CMD
Плохо 💩
FROM ruby:2.5.5
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
RUN bundle install
# Простой сервер на Sinatra, который выводит HUUUUUP, когда получает HUP сигнал
RUN echo 'require "sinatra"; set bind: "0.0.0.0"; Signal.trap("HUP") { puts "HUUUUUP" }; run Sinatra::Application.run!' > config.ru
CMD bundle exec rackup
Хорошо 👍
FROM ruby:2.5.5
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
RUN bundle install
# Простой сервер на Sinatra, который выводит HUUUUUP, когда получает HUP сигнал
RUN echo 'require "sinatra"; set bind: "0.0.0.0"; Signal.trap("HUP") { puts "HUUUUUP" }; run Sinatra::Application.run!' > config.ru
CMD ["bundle", "exec", "rackup"]
Есть два варианта использования команды CMD
:
- shell формат, который вызывает указанную команду из-под shell. Например
CMD bundle exec rackup
- exeс формат, который принимает аргументы в виде JSON массива.
CMD ["bundle", "exec", "rackup"]
Рекомендуется использовать второй вариант. В этом случает процесс 100% запустится с PID'ом 1, что в свою очередь гарантирует правильную обработку сигналов.
Давайте рассмотрим дерево процессов контейнера, который запустили в shell формате(💩)
> docker exec $(docker ps -q) ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.7 0.0 2388 756 pts/0 Ss+ 14:36 0:00 /bin/sh -c bundle exec rackup root 6 0.8 0.2 43948 25556 pts/0 Sl+ 14:36 0:00 /usr/local/bundle/bin/rackup root 13 0.0 0.0 7640 2588 ? Rs 14:37 0:00 ps aux
Как мы видим bundle exec rackup
вызывается из /bin/sh
. То есть у нашего приложения PID отличается от 1. И, например, если послать сигнал HUP, то он не дойдет до приложения и HUUUUUP в консоль не выведется.
А теперь посмотрим что будет если использовать exec формат(👍)
> docker exec $(docker ps -q) ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 29.0 0.2 43988 25632 pts/0 Ssl+ 14:47 0:00 /usr/local/bundle/bin/rackup root 8 0.0 0.0 7640 2668 ? Rs 14:47 0:00 ps aux
Как видно rackup процессы вызывается напрямую и у него PID равен 1. Сейчас если послать сигнал HUP, то он корректно отработает и в STDOUT выведется HUUUUUP.
Почему это важно? Некоторые приложения используют сигналы для освобождения ресурсов при завершении программы или каких-то других своих целей. На примере веб-серверов это закрытие подключений к БД и завершение сетевых запросов. Если для вашего приложения это критично, то не стоит пренебрегать этим правилом.
Не устанавливайте development и test зависимости в продакшн билды
Плохо 💩
FROM ruby:2.5.5
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
RUN bundle install
Хорошо 👍
FROM ruby:2.5.5
RUN echo 'source "https://rubygems.org"; gem "sinatra"' > Gemfile
RUN bundle config set without 'development test'
RUN bundle install
По умолчанию вызов bundle install
или yarn install
устанавливает все зависимости(в том числе те, которые нужны только для разработки/тестирования). В образе для прода они не нужны, поэтому можно не делать лишней работы. Это ускорит сборку образа и уменьшит его размер.
Так же во время установки гемов удобно использовать ключи --jobs
и --retry
, чтобы команда выполнилась даже если будут небольшие сбои в сети.
bundle install --jobs=3 --retry=3
Один Dockerfile для production, development и test окружений через multi-stage билды
Часто бывает, что процесс сборки для test, development и production окружений немного отличается друг от друга. Если вы используете Docker для всех окружений, то обычно для кадого окружения будет отдельный Dockerfile. Синхронизировать изменения в этих файлах становится довольно трудно. Можно легко что-то забыть.
Есть другой подход. Использовать один Dockerfile с multi-stage билдами, где каждый шаг будет отдельным окружением.
FROM ruby:2.5.5-alpine AS builder
# В Gemfile есть тестовая зависимость (minitest)
RUN echo 'source "https://rubygems.org"; gem "sinatra"; group(:test) { gem "minitest" }' > Gemfile
RUN echo 'require "sinatra"; run Sinatra::Application.run!' > config.ru
# По умолчанию тестовые и development зависимости не ставим
RUN bundle install --without development test
# Отдельный шаг, где ставим эти зависимости для тестового окружения
FROM builder AS test
# Устанавливаем всё, что нужно для тестов
RUN bundle install --with test
# Простенький тест
RUN echo 'require "minitest/spec"; require "minitest/autorun"; class TestIndex < MiniTest::Test; def test_it_passes; assert(true); end; end' > test.rb
# Запуск тестов
RUN bundle exec ruby test.rb
# В продакшн образе этих зависимостей нет
FROM ruby:2.5.5-alpine
COPY /usr/local/bundle/ /usr/local/bundle/
COPY /config.ru ./
CMD ["rackup"]
Продовый билд можно собрать так:
DOCKER_BUILDKIT=1 docker build .
А тестовый так:
DOCKER_BUILDKIT=1 docker build --target=test .
Бонус: прогон миграций
Есть несколько способов прогнать миграции в Docker'е, но самый простой из них — это создать стартовый скрипт для приложения:
#!/bin/sh set -e bundle exec rake db:migrate bundle exec rackup
Я обычно сохраняю его в bin/start
и потом использую как команду в CMD:
CMD ["bin/start"]
Нужно учитывать, что Rails не разрешает прогонять миграции в параллель с другими процессами. Деплой двух или более контейнеров в параллель может привести к сбою. Если вы деплоите контейнеры в параллель, то скорее всего используется что-то типа Kubernetes / Nomad / Docker Swarm. Там контейнеры автоматически рестартуют при падении, таким образом можно обойти блокировку Rails миграций.
Собираем всё вместе
Достаточно теории, давайте применим это всё на практике. Одинаковых приложений не существует, поэтому я покажу 3 разных Dockerfile'a для 3-х разных юзкейса.
Начнем с .dockerignore файла, т.к. он будет одинаковый везде. Самый простой способ создать .dockerignore
— это просто скопировать всё из .gitignore плюс добавить туда папку .git/
.
# Копируем .gitignore
cp .gitignore .dockerignore
# Исключаем папку .git/ из образа
echo ".git/" >> .dockerignore
Dockerfile для Ruby/Rails приложения без ассетов
# Образ которому мы доверяем с указанием версии
FROM ruby:2.7.1-alpine AS base
# Ставим системные зависимости, которые нужны и в рантайме и при сборке
RUN apk add --update \
postgresql-dev \
tzdata
# На этом шаге устанавливаем гемы
FROM base AS dependencies
# Устанавливаем системные зависимости для сборки Ruby гемов(pg)
RUN apk add --update build-base
COPY Gemfile Gemfile.lock ./
# Устанавливаем гемы только для прода
RUN bundle config set without "development test" && \
bundle install --jobs=3 --retry=3
# Теперь используем только base образ
FROM base
# Создаем отдельного пользователя под приложение
RUN adduser -D app
# Переключаемся на этого юзера
USER app
# Устанавливаем домашнюю директорию
WORKDIR /home/app
# Копируем установленные гемы с предыдущего шага
COPY /usr/local/bundle/ /usr/local/bundle/
# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY . ./
# Запускаем сервер или что-то другое
CMD ["bundle", "exec", "rackup"]
Собрать образ можно такой командой:
docker build -t my-rails-app .
Dockerfile для Rails приложения с ассетами
# Образ которому мы доверяем с указанием версии
FROM ruby:2.7.1-alpine AS base
# Ставим системные зависимости, которые нужны и в рантайме и при сборке
RUN apk add --update \
postgresql-dev \
tzdata \
nodejs \
yarn
# На этом шаге устанавливаем гемы и npm пакеты
FROM base AS dependencies
# Устанавливаем системные зависимости для сборки Ruby гемов(pg)
RUN apk add --update build-base
COPY Gemfile Gemfile.lock ./
# Устанавливаем гемы только для прода
RUN bundle config set without "development test" && \
bundle install --jobs=3 --retry=3
COPY package.json yarn.lock ./
# Ставим npm пакеты
RUN yarn install --frozen-lockfile
# Теперь используем только base образ
FROM base
# Создаем отдельного пользователя под приложение
RUN adduser -D app
# Переключаемся на этого юзера
USER app
# Устанавливаем домашнюю директорию
WORKDIR /home/app
# Копируем установленные гемы с предыдущего шага
COPY /usr/local/bundle/ /usr/local/bundle/
# Копируем установленные npm пакеты с предыдущего шага
# Обратите внимание на `--chown`
COPY /node_modules/ node_modules/
# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY . ./
# Компилируем ассеты
RUN RAILS_ENV=production SECRET_KEY_BASE=assets bundle exec rake assets:precompile
# Запускаем сервер
CMD ["bundle", "exec", "rackup"]
Dockerfile для Rails приложения с ассетами и приватными зависимостями
# Образ которому мы доверяем с указанием версии
FROM ruby:2.7.1-alpine AS base
# Ставим системные зависимости, которые нужны и в рантайме и при сборке
RUN apk add --update \
postgresql-dev \
tzdata \
nodejs \
yarn
# На этом шаге устанавливаем гемы и npm пакеты
FROM base AS dependencies
# Этот аргумент нужен будет позже при установке приватных зависимостей
ARG GITHUB_TOKEN
# Устанавливаем системные зависимости для сборки Ruby гемов(pg)
RUN apk add --update build-base
COPY Gemfile Gemfile.lock ./
# dev и test зависимости ставить не будем
RUN bundle config set without "development test"
# Ставим гемы(приватные в том числе)
# Здесь используем GITHUB_TOKEN. В самом конце подчищаем его
RUN git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf git@github.com:some-user && \
git config --global --add url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf ssh://git@github && \
bundle install --jobs=3 --retry=3 && \
rm ~/.gitconfig
COPY package.json yarn.lock ./
# Аналогично ставим npm пакеты
RUN git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf git@github.com:some-user && \
git config --global --add url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/some-user".insteadOf ssh://git@github && \
yarn install --frozen-lockfile \
rm ~/.gitconfig
# Теперь используем только base образ
FROM base
# Создаем отдельного пользователя под приложение
RUN adduser -D app
# Переключаемся на этого юзера
USER app
# Устанавливаем домашнюю директорию
WORKDIR /home/app
# Копируем установленные гемы с предыдущего шага
COPY /usr/local/bundle/ /usr/local/bundle/
# Копируем установленные npm пакеты с предыдущего шага
# Обратите внимание на `--chown`
COPY /node_modules/ node_modules/
# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY . ./
# Компилируем ассеты
RUN RAILS_ENV=production SECRET_KEY_BASE=assets bundle exec rake assets:precompile
# Запускаем сервер
CMD ["bundle", "exec", "rackup"]
Собрать этот образ можно так:
docker build --build-arg GITHUB_TOKEN=xxx -t my-rails-app .
Исходники можно посмотреть тут.
Дополнительные материалы
- https://pythonspeed.com/articles/dockerizing-python-is-hard/
- https://blog.docker.com/2019/07/intro-guide-to-dockerfile-best-practices/
- https://vsupalov.com/build-docker-image-clone-private-repo-ssh-key/
- https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development
- https://pythonspeed.com/articles/docker-caching-model/
- https://gmaslowski.com/docker-shell-vs-exec/
- https://medium.com/capital-one-tech/multi-stage-builds-and-dockerfile-b5866d9e2f84
Comments