zoukankan      html  css  js  c++  java
  • 更优雅的配置:docker/运维/业务中的环境变量

    对于使用 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,使用过 nginx 的同学可能注意到其容器镜像文件 /docker-entrypoint.d/20-envsubst-on-templates.sh

    # cat /docker-entrypoint.d/20-envsubst-on-templates.sh | grep envsubst
    auto_envsubst() {
        echo >&3 "$ME: Running envsubst on $template to $output_path"
        envsubst "$defined_envs" < "$template" > "$output_path"
    auto_envsubst
    

    envsubst 支持将模板内容中的占位变量替换成环境变量再输出结果,文件 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 原创,转载请保留出处 www.cnblogs.com/leoninew

  • 相关阅读:
    XAMPP安装和配置
    Myeclipse下使用Maven搭建spring boot2.0项目
    activemq学习总结 (转)Java消息队列--ActiveMq 实战
    websocket学习总结
    Redis学习总结
    (转)使用OpenGL显示图像(七)Android OpenGLES2.0——纹理贴图之显示图片
    当网卡收到的包的目的地址是主机上另一个网卡的地址.arp总结
    当网卡收到一个包的目的地址是本主机其他接口的IP时.2
    网络设置中设置失败
    当网卡收到一个包的目的地址不是自己的地址时
  • 原文地址:https://www.cnblogs.com/leoninew/p/13516223.html
Copyright © 2011-2022 走看看