author: Sophie DeBenedetto author_link: https://github.com/sophiedebenedetto categories: general tags: [‘docker’, ‘mix release’, ‘config’, ‘umbrella apps’] date: 2019-09-20 layout: post title: Releasing an Umbrella App with Docker, Mix Release and Config excerpt: >

The release of Elixir 1.9 gave us mix release and the ability to support basic releases, runtime configuration and more, natively in Elixir. Learn how we were able to build a production release of an Elixir umbrella app with Docker, mix release and the new Config module.

使用 Docker, Mix Release, Config 发布一个 Umbrella App

今年早些时候发布的 Elixir 1.9 引入了一些 强大的新工具mix release 允许我们在没有 Distillery 的情况下构建一个版本;我们的伞形子应用的配置已经被移到了父应用中;增加的 Config 模块取代了 Mix.Config ,使我们的版本配置变得简单,并且通过增加 System.fetch_env! 等功能进一步简化了配置。

让我们利用 这些 新特性,借助 Docker 来构建一个 Elixir umbrella 应用。

背景: 我们的构建 + 部署流程

首先,介绍一下相关应用的构建和部署过程的背景。在 The Flatiron School,我们维护了一个应用程序,即 Registrar,处理我们的学生入学和计费。Registrar 应用是一个 Elixir 伞形应用,使用 CircleCI 和 AWS Fargate 管理的 CI/CD 管道进行构建和部署。Registrar 由 CircleCI 构建,生成的镜像会被推送到 ECR(Elastic Container Repository)。Fargate 将镜像拉下来,并在 ECS 中运行容器化发布。

如果这样的设置让你感到困惑或陌生—没有问题!你唯一需要了解的是,在 ECS 中,你可以使用 Fargate 来发布镜像。在这篇博文中,你唯一需要理解的是,当我们构建发布版本时,我们的应用程序的环境变量是不可用的,但它们在运行时是可用的。

初始化 Release

在我们开始前,我们需要在我们的伞形应用根目录运行 mix release.init。 这将生成下面的文件:

  • rel/env.sh
  • rel/env.bat
  • rel/vm.args

稍后将详细介绍这些文件。

使用 Config 模块配置伞形应用程序。

首先,我们需要做的是确保我们 Elixir umbrella app 的子程序能够通过新的 Config 模块进行正确的配置。

以前我们的伞形应用的帮助每个子应用的配置在该子应用的 config/ 子目录中,现在我们直接在父应用中配置每个子应用。所以,config 目录顶层应用 registrar_umbrella,是所有操作发生的地方。

我们先来看看 registrar_umbrella/config/config.exe 文件。

如果我们有一个伞形应用 registrar_umbrella,有两个子应用 registrarregistrar_web,我们的 config.exs 文件可能看起来像这样。

  1. # registrar_umbrella/config/config.exs
  2. import Config
  3. config :registrar,
  4. stripe_api_base_url: System.get_env("STRIPE_BASE_URL"),
  5. stripe_api_key: System.get_env("STRIPE_SECRET_KEY"),
  6. accounts: Registrar.Accounts,
  7. billing: Registrar.Billing
  8. config :registrar_web,
  9. learn_base_url: System.get_env("LEARN_OAUTH_BASE_URL"),
  10. learn_client_id: System.get_env("LEARN_OAUTH_CLIENT_ID"),
  11. learn_client_secret: System.get_env("LEARN_OAUTH_CLIENT_SECRET"),
  12. learn_client: RegistrarWeb.OAuth.LearnClient
  13. # Configures the endpoint
  14. config :registrar_web, RegistrarWeb.Endpoint,
  15. server: true,
  16. url: [host: "localhost"],
  17. secret_key_base: System.get_env("SECRET_KEY_BASE"),
  18. render_errors: [view: RegistrarWeb.ErrorView, accepts: ~w(html json)],
  19. pubsub: [name: RegistrarWeb.PubSub, adapter: Phoenix.PubSub.PG2]
  20. ...
  21. import_config "#{Mix.env}.exs"

让我们来分析一下。

Config 模块

请注意,我们在文件顶部加入了 import Config。Elixir 1.9 软取消了 use Mix.Config 的用法,原因如下。发布版有自己的配置,包括由 config/releases.exe 文件确定的运行时配置(稍后再谈)。然而,Mix 是一个构建工具。因此,它在你的版本中是不可用的。因此,我们不想依赖它,而是可以使用(新的!)原生 Elixir Config 模块来满足我们所有的配置需求。

特定环境的配置

我们可以继续在 config/dev.exsconfig/test.exsconfig/prod.exs 中设置特定环境的配置。import_config "#{Mix.env}.exs" 这一行将在编译时导入相应的配置文件。

使用 System.get_env/1

在我们的 config.exs 文件中,我们使用 System.get_env/1。这将返回给定环境变量的值,如果它在编译时存在于系统中。否则将返回 nil。在开发和测试环境中使用 System.get_env/1 可以很好地工作,但在生产环境中却无法实现。这是因为,对于我们特定的应用程序的构建和部署管道,我们是在一个环境中构建发行版,而这个环境的系统并不包含我们的应用程序所需要的环境变量,例如 "STRIPE_SECRET_KEY"。然而,我们的生产版本的 运行时 环境会有这些变量。

现在我们已经看到了如何在 Config 模块和 System.get_env/1 的帮助下配置保护伞的子应用程序,让我们来看看我们的发布配置。

配置 Release

config/mix.exs 中定义 Release

我们先在顶层的 mix.exs 文件中,在 project/0 函数中的 :reases 键下配置我们的 release。

  1. # registrar_umbrella/mix.exs
  2. defmodule Registrar.Umbrella.Mixfile do
  3. use Mix.Project
  4. def project do
  5. [
  6. apps_path: "apps",
  7. start_permanent: Mix.env() == :prod,
  8. deps: deps(),
  9. version: "0.1.0",
  10. elixir: "~> 1.9",
  11. releases: [
  12. registrar_umbrella: [
  13. applications: [
  14. registrar: :permanent,
  15. registrar_web: :permanent
  16. ]
  17. ]
  18. ]
  19. ]
  20. end
  21. ...
  22. end

我们可以通过在 :release 下添加后续键来定义多个 release —例如,如果我们想创建一个只运行 registrar 应用程序的 release。现在,我们只定义了一个版本,registrar_umbrella。对于 umbrella 应用程序的 release 配置,我们必须指定当 release 启动时要启动哪些子应用程序。我们通过在 :applications 键下列出我们想要启动的子应用程序来实现。

有许多额外的发布配置选项,你可以查看这里,但我们现在将保持我们的配置非常简单。

config/releases.exs 中配置运行时

由于我们的构建和部署管道要求我们的应用程序的环境变量在 运行时 而不是构建时存在,我们需要 release 拥有运行时配置。为了使我们的发布版本具有运行时配置,我们创建一个文件 config/releases.xs

  1. # registrar_umbrella/config/config.exs
  2. import Config
  3. config :registrar,
  4. stripe_api_base_url: System.fetch_env!("STRIPE_BASE_URL"),
  5. stripe_api_key: System.fetch_env!("STRIPE_SECRET_KEY")
  6. config :registrar_web,
  7. learn_base_url: System.fetch_env!("LEARN_OAUTH_BASE_URL"),
  8. learn_client_id: System.fetch_env!("LEARN_OAUTH_CLIENT_ID"),
  9. learn_client_secret: System.fetch_env!("LEARN_OAUTH_CLIENT_SECRET")
  10. # Configures the endpoint
  11. config :registrar_web, RegistrarWeb.Endpoint,
  12. secret_key_base: System.fetch_env!("SECRET_KEY_BASE")

这里我们借助 System.fetch_env!/1 来配置所有的运行时应用环境变量。如果给定的环境变量在运行时不存在于系统中,这个函数将引发一个错误。我们希望这样的验证能够到位,这样我们的应用程序在缺少必要的环境变量时就无法启动—下游不会出现无声的失败。

重要的是,我们仍然利用 config/prod.exs 文件(这里不包括)来做一些事情,比如为生产配置 ReigstrarWeb.Endpoint。这个文件是专门为我们的运行时发布配置的。

在我们继续之前,还有最后一件事要指出。

比方说,我们在运行时发布的版本中设置了以下应用环境变量。

  1. # registrar_umbrella/config/config.exs
  2. import Config
  3. config :registrar,
  4. stripe_api_base_url: System.fetch_env!("STRIPE_BASE_URL")

我们有一个模块,Registrar.StripeApiClient,它使用模块属性来查找和存储该应用环境变量的值。

  1. # registrar_umbrella/apps/registrar/lib/stripe_api_client.ex
  2. defmodule Registrar.StripeApiClient do
  3. @stripe_api_base_url Application.get_env(:registrar, :stripe_api_base_url)
  4. def get(url) do
  5. HTTPoison.get(@stripe_api_base_url <> url)
  6. end
  7. end

虽然开发人员经常使用用户定义的模块属性作为常量,但重要的是要记住,该值是在编译时读取的,而不是在运行时读取的 由于 Application.get_env(:registrar, :stripe_api_base_url) (来自系统环境变量)的值只存在于 运行时,所以在这里使用模块属性是行不通的!相反,我们将使用一个函数在运行时动态地查找该值。

取而代之的是,我们将使用一个函数在运行时动态地查找该值。

  1. # registrar_umbrella/apps/registrar/lib/stripe_api_client.ex
  2. defmodule Registrar.StripeApiClient do
  3. defp stripe_api_base_url, do: Application.get_env(:registrar, :stripe_api_base_url)
  4. def get(url) do
  5. HTTPoison.get(stripe_api_base_url() <> url)
  6. end
  7. end

现在我们已经完成了运行时的配置,我们已经准备好构建我们的发行版了。

用 Docker + mix release 构建版本

我们使用 Docker 来构建我们的版本,因为我们的应用程序将在 ECS 集群内的容器中运行。

我们的 Docker 文件是非常简单直接的。

  1. FROM bitwalker/alpine-elixir-phoenix:1.9.0 as releaser
  2. WORKDIR /app
  3. # Install Hex + Rebar
  4. RUN mix do local.hex --force, local.rebar --force
  5. COPY config/ /app/config/
  6. COPY mix.exs /app/
  7. COPY mix.* /app/
  8. COPY apps/registrar/mix.exs /app/apps/registrar/
  9. COPY apps/registrar_web/mix.exs /app/apps/registrar_web/
  10. ENV MIX_ENV=prod
  11. RUN mix do deps.get --only $MIX_ENV, deps.compile
  12. COPY . /app/
  13. WORKDIR /app/apps/registrar_web
  14. RUN MIX_ENV=prod mix compile
  15. RUN npm install --prefix ./assets
  16. RUN npm run deploy --prefix ./assets
  17. RUN mix phx.digest
  18. WORKDIR /app
  19. RUN MIX_ENV=prod mix release
  20. ########################################################################
  21. FROM bitwalker/alpine-elixir-phoenix:1.9.0
  22. EXPOSE 4000
  23. ENV PORT=4000 \
  24. MIX_ENV=prod \
  25. SHELL=/bin/bash
  26. WORKDIR /app
  27. COPY --from=releaser app/_build/prod/rel/registrar_umbrella .
  28. COPY --from=releaser app/bin/ ./bin
  29. CMD ["./bin/start"]

让我们仔细看看我们真正关心的部分。

首先,我们将 MIX_ENV 改为 prod,并获取和编译我们的生产依赖。

  1. ENV MIX_ENV=prod
  2. RUN mix do deps.get --only $MIX_ENV, deps.compile

稍后,我们为子应用 registrar_web 构建了生产环境的静态资源

  1. WORKDIR /app/apps/registrar_web
  2. RUN MIX_ENV=prod mix compile
  3. RUN npm install --prefix ./assets
  4. RUN npm run deploy --prefix ./assets
  5. RUN mix phx.digest

然后我们使用 mix release 根据 mix.exs 文件中 project/0 函数的 :release 键中的配置来构建我们的 release。

  1. WORKDIR /app
  2. RUN MIX_ENV=prod mix release

这将构建我们的发布版本,并将其放在 _build/prod/rel/registrar_umbrella 中。

最后,我们将 release 复制到容器中,并指定启动脚本在 ./bin/start 中。

现在我们来谈谈启动脚本。

启动脚本

启动我们的发布版本很简单,我们的 ./bin/start 脚本看起来像这样:

  1. #!/usr/bin/env bash
  2. set -e
  3. echo "Starting app..."
  4. bin/registrar_umbrella start

在这一点上,你可能还记得 Distillery 提供了一个 “引导钩” 功能,允许你在应用程序启动时运行某些命令/执行一些代码。你 可能 会想知道我们如何使用 mix release 来实现同样的目标。例如,我们如何确保我们的迁移在发布启动时运行?请继续阅读,了解更多

rel/env.sh 预启动脚本。

mix release.init 生成的 rel/env.sh 文件将在发布开始时运行。这就是我们将调用迁移脚本的地方。

假设我们有一个模块 Registrar.ReleaseTasks 和一个函数 migrate/0,它启动应用程序并执行 Ecto 迁移。

  1. defmodule Registrar.ReleaseTasks do
  2. @moduledoc false
  3. @start_apps [
  4. :crypto,
  5. :ssl,
  6. :postgrex,
  7. :ecto,
  8. :ecto_sql
  9. ]
  10. @repos Application.get_env(:registrar, :ecto_repos, [])
  11. def migrate do
  12. start_services()
  13. run_migrations()
  14. stop_services()
  15. end
  16. defp start_services do
  17. IO.puts("Starting dependencies..")
  18. # Start apps necessary for executing migrations
  19. Enum.each(@start_apps, &Application.ensure_all_started/1)
  20. # Start the Repo(s) for app
  21. IO.puts("Starting repos..")
  22. # Switch pool_size to 2 for ecto > 3.0
  23. Enum.each(@repos, & &1.start_link(pool_size: 2))
  24. end
  25. defp stop_services do
  26. IO.puts("Success!")
  27. :init.stop()
  28. end
  29. defp run_migrations do
  30. Enum.each(@repos, &run_migrations_for/1)
  31. end
  32. end

我们可以使用 eval 在我们的发布中执行这个函数。调用 bin/My_RELEASE eval 将启动你的发布版本,并执行你给 eval 提供的任何函数。要在我们的版本中执行我们的迁移函数。

  1. bin/registrar_umbrella eval "Registrar.ReleaseTasks.migrate()"

记得我们在 ./bin/start 中用 start 命令启动我们的发布。

  1. bin/registrar_umbrella start

这将依次执行 rel/env.sh 文件。该文件应包含一个执行以下操作的脚本。

  • 如果发布的命令是 start,使用 eval 运行迁移。

类似这样的脚本应该可以做到。

  1. if [ "$RELEASE_COMMAND" = "start" ]; then
  2. echo "Beginning migration script..."
  3. bin/registrar_umbrella eval "Registrar.ReleaseTasks.migrate()"
  4. fi

就是这样!

结语

有了 Elixir 1.9,我们可以在不添加任何外部依赖关系的情况下构建一个版本— Elixir 现在原生地提供了我们所需的一切。我们可以为我们的 umbrella 应用配置多个版本,定义在给定的版本中启动哪些子应用。我们可以配置运行时与构建时的环境变量,我们甚至可以定义自定义的启动脚本来做一些事情,比如运行我们的迁移。总而言之,mix release 为我们提供了一套全面而强大的工具。