乐趣区

关于docker:更优雅的配置docker运维业务中的环境变量

[TOC]

对于应用 docker/docker-compose/docker stack 进行开发、部署的用户,可能会遇到以下问题

  • 如何无效地区分 develop/staging/production 环境配置?
  • 如何有效应对在不同环境甚至差别的架构下部署的需要?

有教训的同学晓得环境变量是问题的答案,但本内容并不止于夸夸其谈,而是联合 aspnet core 示例进行阐明,并给出 GNU 工具阐明和进行 python 实现。

docker-compose

咱们经常基于 compose 文件进行部署,但纯动态的 compose 文件可能无奈满足以下需要

  • 为了从宿主机读取数据或者从容器长久化数据,咱们须要调整目录挂载地位;
  • 为了防止端口抵触咱们须要批改端口映射;

环境变量

docker-compose 反对环境变量,咱们能够在 compose 文件中退出动静元素来批改局部行为,一个应用变量进行目录和端口映射的 compose 文件如下:

version: '3'

networks:
  default:

services:
  nginx:
    image: nginx
    networks:
      - default
    volume:
      - ${nginx_log}:/var/log/nginx
    ports:
      - ${nginx_port-81}:80

该 compose 文件对变量 nginx_port 提供了默认值 81。在 linux 下为了应用环境变量咱们有若干种形式:

  1. 全局环境变量:能够应用 export 申明
  2. 过程级别环境变量:能够应用 sourceenv 引入

souce 是 bash 脚本的一部分,这会引入额定的复杂度,而 env 应用起来很简略,应用它加上键值对及指标命令即可,模式如 env [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...],咱们应用它进行演示。

$ rm .env
$ docker-compose up -d
WARNING: The Docker Engine you're using is running in swarm mode.

Compose does not use swarm mode to deploy services to multiple nodes in a swarm. All containers will be scheduled on the current node.

To deploy your application across the swarm, use `docker stack deploy`.

Starting docker-compose-env-sample_nginx_1 ... done

$ docker-compose ps
              Name                             Command               State         Ports
-----------------------------------------------------------------------------------------------
docker-compose-env-sample_nginx_1   /docker-entrypoint.sh ngin ...   Up      0.0.0.0:81->80/tcp

$ docker-compose down
$ env nginx_port=82 docker-compose up -d
WARNING: The Docker Engine you're using is running in swarm mode.

Compose does not use swarm mode to deploy services to multiple nodes in a swarm. All containers will be scheduled on the current node.

To deploy your application across the swarm, use `docker stack deploy`.

Creating network "docker-compose-env-sample_default" with the default driver
Creating docker-compose-env-sample_nginx_1 ... done

$ docker-compose ps
              Name                             Command               State         Ports
-----------------------------------------------------------------------------------------------
docker-compose-env-sample_nginx_1   /docker-entrypoint.sh ngin ...   Up      0.0.0.0:82->80/tcp

能够看到应用 env 申明的变量 nginx_port=82 批改了容器的端口映射。尽管 env 反对多条键值对,但实在环境里变量较多、变量值简短,尽管能够通过 bash 脚本来治理,但可读性、可维护性太差,所以 docker-compose 提供了基于文件的环境变量机制。

.env 文件

浏览认真的同学看到命令起始语句 rm .env 时可能心生疑难,这便是反对的基于文件的环境变量机制,它寻找 docker-compose.yml 文件同目录下的 .env 文件,并将其解析成环境变量,以影响 docker-compose 的启动行为。

咱们应用以下命令生成多行键值对作为 .env 文件内容,留神 >>> 的差别

$ echo 'nginx_log=./log' > .env
$ echo 'nginx_port=83' >> .env
$ cat test
nginx_log=./log
nginx_port=83

重新启动并查看利用,能够看到新的端口映射失效了。

$ docker-compose down
Removing docker-compose-env-sample_nginx_1 ... done
Removing network docker-compose-env-sample_default

$ docker-compose up -d
WARNING: The Docker Engine you're using is running in swarm mode.

Compose does not use swarm mode to deploy services to multiple nodes in a swarm. All containers will be scheduled on the current node.

To deploy your application across the swarm, use `docker stack deploy`.

Creating network "docker-compose-env-sample_default" with the default driver
Creating docker-compose-env-sample_nginx_1 ... done

$ docker-compose ps
              Name                             Command               State         Ports
-----------------------------------------------------------------------------------------------
docker-compose-env-sample_nginx_1   /docker-entrypoint.sh ngin ...   Up      0.0.0.0:83->80/tcp

通过 .env 文件的应用,咱们能将相干配置管理起来,升高了复杂度。

env_file

即使利用曾经打包,咱们依然有动静配置的需要,比方 aspnet core 程序应用 ASPNETCORE_ENVIRONMENT 管制异样显示、postgresql 应用 POSTGRES_USER 和 POSTGRES_PASSWORD 传递凭据。由前文可知咱们能够将变量存储在额定的 env 文件中,但业务应用的环境变量与 compose 文件混淆在一起并不是很好的实际。

比方咱们有用于微信登录和反对的站点,它带来大量的配置变量,可能的 compose 文件内容如下:

version: '3'

networks:
  default:
  
services:
  pay:
    image: mcr.microsoft.com/dotnet/core/aspnet:3.1
    volumes:
      - ${site_log}:/app # 日志门路
      - ${site_ca}: /ca  # 领取证书
    working_dir: /app
    environment: 
      - ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT}
      - redis: ${redis}
      - connection_string: ${connection_string}
      - wechat_app_id: ${wechat_app_id}
      - wechat_app_secret: ${wechat_app_secret}
      - wechat_mch_app_id: ${wechat_mch_app_id}
    entrypoint: ['dotnet', 'some-site.dll']
    ports: 
      - ${site_port}:80

  mall:
    image: openjdk:8-jdk-alpine
    environment:
      - ?
    # 疏忽

真实情况下配置项可能更多,这应用 compose 文件简短,带来各种治理问题。对此 compose 文件反对以 env_file 简化配置,参考 compose-file/#env_file,咱们能够应用独自的文件寄存和治理 environment 选项。

-    environment: 
-      - ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT}
-      - redis: ${redis}
-      - connection_string: ${connection_string}
-      - wechat_app_id: ${wechat_app_id}
-      - wechat_app_secret: ${wechat_app_secret}
-      - wechat_mch_app_id: ${wechat_mch_app_id}
+    env_file:
+      - pay_env   

至此咱们能够将系统配置与业务配置拆散。env_file 应用和 .env 机制类似,不再赘述。

docker stack

和 docker-compose 比起来,docker stack 带来了诸多变动。

  • 从技术上来说,docker-compose 应用 python 编写,而 docker stack 是 docker engine 的一部分。前者只是单机实用,后者带来了 swarm mode,使可能分布式部署 docker 利用。尽管不能疏忽 Kubernetes 的存在,但 docker swarm 提供必要个性时放弃了足够轻量。
  • 从跨平台需要来说,docker-compose 目前只散发了 x86_64 版本,docker stack 无此问题。

不反对基于文件的环境变量

能够看到 docker stack 是 docker-compose 的代替,但 在 compose 文件规格上,docker-compose 与 docker stack 有显著差别,后者不反对基于文件的环境变量,但反对容器的 env_file 选项,咱们应用 docker stack 对前文的示例进行测试。

$ rm .env
$ docker stack deploy -c docker-compose.yml test
Creating network test_default
Creating service test_nginx

$ docker service ls
ID                  NAME                MODE                REPLICAS            IMAGE               PORTS
4np70r5kl01m        test_nginx          replicated          0/1                 nginx:latest        *:81->80/tcp

$ docker stack rm test
Removing service test_nginx
Removing network test_default

$ env nginx_port=82 docker stack deploy -c docker-compose.yml test
Creating network test_default
Creating service test_nginx

$ docker service ls
ID                  NAME                MODE                REPLICAS            IMAGE               PORTS
jz16fgu76btp        test_nginx          replicated          0/1                 nginx:latest        *:82->80/tcp

$ echo 'nginx_port=83' > .env
$ docker stack rm test
Removing service test_nginx
Removing network test_default

$ docker stack deploy -c docker-compose.yml test
Creating network test_default
Creating service test_nginx

$ docker service ls
ID                  NAME                MODE                REPLICAS            IMAGE               PORTS
4lmoexqbyexc        test_nginx          replicated          0/1                 nginx:latest        *:81->80/tcp

能够看到 docker stack 并不反对基于文件的环境变量,这会使得咱们开倒车增加了 exportsourceenv 的 bash 脚本和部署吗?

envsubst

envsubst 是 Unix/Linux 工具,CentOS 装置命令为 yum install -y gettext,它反对将模板内容中的占位变量替换成环境变量再输入后果,文件 docker.yml 蕴含了两个变量 redis_tagredis_port,咱们用作示例演示 envsubst 的能力。

$ cat docker.yml
version: '3'

services:
  redis:
    image: redis:${redis_tag}
    ports:
      - ${redis_port}:6379

咱们应用 env 提供环境变量,将文件 docker.yml 提供给 envsubst

$ env redis_tag=6.0.5 redis_port=6379 envsubst < docker.yml
version: '3'

services:
  redis:
    image: redis:6.0.5
    ports:
      - 6379:6379

能够看到 redis_tagredis_port 被替换成变量值,envsubst 就像 aspnet razor 一样把输出参数当作模板解析进去了。聪慧的你马上可能理解能够行部署构造与步骤:

  1. 提供基于变量的 compose 文件
  2. 提供差异化的环境变量文件
  3. 须要部署时,应用 envsub 填充 / 解析 compose 文件,作为具体的运行文件

一个可行的目录构造如下:

$ tree .
.
├── develop.env
├── docker.debug.yml
├── docker.production.yml
├── docker.yml
└── production.env

0 directories, 5 files

该目录中,docker.debug.yml 和 docker.production.yml 是模板解析的输入文件,用于具体部署。为了生成该文件,咱们能够应用 bash 脚本解析 develop.env 或 production.env,用于为 envenvsubst 提供参数,Parse a .env (dotenv) file directly using BASH 既是相干探讨,能够看到花样百出的解析方法。而对 envsubst 的进一步理解,我意识到它的规定有些许困惑:

  • 默认应用零碎环境变量下;
  • 未提供参数列表时,所有变量均被解决,查找失败的变量被当作空白字符;
  • 提供参数列表时,跳过没有列出的变量,查找失败的变量被疏忽并放弃原样;

为了改良,这里额定进行了 python 实现。

envsubst.py

envsubst.py 代码仅 74 行,可见于文章开端,它基于以下指标实现。

  • [x] 零依赖
  • [x] 反对行内键值对
  • [x] 反对基于文件的键值对
  • [x] 反对手动疏忽内部环境变量
  • [x] 反对行内模板输出
  • [x] 反对基于文件的模板输出
  • [] 严格模式

1. 应用行内键值对

$ python envsubst.py --env user=root password=123456 -i '${OS} ${user}:${password}'
Windows_NT root:123456

2. 疏忽环境变量

$ python src/envsubst.py --env user=root password=123456 --env-ignore -i '${OS} ${user}:${password}'
${OS} root:123456

3. 应用基于文件的环境变量

$ echo 'OS=macOS' > 1.env
$ python src/envsubst.py --env-file 1.env -i '${OS} ${user}:${password}'
macOS ${user}:${password}

4. 应用文本内容作为输出参数

$ echo '${OS} ${user}:${password}' > 1.yml
$ python src/envsubst.py --env-file 1.env -f 1.yml
macOS ${user}:${password}

至此咱们的能力被大大加强,应用 envsubst.py 能够实现以下性能:

  • 实现基于文件的环境变量解析,联合 env 命令实现 docker stack 应用;
  • 联合环境变量转换各种模板内容,像 compose 文件、系统配置等,间接应用转换后的内容。

envsubst.py 关注易于应用的变量提供与模板解析,为放弃简略有以下限度:

  • 变量记法$user${user} 在 bash 脚本和 envsubst 中均无效,为防止复杂度和代码量晋升,未予反对;
  • envsubst 中形如 ${nginx_ports:-81}:80的默认值写法等个性,未予反对。

当然你能够基于该逻辑进行基于文件的键值对解析,再配合 envsubstenv 工作,这齐全没有问题,也没有难点,就不再赘述。

业务中的环境变量

尽管各业务如何应用环境变量是其本身逻辑,但在看到许多 anti-pattern 后我认为相干内容仍值得形容,因为以下事实存在:

  • 各种业务零碎的配置形式不统一,第三方组件依赖的配置模式不同,比方少数 aspnet dotnet 利用应用 json 文件进行配置,java 利用应用相似 ini 格局的 properties 文件进行配置,node 利用和 SPA 前端形式更多无奈开展。
  • 业务复杂度各不相同,出于便于管理的须要,有些配置被分拆成多个零散文件;

因为业务的差异性与复杂度的客观存在,而开发人员生而自由(笑),利用的配置形式切实难以枚举。这对于运维人员来说不异于劫难,在生产环境因配置不存在导致的事变亘古未有。尽管运维人员难辞其咎,但 开发人员有责任防止零散、简单、难以治理的配置形式

值得庆幸的是,环境变量是通用语言,少数利用都能够基于环境变量进行配置。以集成 elastic apm 的状况进行阐明,园友文章 应用 Elastic APM 监控你的.NET Core 利用 有所形容,咱们须要以下模式的 ElasticApm 配置:

{
  "ElasticApm": {
    "LogLevel": "Error",
    "ServerUrls": "http://apm-server:8200",
    "TransactionSampleRate": 1.0
  }
}

在部署到生产环境时,咱们须要告之运维同学:”xxxx.json 里有一个叫 ElasticApm 的配置项,须要把它的属性 ServerUrls 值批改到 http://10.xx.xx.xx:8200″, 联合前文形容,咱们看如何改良。

  1. 增加依赖 Microsoft.Extensions.Configuration.EnvironmentVariables 以启用基于环境的配置
  2. 增加 env_file,将 ElasticApm__ServerUrls=http://10.xx.xx.xx:8200 写入其中

仅此而已,咱们须要理解的内容是:如何增加环境变量,使可能笼罩 json 文件中的配置,文档 aspnetcore-3.1#environment-variables 具体阐明了应用办法:应用双下划线以映射到冒号,应用前缀以过滤和获取所须要环境变量

示例代码应用了 set 命令增加环境变量,和在 linux 和 cygwin 上应用 exportenv 成果雷同,留神它们不是必须步骤。

咱们应用以下控制台程序输入失效的配置信息:

static void Main(string[] args)
{var configuration = new ConfigurationBuilder()
        .AddJsonFile($"appsettings.json")
        .AddEnvironmentVariables(prefix: "TEST_")
        .Build();            
    Console.WriteLine("ElasticApm:ServerUrls = {0}", configuration.GetValue<String>("ElasticApm:ServerUrls"));
}

间接应用 dotnet run

$ dotnet run
ElasticApm:ServerUrls = http://apm-server:8200

$ env TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200 dotnet run
ElasticApm:ServerUrls = http://10.x.x.x:8200

在 docker 中运行

$ docker run --rm -it -v $(pwd)/bin/debug/netcoreapp3.1:/app -w /app mcr.microsoft.com/dotnet/core/runtime dotnet dotnet-environment-variables.dll
ElasticApm:ServerUrls = http://apm-server:8200

$ docker run --rm -it -e TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200 -v $(pwd)/bin/debug/netcoreapp3.1:/app -w /app mcr.microsoft.com/dotnet/core/runtime dotnet dotnet-environment-variables.dll
ElasticApm:ServerUrls = http://10.x.x.x:8200

$ echo 'TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200' > env
$ docker run --rm -it --env_file $(pwd)/env -v $(pwd)/bin/debug/netcoreapp3.1:/app -w /app mcr.microsoft.com/dotnet/core/runtime dotnet dotnet-environment-variables.dll

在 docker-compose 文件中运行

$ echo 'TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200' > env
$ cat docker-compose.yml | grep env
    env_file: ./env
    entrypoint: ['dotnet', 'dotnet-environment-variables-console-sample.dll']

$ docker-compose up
WARNING: The Docker Engine you're using is running in swarm mode.

Compose does not use swarm mode to deploy services to multiple nodes in a swarm. All containers will be scheduled on the current node.

To deploy your application across the swarm, use `docker stack deploy`.

Creating network "dotnet-environment-variables-console-sample_default" with the default driver
Creating dotnet-environment-variables-console-sample_dotnet_1 ... done
Attaching to dotnet-environment-variables-console-sample_dotnet_1
dotnet_1  | ElasticApm:ServerUrls = http://10.x.x.x:8201
dotnet-environment-variables-console-sample_dotnet_1 exited with code 0

在 docker stack 中运行

与 docker-compose 并无太大区别,只是控制台程序很快退出,无奈看到无效输入,应用 aspnet core 进行验证更适宜,不再赘述,至此咱们对运维人员的配置批改形容有了改良:

- 找到文件 xxxx.json 里有一个叫 ElasticApm 的配置项,把它的属性 ServerUrls 值批改到 http://10.xx.xx.xx:8200
+ 在文件 env 下增加记录 `TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200`

小结

本内容形容了基于 docker 部署的状况下环境变量的应用,对工具 envenvsubst 的应用进行了示例,并给出了 python 实现 envsubst.py,最初以 dotnet 利用对业务中如何应用环境变量并与 docker 集成进行了示范。

envsubst.py

import argparse
import logging
import os
import sys
from typing import Dict, Iterable


class EnvironmentContext:
    _args: Dict[str, str]

    def __init__(self, env_ignore: bool):
        if env_ignore:
            self._args = {}
        else:
            self._args = os.environ.copy()

    def update(self, args: Dict[str, str]):
        self._args.update(args)

    def transform(self, input: str) -> str:
        for k, v in self._args.items():
            # ${key} = value
            k2 = '${' + k + '}'
            input = input.replace(k2, v, -1)
        return input


def _parse_env_args(lines: Iterable[str]) -> Dict[str, str]:
    dict = {}
    for line in lines:
        arr = line.split('=', 1)
        assert len(arr) == 2, 'Arg"{}"invalid'.format(line)
        dict[arr[0]] = arr[1]
    return dict


def _parse_env_file(env_file: str) -> Dict[str, str]:
    dict = {}
    with open(env_file) as f:
        for num, line in enumerate(f):
            if line and not line.startswith('#'):
                arr = line.split('=', 1)
                assert len(arr) == 2, 'Arg"{}"invalid'.format(line)
                dict[arr[0]] = arr[1].strip().strip('"')
    return dict


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('--env', dest='env', type=str, nargs='*', required=False)
    parser.add_argument('--env-file', dest='env_file', action='store', required=False)
    parser.add_argument('--env-ignore', dest='env_ignore', help='ignore environment variables', action='store_true', required=False)
    parser.add_argument('-f', '--file', dest='file', action='store', required=False)
    parser.add_argument('-i', '--input', dest='input', action='store', required=False)

    if len(sys.argv) <= 2:
        parser.print_help()
    else:
        argv = parser.parse_args()
        context = EnvironmentContext(argv.env_ignore)
        if argv.env_file:
            env_args = _parse_env_file(argv.env_file)
            context.update(env_args)
        if argv.env:
            env_args = _parse_env_args(argv.env)
            context.update(env_args)

        input = argv.input
        if argv.file:
            with open(argv.file) as f:
                input = f.read()
        output = context.transform(input)
        print(output)

leoninew 原创,转载请保留出处 segmentfault.com

退出移动版