构建镜像不接触数据库

大家好。

我运行着一个非常小型的 Discourse 实例(实际上已经运行了很多年,几乎没有任何问题):https://discuss.cubeisland.de/。
我一直使用标准的基于 launcher 的部署流程,运行在专用虚拟机上(位于数据中心的自有硬件上)。多年来我唯一做的更改是将数据库迁移到外部运行的共享 PostgreSQL 数据库。

最近,我开始将应用程序从专用虚拟机迁移到 Docker Swarm,作为最终迁移到 Kubernetes 集群的准备工作,主要是为了节省资源并使部分基础设施更具“弹性”。

今天,我着手处理这个 Discourse 实例,它是少数几个仍运行在专用应用虚拟机上的实例之一。“它已经在 Docker 上运行了,把它部署到 Swarm 能有多难呢?”我心想。根据我的阅读,这确实可行。我可以直接从当前运行的实例中获取镜像,推送到我们的内部注册表,然后在 Swarm 中运行,一切都会正常工作,这很好。

我查看了 launcher 文件,特别是模板和示例文件,觉得在这种部署中将 Redis 独立出来可能是个好主意,也许我可以设置一个 CI 作业,在我添加插件或需要更新时构建新镜像。因此,我在本地签出了 discourse_docker,将现有的 app.yml 容器定义复制到克隆目录中,并尝试运行 ./launcher bootstrap app 来构建一个可以推送到我的注册表的镜像,而不立即部署它。

令我惊讶的是,脚本试图连接“生产”PostgreSQL 服务器以迁移数据库,幸运的是,我的本地工作站无法访问该服务器。

我在这里查阅了一下,看来这就是它的工作方式,这让我感到疑惑:

  1. 如果我要为一个新实例构建容器,而该实例还没有数据库,该怎么办?我是否需要在构建镜像之前先设置生产数据库?
  2. 我假设 db:migrate 只会在构建时运行一次。因此,如果我有几个类似的实例(例如生产和测试),我需要升级其中一个实例来构建新镜像,然后无法为第二个实例使用相同的镜像,即使这两个镜像是完全相同的。
  3. 对于数据库服务器无法从构建镜像的系统访问的实例(这种情况并不少见),我该如何构建镜像?

在阅读了几篇帖子(显然包括 这篇)后,我完全理解当前构建流程的原因,也看到了它对提到的 99% 在标准全功能虚拟机上随意部署 Discourse 的人的价值。我也非常习惯“全合一”的容器模型,并不反对这种做法。毕竟,Docker 的关键价值在于软件供应商可以预先构建高度优化的配置,并将它们打包成可重现的运行时环境,从而消除了运维人员需要大量特定于应用程序的知识的需求。所以我完全支持使用你们提供的工具,毕竟我为什么要指望别人比软件供应商自己构建更好的容器呢?为什么要将 nginx 和 Ruby 应用拆分,明明没有任何好处,只是为了让部署看起来更“纯粹”(不管那是什么意思……)?

然而,看到一个容器在尚未运行时就在改变运行时状态,这确实有些奇怪。我已经在容器中运行了许多应用程序,自己也容器化了许多应用程序,其中一些原本并不打算在容器中运行。

我脑海中首先想到的一个例子是 GitLab,它在处理类似需求/问题时采用了与 Discourse 类似的方式。虽然他们现在提供了一个精美的 Helm 图表,用于完全解耦的“理想”Kubernetes 部署,但我猜测(虽然没有查看具体数据),其 99% 的中小规模部署仍然使用 GitLab 的 Omnibus Docker 镜像(或 OS 包,这实际上是一样的)。他们有一个类似的引导过程,但基于容器内的 Chef,每次启动时都会执行,完成数据库迁移和资产编译等常规操作。

是的,由于这些原因,GitLab 的启动可能需要几分钟,但在我见过的部署中(包括一些大型公司),这从未成为问题。特别是配合现代编排系统(如 Docker Swarm、Kubernetes 等),它们可以为你执行滚动升级,只有在新实例运行并通过健康和就绪检查后,旧实例才会被关闭,冗长的部署过程实际上可能并不是问题。但即使没有复杂的滚动升级(这些升级可能有效也可能无效),在许多情况下,你仍然可以接受相当程度的停机时间。

所以:是否有可能配置 launcher,使其在构建镜像时跳过依赖数据库的操作,而将这些操作改为在容器启动时执行?

我非常愿意投入一些时间来解决这个问题,但我晚上的时间有限,所以任何指导都将非常欢迎。

如果你认为这个想法很愚蠢或根本不可行,我也完全愿意接受完全不同的流程。

感谢任何反馈!

我想做和你一样的事情——我们在 Amazon ECS 上运行 Discourse,因此需要能够仅构建 Web 镜像并将其推送到注册表。我不愿意去修改 Discourse 的构建流程,因为我们希望尽可能贴近官方支持的安装方式。

相反,我们在本地机器上使用标准的 launcher 脚本来构建一个 双容器架构,但忽略数据容器,仅将 Web 容器推送到注册表。在运行时,我们通过环境变量覆盖 PostgreSQL 和 Redis 的连接信息。

部署新镜像分为三个步骤:

  1. 执行安全的预迁移。让 ECS 使用新镜像运行以下命令:

     SKIP_POST_DEPLOYMENT_MIGRATIONS=1 rake db:migrate
    
  2. 部署新镜像。更新 ECS 服务。

  3. 执行后迁移。让 ECS 运行以下命令:

     SKIP_POST_DEPLOYMENT_MIGRATIONS=0 rake db:migrate
    

在构建镜像时让本地数据容器运行可能有些浪费,但这意味着我们可以直接使用标准的 web.template.yml,而无需担心哪些部分会尝试连接数据库或 Redis。

感谢!我也想到可以在镜像构建期间启动一个 PostgreSQL 实例,待实际构建完成后将其丢弃。

我终于抽出时间实现了这个!

我使用 GitLab CI 流水线实现了镜像构建,该流水线在构建期间运行 PostgreSQL 和 Redis 作为服务,并在构建完成后将其销毁:

现在我只需自动化部署流程并处理数据库迁移即可。

这个东西已经运行了一年多了,从来没有动过它,甚至连 2.8 版本都没有动过。

我已将镜像构建移至 GitHub:https://github.com/pschichtel/discourse-docker。

该镜像已发布到 pschichtel/discourse:stable-web_only

看起来这最终还是出错了。从 3.0.6 升级到 3.1.0 时,没有执行任何数据库迁移。然而,在容器运行后执行最后的 bundle exec rake db:migrate 命令确实有效,但那是在又一次重启容器之后。

当新映像在未设置该环境变量的情况下启动时,您必须再次迁移。有一个 Rake 任务可以执行此操作,但我无法从手机上记住或找到它。有点像 ensure_post_migrations。

就我而言,我没有注意到任何中断。我主要遵循 beta 发布分支,据我所知,migrations 在 3.1.0.beta… 系列的每一步都已正确运行。

我通过 rake -AT 找到了 db:ensure_post_migrations

db:migrate 配合 SKIP_POST_DEPLOYMENT_MIGRATIONS=0db:ensure_post_migrations 有什么区别?

好的,在查看了代码之后,我明白了 db:ensure_post_migrations 的作用。它应该在 db:migrate 的同一个 rake 执行中使用,以确保 SKIP_POST_DEPLOYMENT_MIGRATIONS 被设置为 0。我的脚本已经确保了这一点:

.gitlab-ci.yml

./migrate.sh pre || echo "Redis not running during pre migrations, skipping..."
docker stack deploy --prune --resolve-image always -c "$STACK.yml" "$STACK"
./docker-stack-wait.sh -t 180 "$STACK"
./migrate.sh post

migrate.sh

#!/usr/bin/env sh

if [ "$(docker ps -q --filter "label=com.docker.stack.namespace=${STACK}" --filter "label=com.docker.swarm.service.name=${STACK}_${DISCOURSE_REDIS_HOST}" | wc -l)" = "0" ]
then
    echo "No redis container found, unable to run migrations!"
    exit 1
fi

if [ "$1" = "pre" ]
then
    skip_post=1
else
    skip_post=0
fi


docker run \
    --rm \
    --name "discourse-migration-${DISCOURSE_DB_HOST}-${DISCOURSE_DB_NAME}" \
    --network "${STACK}_discourse" \
    --workdir /var/www/discourse \
    -u discourse \
    -e SKIP_POST_DEPLOYMENT_MIGRATIONS="$skip_post" \
    -e LANG="${LANG}" \
    -e DISCOURSE_DEFAULT_LOCALE="${DISCOURSE_DEFAULT_LOCALE}" \
    -e DISCOURSE_HOSTNAME="${DISCOURSE_HOSTNAME}" \
    -e DISCOURSE_DEVELOPER_EMAILS="${DISCOURSE_DEVELOPER_EMAILS}" \
    -e DISCOURSE_SMTP_ADDRESS="${DISCOURSE_SMTP_ADDRESS}" \
    -e DISCOURSE_SMTP_PORT="${DISCOURSE_SMTP_PORT}" \
    -e DISCOURSE_DB_USERNAME="${DISCOURSE_DB_USERNAME}" \
    -e DISCOURSE_DB_PASSWORD="${DISCOURSE_DB_PASSWORD}" \
    -e DISCOURSE_DB_HOST="${DISCOURSE_DB_HOST}" \
    -e DISCOURSE_DB_NAME="${DISCOURSE_DB_NAME}" \
    -e DISCOURSE_REDIS_HOST="${DISCOURSE_REDIS_HOST}" \
    "$DISCOURSE_IMAGE" \
    bundle exec rake db:migrate

它在 docker swarm 中使用新镜像以 SKIP_POST_DEPLOYMENT_MIGRATIONS=1 运行 db:migrate,此时 discourse 仍在运行旧版本。然后它将新镜像部署到 swarm 并等待其收敛。最后,它再次运行 db:migrate,但这次使用 SKIP_POST_DEPLOYMENT_MIGRATIONS=0

这在过去两年多的时间里对每个版本都一直可靠地工作。鉴于它对您 @simonk 也有效,您是否做了与我的脚本根本不同的事情?

不,我仍然遵循我之前概述的相同流程(Building the image without touching the database - #2 by simonk rake db:migrate 而不是 bundle exec rake db:migrate,但我无法想象这会有多大区别。

我从未使用过 docker stack 或 swarm。你的脚本中是否存在可能导致 migrate.sh 脚本使用旧镜像而不是新镜像的 bug?

我还没有明确检查过,我会去看看。Swarm肯定会使用最新的镜像,但也许CI脚本因为某些原因没有使用最新的。

我已针对 3.1.1 更新进行了查看。确实,CI 脚本使用了旧版本的容器。