1. 程式人生 > >更優雅的配置:docker/運維/業務中的環境變數

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

[TOC] 對於使用 docker/docker-compose/docker stack 進行開發、部署的使用者,可能會遇到以下問題 * 如何有效地區分 develop/staging/production 環境配置? * 如何有效應對在不同環境甚至差異的架構下部署的需求? 有經驗的同學知道環境變數是問題的答案,但本內容並不止於紙上談兵,而是結合 aspnet core 示例進行說明,並給出 GNU 工具說明和進行 python 實現。 ## docker-compose 我們常常基於 compose 檔案進行部署,但純靜態的 compose 檔案可能無法滿足以下需求 * 為了從宿主機讀取資料或者從容器持久化資料,我們需要調整目錄掛載位置; * 為了避免埠衝突我們需要修改埠對映; ### 環境變數 docker-compose 支援環境變數,我們可以在 compose 檔案中加入動態元素來修改部分行為,一個使用變數進行目錄和埠對映的 compose 檔案如下: ```dockerfile 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]...]`,我們使用它進行演示。 ```bash $ 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 檔案內容,注意 *>* 和 *>>* 的差異 ```bash $ echo 'nginx_log=./log' > .env $ echo 'nginx_port=83' >> .env $ cat test nginx_log=./log nginx_port=83 ``` 重新啟動並檢查應用,可以看到新的埠對映生效了。 ```bash $ 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 檔案內容如下: ```yml 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](https://docs.docker.com/compose/compose-file/#env_file),我們可以使用單獨的檔案存放和管理 *environment* 選項。 ```diff - 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](https://docs.docker.com/engine/swarm/),使能夠分散式部署 docker 應用。雖然不能忽略 Kubernetes 的存在,但 docker swarm 提供必要特性時保持了足夠輕量。 * 從跨平臺需求來說,docker-compose 目前只分發了 x86_64 版本,docker stack 無此問題。 ### 不支援基於檔案的環境變數 可以看到 docker stack 是 docker-compose 的替代,但**在 compose 檔案規格上,docker-compose 與 docker stack 有顯著差異**,後者不支援基於檔案的環境變數,但支援容器的 *env_file* 選項,我們使用 docker stack 對前文的示例進行測試。 ```bash $ 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 *envsubst* 是 Unix/Linux 工具,CentOS 安裝命令為 `yum install -y gettext`,它支援文字內容,將模板中的佔位變數替換成環境變數再輸出結果,檔案 docker.yml 包含了兩個變數 *redis_tag* 和 *redis_port* ,我們用作示例演示 *envsubst* 的能力。 ```bash $ cat docker.yml version: '3' services: redis: image: redis:${redis_tag} ports: - ${redis_port}:6379 ``` 我們使用 *env* 提供環境變數,將檔案 docker.yml 提供給 *envsubst*。 ```bash $ 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 檔案,作為具體的執行檔案 一個可行的目錄結構如下: ```bash $ 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* 及 *envsub* 提供引數,[Parse a .env (dotenv) file directly using BASH](https://gist.github.com/judy2k/7656bfe3b322d669ef75364a46327836) 既是相關討論,可以看到花樣百出的解析辦法。而對 *envsubst* 的進一步瞭解,我認識到它的規則有些許困惑: * 預設使用系統環境變數下; * 未提供引數列表時,所有變數均被處理,查詢失敗的變數被當作空白字元; * 提供引數列表時,跳過沒有列出的變數,查詢失敗的變數被忽略並保持原樣; 為了改進,這裡額外進行了 python 實現。 ## envsubst.py *envsubst.py* 程式碼僅 74 行,可見於文章末尾,它基於以下目標實現。 - [x] 零依賴 - [x] 支援行內鍵值對 - [x] 支援基於檔案的鍵值對 - [x] 支援手動忽略外部環境變數 - [x] 支援行內模板輸入 - [x] 支援基於檔案的模板輸入 - [ ] 嚴格模式 ### 1. 使用行內鍵值對 ```bash $ python envsubst.py --env user=root password=123456 -i '${OS} ${user}:${password}' Windows_NT root:123456 ``` ### 2. 忽略環境變數 ```bash $ python envsubst.py --env user=root password=123456 --env-ignore -i '${OS} ${user}:${password}' ${OS} root:123456 ``` ### 3. 使用基於檔案的環境變數 ```bash $ echo 'OS=macOS' > 1.env $ python envsubst.py --env-file 1.env -i '${OS} ${user}:${password}' macOS ${user}:${password} ``` ### 4. 使用文字內容作為輸入引數 ```bash $ echo '${OS} ${user}:${password}' > 1.yml $ python 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應用](https://www.cnblogs.com/xiandnc/p/11480624.html) 有所描述,我們需要以下形式的 *ElasticApm* 配置: ```json { "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](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#environment-variables) 就詳細說明了使用方法:**使用雙下劃線以對映到冒號,使用字首以過濾和獲取所需要環境變數**。 > 示例程式碼使用了 *set* 命令新增環境變數,和在 linux 和 cygwin 上使用 *export* 或 *env* 效果相同,注意它們不是必須步驟。 我們使用以下控制檯程式輸出生效的配置資訊: ```c# static void Main(string[] args) { var configuration = new ConfigurationBuilder() .AddJsonFile($"appsettings.json") .AddEnvironmentVariables(prefix: "TEST_") .Build(); Console.WriteLine("ElasticApm:ServerUrls = {0}", configuration.