天天看點

更優雅的配置:docker/運維/業務中的環境變量

目錄

  • docker-compose
    • 環境變量
    • .env 檔案
    • env_file
  • docker stack
    • 不支援基于檔案的環境變量
  • envsubst
  • envsubst.py
    • 1. 使用行内鍵值對
    • 2. 忽略環境變量
    • 3. 使用基于檔案的環境變量
    • 4. 使用文本内容作為輸入參數
  • 業務中的環境變量
    • 直接使用 dotnet run
    • 在 docker 中運作
    • 在 docker-compose 檔案中運作
    • 在 docker stack 中運作
  • 小結

對于使用 docker/docker-compose/docker stack 進行開發、部署的使用者,可能會遇到以下問題

  • 如何有效地區分 develop/staging/production 環境配置?
  • 如何有效應對在不同環境甚至差異的架構下部署的需求?

有經驗的同學知道環境變量是問題的答案,但本内容并不止于紙上談兵,而是結合 aspnet core 示例進行說明,并給出 GNU 工具說明和進行 python 實作。

我們常常基于 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. 程序級别環境變量:可以使用 source 或 env 引入

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 提供了基于檔案的環境變量機制。

閱讀仔細的同學看到指令起始語句

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 檔案的使用,我們能将相關配置管理起來,降低了複雜度。

即便應用已經打包,我們仍然有動态配置的需求,比如 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-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 并不支援基于檔案的環境變量,這會使得我們開倒車添加了 export 或 source 或 env 的 bash 腳本和部署嗎?

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_tag 和 redis_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_tag 和 redis_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,用于為 env 及 envsubst 提供參數,Parse a .env (dotenv) file directly using BASH 既是相關讨論,可以看到花樣百出的解析辦法。而對 envsubst 的進一步了解,我認識到它的規則有些許困惑:

  • 預設使用系統環境變量下;
  • 未提供參數清單時,所有變量均被處理,查找失敗的變量被當作空白字元;
  • 提供參數清單時,跳過沒有列出的變量,查找失敗的變量被忽略并保持原樣;

為了改進,這裡額外進行了 python 實作。

envsubst.py 代碼僅 74 行,可見于文章末尾,它基于以下目标實作。

  • [x] 零依賴
  • [x] 支援行内鍵值對
  • [x] 支援基于檔案的鍵值對
  • [x] 支援手動忽略外部環境變量
  • [x] 支援行内模闆輸入
  • [x] 支援基于檔案的模闆輸入
  • [ ] 嚴格模式

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

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

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

$ 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

    的預設值寫法等特性,未予支援。

當然你可以基于該邏輯進行基于檔案的鍵值對解析,再配合 envsubst 或 env 工作,這完全沒有問題,也沒有難點,就不再贅述。

雖然各業務如何使用環境變量是其自身邏輯,但在看到許多 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 上使用 export 或 env 效果相同,注意它們不是必須步驟。

我們使用以下控制台程式輸出生效的配置資訊:

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
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 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
           

$ 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-compose 并無太大差別,隻是控制台程式很快退出,無法看到有效輸出,使用 aspnet core 進行驗證更适合,不再贅述,至此我們對運維人員的配置修改描述有了改進:

- 找到檔案 xxxx.json 裡有一個叫 ElasticApm 的配置項,把它的屬性 ServerUrls 值修改到 http://10.xx.xx.xx:8200
+ 在檔案 env 下添加記錄 `TEST_ElasticApm__ServerUrls=http://10.x.x.x:8200`
           

本内容描述了基于 docker 部署的情況下環境變量的使用,對工具 env 和 envsubst 的使用進行了示例,并給出了 python 實作 envsubst.py,最後以 dotnet 應用對業務中如何使用環境變量并與 docker 內建進行了示範。

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