配置存放於環境:Go 應用的配置實踐

配置存放於環境:Go 應用的配置實踐
Photo by Josh Redd / Unsplash

在雲原生的環境中,程式通常採用容器部署,而不同環境間所需要的配置也會不同,像是開發環境的資料需要與生產環境分離;金絲雀部署要分流生產環境的流量,但不會寫資料到生產環境中;開發環境為了除錯,要印出 level 低的 log;開發環境跟生產環境要拿取的 key vault 的 key 跟 version 不同;等等。在雲原生的時代前,開發人員或維運人員通常會各自維護一份執行的程式,開發在開發環境中驗證後,交付維運部署上線,一次性處理好配置;但在雲原生時代,部署變得越來越頻繁,幾乎不太可能手動管理。這時要把問題倒過來想,不是因應開發出來的程式來設定配置,而是有沒有可能,因應部署會遇到的問題來設計開發?

雲原生常見的部署架構

Store config in the environment

Heroku 基於 SaaS(Software-as-a-Service) 實踐,歸納出 12 條雲原生應用的設計原則,稱為 The Twelve-Factor App,其中關於配置,Heroku 要求「代碼與配置分離」,讓一份代碼,可以部署在多個環境。在實踐上,硬編碼當然不是個選擇,但即使用配置檔來管理配置,也還是有一些問題:配置文件要放在哪個路徑呢?它的格式應該採用 JSON 還是 YAML?別忘了微服務有可能用不同語言來實現,不同語言都支援選用的格式嗎?

考量這些問題,Heroku 建議將配置放在環境變數中,這確實更符合雲原生的概念。讓我們假設一個場景,現在是發佈前夕,版本要上到金絲雀部署環境,SRE 需要建立容器後,用命令行進入容器內,修改配置檔,然後重啟容器程序。這個流程聽起來繁複且不太合理。如果好一點,SRE 將這些流程寫成腳本,讓腳本自動修改配置,那當配置檔的路徑或格式變更時,腳本也要跟著異動,這會產生摩擦力,阻止開發者對配置的修改。即使在最好的狀況下,SRE 在啟動容器時指定配置檔,也需要將配置檔事先放入 pod 中。可以看到,單單是路徑跟格式這兩點,就會生出複雜度。

相較下,環境變數是鍵值對結構,更清晰易讀,更重要的是,它更容易標準化,不會受到語言限制。假設 SRE 要在啟動容器時加入配置,他只需要使用 -e 之類的參數;或者更單純,使用 CI/CD Pipeline 管理環境變數,讓不同的 Job 自行取用。

使用 .env

然而從開發者的角度,這勢必關係到開發者體驗的異動。在原本的流程中,開發者可以用配置檔來管理配置,例如在本地環境與開發環境間切換。改成環境變數後,要切換環境需要改用命令行建立環境變數,不容易在專案中管理。

一個折衷的辦法是將環境變數寫成腳本導出,類似

# env.sh

export API_GATEWAY_ENV=dev
export API_GATEWAY_CORS_ALLOW_ORIGINS=https://api.gateway.com
export API_GATEWAY_LOG_LOG_LEVEL=0
export API_GATEWAY_LOG_PRETTY_OUTPUT=false

然後在執行程式前,先執行腳本導出環境變數,例如修改 makefile 為

all: api-gateway

api-gateway:
    source configs/env.sh
    go run main.go

這是從專案著手,解決開發配置問題。相應的,也要求所有專案都採用同樣的慣例,統一 makefile 的寫法。如果想進一步降低複雜度,使用 codebase 來解決開發配置的問題,應該要怎麼做呢?有沒有可能對應配置檔的寫法,讓程式啟動時從檔案讀取環境變數?這裡可以用上 joho/godotenv 這個第三方庫。

用法是執行 func 後載入環境變數,如果沒指定檔案,載入 .env,.env 在這裡扮演了類似配置檔的角色

package main

import (
    "log"
    "os"
    "github.com/joho/godotenv"
)

func main() {
    err := godotenv.Load()
    if err != nil {
    log.Fatal("Error loading .env file")
    }
    s3Bucket := os.Getenv("S3_BUCKET")
    secretKey := os.Getenv("SECRET_KEY")
    // now do something with s3 or whatever
}

godotenv 的原理是包裝了標準庫 Env 相關 func

func loadFile(filename string, overload bool) error {
    envMap, err := readFile(filename)
    // ...
    currentEnv := map[string]bool{}
    rawEnv := os.Environ()
    for _, rawEnvLine := range rawEnv {
        key := strings.Split(rawEnvLine, "=")[0]
        currentEnv[key] = true
    }
    for key, value := range envMap {
        if !currentEnv[key] || overload {
            os.Setenv(key, value)
        }
    }
    return nil
}

而 .env 的內容則是

# .env file

API_GATEWAY_ENV=dev
API_GATEWAY_CORS_ALLOW_ORIGINS=https://api.gateway.com
API_GATEWAY_LOG_LOG_LEVEL=0
API_GATEWAY_LOG_PRETTY_OUTPUT=false

如果環境變數已經存在,使用原本的環境變數,如果環境變數不存在,套用 .env 中的設定。

但這樣畢竟還是需要手動指定,如果我們想套用「約定優於配置」的原則,讓程式自動載入 .env 的話呢?在 godotenv 底下有個 package 可以用來自動載入

import _ "github.com/joho/godotenv/autoload"

利用的是 init() 的機制

func init() {
    godotenv.Load()
}

使用 .env 的另一個優點是,當開發者需要跟 SRE 溝通要設置的環境變數時,他們可以基於同一份檔案來討論。有必要的話,.env 中還可以加上範例跟註解,幫助 SRE 理解環境變數的用途。

Load environment variable as a struct

讓我們來看看在 Go 的應用程式中,要如何使用這些環境變數,當然,既然它已經是環境變數了,我們可以用標準庫的 os.Getenv 來讀取

envName := os.Getenv("API_GATEWAY_ENV");

可是假設,你有超過 10 個以上的環境變數,這會變成是一件苦差事,而且難以維護。如果可以,我們希望用一個 struct 來存放環境變數,讓程式在啟動直接讀進 struct,省下後面轉換的功夫

這裡要用到另一個庫 viper

viper 是設計來處理配置,要從環境變數載入配置,可以用

type LogConfig struct {
    Level        int    `yaml:"level" mapstructure:"level"`
    PrettyOutput string `yaml:"pretty_output" mapstructure:"pretty_output"`
}

type Config struct {
    Env             string    `yaml:"env" mapstructure:"env"`
    CorsAllowOrigin string    `yaml:"cors_allow_origin" mapstructure:"cors_allow_origin"`
    Log             LogConfig `yaml:"log" mapstructure:"log"`
}

func main() {
    b, _ := yaml.Marshal(&Config{})
    defaultConfig := bytes.NewReader(b)
    viper.SetConfigType("yaml")
    viper.MergeConfig(defaultConfig)
    viper.AutomaticEnv()
    viper.SetEnvPrefix("API_GATEWAY")
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    fmt.Println(viper.AllSettings())

    _config := &Config{}
    if err := viper.Unmarshal(&_config); err != nil {
        return
    }
    fmt.Println(_config)
}

坦白說,流程還是有些複雜。因為 viper 是採用覆寫的方式,所以前面要先設定個 yaml 格式的 config 當預設;再用 AutomaticEnv 跟 Unmarshal 把環境變數的值反序列化到 struct 內。為了跟其他環境變數區別,應用相關的環境變數最好加上前綴。

執行結果可以得到

go run main.go
&{dev [https://api.gateway.com](https://api.gateway.com) {0 true}}

viper v2 可能會讓流程更開發友善些,但在有更好的選擇前,可以先用這方式來降低讀取配置的維護成本。

小結

這篇嘗試從「Design for Operation」的角度梳理配置設計。不得不說環境變數是個相當漂亮的提案,小小的改動,大大的效益,既符合配置需求,又能簡化原本流程,讓概念變得更加清晰。我們可以注意到,在雲原生時代,有些責任會從原本的位置左移,而面對這樣的轉變,開發者也需要調整思維與手中的彈藥庫,嘗試從設計,而不是從工具來解決問題。

Reference

Read more

Weekly Issue 第 3 期:Cloudflare 宣布內容獨立日

最近用了很多 Cloudflare 的產品,像是 Zero Trust、WARP,還有 Cloudflare Tunnel。每次的體驗都讓我嘖嘖稱奇,好像它們預判了我的需求一樣。這家公司始終追求著「更好的網路」這個目標,內容付費又是另一個例子。 🗞️ 熱門新聞 Content Independence Day: no AI crawl without compensation! 賽博佛陀 Cloudflare 又來普渡眾生了。這次是針對 AI 爬蟲收費。 「網路正在改變。它的商業模式也將改變。在這個過程中,我們有機會從過去 30 年網路的優點中學習,並為未來的網路創造更好的環境。 」 Cloudflare 真的很有意思,連思考的角度都很有趣。 內容當然是有價的,只是價格會怎麼支付呢?在現代的內容創作,這題變得非常複雜。 Folklore.org: Joining Apple Computer

By Ken Chen

Weekly Issue 第 2 期:Linux 基金會啟動 FAIR 專案

有些產品看到會覺得行不通,有些產品則相反,只要聽到就覺得是個好主意。Sentry 的產品通常都是後者。我猜有部分,也是因為它們的產品都指向同一個使命:可除錯性。 🗞️ 熱門新聞 Linux Foundation Announces the FAIR Package Manager Project for Open Source Content Management System Stability Linux 基金會啟動 FAIR 專案,為 WordPress 外掛程式提供替代方案。 底下的 Supporting Quotes 可以看看,講話都很客氣,左一句「去中心化」右一句「透明的治理架構」,在講什麼大家都很清楚 😜 。 Uber 與 Airbnb 重塑 VC 玩法,一文看懂 a16z 創辦人

By Ken Chen

Weekly Issue 第 1 期:Stack Overflow 流量大跌

來自阮一峰老師的靈感與嘗試,我會在 Weekly Issue 中記錄每周值得分享的科技內容,周一發刊。多數內容都有刊在我的 X、Threads 或 Facebook 中,你可以追蹤上述社群媒體得到最新消息。這裡的性質更接近單周回顧與歷史歸檔。 🗞️ 熱門新聞 The Pulse #134: Stack overflow is almost dead StackOverflow 的情況比我想的還糟,退化到剛成立三個月的狀況?太要命了。 我自己好奇的是 2020 年的衰退如何引起?平台治理的問題嗎? Cloudflare service outage June 12, 2025 Cloudflare 近幾次中斷事故都有出報告,內容包括背景跟時間軸,還有改善方式,這是很正確也很重要的實踐。 我也曾經遇過幾次重要的服務停機事件,當時都會盡可能擠出時間即時更新 + 出報告。後來服務也的確越來越穩。這種問題很多都是文化層面的問題。 Ask HN: How

By Ken Chen