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
这允许我们运行rails
、rake
和rspec
其他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:
我们定义了八个服务。为什么这么多?其中一些服务只为其他服务定义了共享配置(抽象服务,例如app
和backend
),其他服务则用于执行使用应用程序容器的特定命令(例如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_URL
、REDIS_URL
和变量将我们的 Ruby 应用程序连接到其他服务。Rails (分别为 ActiveRecord 和 Webpacker)开箱即用地支持和变量。一些库(例如 Sidekiq)也支持WEBPACKER_DEV_SERVER_HOST
,但并非所有库都支持(例如,Action Cable 必须明确配置)。DATABASE_URL
WEBPACKER_DEV_SERVER_HOST
REDIS_URL
我们使用bootsnap来加快应用程序的加载速度。我们将它的缓存与 Bundler 数据存储在同一个卷中,因为这个缓存主要包含 gem 数据;因此,例如,如果我们再次进行 Ruby 版本升级,我们应该将所有内容全部删除。
HISTFILE=/app/log/.bash_history
从开发人员的 UX 角度来看,这是一个重要的设置:它告诉 Bash 将其历史记录存储在指定位置,从而使其持久化。
例如EDITOR=vi
,通过rails credentials:edit
命令来管理凭证文件。
最后,最后两个设置MALLOC_ARENA_MAX
和WEB_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 runner
(dcr
是的别名docker-compose run
)以登录到容器的shell;这是的别名:
$ docker-compose run --rm runner
- 我在这个容器内运行(几乎)所有东西:测试、迁移、Rake 任务等等。
正如您所看到的,每次我需要运行任务时,我不会旋转一个新容器,而且我总是使用同一个容器。
因此,我采用的dcr runner
方法vagrant ssh
与几年前相同。
runner
例如,之所以调用它而不是的唯一原因shell
是它也可以用于在容器内运行任意命令。
注意:该服务是一种品味问题,除了默认服务( )之外,runner
它与服务相比没有带来任何新东西;因此,与完全相同(但更短😉)。web
command
/bin/bash
docker-compose run runner
docker-compose run web /bin/bash
奖励:dip.yml
如果您仍然认为Docker Compose方式过于复杂,那么我的一位 Evil Martians 同事开发了一款名为Dip的工具,旨在让开发人员的体验更加流畅。
如果您有多个组合文件或依赖于平台的配置,它特别有用,因为它可以将它们粘合在一起并提供通用接口来管理 Docker 开发环境。
我们以后会分享更多相关信息。敬请期待!
PS:特别感谢Sergey Ponomarev和Mikhail Merkushin分享他们关于这个主题的建议。🤘
阅读更多开发文章,请访问https://evilmartians.com/chronicles!
文章来源:https://dev.to/evilmartians/ruby-on-whales-dockerizing-ruby-and-rails-development-4dm7