Ruby on Whales:Docker 化 Ruby 和 Rails 开发

2025-06-05

Ruby on Whales:Docker 化 Ruby 和 Rails 开发

最初发表于《火星编年史》

这篇文章是我最近的 RailsConf 演讲“Terraforming 遗留 Rails 应用程序”(视频幻灯片)的 B 面

在这篇文章中,我并不是要说服你转向使用 Docker 进行应用程序开发(尽管你可以看看RailsConf 视频来了解一些论据)。我的目标是分享我目前在 Rails 项目中使用的配置,它诞生于 生产由Evil Martians开发。欢迎使用!

大约三年前,我开始在开发环境中使用 Docker(而不是 Vagrant,因为它对我的 4GB 内存笔记本电脑来说太重了)。当然,一开始并非一帆风顺——我花了两年时间才找到一个足够好的配置不仅适合我自己,也适合我的团队。

让我在这里介绍这个配置并解释(几乎)它的每一行,因为我们都已经受够了那些假设你知道一些东西的神秘教程。

源代码可以在GitHub 上的evilmartians/terraforming-rails存储库中找到。

我们在这个例子中使用以下堆栈:

  • Ruby 2.6.3
  • PostgreSQL 11
  • NodeJS 11 和 Yarn(用于 Webpacker 支持的资产编译)

Dockerfile

Dockerfile定义我们的 Ruby 应用程序的环境:这是我们运行服务器、控制台()、测试、Rake 任务的地方,以开发人员的身份rails c以任何方式与我们的代码交互

ARG RUBY_VERSION
# See explanation below
FROM ruby:$RUBY_VERSION

ARG PG_MAJOR
ARG NODE_MAJOR
ARG BUNDLER_VERSION
ARG YARN_VERSION

# Add PostgreSQL to sources list
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
  && echo 'deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list

# Add NodeJS to sources list
RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -

# Add Yarn to the sources list
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
  && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list

# Install dependencies
# We use an external Aptfile for that, stay tuned
COPY .dockerdev/Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    build-essential \
    postgresql-client-$PG_MAJOR \
    nodejs \
    yarn=$YARN_VERSION-1 \
    $(cat /tmp/Aptfile | xargs) && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
    truncate -s 0 /var/log/*log

# Configure bundler and PATH
ENV LANG=C.UTF-8 \
  GEM_HOME=/bundle \
  BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3
ENV BUNDLE_PATH $GEM_HOME
ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH \
  BUNDLE_BIN=$BUNDLE_PATH/bin
ENV PATH /app/bin:$BUNDLE_BIN:$PATH

# Upgrade RubyGems and install required Bundler version
RUN gem update --system && \
    gem install bundler:$BUNDLER_VERSION

# Create a directory for the app code
RUN mkdir -p /app

WORKDIR /app

此配置仅包含基本内容,可作为起点。让我展示一下我们在这里要做的事情。

前两行可能看起来有点奇怪:

ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION

为什么不直接使用FROM ruby:2.6.3,或者使用任何当前流行的 Ruby 稳定版本?我们希望使用 Dockerfile 作为模板,让我们的环境可以从外部进行配置:

  • docker-compose.yml运行时依赖项的确切版本在(见下文)中指定;
  • 可安装依赖项列表apt存储在单独的文件中(另见下文)。

以下三行定义了 PostgreSQL、NodeJS、Yarn 和 Bundler 版本的参数:

ARG PG_MAJOR
ARG NODE_MAJOR
ARG BUNDLER_VERSION
ARG YARN_VERSION

由于我们不希望任何人在没有Docker Compose 的情况下使用此 Dockerfile ,因此我们不提供默认值。

通过安装 PostgreSQL、NodeJS、Yarnapt需要将其 deb 包存储库添加到源列表中。

对于 PostgreSQL(基于官方文档):

RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
  && echo 'deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list

对于 NodeJS(来自NodeSource repo):

RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -

对于 Yarn(来自官方网站):

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
  && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list

现在是时候安装依赖项了,即运行apt-get install

COPY .dockerdev/Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    build-essential \
    postgresql-client-$PG_MAJOR \
    nodejs \
    yarn \
    $(cat /tmp/Aptfile | xargs) && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
    truncate -s 0 /var/log/*log

首先,我们来谈谈 Aptfile 技巧:

COPY .dockerdev/Aptfile /tmp/Aptfile
RUN apt-get install \
    $(cat /tmp/Aptfile | xargs)

这个想法借鉴了heroku-buildpack-apt,它允许在 Heroku 上安装额外的软件包。如果你使用这个 buildpack,你甚至可以在本地和生产环境中复用同一个 Aptfile(尽管 buildpack 的 Aptfile 提供了更多功能)。

我们的默认 Aptfile仅包含一个包(我们使用 Vim 来编辑 Rails Credentials):

vim

在我之前参与的一个项目中,我们使用 LaTeX 和TexLive生成​​了 PDF 文件。我们的 Aptfile 文件可能看起来像这样(那时候我还没用这个技巧):

vim
texlive
texlive-latex-recommended
texlive-fonts-recommended
texlive-lang-cyrillic

这样,我们将特定于任务的依赖项保存在单独的文件中,从而使我们的 Dockerfile 更加通用。

关于此问题DEBIAN_FRONTEND=noninteractive,我恳请您查看Ask Ubuntu 上的答案

通过不安装推荐的软件包,此--no-install-recommends开关可以帮助我们节省一些空间(并使我们的镜像更精简)。更多信息请参见此处

RUN这个( )的最后一部分apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && truncate -s 0 /var/log/*log也起到同样的作用——清除本地存储库中检索到的软件包文件(我们安装了所有东西,现在不再需要它们了)以及安装过程中创建的所有临时文件和日志。我们需要将这些清理操作放在同一条RUN语句中,以确保这个特定的Docker 层不包含任何垃圾。

最后一部分主要介绍 Bundler:

ENV LANG=C.UTF-8 \
  GEM_HOME=/bundle \
  BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3
ENV BUNDLE_PATH $GEM_HOME
ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH \
  BUNDLE_BIN=$BUNDLE_PATH/bin
ENV PATH /app/bin:$BUNDLE_BIN:$PATH

# Upgrade RubyGems and install required Bundler version
RUN gem update --system && \
    gem install bundler:$BUNDLER_VERSION

LANG=C.UTF-8会将默认语言环境设置为 UTF-8。否则,Ruby 会使用 US-ASCII 来处理字符串,跟那些可爱的表情符号说再见啦👋

我们通过 设置 gem 安装路径GEM_HOME=/bundle。什么是?这是我们稍后要作为/bundle挂载的路径,用于在主机系统(即您的开发机器)上持久保存依赖项(请参阅下文)。docker-compose.yml

BUNDLE_PATH变量BUNDLE_BIN告诉 Bundler 在哪里寻找 gem 和 Ruby 可执行文件。

最后,我们全局公开 Ruby 和应用程序二进制文件:

ENV PATH /app/bin:$BUNDLE_BIN:$PATH

这允许我们运行railsrakerspec其他binstubbed命令,而无需在其前面加上前缀bundle exec

docker-compose.yml

Docker Compose是一个用于编排容器化环境的工具。它允许我们将容器相互连接,定义持久卷和服务。

下面是使用 PostgreSQL 作为数据库、使用 Sidekiq 后台作业处理器的典型 Rails 应用程序开发的 Compose 文件:

version: '3.4'

services:
  app: &app
    build:
      context: .
      dockerfile: ./.dockerdev/Dockerfile
      args:
        RUBY_VERSION: '2.6.3'
        PG_MAJOR: '11'
        NODE_MAJOR: '11'
        YARN_VERSION: '1.13.0'
        BUNDLER_VERSION: '2.0.2'
    image: example-dev:1.0.0
    tmpfs:
      - /tmp

  backend: &backend
    <<: *app
    stdin_open: true
    tty: true
    volumes:
      - .:/app:cached
      - rails_cache:/app/tmp/cache
      - bundle:/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
      - .dockerdev/.psqlrc:/root/.psqlrc:ro
    environment:
      - NODE_ENV=development
      - RAILS_ENV=${RAILS_ENV:-development}
      - REDIS_URL=redis://redis:6379/
      - DATABASE_URL=postgres://postgres:postgres@postgres:5432
      - BOOTSNAP_CACHE_DIR=/bundle/bootsnap
      - WEBPACKER_DEV_SERVER_HOST=webpacker
      - WEB_CONCURRENCY=1
      - HISTFILE=/app/log/.bash_history
      - PSQL_HISTFILE=/app/log/.psql_history
      - EDITOR=vi
    depends_on:
      - postgres
      - redis

  runner:
    <<: *backend
    command: /bin/bash
    ports:
      - '3000:3000'
      - '3002:3002'

  rails:
    <<: *backend
    command: bundle exec rails server -b 0.0.0.0
    ports:
      - '3000:3000'

  sidekiq:
    <<: *backend
    command: bundle exec sidekiq -C config/sidekiq.yml

  postgres:
    image: postgres:11.1
    volumes:
      - .psqlrc:/root/.psqlrc:ro
      - postgres:/var/lib/postgresql/data
      - ./log:/root/log:cached
    environment:
      - PSQL_HISTFILE=/root/log/.psql_history
    ports:
      - 5432

  redis:
    image: redis:3.2-alpine
    volumes:
      - redis:/data
    ports:
      - 6379

  webpacker:
    <<: *app
    command: ./bin/webpack-dev-server
    ports:
      - '3035:3035'
    volumes:
      - .:/app:cached
      - bundle:/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
    environment:
      - NODE_ENV=${NODE_ENV:-development}
      - RAILS_ENV=${RAILS_ENV:-development}
      - WEBPACKER_DEV_SERVER_HOST=0.0.0.0

volumes:
  postgres:
  redis:
  bundle:
  node_modules:
  rails_cache:
  packs:

我们定义了八个服务。为什么这么多?其中一些服务只为其他服务定义了共享配置(抽象服务,例如appbackend),其他服务则用于执行使用应用程序容器的特定命令(例如runner)。

使用这种方法,我们不需要使用docker-compose up命令来运行应用程序,而是始终指定要运行的具体服务(例如docker-compose up rails)。这很合理:在开发过程中,你很少需要启动并运行所有服务(例如 Webpacker、Sidekiq 等)。

让我们仔细看看每项服务。

app

该服务的主要目的是提供构建我们的应用程序容器(上面定义的容器Dockerfile)所需的所有信息:

build:
  context: .
  dockerfile: ./.dockerdev/Dockerfile
  args:
    RUBY_VERSION: '2.6.3'
    PG_MAJOR: '11'
    NODE_MAJOR: '11'
    YARN_VERSION: '1.13.0'
    BUNDLER_VERSION: '2.0.2'

context目录定义了Docker 的构建上下文:这就像构建过程的工作目录,COPY例如,它由命令使用。

由于我们没有将其保存在项目根目录中,因此我们明确指定了 Dockerfile 的路径,而是将所有与 Docker 相关的文件打包在一个隐藏.dockerdev目录中。

args而且,正如我们前面提到的,我们使用Dockerfile 中的声明来指定依赖项的确切版本。

我们应该注意的一件事是我们标记图像的方式:

image: example-dev:1.0.0

使用 Docker 进行开发的好处之一是能够自动在团队中同步配置更改。每次更改本地镜像版本(或其依赖的参数或文件)时,只需升级即可。最糟糕的做法就是将其用作构建example-dev:latest标签。

保留镜像版本也有助于在两个不同的环境中轻松工作,避免任何额外的麻烦。例如,当你在长期运行的“chore/upgrade-to-ruby-3”分支上工作时,你可以轻松地切换到master并使用旧版本的 Ruby 镜像,而无需重新构建任何内容。

你能做的最糟糕的事情就是在你的 中使用latest图像标签docker-compose.yml

我们还告诉Docker使用 tmpfs来处理/tmp容器内的文件夹,以加快速度:

tmpfs:
  - /tmp

backend

我们到达了这篇文章最有趣的部分。

该服务定义了所有 Ruby 服务的共享行为。

我们先来谈谈卷数:

volumes:
  - .:/app:cached
  - bundle:/bundle
  - rails_cache:/app/tmp/cache
  - node_modules:/app/node_modules
  - packs:/app/public/packs
  - .dockerdev/.psqlrc:/root/.psqlrc:ro

卷列表中的第一项使用策略将当前工作目录(项目的根目录)挂载到/app容器内的文件夹cached。此cached修饰符是 MacOS 上高效 Docker 开发的关键。我们不会在本文中深入探讨(我们正在撰写一篇关于这个主题的单独文章😉),但您可以查看文档

下一行代码告诉我们的容器使用一个名为的卷bundle来存储/bundle内容。这样,我们就能在运行过程中持久化我们的 gem 数据:所有在 中定义的卷都会docker-compose.yml一直保留,直到我们运行docker-compose down --volumes

下面三行代码也是为了摆脱“Docker 在 Mac 上很慢”的魔咒。我们将所有生成的文件放入 Docker 卷中,以避免在主机上进行繁重的磁盘操作:

- rails_cache:/app/tmp/cache
- node_modules:/app/node_modules
- packs:/app/public/packs

为了使 Docker 在 MacOS 上足够快,请遵循以下两个规则:用于:cached挂载源文件并使用卷来存储生成的内容(资产、捆绑包等)。

最后一行psql向容器添加了一个特定的配置。我们主要需要它来将命令历史记录存储在应用程序的log/.psql_history文件中,以便持久化。为什么psql是 Ruby 容器?因为它会在运行 时在内部使用rails dbconsole

我们的.psqlrc文件包含以下技巧,使得可以通过环境变量指定历史文件的路径(允许通过PSQL_HISTFILE环境变量指定历史文件的路径,否则回退到默认值$HOME/.psql_history):

\set HISTFILE `[[ -z $PSQL_HISTFILE ]] && echo $HOME/.psql_history || echo $PSQL_HISTFILE`

我们来谈谈环境变量:

environment:
  - NODE_ENV=${NODE_ENV:-development}
  - RAILS_ENV=${RAILS_ENV:-development}
  - REDIS_URL=redis://redis:6379/
  - DATABASE_URL=postgres://postgres:postgres@postgres:5432
  - WEBPACKER_DEV_SERVER_HOST=webpacker
  - BOOTSNAP_CACHE_DIR=/bundle/bootsnap
  - HISTFILE=/app/log/.bash_history
  - PSQL_HISTFILE=/app/log/.psql_history
  - EDITOR=vi
  - MALLOC_ARENA_MAX=2
  - WEB_CONCURRENCY=${WEB_CONCURRENCY:-1}

这里有几件事,我想重点谈一件事。

首先,X=${X:-smth}语法。它可以翻译为“对于容器内的 X 变量,如果存在,则使用主机 X 环境变量的值,否则使用其他值”。这样,我们就可以在命令提供的不同环境中运行服务,例如RAILS_ENV=test docker-compose up rails

DATABASE_URLREDIS_URL变量将我们的 Ruby 应用程序连接到其他服务。Rails (分别为 ActiveRecord 和 Webpacker)开箱即用地支持和变量。一些库(例如 Sidekiq)也支持WEBPACKER_DEV_SERVER_HOST但并非所有库都支持(例如,Action Cable 必须明确配置)。DATABASE_URLWEBPACKER_DEV_SERVER_HOSTREDIS_URL

我们使用bootsnap来加快应用程序的加载速度。我们将它的缓存与 Bundler 数据存储在同一个卷中,因为这个缓存主要包含 gem 数据;因此,例如,如果我们再次进行 Ruby 版本升级,我们应该将所有内容全部删除。

HISTFILE=/app/log/.bash_history从开发人员的 UX 角度来看,这是一个重要的设置:它告诉 Bash 将其历史记录存储在指定位置,从而使其持久化。

例如EDITOR=vi,通过rails credentials:edit命令来管理凭证文件。

最后,最后两个设置MALLOC_ARENA_MAXWEB_CONCURRENCY可以帮助您控制 Rails 内存处理。

该服务尚未覆盖的线路仅有:

stdin_open: true
tty: true

它们使此服务具有交互性,即提供 TTY。例如,我们需要它来在容器内运行 Rails 控制台或 Bash。

这与使用选项运行 Docker 容器相同-it

webpacker

我在这里唯一想提的是WEBPACKER_DEV_SERVER_HOST=0.0.0.0设置:它使得 Webpack dev 服务器可以从外部访问(默认情况下它在上运行localhost)。

runner

为了解释这个服务是用来做什么的,我先分享一下我使用Docker进行开发的方式:

  • 我启动一个运行自定义docker-start脚本的 Docker 守护进程:
#!/bin/sh

if ! $(docker info > /dev/null 2>&1); then
  echo "Opening Docker for Mac..."
  open -a /Applications/Docker.app
  while ! docker system info > /dev/null 2>&1; do sleep 1; done
  echo "Docker is ready to rock!"
else
  echo "Docker is up and running."
fi
  • 然后我在项目目录中运行dcr runnerdcr是的别名docker-compose run)以登录到容器的shell;这是的别名:
$ docker-compose run --rm runner
  • 我在这个容器内运行(几乎)所有东西:测试、迁移、Rake 任务等等。

正如您所看到的,每次我需要运行任务时,我不会旋转一个新容器,而且我总是使用同一个容器。

因此,我采用的dcr runner方法vagrant ssh与几年前相同。

runner例如,之所以调用它而不是的唯一原因shell是它也可以用于在容器内运行任意命令。

注意:该服务是一种品味问题,除了默认服务( )之外,runner它与服务相比没有带来任何新东西;因此,与完全相同(但更短😉)。webcommand/bin/bashdocker-compose run runnerdocker-compose run web /bin/bash

奖励:dip.yml

如果您仍然认为Docker Compose方式过于复杂,那么我的一位 Evil Martians 同事开发了一款名为Dip的工具,旨在让开发人员的体验更加流畅。

如果您有多个组合文件或依赖于平台的配置,它特别有用,因为它可以将它们粘合在一起并提供通用接口来管理 Docker 开发环境。

我们以后会分享更多相关信息。敬请期待!


PS:特别感谢Sergey PonomarevMikhail Merkushin分享他们关于这个主题的建议。🤘


阅读更多开发文章,请访问https://evilmartians.com/chronicles

文章来源:https://dev.to/evilmartians/ruby-on-whales-dockerizing-ruby-and-rails-development-4dm7
PREV
AWS 中的所有云
NEXT
Faster WebGL/Three.js 3D graphics with OffscreenCanvas and Web Workers