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 なので、起動時に今の値を写し取る順に見ていく。
- 写し取るか、見に行くか: 環境変数は起動時に確定し、ファイルは実体を参照する
- 反映するには作り直す: 環境変数で受け取ったものに、後から届ける手段はない
- Secret も仕組みは同じ: 違うのは扱いの慎重さで、仕組みとしての秘匿はほとんど無い
① 置き場を作る
まず、設定の置き場を作る:
// 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 を分けているが、Put も Get も扱いは同じになっている。これは手抜きでなく、実物もそうだからだ。Secret は base64 で保存されるが、それは符号化であって暗号ではない。読める人が読めば読める。名前が示すほど守ってくれない、ということを知らずに使うほうが危ない。実際に秘匿したいなら、保存時の暗号化や、外部の秘密管理の仕組みを別に組む必要がある。テストで、Secret も ConfigMap と同じように読めることを固定した。
Put が差分であることにも意味がある。触れていない鍵は残る。全部を書き直さなくてよいので、1つの値だけを変えるのが簡単になる。
② 写し取るか、見に行くか
受け取り方の違いが、この章の中心になる:
// 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 の中に、この章のほとんどが入っている。Source が EnvVar のとき、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 を、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 を作り直す仕組みは扱わない
- 鍵の削除なし: 書き換えは足し算のみ
参考資料
- ConfigMaps — 環境変数とボリュームでの更新の違い
- Secrets — base64 は暗号ではない旨と、保存時の暗号化
- 実装: orchestration/config