Skip to content

ConfigMapとSecret

実装: orchestration/config/ / 実行: go test ./orchestration/config/

設定をイメージに焼き込むと、環境ごとに別のイメージが要る。外に出せば同じイメージを使い回せる。出し方は環境変数かファイルかの2つだが、この2つは見た目が似ているのに更新の振る舞いがまったく違う。環境変数は起動時に写し取られるので後から変わらない。設定を変えたのに反映されない、という形で表に出るのがこの違いになる。

この章で作るもの

設定をイメージに焼き込むと、環境ごとに別のイメージが要る。開発と本番で中身の違うイメージを配ると、本番で動くものをテストしたことにならない。だから設定は外に出して、同じイメージに別の設定を渡す。

出し方は2つある。環境変数として渡すか、ファイルとして見せるか。どちらでも「設定を外から渡す」という目的は果たせるので、好みの問題に見える。

だがこの2つは、更新のときの振る舞いがまったく違う。そして、その違いを知らずに使うと、設定を変えたのに反映されないという形で表に出る。ConfigMap を書き換えたのに古い値で動き続ける Pod は、たいてい環境変数で受け取っている。

  ConfigMap app-config
   log_level: info → debug に書き換える

        ├─ 環境変数として渡した Pod
        │    起動時に値を写し取っている
        │    → info のまま。届かない

        └─ ファイルとして見せた Pod
             実体を見に行く
             → debug になる

  届けるには作り直すしかない
   → 作り直しは新しい Pod なので、起動時に今の値を写し取る
同じ設定を、同じように書き換えても、受け取り方だけで届くかどうかが変わる

順に見ていく。

  1. 写し取るか、見に行くか: 環境変数は起動時に確定し、ファイルは実体を参照する
  2. 反映するには作り直す: 環境変数で受け取ったものに、後から届ける手段はない
  3. Secret も仕組みは同じ: 違うのは扱いの慎重さで、仕組みとしての秘匿はほとんど無い

① 置き場を作る

まず、設定の置き場を作る:

go

// Kind は設定の種類。仕組みはほぼ同じで、扱いの慎重さだけが違う。
type Kind int

const (
	// ConfigMap は秘密でない設定。
	ConfigMap Kind = iota
	// Secret は秘密の設定。保存形式は違うが、暗号ではない。
	Secret
)

func (k Kind) String() string {
	if k == Secret {
		return "Secret"
	}
	return "ConfigMap"
}

// Entry は1つの設定。名前と、鍵と値の集まりを持つ。
type Entry struct {
	Name    string
	Kind    Kind
	data    map[string]string
	Version int // 書き換えるたびに上がる(観測用)
}

// Get は鍵の値を返す。
func (e *Entry) Get(key string) string { return e.data[key] }

// Keys は鍵を名前順に返す。
func (e *Entry) Keys() []string {
	out := make([]string, 0, len(e.data))
	for k := range e.data {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// Store は設定の置き場。
type Store struct {
	entries map[string]*Entry
	Log     []string
}

// NewStore は空の置き場を作る。
func NewStore() *Store { return &Store{entries: map[string]*Entry{}} }

// Put は設定を作るか、書き換える。書き換えると版が上がる。
func (s *Store) Put(name string, kind Kind, data map[string]string) *Entry {
	e, ok := s.entries[name]
	if !ok {
		e = &Entry{Name: name, Kind: kind, data: map[string]string{}}
		s.entries[name] = e
	}
	for k, v := range data {
		e.data[k] = v
	}
	e.Version++
	s.logf(kind.String() + " " + name + " を更新(版 " + itoa(e.Version) + ")")
	return e
}

// Get は設定を返す。
func (s *Store) Get(name string) *Entry { return s.entries[name] }

Entry が鍵と値の集まりを持ち、Version が書き換えのたびに上がる。この版が、後で「古いかどうか」を判定するのに効く。

Kind で ConfigMap と Secret を分けているが、PutGet も扱いは同じになっている。これは手抜きでなく、実物もそうだからだ。Secret は base64 で保存されるが、それは符号化であって暗号ではない。読める人が読めば読める。名前が示すほど守ってくれない、ということを知らずに使うほうが危ない。実際に秘匿したいなら、保存時の暗号化や、外部の秘密管理の仕組みを別に組む必要がある。テストで、Secret も ConfigMap と同じように読めることを固定した。

Put が差分であることにも意味がある。触れていない鍵は残る。全部を書き直さなくてよいので、1つの値だけを変えるのが簡単になる。

② 写し取るか、見に行くか

受け取り方の違いが、この章の中心になる:

go

// Cluster は置き場と Pod をまとめて持つ。
type Cluster struct {
	Store *Store
	pods  map[string]*Pod
	Log   []string
}

// New は空のクラスタを作る。
func New() *Cluster { return &Cluster{Store: NewStore(), pods: map[string]*Pod{}} }

// Start は Pod を起動する。この瞬間に、環境変数として渡す分が写し取られる。
//
// 写し取るという一語が、この章のほとんどを説明する。環境変数はプロセスに
// 渡された時点で値が確定し、置き場が後から変わっても届かない。届けるには
// プロセスを作り直すしかない。
func (c *Cluster) Start(name string, refs ...Ref) *Pod {
	p := &Pod{Name: name, refs: refs, env: map[string]string{},
		bornVersion: map[string]int{}, store: c.Store}
	for _, r := range refs {
		e := c.Store.Get(r.Entry)
		if e == nil {
			continue
		}
		p.bornVersion[r.Entry] = e.Version
		if r.Source == EnvVar {
			for _, k := range e.Keys() {
				p.env[k] = e.Get(k) // ここで写し取る
			}
		}
	}
	c.pods[name] = p
	c.logf(name + " を起動(環境変数は起動時の値を写し取る)")
	return p
}

// Restart は Pod を作り直す。写し取り直すので、環境変数にも今の値が入る。
func (c *Cluster) Restart(name string) *Pod {
	p, ok := c.pods[name]
	if !ok {
		return nil
	}
	c.logf(name + " を作り直す")
	return c.Start(name, p.refs...)
}

// Pods は Pod を名前順に返す。
func (c *Cluster) Pods() []*Pod {
	names := make([]string, 0, len(c.pods))
	for n := range c.pods {
		names = append(names, n)
	}
	sort.Strings(names)
	out := make([]*Pod, len(names))
	for i, n := range names {
		out[i] = c.pods[n]
	}
	return out
}

// Read は Pod から見えている値を返す。
//
// 環境変数なら起動時に写し取った値、ファイルなら今の置き場の値。
// 同じ設定を同じ Pod が読んでいるのに、受け取り方だけで結果が変わる。
func (p *Pod) Read(entry, key string) string {
	for _, r := range p.refs {
		if r.Entry != entry {
			continue
		}
		if r.Source == EnvVar {
			return p.env[key]
		}
		if e := p.store.Get(entry); e != nil {
			return e.Get(key) // 実体を見に行くので、今の値が返る
		}
	}
	return ""
}

// Stale は Pod が古い値を持ったままかを返す。
// 環境変数で受け取っていて、置き場のほうが新しければ古い。
func (p *Pod) Stale() bool {
	for _, r := range p.refs {
		if r.Source != EnvVar {
			continue
		}
		e := p.store.Get(r.Entry)
		if e != nil && e.Version > p.bornVersion[r.Entry] {
			return true
		}
	}
	return false
}

// Sources は Pod がどの設定をどう受け取っているかを返す。
func (p *Pod) Sources() []Ref { return append([]Ref(nil), p.refs...) }

Start の中に、この章のほとんどが入っている。SourceEnvVar のとき、p.env[k] = e.Get(k) で値を写し取る。この一行が、以降の振る舞いを決める。プロセスに環境変数が渡された時点で値は確定していて、置き場が後から変わっても、そのプロセスには届かない。

Read を見ると、対比がはっきりする。環境変数なら p.env を読み、ファイルなら p.store.Get(entry) で実体を見に行く。同じ設定を、同じ Pod が読んでいるのに、受け取り方だけで結果が変わる。テストで、書き換えた後にファイル側だけが新しい値を見ることを固定した。

なぜ環境変数だけ届かないのかというと、それが環境変数という仕組みの性質だからだ。プロセスの起動時に渡されるもので、外から書き換える手段が無い。Kubernetes の都合ではなく、その下の層の話になる。ファイルなら、実体を差し替えれば次に読んだときに新しい値が見える。だから届く。

Stale は、古い値を持ったままであることを検出する。置き場の版と、起動時に見えていた版を比べるだけの単純な判定だが、これがあると「なぜ反映されないのか」に気づける。実物ではこの検出は自動で行われないので、設定の変更で Pod を作り直す仕組みを別に組むことになる。テストで、環境変数で受け取っている側だけが古くなることを固定した。

③ 反映するには作り直す

Restart は、同じ参照でもう一度 Start を呼ぶだけになる。作り直せば、その時点の値を写し取り直すので、新しい値が入る。テストで、作り直せば新しい値になり、古い扱いも解けることを固定した。

これは回避策のように見えて、実は設定の外出しの一部になっている。設定が変わるということは、アプリの振る舞いが変わるということだ。動いている最中に振る舞いが変わると、変わる前と後の処理が混ざる。作り直せば、その Pod は一貫して新しい設定で動く。ローリング更新で版を入れ替えるのと同じ形で、設定の入れ替えができる。

ファイル経由なら作り直さずに届く、というのも万能ではない。値が変わった瞬間にアプリが気づくとは限らないからだ。起動時に一度だけ読むアプリなら、ファイルが変わっても読み直さない。届いているのに使われない。この場合も結局は作り直しになる。届くかどうかと、使われるかどうかは別の話になる。

動かす

下のデモは、同じ ConfigMap を env-pod は環境変数で、file-pod はファイルで受け取っている。書き換えると file-pod だけが新しい値を見る。env-pod は古いまま赤くなり、「作り直す」を押すと追いつく。

デモConfigMapとSecretConfigMap 版 1 ・ 古い Pod 0
ConfigMap を書き換える
ConfigMap app-config版 1
log_levelinfo
timeout30
env-pod環境変数で受け取る
log_levelinfo
timeout30
起動時に見えていた版 1
file-podファイルで受け取る
log_levelinfo
timeout30
起動時に見えていた版 1
両方が今の値を見ている。ファイル側は実体を見に行くので、書き換えればそのまま変わる
起きたこと
(まだ何も起きていない)

同じ ConfigMap を、env-pod は環境変数で、file-pod はファイルで受け取っている。書き換えると file-pod だけが 新しい値を見る。env-pod はプロセスに渡された時点で値が確定しているので、置き場が変わっても届かない。 設定を変えたのに反映されない、という形で表に出るのがこれで、直すには作り直すしかない。 Secret も仕組みは同じで、違うのは扱いの慎重さだけになる。仕組みとしての秘匿はほとんど無い。

設計の観点

  • 受け取り方は更新の設計: 環境変数かファイルかは好みでなく、更新をどう届けるかの選択になる。決めずに使うと、届かないことに後で気づく
  • 届くことと使われることは別: ファイルで届いても、アプリが読み直さなければ意味がない。読み直す作りにするか、作り直すかを決めておく
  • 作り直しは欠点ではない: 設定が変わればアプリの振る舞いも変わる。作り直せば、その Pod は一貫して新しい設定で動く
  • Secret は名前ほど守らない: 保存形式が違うだけで、仕組みとしての秘匿はほとんど無い。本当に秘匿したいなら別の層で組む
  • 設定の版を持つ: 版があれば、古い値で動いているものを見つけられる。無いと、なぜ反映されないのかを追えない
  • StatefulSetとの対比: あちらは Pod より長生きするボリューム、こちらは Pod と寿命を共にする環境変数。何と寿命を揃えるかが、そのまま振る舞いを決めている

対照と実例

環境変数ファイル
値が決まるときプロセスの起動時読んだとき
置き場を書き換えると届かない届く
反映するには作り直すそのまま(アプリが読み直せば)
向く相手変わらない設定動的に読み直せる設定
落とし穴変えたのに反映されない届いても読み直さない

裏どり:

  • ConfigMap と Secret: 仕組みはほぼ同じで、Secret は base64 で保存される。符号化であって暗号ではない
  • 環境変数は更新されない: 公式の文書も、環境変数として使った ConfigMap の更新は反映されないと明示している
  • ファイルは更新される: ボリュームとしてマウントした場合は反映される。ただし subPath を使うと反映されない
  • 反映の遅れ: ファイルの更新にも、kubelet の同期周期のぶんの遅れがある

簡略化したこと

  • 反映の遅れなし: 実物のファイル更新にも伝播の遅れがある。ここでは即座に見える
  • Secret の保存形式なし: base64 での保存は再現していない。暗号でない点は同じ
  • subPath なし: 実物で subPath を使うと、ファイルでも更新が届かなくなる
  • 参照の追跡なし: 設定の変更で Pod を作り直す仕組みは扱わない
  • 鍵の削除なし: 書き換えは足し算のみ

参考資料