Как написать качественный Dockerfile для Ruby приложения?

31 October 2022
379 views

Это перевод статьи от Florin Lipan, оригинал можно почитать тут.

Простота структуры Dockerfile — это одна из главных причин, почему Docker стал таким популярным. Получить хоть что-то работающее действительно очень легко, тем не менее cобрать чистый и легковесный образ, да еще и чтобы secret'ы не утекли, не так-то просто.

В этой статье будут рассмотрены лучшие практики по написания Dockerfile для приложений на Ruby, но большинство правил подходит и для других языков. В конце я покажу примеры для 3х разных кейсов.

Вот список того, что мы рассмотрим:

  1. Всегда указывайте версию базового образа
  2. Используйте образы только из официальных репозиториев
  3. Всегда указывайте версии зависимостей приложения
  4. Добавьте .dockeringore в репозиторий
  5. Группируйте команды в зависимости от частоты изменений
  6. Выносите редко меняющиеся команды на самый верх
  7. Избегайте запуска приложения из-под рута
  8. Используйте --chown при выполнении COPY или ADD
  9. Избегайте утечки secret'ов внутри образа
  10. Всегда очищайте вставленные secret'ы на одном и том же шаге
  11. Установка приватных зависимостей с Github через gitconfig
  12. Используйте минимальные размеры базовых образов, чтобы уменьшить финальный образ
  13. Используйте multi-stage билды для уменьшения размера образа
  14. Используйте multi-stage билды, чтобы secret'ы не попали в историю
  15. Используйте формат exec, а не shell
  16. Не устанавливайте development и test зависимости в продакшн билды
  17. Опционально: один Dockerfile для production, development и test окружений через multi-stage билды
  18. Бонус: прогон миграций
  19. Всё вместе

Всегда указывайте версию базового образа

Плохо 💩

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 --chown=my-sinatra-user 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: "[email protected]:some-user/some-private-gem"' > Gemfile

# Сначала делаем замену в Gemfile, а потом в package.json
# По идее это должно работать и для других менеджеров зависимостей
# Здесь используется ключ --add (чтобы не было перезаписи)
# В самом конце удаляем файл
RUN git config --global url."https://${GITHUB_TOKEN}:[email protected]/some-user".insteadOf [email protected]:some-user && \
  git config --global --add url."https://${GITHUB_TOKEN}:[email protected]/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 --from=builder /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 --from=builder /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 --from=builder /usr/local/bundle/ /usr/local/bundle/
COPY --from=builder /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 --from=dependencies /usr/local/bundle/ /usr/local/bundle/

# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY --chown=app . ./

# Запускаем сервер или что-то другое
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 --from=dependencies /usr/local/bundle/ /usr/local/bundle/

# Копируем установленные npm пакеты с предыдущего шага
# Обратите внимание на `--chown`
COPY --chown=app --from=dependencies /node_modules/ node_modules/

# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY --chown=app . ./

# Компилируем ассеты
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}:[email protected]/some-user".insteadOf [email protected]:some-user && \
  git config --global --add url."https://${GITHUB_TOKEN}:[email protected]/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}:[email protected]/some-user".insteadOf [email protected]:some-user && \
  git config --global --add url."https://${GITHUB_TOKEN}:[email protected]/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 --from=dependencies /usr/local/bundle/ /usr/local/bundle/

# Копируем установленные npm пакеты с предыдущего шага
# Обратите внимание на `--chown`
COPY --chown=app --from=dependencies /node_modules/ node_modules/

# Копируем сам код
# Здесь скопируется всё, что не указано в .dockerignore
# Обратите внимание на `--chown`
COPY --chown=app . ./

# Компилируем ассеты
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 .

Исходники можно посмотреть тут.

Дополнительные материалы