版本发布

版本发布

2019 年 9 月 24 日

OTP 高级概述 中,我们从宏观角度了解了如何将应用程序组合成一个版本,并在 开发 的后续章节中构建和测试了 OTP 应用程序。我们已经讨论了 Erlang 系统的设置方式与许多其他语言(这些语言具有单个项目代码入口点,例如程序启动时调用的 main() 函数)的不同之处。最终,启动运行代码的 Erlang 节点与其他语言和运行时环境非常相似,并且可以打包到 Docker 容器中以像其他任何容器一样运行,并且一旦通过启动容器运行,就不会显得那么不同。但是,在拥有一个捆绑的版本(带有易于使用的 shell 脚本)或一个 Docker 镜像之前,一开始可能会让人感到困惑,并让人觉得 Erlang 太过不同或难以在您的环境中运行。

本章将提供有关版本构建的一些底层细节,但主要目的是使用 service_discovery 项目来展示如何使用 rebar3 构建适合本地开发的版本,然后是生产就绪的版本,最后是如何使用 rebar3 提供的工具来配置和操作版本。我们将展示基于启动并发运行的应用程序的系统带来的灵活性的优势,不必以晦涩的操作为代价。

细节

回到操作系统比较,启动 Erlang 版本类似于操作系统引导序列,随后 init 系统启动服务。Erlang 节点通过运行在 引导文件 中找到的指令启动。这些指令加载模块并启动应用程序。Erlang 提供了根据所有必需应用程序列表生成引导脚本的功能,这些应用程序在扩展名为“.rel”的版本资源文件中定义,以及每个应用程序具有的相应应用程序资源文件“.app”文件。应用程序资源文件定义了引导脚本必须加载的模块以及每个应用程序的依赖项,以便引导脚本按正确的顺序启动应用程序。当仅将版本中使用的应用程序与引导脚本捆绑在一起以复制并安装到目标时,称为目标系统

在 Erlang/OTP 的早期,只有 systools 及其用于从版本资源文件生成引导脚本的功能。那时,版本处理是一个手动过程,用户在其周围构建了自己的工具。然后出现了 reltool,这是一个随 Erlang/OTP 一起提供的版本管理工具,旨在简化版本的创建——它甚至具有 GUI。虽然创建和安装目标系统从未在 sasl 应用程序中找到的示例模块 sasl/examples/src/target_system.erl 之外提供。

版本对于许多用户来说仍然神秘且难以构建。relx 的创建目标是使版本创建和管理变得如此简单,以至于用户不再认为这是一个最好不承担的负担——这部分是通过要求最少的配置即可开始以及在生成的版本中包含运行时管理工具来实现的。当 Rebar3 启动时,它捆绑了 relx 以提供其版本构建功能。

除了构建和打包之外,relx 还带有一个 shell 脚本,用于启动版本并与正在运行的版本进行交互。虽然运行版本可以像 erl 一样简单(它本身运行从您可以在 Erlang 安装的 bin/ 目录中找到的 start.script 构建的引导脚本),但提供的脚本处理设置适当的参数以指向配置文件、将远程控制台附加到正在运行的节点、在正在运行的节点上运行函数等。

在以下部分中,我们将剖析 service_discovery 项目的版本构建。重点是工具的实际使用,而不是版本构建和运行的底层细节。

构建开发版本

service_discovery 中首先要查看的是 rebar.config 中的 relx 部分

{relx, [{release, {service_discovery, {git, long}},
         [service_discovery_postgres,
          service_discovery,
          service_discovery_http,
          service_discovery_grpc,
          recon]},

        {sys_config, "./config/dev_sys.config"},
        {vm_args, "./config/dev_vm.args"},

        {dev_mode, true},
        {include_erts, false},

        {extended_start_script, true},

        {overlay, [{copy, "apps/service_discovery_postgres/priv/migrations/*", "sql/"}]}]}.

relx 配置列表中的 release 元组定义了版本的名称、版本和版本中包含的应用程序。此处的版本为 {git, long},它告诉 relx 使用当前提交的完整 git sha 引用作为版本的版本。版本将启动的应用程序包括 Postgres 存储后端、用作服务主要接口的应用程序以及 DNS 设置、HTTP 和 grpc 前端以及一个用于检查生产节点的有用工具 recon

此处列出应用程序的顺序很重要。构建引导脚本时,会对所有应用程序基于其依赖项进行稳定排序,以确定启动它们的顺序。但是,当依赖项顺序不够具体以做出决定时,将保留列表中定义的顺序。service_discovery_postgresservice_discovery_httpservice_discovery_grpc 中的每一个都依赖于 service_discovery,这导致后者首先启动。下一个将是 service_discovery_postgres,因为它首先列出。这很重要,因为我们需要在 HTTP 和 grpc 服务可用之前提供存储后端。

rebar3 release 使用基于 rebar.configrelx 部分和 Rebar3 项目结构的配置运行 relx,允许 relx 找到构建版本所需的应用程序。

$ rebar3 release
===> Verifying dependencies...
===> Compiling service_discovery_storage
===> Compiling service_discovery
===> Compiling service_discovery_http
===> Compiling service_discovery_grpc
===> Compiling service_discovery_postgres
===> Starting relx build process ...
===> Resolving OTP Applications from directories:
          /app/src/_build/default/lib
          /app/src/apps
          /root/.cache/erls/otps/OTP-22.1/dist/lib/erlang/lib
          /app/src/_build/default/rel
===> Resolved service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70
===> Dev mode enabled, release will be symlinked
===> release successfully created!

在输出中看到的第一个 relx 步骤是“解析 OTP 应用程序”,后跟它将搜索已构建应用程序的目录列表。对于 relx 版本配置中的每个应用程序,在本例中为 service_discovery_postgresservice_discoveryservice_discovery_httpservice_discovery_grpcreconrelx 将找到已构建应用程序的目录,然后对其 .app 文件中列出的任何应用程序执行相同的操作。

注意

应用程序 sasl 未包含在 relx 配置的应用程序列表中。在 Rebar3 模板中,它默认位于列表中。这是因为 sasl 对于某些版本操作是必需的。sasl 应用程序包含 release_handler,它提供了执行版本升级和降级功能。由于我们这里专注于创建将被替换而不是实时升级的容器,因此不需要包含 sasl

由于此版本使用 {dev_mode, true} 构建,因此在版本的 lib 目录中创建了指向每个应用程序的符号链接,而不是复制它们

$ ls -l _build/default/rel/service_discovery/lib
lrwxrwxrwx ... service_discovery-c9e1c80 -> .../_build/default/lib/service_discovery

运行时配置文件 dev_sys.configdev_vm.args 也执行了相同的操作

$ ls -l _build/default/rel/service_discovery/releases/c9e1c805d57a78d9eb18af1124962960abe38e70
lrwxrwxrwx [...] sys.config -> [...]/config/dev_sys.config
lrwxrwxrwx [...] vm.args -> [...]/config/dev_vm.args

这允许在本地测试运行我们的版本时更快地获得反馈循环。只需停止并再次启动版本即可获取对 beam 文件或配置的任何更改,无需运行 rebar3 release

警告

在 Windows 上,relx 的 dev_mode 不一定会工作,但它将回退到复制。

开发版本的完整文件系统树现在如下所示

_build/default/rel/service_discovery/
├── bin/
│   ├── install_upgrade.escript
│   ├── nodetool
│   ├── no_dot_erlang.boot
│   ├── service_discovery
│   ├── service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70
│   └── start_clean.boot
├── lib/
│   ├── acceptor_pool-1.0.0 -> /app/src/_build/default/lib/acceptor_pool
│   ├── base32-0.1.0 -> /app/src/_build/default/lib/base32
│   ├── chatterbox-0.9.1 -> /app/src/_build/default/lib/chatterbox
│   ├── dns-0.1.0 -> /app/src/_build/default/lib/dns
│   ├── elli-3.2.0 -> /app/src/_build/default/lib/elli
│   ├── erldns-1.0.0 -> /app/src/_build/default/lib/erldns
│   ├── gproc-0.8.0 -> /app/src/_build/default/lib/gproc
│   ├── grpcbox-0.11.0 -> /app/src/_build/default/lib/grpcbox
│   ├── hpack-0.2.3 -> /app/src/_build/default/lib/hpack
│   ├── iso8601-1.3.1 -> /app/src/_build/default/lib/iso8601
│   ├── jsx-2.10.0 -> /app/src/_build/default/lib/jsx
│   ├── recon-2.4.0 -> /app/src/_build/default/lib/recon
│   ├── service_discovery-c9e1c80 -> /app/src/_build/default/lib/service_discovery
│   ├── service_discovery_grpc-c9e1c80 -> /app/src/_build/default/lib/service_discovery_grpc
│   ├── service_discovery_http-c9e1c80 -> /app/src/_build/default/lib/service_discovery_http
│   └── service_discovery_storage-c9e1c80 -> /app/src/_build/default/lib/service_discovery_storage
├── releases/
│   ├── c9e1c805d57a78d9eb18af1124962960abe38e70
│   │   ├── no_dot_erlang.boot
│   │   ├── service_discovery.boot
│   │   ├── service_discovery.rel
│   │   ├── service_discovery.script
│   │   ├── start_clean.boot
│   │   ├── sys.config.src -> /app/src/config/sys.config.src
│   │   └── vm.args.src -> /app/src/config/vm.args.src
│   ├── RELEASES
│   └── start_erl.data
└── sql
    ├── V1__Create_services_endpoints_table.sql
    └── V2__Add_updated_at_trigger.sql

最后一个目录 sql 是由 overlay 创建的,{copy, "apps/service_discovery_postgres/priv/migrations/*", "sql/"}overlay 会告诉 relx 有关要包含的文件的信息,这些文件位于应用程序和引导文件的常规版本结构之外。它支持创建目录、使用 mustache 进行基本模板化和复制文件。在本例中,我们只需要将 service_discovery_postgres 应用程序的迁移复制到版本的顶层目录 sql。迁移无论如何都会包含在内,因为它们位于版本的一部分应用程序的 priv 中,但由于我们打算一次只提供一个版本(有关更多信息,请参见通知框),因此它简化了我们稍后将看到的迁移脚本,以将其保留在已知的顶层目录中。

信息

OTP 版本结构支持具有同一版本的多个版本。它们共享相同的 lib 目录,但可能具有每个应用程序的不同版本,甚至不同的 erts。这就是为什么有一个 releases 目录,其下有一个版本号目录以及文件 RELEASES。在这样的环境中,将 SQL 文件放在所有版本共享的目录中可能会出现问题。但是,由于我们专注于构建自包含的版本,这些版本将在容器中与任何其他版本分开运行,因此我们可以假设只存在一个版本。

由于该版本使用 Postgres 存储后端,因此在启动版本之前需要运行数据库并可以访问它。在项目的顶层运行 Docker Compose 将启动数据库并运行迁移

$ docker-compose up

提示

您不必启动所有内容即可从版本中获取 Erlang shell。每个使用 Rebar3 构建的版本都带有一个名为 start_clean 的引导脚本,该脚本仅启动 kernelstdlib 应用程序。这可以使用命令 console_clean 运行,并且可用于调试目的。

要将开发版本引导到交互式 Erlang shell,请使用命令 console 运行扩展的启动脚本

$ _build/default/rel/service_discovery/bin/service_discovery console
Erlang/OTP 22 [erts-10.5] [source] [64-bit] [smp:1:1] [ds:1:1:10] [async-threads:30] [hipe]

(service_discovery@localhost)1>

使用 service_discovery 运行,curl 可用于访问 HTTP 接口。以下命令创建一个服务 service1,通过列出所有服务来验证它是否已创建,在 IP 127.0.0.3 上的服务注册一个端点,并在端口 8000 上添加一个名为 http 的命名端口。

$ curl -v -XPUT https://127.0.0.1:3000/service \
    -d '{"name": "service1", "attributes": {"attr-1": "value-1"}}'
$ curl -v -XGET https://127.0.0.1:3000/services
[{"attributes":{"attr-1":"value-1"},"name":"service1"}]
$ curl -v -XPUT https://127.0.0.1:3000/service/service1/register \
    -d '{"ip": "127.0.0.3", "port": 8000, "port_name": "http", "tags": []}'
$ curl -v -XPUT https://127.0.0.1:3000/service/service1/ports \
    -d '{"http": {"protocol": "tcp", "port": 8000}}'

service_discovery DNS 服务器在端口 8053 上运行,使用 dig 可以看到 service1 是在正确的 IP 上注册的端点,并且服务 (SRV) DNS 查询返回服务的端口和 DNS 名称。

$ dig -p8053 @127.0.0.1 A service1
;; ANSWER SECTION:
service1.		3600	IN	A	127.0.0.3
$ dig -p8053 @127.0.0.1 SRV _http._tcp.service1.svc.cluster.local
;; ANSWER SECTION:
_http._tcp.service1.svc.cluster.local. 3600 IN SRV 1 1 8000 service1.svc.cluster.local.

构建生产版本

准备要部署到生产环境的版本需要与本地开发期间最适合使用的选项不同的选项。Rebar3 配置文件允许我们覆盖和添加到 relx 配置中。此配置文件通常名为 prod

{profiles, [{prod, [{relx, [{sys_config_src, "./config/sys.config.src"},
                            {vm_args_src, "./config/vm.args.src"},
                            {dev_mode, false},
                            {include_erts, true},
                            {include_src, false},
                            {debug_info, strip}]}]
            }]}.

我们在 prod 配置文件中覆盖了两个配置值。dev_mode 设置为 false,因此所有内容都复制到版本目录中,我们无法利用来自另一台机器的 _build 目录的符号链接,并且生产版本应该是项目的不可变快照。include_erts 将 Erlang 运行时和版本依赖的 Erlang/OTP 应用程序复制到版本目录中,并将引导脚本配置为指向此运行时副本。

添加到配置中的条目将include_src设置为false,将debug_info设置为strip,并包含配置文件的_src版本。在生产环境中运行发布版不需要源代码,因此我们将include_src设置为false,以便将其从最终发布版中删除以节省空间。通过使用debug_info设置为strip从beam文件中剥离调试信息,可以节省额外的空间。调试信息被调试器、xrefcover等工具使用,但在发布版中,这些工具不会被使用,并且除非显式包含,否则甚至不可用。

sys_config_srcvm_args_src优先于我们在默认配置文件中使用的条目sys_configvm_args,这两者将在下一节运行时配置中详细讨论。如果用户并非有意这样做,则在构建生产发布版时会打印一条警告,但我们故意这样做,因此可以忽略该警告。

启用生产配置文件构建会导致工件写入配置文件目录_build/prod/

$ rebar3 as prod release
===> Verifying dependencies...
===> Compiling service_discovery_storage
===> Compiling service_discovery
===> Compiling service_discovery_http
===> Compiling service_discovery_grpc
===> Compiling service_discovery_postgres
===> Starting relx build process ...
===> Resolving OTP Applications from directories:
          /app/src/_build/prod/lib
          /app/src/apps
          /root/.cache/erls/otps/OTP-22.1/dist/lib/erlang/lib
===> Resolved service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70
===> Both vm_args_src and vm_args are set, vm_args will be ignored
===> Both sys_config_src and sys_config are set, sys_config will be ignored
===> Including Erts from /root/.cache/erls/otps/OTP-22.1/dist/lib/erlang
===> release successfully created!

查看新prod配置文件的发布目录的树结构,我们看到

_build/prod/rel/service_discovery
├── bin
│   ├── install_upgrade.escript
│   ├── nodetool
│   ├── no_dot_erlang.boot
│   ├── service_discovery
│   ├── service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70
│   └── start_clean.boot
├── erts-10.5
│   ├── bin
│   ├── doc
│   ├── include
│   ├── lib
│   └── man
├── lib
│   ├── acceptor_pool-1.0.0
│   ├── asn1-5.0.9
│   ├── base32-0.1.0
│   ├── chatterbox-0.9.1
│   ├── crypto-4.6
│   ├── dns-0.1.0
│   ├── elli-3.2.0
│   ├── erldns-1.0.0
│   ├── gproc-0.8.0
│   ├── grpcbox-0.11.0
│   ├── hpack-0.2.3
│   ├── inets-7.0.8
│   ├── iso8601-1.3.1
│   ├── jsx-2.10.0
│   ├── kernel-6.5
│   ├── mnesia-4.16
│   ├── public_key-1.6.7
│   ├── recon-2.4.0
│   ├── service_discovery-c9e1c80
│   ├── service_discovery_grpc-c9e1c80
│   ├── service_discovery_http-c9e1c80
│   ├── service_discovery_storage-c9e1c80
│   ├── ssl-9.3.1
│   └── stdlib-3.10
├── releases
│   ├── c9e1c805d57a78d9eb18af1124962960abe38e70
│   │   ├── no_dot_erlang.boot
│   │   ├── service_discovery.boot
│   │   ├── service_discovery.rel
│   │   ├── service_discovery.script
│   │   ├── start_clean.boot
│   │   ├── sys.config.src
│   │   └── vm.args.src
│   ├── RELEASES
│   └── start_erl.data
└── sql
    ├── V1__Create_services_endpoints_table.sql
    └── V2__Add_updated_at_trigger.sql

lib下没有符号链接,并且包含了stdlib-3.10等OTP应用程序。树的顶部是erts-10.5,其中包含Erlang运行时、bin/beam.smp以及运行和交互发布版所需的erlexecerlescript等可执行文件。

要构建发布版的目标系统,我们在prod配置文件中运行tar命令

$ rebar3 as prod tar

现在我们有一个tarball文件_build/prod/rel/service_discovery/service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70.tar.gz,可以将其复制到任何兼容的主机上,解包并运行。但在我们这样做之前,我们需要知道如何在启动发布版时配置sys.config.srcvm.args.src

运行时配置

service_discovery项目中,我们在config/目录下有两个用于配置的文件包含在生产发布版中:vm.args.srcsys.config.src。这些文件充当模板,根据环境变量在运行时填充。有两个单独的文件,因为运行发布版涉及两个级别的配置。首先,是底层Erlang虚拟机的设置。这些值必须在VM启动之前设置,因此它们不能是像sys.config这样的term文件的一部分,因为读取和解析Erlang term文件需要一个正在运行的VM。相反,VM参数直接传递给erlerl是用于启动发布版的命令。为了简化这一点,erl命令有一个-args_file参数,允许从纯文本文件中读取命令行参数。此文件通常命名为vm.args

第二个级别是构成发布版的Erlang应用程序的配置。这是通过-config参数传递给erl的文件完成的。该文件是一个2元组列表,其中第一个元素是设置环境的应用程序的名称,第二个元素是要在环境中设置的一系列键值对。

当然,静态文件可能非常有限,并且通常希望通过操作系统环境变量设置配置。为了提供灵活性和支持环境变量配置,在使用Rebar3时生成的发布版启动脚本可以将${FOO}形式的变量替换为当前环境中找到的值。如果文件以扩展名.src结尾,则会自动执行此操作。

relx配置中,我们使用vm_args_srcsys_config_src来包含这些文件并表明它们是模板——这是必要的,这样发布版构建就不会尝试验证sys.config是否为正确的Erlang项列表,例如,{port, ${PORT}}不是。

{relx, [...
        {sys_config_src, "config/sys.config.src"},
        {vm_args_src, "config/vm.args.src"},
        ...
       ]}.

在Docker和Kubernetes章节中,我们将讨论为什么需要设置sbwt,但现在我们只关心如何在vm.args.src中完成。

+sbwt ${SBWT}

sys.config.src中,我们也将logger级别设为变量,这样我们就可以例如打开debuginfo级别的日志记录,以便在调查已部署的服务时获得更多详细信息。

{kernel, [{logger_level, ${LOGGER_LEVEL}}]}

现在,在运行发布版时,我们必须设置这些变量,否则发布版将无法启动。

$ DB_HOST=localhost LOGGER_LEVEL=debug SBWT=none \
  _build/prod/rel/service_discovery/bin/service_discovery console
Erlang/OTP 22 [erts-10.5] [source] [64-bit] [smp:1:1] [ds:1:1:10] [async-threads:30] [hipe]

(service_discovery@localhost)1>

在未设置必要的环境变量的情况下运行时产生的错误可能令人困惑。目前没有进行验证来检查配置文件中使用的所有环境变量是否都已设置,然后打印出哪些变量缺失。相反,缺失的变量将被替换为空字符串。如果该变量用于字符串中,例如"${DB_HOST}",则应用程序将启动但无法连接到数据库。当缺失的变量创建无法解析的sys.config时,例如在${LOGGER_LEVEL}的情况下,将出现语法错误。

$ DB_HOST=localhost SBWT=none \
  _build/prod/rel/service_discovery/bin/service_discovery console
{"could not start kernel pid",application_controller,"error in config file \"/app/src/_build/prod/rel/service_discovery/releases/71e109d8f34ef5e5ccfcd666e0d9e544836044f1/sys.config\" (48): syntax error before: ','"}
could not start kernel pid (application_controller) (error in config file "/home/tristan/Devel/service_discovery/_build/prod/rel/service_discovery/releases/71e109d8f34ef5e5ccfcd666e0d9e544836044f1/sys

Crash dump is being written to: erl_crash.dump...done

vm.args中的值缺失时,您可能会看到有关传递给标志的参数的错误。对于+sbwt ${SBWT},它会导致尝试使用下一行作为+sbwt的参数,在本例中为+C multi_time_warp,从而产生一个引导错误,即+C无效。

$ DB_HOST=localhost LOGGER_LEVEL=debug \
  _build/prod/rel/service_discovery/bin/service_discovery console
bad scheduler busy wait threshold: +C
Usage: service_discovery [flags] [ -- [init_args] ]
The flags are:
...

并非所有由于变量缺失而导致的故障都如此简单,它们可能导致非常奇怪的崩溃,或者在引导时没有崩溃,但在依赖该值的应用程序中出现故障。我们希望在未来的Rebar3版本中改进这一点,以便在启动前进行检查,并在检查失败时列出缺失的环境变量。但就目前而言,如果您遇到奇怪的行为或无法理解的崩溃,检查环境变量是一个不错的起点。

即将推出

为了了解我们将在接下来的章节中做什么,请将_build/prod/rel/service_discovery/service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70.tar.gz复制到/tmp/service_discovery,解压缩并启动节点。

$ export LOGGER_LEVEL=debug
$ export SBWT=none
$ export DB_HOST=localhost
$ mkdir /tmp/service_discovery
$ cp _build/prod/rel/service_discovery/service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70.tar.gz /tmp/service_discovery
$ cd /tmp/service_discovery
$ tar -xvf service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70.tar.gz && rm service_discovery-c9e1c805d57a78d9eb18af1124962960abe38e70.tar.gz
...
$ ls
bin  erts-10.5  lib  releases
$ bin/service_discovery console
...
(service_discovery@localhost)1>

使用console运行会提供一个交互式Erlang shell。要运行没有交互式shell的发布版,请使用foreground,这正是我们将在下一章中在Docker容器中运行发布版的方式。在使用foregroundconsole运行发布版后,打开一个单独的终端并尝试使用参数remote_console运行相同的脚本。

$ export LOGGER_LEVEL=debug
$ export SBWT=none
$ export DB_HOST=localhost
$ bin/service_discovery remote_console
...
(service_discovery@localhost)1>

remote_console使用-remsh erl标志将交互式shell连接到正在运行的发布版。该命令根据相同的配置(来自vm.args)知道要连接哪个正在运行的Erlang节点,该配置设置了最初运行的节点的名称和cookie。这意味着在启动节点时用于填充vm.args的任何环境变量都必须在连接远程控制台时设置为相同的值。