logo

用 Docker Compose 部署 Rails 到腾讯云的踩坑记录

今天要把一直做的项目部署到腾讯云一版,原本以为半天的工作量,最后从上午十点搞到晚上八九点,遇到了很多必须立刻记下来否则明天起床就忘了的问题。我不想谈论项目本身,只从技术角度展示所遇到的报错和对应的解决方案,有些问题我不是特别清楚为什么可以使用那种方案解决,但人在江湖、也没时间去深究了。

需要说明的是,之前为了避免这些糟心的问题,我做的一些 Demo 项目一直是在外面的服务器上部署的。但我知道迟早会有面对疾风的一天,所以它还是来了。

如前所述,本项目是基于以下环境开发的:

ruby -v
# ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [x86_64-linux]

rails -v
# Rails 8.0.1

rails new xxx -c tailwind -j esbuild -d mysql

部署所用的 Dockerfile 也是基于 Rails 自动生成的文件所修改,只是自己添加了以下文件:

我尽量回忆每个问题我尝试了怎样的方式去解决的,但回忆未必完整,也不必要,所以只捡重要的记一下,最后列出解决方案

1. 安装 Docker

要部署的服务器是 Ubuntu,按照 https://cloud.tencent.com/document/product/213/46000 安装 Docker 没有遇到什么问题,似乎安装 Docker 会自动安装 Docker Compose。

2. 网络问题

毫不奇怪这是第一个问题,虽然早有准备,但依然花了很多时间在这个问题上。Dockerfile 是基于 ruby:3.4.1-slim 的:

一开始我误以为这个镜像基于 Ubuntu,所以按照下面的方式直接修改了 apt 源:

RUN sed -i 's/archive.ubuntu.com/mirrors.tencent.com/g' /etc/apt/sources.list && \
    sed -i 's/security.ubuntu.com/mirrors.tencent.com/g' /etc/apt/sources.list && \
    apt-get update -qq \
    # ...

但镜像构建速度很慢,慢到不合理。然后我查看 Docker Hub 发现它基于 Debian,所以换源代码改成了:

RUN echo "deb http://mirrors.tencentyun.com/debian/ bookworm main contrib non-free" > /etc/apt/sources.list && \
    echo "deb http://mirrors.tencentyun.com/debian-security/ bookworm-security main contrib non-free" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.tencentyun.com/debian/ bookworm-updates main contrib non-free" >> /etc/apt/sources.list && \
    apt-get update -qq && \
    # ...

但还是很慢,用 echo "deb http://mirrors.tencentyun.com/debian/ bookworm main contrib non-free" > /etc/apt/sources.list && time apt-get update 在容器里测试,结果如下图:

tencent-speed

我看到上图右侧的 41 分钟时是有点慌乱的,甚至换成阿里云的镜像尝试了一下,8 分钟当然也是不可接受的。但我冷静了一下发现图中没有完全走源,所以最后的解决方案是

RUN echo "deb http://mirrors.tencentyun.com/debian/ bookworm main contrib non-free" > /etc/apt/sources.list && \
    echo "deb http://mirrors.tencentyun.com/debian-security/ bookworm-security main contrib non-free" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.tencentyun.com/debian/ bookworm-updates main contrib non-free" >> /etc/apt/sources.list && \
    # 把可能存在官方源的文件删除
    rm -f /etc/apt/sources.list.d/* && \
    apt-get update -qq && \

腾讯云软件源加速软件包下载安装和更新 (也包含了 RubyGems 源): https://cloud.tencent.com/document/product/213/8623

除此之外,Gem 的源是早早设定的,没有相关的报错:

RUN gem sources --add https://mirrors.cloud.tencent.com/rubygems/ --remove https://rubygems.org/ && \
    bundle config mirror.https://rubygems.org https://mirrors.cloud.tencent.com/rubygems/

3. 找不到 net-smtp gem

这个问题就不得不提一句 Ruby 官方最近喜欢从标准库里删东西,之前有一次我更新版本后发现 SortedSet 没了,一查是官方从标准库移除了,这次也是。

构建过程的报错是 Could not find net-smtp-0.5.0 in locally installed gems (Bundler::GemNotFound),前几天我在 GitHub CI 里已经遇到并做了修复,方法是在 Gemfile 里添加 gem "net-smtp", "~> 0.5.0", group: :test

这次既然又报这个错误,我就把 group: :test 删除了,但构建之后仍然报错。我去看了一下 net-smtp 的仓库, 0.5.00.5.1 版本之间差了十个月左右,考虑到 Ruby 3.4.1 也是最近才发布,我修改了 net-smtp 的版本,所以最终的解决方案是gem "net-smtp", "~> 0.5.1"

因为不确定是不是还有 bundle 的问题,所以我一并修改了 bundle cache 相关的构建命令:

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --global path "${BUNDLE_PATH}" && \
    bundle config set --global without "${BUNDLE_WITHOUT}" && \
    bundle install --jobs=4 --retry=3 && \
    rm -rf "${BUNDLE_PATH}"/ruby/*/cache && \
    bundle exec bootsnap precompile --gemfile

4. ./bin/rails: Permission denied

这是继续构建镜像遇到的下一个报错,很明显,bin 目录内的 rails 文件没有权限,于是我在 Dockerfile 的某个合适的位置添加了 RUN chmod +x bin/*,对 bin 目录内的所有文件都赋予执行权限。

5. chmod: cannot access '/rails/bin/docker-entrypoint': Permission denied

上面的修改给 bin 目录内的所有文件都添加了执行权限,但是依然遇到了这个报错,看起来不是执行权限的问题,而是用户甚至没有权限访问这个文件。

查看 Dockerfile 发现修改权限之后,镜像后来又从 root 用户切换到了 rails 用户。所以改成在多阶段构建的最后一个阶段添加权限:

RUN chmod +x /rails/bin/docker-entrypoint && \
    chown -R rails:rails /rails/bin/*

6. chown: invalid user: ‘rails:rails’

这个报错的原因完全是上一步做的不对,因为我把添加权限的命令放到了创建 rails 用户之前,所以我调整了一下顺序:

# 必须先创建用户和组
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash

# 然后设置权限
RUN chmod 755 /rails/bin/docker-entrypoint && \
    chown -R rails:rails db log storage tmp

USER 1000:1000

7. cannot load such file -- /rails/config/boot (LoadError) from ./bin/rails:3:in '<main>'

然后遇到了这个报错,可以很确定的是 config/boot.rb 文件是存在于镜像内的,但没有被加载应该还是权限的问题,我观察到上一步的 chown -R rails:rails db log ... 中,并没有给 rails 用户 /rails 目录的权限,所以综合 4-7 关于权限的最终解决方案是

RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chmod 755 /rails/bin/docker-entrypoint && \
    find /rails/bin -type f -exec chmod 755 {} + && \
    chown -R rails:rails /rails db log storage tmp
USER 1000:1000

到此为止,镜像总算是构建成功了。下面遇到了新的问题。

8. HTTP Origin header (http://<ip>) didn't match request.base_url (https://<ip>)

登录页面的时候遇到了这个报错。

因为暂时没有绑定域名,所以计划是先用 IP 访问,由于 Caddy 和 Rails 默认都启用 SSL,所以需要先关闭设置,最终修改的 Caddyfile 和 Rails 配置分别如下:

# config/caddy/Caddyfile

# Caddyfile 临时配置
http://<your_tencent_ip_address> {
    reverse_proxy app:3000 {
        header_up X-Forwarded-Proto {scheme}
    }
}
# config/environments/production.rb

# ...
config.assume_ssl = false
config.force_ssl = false
config.ssl_options = { hsts: false }

9. ActiveRecord::StatementInvalid (Mysql2::Error: Table 'xxx_db.solid_cache_entries' doesn't exist)

最后一个报错是数据库问题,众所周知,Rails 8 默认使用四个数据库,除了主数据库之外,还有 cache、queue、cable。

我原本想着后面三个数据库数据量很小,把这四个数据库放到一个数据库里,但设置起来也是一番波折,而且如果以后需要再拆分出来,也许还要费功夫,所以还是按照官方推荐使用四个数据库。

Dockerfile 中 /docker-entrypoint 执行了 db:prepare 命令,但后面三个数据库确实没被创建,也许 db:prepare 只针对主数据库?但 solid_cache 仓库的文档里确实说只要如此这般设置 config/database.yml 然后再运行 db:prepare 就可以了,参考下面的链接:

https://github.com/rails/solid_cache?tab=readme-ov-file#installation

但这后三个数据库的确没有被创建,所以我的方案是让 MySQL 自己创建,MySQL 的 DockerHub 链接 https://hub.docker.com/_/mysql 里有这样一段话:

When a container is started for the first time, a new database with the specified name will be created and initialized with the provided configuration variables. Furthermore, it will execute files with extensions .sh, .sql and .sql.gz that are found in /docker-entrypoint-initdb.d. Files will be executed in alphabetical order.

所以把创建数据库的语句通过 Volume 放到 MySQL 镜像的 docker-entrypoint-initdb.d/ 目录内就会自动创建了。相关的文件修改如下:

数据库配置文件:

# config/database.yml

# ...
production:
  primary: &primary_production
    <<: *default
    url: <%= ENV["DATABASE_URL"] %>
  cache:
    <<: *primary_production
    url: <%= ENV["SOLID_CACHE_DATABASE_URL"] %>
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    url: <%= ENV["SOLID_QUEUE_DATABASE_URL"] %>
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_production
    url: <%= ENV["SOLID_CABLE_DATABASE_URL"] %>
    migrations_paths: db/cable_migrate

Docker Compose 配置文件:

# docker-compose.yml

services:
  app:
    build:
      context: .
      args:
        RUBY_VERSION: 3.4.1
    environment:
      RAILS_ENV: production
      RAILS_MASTER_KEY: ${RAILS_MASTER_KEY}
      DATABASE_URL: mysql2://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
      SOLID_CACHE_DATABASE_URL: mysql2://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}_cache
      SOLID_QUEUE_DATABASE_URL: mysql2://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}_queue
      SOLID_CABLE_DATABASE_URL: mysql2://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}_cable
    depends_on:
      db:
        condition: service_healthy
    networks:
      - backend

  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql_init.sql:/docker-entrypoint-initdb.d/init.sql
    # ...

映射到 MySQL 内的自动创建数据库的 SQL 文件:

-- docker/mysql_init.sql

-- 创建附属数据库
CREATE DATABASE IF NOT EXISTS `xxx_db_cache`;
CREATE DATABASE IF NOT EXISTS `xxx_db_queue`;
CREATE DATABASE IF NOT EXISTS `xxx_db_cable`;

-- 授予用户权限
GRANT ALL PRIVILEGES ON `xxx_db`.* TO 'xxx_user'@'%';
GRANT ALL PRIVILEGES ON `xxx_db_cache`.* TO 'xxx_user'@'%';
GRANT ALL PRIVILEGES ON `xxx_db_queue`.* TO 'xxx_user'@'%';
GRANT ALL PRIVILEGES ON `xxx_db_cable`.* TO 'xxx_user'@'%';
FLUSH PRIVILEGES;

还可以给它们写一个迁移文件:

namespace :db do
  task :multi_migrate => :environment do
    Rails.application.configure do |config|
      config.paths.add 'db/cache_migrate', eager_load: true
      config.paths.add 'db/queue_migrate', eager_load: true
      config.paths.add 'db/cable_migrate', eager_load: true
    end

    Rake::Task['db:migrate:primary'].invoke
    Rake::Task['db:migrate:cache'].invoke
    Rake::Task['db:migrate:queue'].invoke
    Rake::Task['db:migrate:cable'].invoke
  end
end

需要时可以通过 docker-compose exec app bin/rails db:multi_migrate 执行迁移。

最后清理 Volumes 并重新运行 Docker Compose:

docker-compose down -v
docker-compose up -d --build
docker-compose exec app bin/rails db:multi_migrate

10. 总结

之前的开发中也做过运维相关的工作,大都是站在巨人的肩膀上一知半解。这算是第一次在这种环境下独立部署,层出不穷的报错一度让人无比沮丧,但好在坚持不懈最终一一解决。

虽然遇到的问题已经解决了,但还有更多亟待提高和优化的地方,比如:

不过最近越来越明白的一个道理是欲速则不达,遇到问题时先从简单、通用的方案着手解决,先做出来一个可行的方案,然后一点一滴的优化它才是王道。这次的项目虽然不大,但是我独立完成前端、后端、测试、运维的所有工作,和这种思维是分不开的:

诸如此类的选择,让我对代码拥有更多的控制力,也对解决方案有了进一步的认识:所谓大道至简