LoginSignup
37
31

More than 5 years have passed since last update.

Goアプリケーションの設定情報をTOML・環境変数に格納する

Last updated at Posted at 2018-08-20

Goでデータベースなどを扱うサービスを実装する際には、接続情報などの環境ごとの「設定情報」がありますが、今回はそれをどう扱うかについてです。

発表資料

本記事に関する発表資料はこちらです。

Go Applicationでの設定情報の扱い方

実現すること

接続情報を設定ファイルなどGoファイルから分離したい
アプリケーションを作るに当たり必須項目ですが、接続先やパスワードをGoの中にべた書きはだめなので、tomlやyamlファイルに分離したい。

パスワードといった秘匿性の高い情報をコードベースから分離したい
プロジェクトやチームによって異なるかもしれませんが、databaseのパスワードなどはコードベースからは分離して、本番環境の情報は一部のコアな開発者のみが知っている状態を実現したい。

上記を実現するに当たり、まず設定について考えるに当たり、Twelve-Factor Appを見ていきます。

Twelve-Factor App

Screen Shot 2018-08-20 at 13.07.25.png
https://12factor.net/ja/

Twelve-Factor Appとは、Webアプリケーションを使いやすい形でスケーラブルにするための方法論について、Herokuの方がまとめてくださっているものです。

現代では、ソフトウェアは一般にサービスとして提供され、Webアプリケーション や Software as a Service と呼ばれる。Twelve-Factor Appは、次のようなSoftware as a Serviceを作り上げるための方法論である。

その中で、「設定」について取り扱っている章があります。

Twelve-Factor App: 3.設定について

Screen Shot 2018-08-20 at 13.12.33.png
https://12factor.net/ja/config

Twelve-Factor Appでは 設定をコードから厳密に分離すること を要求し、Twelve-Factor Appは設定を 環境変数 に格納する方法を推奨しています。

合わせて、環境変数に設定情報を置くこと以外の方法についても言及しています。

バージョン管理システムにチェックインされない設定ファイルを使う

まず、Railsのconfig/database.ymlのようにバージョン管理システムにチェックインされない設定ファイルを使う方法について下記のように言及しています。

設定に対するもう1つのアプローチは、バージョン管理システムにチェックインされない設定ファイルを使う方法である。例として、Railsにおけるconfig/database.ymlがある。この方法は、リポジトリにチェックインされる定数を使うことに比べると非常に大きな進歩であるが、まだ弱点がある。設定ファイルが誤ってリポジトリにチェックインされやすいことと、設定ファイルが異なる場所に異なるフォーマットで散乱し、すべての設定を一つの場所で見たり管理したりすることが難しくなりがちであることである。その上、これらのフォーマットは言語やフレームワークに固有のものになりがちである。

「誤ってコードをチェックインしてしまう」・「設定ファイルの管理が難しくなる」と行った点を指摘しています。

設定をグループにまとめる

また、設定をdevelopmentproductionのように環境ごとのグループにまとめる方法については下記のように言及しています。

設定管理のもう1つの側面はグルーピングである。アプリケーションは設定を名前付きのグループ(しばしば“環境”と呼ばれる)にまとめることがある。グループは、Railsにおけるdevelopment、test、production環境のように、デプロイの名前を取って名付けられる。この方法はうまくスケールしない。アプリケーションのデプロイが増えるにつれて、新しい環境名(stagingやqa)が必要になる。さらにプロジェクトが拡大すると、開発者はjoes-stagingのような自分用の環境を追加する。結果として設定が組み合わせ的に爆発し、アプリケーションのデプロイの管理が非常に不安定になる。

新しい環境が増えた際のスケールのしにくさ・デプロイ管理の不安定さについて指摘しています。

今回どうするか

結論から言うと、今回のケースは一部Twelve-Factor Appの推奨方法を採用して、設定をコードから一部分離する方式をやります。具体的は下記のやり方を進めていきます。

アプリケーションを環境ごとのグループにまとめる

developmentlocalhostといった環境ごとにまとめて設定ファイルに格納します。こうした理由としては、デプロイの手順に、cp config.yml.localhost config.ymlといったものを入れずにビルドのみで完結したいという意図があります。

  • パスワードといった情報のみ環境変数に格納する

環境変数にすべての設定情報を入れて、設定をコードから厳密に分離することを検討しました。その際リスクとして、サーバー設定ミスや脆弱性などで環境変数が読まれすべての情報が漏洩する可能性を危惧しました。そのため、すべての情報を入れるのではなく、もともとコードベースから分離したい対象であった「パスワード」のみを環境変数に格納することにしました。

実装

以下 2点の方針のもと、実際の実装を見ていきます。

  • アプリケーションを環境ごとのグループにまとめる
  • パスワードといった情報のみ環境変数に格納する

また、今回の実装は以下のgithubリポジトリに全量公開しています。

今回扱う「設定情報」

今回のサンプルでは、以下のdatabase接続情報を扱います。

情報
 host  localhost
 user  sample_user
 database  sample
 password sample_password
port 3306

アプリケーションの基本的な流れ

今回のサンプルアプリケーションでは、主に下記のようなデータベースを利用するAPIサーバーを想定します。

main.go
// NewDB return database global connection handle.
func NewDB(conf DBConfig) (*sql.DB, error) {
    db, err := sql.Open(
        "mysql",
        fmt.Sprintf(
            "%s:%s@tcp(%s:%d)/%s",
            conf.User,
            conf.Password,
            conf.Host,
            conf.Port,
            conf.Name))
    if err != nil {
        return nil, err
    }
    if err := db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

func main() {
    var err error

    // Get configuration
    conf, err := config.NewConfig()
    if err != nil {
        panic(err.Error())
    }

    // Get database Handle
    db, err := NewDB(conf.DB)
    if err != nil {
        panic(err.Error())
    }
}

以後、NewDB関数に渡している、Config構造体に対して設定情報を格納するかについて書いていきます。これからの流れとして説明の都合上、「べた書き」→「設定ファイルに格納」→「環境変数に格納」という流れで進めます。

目次

  1. べた書きの実装をする
  2. 設定ファイルに格納する
  3. 環境変数にパスワードを格納

1. べた書きの実装をする

(githubでは、v0.1タグで公開しています。)

接続情報を扱うに当たり、configというパッケージを用意して以下のようにconfig構造体を返す実装をします。NewConfig内には最初の期待値として、べた書き部分で設定情報で書いています。

config
// DBConfig represents database connection configuration information.
type DBConfig struct {
    User     string
    Password string
    Host     string
    Port     int
    Name     string
}

// Config represents application configuration.
type Config struct {
    DB DBConfig
}

// NewConfig get configuration struct.
func NewConfig() (Config, error) {
    conf := Config{}

    conf.DB = DBConfig{
        User:     "sample_user",
        Password: "sample_password",
        Host:     "master_mysql",
        Port:     3306,
        Name:     "sample",
    }

    return conf, nil
}

以後、設定情報をコードから逃がすリファクタリングのためのテストを書いておきます。

config_test.go
func TestNewConfig(t *testing.T) {
    cases := []struct{
        name string
        expected config.Config
    }{
        {
            name: "localhost",
            expected: config.Config{
                DB: config.DBConfig{
                    User:     "sample_user",
                    Password: "sample_password",
                    Host:     "master_mysql",
                    Port:     3306,
                    Name:     "sample",
                },
            },
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            res, err := config.NewConfig()

            assert.Equal(t, nil, err)
            assert.Equal(t, c.expected.DB.User, res.DB.User)
            assert.Equal(t, c.expected.DB.Password, res.DB.Password)
            assert.Equal(t, c.expected.DB.Host, res.DB.Host)
            assert.Equal(t, c.expected.DB.Port, res.DB.Port)
            assert.Equal(t, c.expected.DB.Name, res.DB.Port)
        })
    }
}

次に、「設定情報」を設定ファイルに移動します。

2. 設定ファイルに格納する

(githubでは、v0.2タグで公開しています。)

設定情報を設定ファイルを移動します。設定ファイルには、TOMLファイルを利用しています。TOMLを利用するために、github.com/BurntSushi/tomlを使います。

今回、アプリケーションを環境ごとのグループにまとめるということを実現するに当たり、今後設定が増えていくことを考慮してそれぞれ環境ごとにファイルに分けています。

config/env/localhost.toml
[database]
user = "sample_user"
password = "sample_password"
host = "master_mysql"
port = 3306
name = "sample"

今回、一時的にパスワードもTOMLファイルの中に入れていますが次に環境変数に逃します。
先程、書いたconfigパッケージはTOMLファイルを利用するために下記のように書き換えます。

config/config.go
// DBConfig represents database connection configuration information.
type DBConfig struct { // toml内の名前を入れる
    User     string `toml:"user"` 
    Password string `toml:"password"`
    Host     string `toml:"host"`
    Port     int    `toml:"port"`
    Name     string `toml:"name"`
}

// Config represents application configuration.
type Config struct { // toml内の名前を入れる
    DB DBConfig `toml:"database"`
}

// NewConfig return configuration struct.
func NewConfig(path string, appMode string) (Config, error) {
    var conf Config

    confPath := path + appMode + ".toml" // tomlファイルを読み設定情報を取得
    if _, err := toml.DecodeFile(confPath, &conf); err != nil {
        return conf, err
    }

    return conf, nil
}

今回、tomlファイルがどこにあるかを求めるのに引数で渡しているのは、テスト時にNewConfig関数内で使いたいTOMLファイルがテスト用を使いたいと行ったケースの際を考慮して、外から渡せるようにするという意図です。

環境ごとに必要な情報に過不足があると行ったことが無いよう、上記で書いたユニットテストではアプリケーションで利用する設定ファイルを使ったテストを書いて保護します。

config/config_test.go
func TestNewConfig(t *testing.T) {
    cases := []struct {
        name     string
        appMode  string // どの環境かを示す
        expected config.Config
    }{
        {
            name:    "localhost",
            appMode: "localhost",
            expected: config.Config{
                DB: config.DBConfig{
                    User:     "sample_user",
                    Password: "sample_password",
                    Host:     "master_mysql",
                    Port:     3306,
                    Name:     "sample",
                },
            },
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            confDir := "./env/" // config_testから設定ファイルdirへの相対パス
            res, err := config.NewConfig(confDir, c.appMode)

            assert.Equal(t, nil, err)
            assert.Equal(t, c.expected.DB.User, res.DB.User)
            assert.Equal(t, c.expected.DB.Password, res.DB.Password)
            assert.Equal(t, c.expected.DB.Host, res.DB.Host)
            assert.Equal(t, c.expected.DB.Port, res.DB.Port)
            assert.Equal(t, c.expected.DB.Name, res.DB.Name)
        })
    }
}

configパッケージに対して、main関数では以下2点の処理を追加しています。

  • 設定ファイルへの実行ファイルからの相対パスを指定
  • 動作環境をAPP_MODEという形で環境変数に格納する
main.go
const confDir = "./config/env/" // 設定ファイルへの実行ファイルからの相対パスを指定

func main() {
    var err error

    appMode := os.Getenv("APP_MODE") // 動作環境をAPP_MODEという形で環境変数に格納する
    if appMode == "" {
        panic("failed to get application mode, check whether APP_MODE is set.")
    }

    // Get configuration
    conf, err := config.NewConfig(confDir, appMode) // 引数に渡す
    if err != nil {
        panic(err.Error())
    }

    // 以降続く...
}

3. 環境変数にパスワードを格納

(githubでは、v0.3タグで公開しています。)
最後に、パスワード情報をソースコードから分離します。

まず、tomlファイルからパスワードを削除

config/env/localhost.toml
[database]
user = "sample_user"
host = "master_mysql"
port = 3306
name = "sample"

configパッケージでは、パスワードの取得をtomlファイルから環境変数に変えます。

config/config.go
// DBConfig represents database connection configuration information.
type DBConfig struct {
    User     string `toml:"user"`
    Password string // tomlファイルから分離するためタグを削除
    Host     string `toml:"host"`
    Port     int    `toml:"port"`
    Name     string `toml:"name"`
}

// Config represents application configuration.
type Config struct {
    DB DBConfig `toml:"database"`
}

// NewConfig return configuration struct.
func NewConfig(path string, appMode string) (Config, error) {
    var conf Config

    confPath := path + appMode + ".toml"
    if _, err := toml.DecodeFile(confPath, &conf); err != nil {
        return conf, err
    }

    conf.DB.Password = os.Getenv("DB_PASSWORD") // 環境変数から読み込む

    return conf, nil
}

テストも修正します。(今回は説明の都合上テストから先に修正いますが、本来の流れでは、テストから直して実コードを修正する方が良さそうです。)

config/config_test.go
func TestNewConfig(t *testing.T) {
    cases := []struct {
        name     string
        appMode  string
        expected config.Config
    }{
        {
            name:    "localhost",
            appMode: "localhost",
            expected: config.Config{
                DB: config.DBConfig{
                    User:     "sample_user",
                    Password: "sample_password",
                    Host:     "master_mysql",
                    Port:     3306,
                    Name:     "sample",
                },
            },
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            confDir := "./env/"
            os.Setenv("DB_PASSWORD", c.expected.DB.Password) // 環境変数に期待するパスワードをセットする

            res, err := config.NewConfig(confDir, c.appMode)

            assert.Equal(t, nil, err)
            assert.Equal(t, c.expected.DB.User, res.DB.User)
            assert.Equal(t, c.expected.DB.Password, res.DB.Password)
            assert.Equal(t, c.expected.DB.Host, res.DB.Host)
            assert.Equal(t, c.expected.DB.Port, res.DB.Port)
            assert.Equal(t, c.expected.DB.Name, res.DB.Name)
        })
    }
}

テストを修正後、テストが通ればOKです。

test
go test ./config -v  
=== RUN   TestNewConfig
=== RUN   TestNewConfig/localhost
--- PASS: TestNewConfig (0.00s)
    --- PASS: TestNewConfig/localhost (0.00s)
PASS
ok      github.com/Khigashiguchi/go-config-example/src/config   0.019s

まとめ

  • Twelve-Factor Appの設定ファイルの思想を一部適用して、設定をコードから一部分離した
  • 接続情報を設定ファイルとして環境ごとにTOMLファイルに定義した
  • パスワードを環境変数に格納する方式にした
37
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
31