Skip to content

NetworkPolicy

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

既定では全部の Pod が全部の Pod に繋がる。そうでないと何も動かないからだが、本番では困る。web が乗っ取られたら、そこから db へ直行できてしまう。NetworkPolicy は繋いでよい相手をラベルの条件で宣言する。ここで既定が反転する。方針を1つ向けた瞬間、その Pod への通信は拒否が既定になる。許可を書くことが、同時に書かなかった分を閉じることになる。

この章で作るもの

ここまでの章は、Pod をどう作り、どこに置き、どう繋ぐかを扱ってきた。繋ぐところまではServiceがやったが、そこには誰が誰に繋いでよいかの話が無かった。

実際、既定では全部が全部に繋がる。web も、api も、db も、監視の agent も、互いに素通しになっている。これは意図された既定で、そうでないと何も動かない。ラベルも Service も無い状態から始めて、まず動くところまで持っていくには、通信が通っている必要がある。

だが本番では困る。web が乗っ取られたら、そこから db に直接繋がれる。db に必要なのは api からの接続だけで、web から繋がる理由はどこにもない。攻撃者から見れば、1つ入り込めば全部に手が届くということになる。

NetworkPolicy は、この「繋いでよい相手」をラベルの条件で宣言する。仕組みとしては素直な許可リストだが、1つだけ引っかかるところがある。既定の扱いだ。

  方針が無いとき                方針を db に1つ向けたとき

  web ──────┐                  web ──✕── db   ← 書かなかったので閉じた
             ├──→ db           api ──────→ db   ← 書いたので通る
  api ──────┘                  batch ✕── db   ← 書かなかったので閉じた
  batch ────┘
                                api への通信は?
  すべて通る(既定)               → api に方針が向いていないので既定のまま通る

  「許可を1つ足した」= 「その Pod への既定を拒否に落とした」
方針を1つ向けると、その Pod への既定が反転する。許可を書くことが、同時に書かなかった分を閉じることになる

順に見ていく。

  1. 既定は全通し: 方針が無いうちは全部繋がる。何も設定しないことは、何も守らないこと
  2. 方針を向けると既定が反転する: 選ばれた Pod への通信は既定で拒否になる
  3. 許可は足し算: どれか1つの規則が合えば通る。後から塞ぐことはできない

① 方針は受け側に付く

まず、宣言の形を作る:

go

// Pod は通信の端点。ラベルで選ばれる。
type Pod struct {
	Name   string
	labels map[string]string
}

// matches は Pod が selector をすべて満たすかを返す。
// 空の selector はすべてに一致する(全 Pod を指す書き方)。
func (p *Pod) matches(selector map[string]string) bool {
	for k, v := range selector {
		if p.labels[k] != v {
			return false
		}
	}
	return true
}

// Rule は「この条件の相手からなら通してよい」という許可1つ。
type Rule struct {
	From map[string]string // 送り元のラベル条件
	Port int               // 0 ならすべてのポート
}

// Policy はある条件の Pod への通信について、許可する相手を並べたもの。
//
// 向きに注意が要る。Selector は「守られる側」で、Rules は「入ってくる側」を
// 指す。つまり方針は受け側に付き、送り側には付かない。
type Policy struct {
	Name     string
	Selector map[string]string
	Rules    []Rule
}

PolicySelector が守られる側で、RulesFrom が入ってくる側になる。この向きが最初は分かりにくい。方針は受け側に付き、送り側には付かない。

なぜそうなっているかというと、守りたいのは受け側だからだ。db を守りたい人は db の担当者で、その人は「db に繋いでよいのは api だけ」と書ける。一方、web の担当者に「web は db に繋がない」と書かせても、それは web が正直であることに頼っている。乗っ取られた web は、自分に付けられた方針を守らない。守られる側に方針を置けば、送り側が何であっても効く。テストで、方針を向けた Pod への通信は止まるが、その Pod から出ていく通信は縛られないことを固定した。

RuleFrom が空の場合、すべての Pod に一致する。これは「全員を許可する」という書き方になり、実質的に既定を元に戻すことになる。ラベルの条件を空にすると全部に当たる、という規則は素直だが、意図せず全開にしてしまう書き方でもある。

② 既定が反転する

判定の本体は2段になっている:

go

// Verdict は1回の判定の結果と、その理由。
type Verdict struct {
	Allowed bool
	Reason  string
	Policy  string // 効いた方針の名前(既定で通った場合は空)
}

// Cluster は Pod と方針を持ち、通信の可否を判定する。
type Cluster struct {
	pods     map[string]*Pod
	policies []*Policy

	Allowed int
	Denied  int
	Log     []string
}

// New は空のクラスタを作る。方針が1つも無いうちは、すべて通る。
func New() *Cluster { return &Cluster{pods: map[string]*Pod{}} }

// AddPod は端点を1つ足す。
func (c *Cluster) AddPod(name string, labels map[string]string) *Pod {
	p := &Pod{Name: name, labels: labels}
	c.pods[name] = p
	return p
}

// AddPolicy は方針を1つ足す。この瞬間、選ばれた Pod への通信は
// 既定で拒否に変わる。許可を足したつもりが、同時に既定を落としている。
func (c *Cluster) AddPolicy(p *Policy) *Policy {
	c.policies = append(c.policies, p)
	c.logf("方針 " + p.Name + " を追加(選ばれた Pod への通信は既定で拒否になる)")
	return p
}

// 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
}

// Protected は dst が1つでも方針に選ばれているかを返す。
// 選ばれていれば既定は拒否、そうでなければ既定は許可になる。
func (c *Cluster) Protected(dst *Pod) bool {
	for _, pol := range c.policies {
		if dst.matches(pol.Selector) {
			return true
		}
	}
	return false
}

// Decide は src から dst の port への通信が通るかを判定する。
//
// 判定は2段になっている。まず dst を選ぶ方針が1つでもあるかを見る。
// 無ければ既定のまま通す。あれば既定は拒否に変わり、その方針のどれか1つが
// src を許可しているときだけ通す。許可は足し算で、1つでも合えば通る。
// 逆に言えば、方針を足しても他の方針で許されている通信は止まらない。
func (c *Cluster) Decide(src, dst *Pod, port int) Verdict {
	if !c.Protected(dst) {
		return Verdict{Allowed: true, Reason: dst.Name + " に向けられた方針が無いので既定で通る"}
	}
	for _, pol := range c.policies {
		if !dst.matches(pol.Selector) {
			continue
		}
		for _, r := range pol.Rules {
			if !src.matches(r.From) {
				continue
			}
			if r.Port != 0 && r.Port != port {
				continue
			}
			return Verdict{Allowed: true, Policy: pol.Name,
				Reason: pol.Name + " が " + src.Name + " からの接続を許可している"}
		}
	}
	return Verdict{Reason: dst.Name + " は方針で守られていて、" + src.Name + " を許可する規則が無い"}
}

// Connect は判定したうえで数を記録する。
func (c *Cluster) Connect(srcName, dstName string, port int) Verdict {
	src, dst := c.pods[srcName], c.pods[dstName]
	if src == nil || dst == nil {
		return Verdict{Reason: "端点が見つからない"}
	}
	v := c.Decide(src, dst, port)
	if v.Allowed {
		c.Allowed++
	} else {
		c.Denied++
		c.logf(srcName + " → " + dstName + ":" + itoa(port) + " を遮断(" + v.Reason + ")")
	}
	return v
}

Decide はまず Protected を見る。宛先を選ぶ方針が1つでもあるかどうか。無ければ既定のまま通す。あれば既定は拒否に変わり、そのうえで許可する規則を探す。

ここが、この仕組みでいちばん間違えやすいところになる。「db は api からだけ受ける」という方針を1つ書いたとき、書いた人の意識は「api を許可した」にある。だが実際に起きているのは「db への通信を既定で拒否にして、そのうえで api だけを許可した」になる。許可を1つ足す操作が、同時に他の全部を閉じている。

これは強力で、危険でもある。強力なのは、許可を1つ書けば残りが自動的に閉じるからだ。閉じたいものを列挙する必要がない。危険なのは、書いた覚えのない通信まで一緒に閉じるからだ。監視の agent が db を叩いていたなら、それも止まる。方針を1つ足したあと、想定していなかったものが壊れる、という形の事故になる。テストで、db に方針を向けると許可した api だけが通り、他は塞がることを固定した。

方針が向いていない Pod は既定のままである点も大事になる。db に方針を書いても、api への通信は何も変わらない。方針は Pod 単位で効き、クラスタ全体の既定を変えるわけではない。だから段階的に閉じていける。

③ 許可は足し算で、後から塞げない

規則が複数あるときの扱いも決めておく。Decide は宛先を選ぶ方針を順に見て、どれか1つでも src を許可していれば通す。つまり許可は足し算になる。

この性質から、1つ言えることがある。後から方針を足して、既に通っている通信を塞ぐことはできない。「全員許可」の方針が1つあると、そこへ「api だけ許可」を足しても、web からの通信は前者で通ってしまう。塞ぎたいなら、通していた方針のほうを消すしかない。テストで、全許可の方針が残っているかぎり後から足した方針で塞げないことを固定した。

規則を1つも持たない方針も書ける。これは「何も許可しない」であり、その Pod への通信を完全に止める。既定を反転させるだけで、許可を何も足さない書き方になる。まずこれで全部閉じてから、必要な通信を1つずつ足していく、という進め方ができる。テストで、規則の無い方針がすべての通信を止めることを固定した。

判定は方針の集合から計算されるだけで、通信の記録には依存しない。同じ方針の集合なら、いつ判定しても同じ結果になる。この編で繰り返し出てきた level-triggered の性質が、ここにもある。

動かす

下のデモは、4つの Pod の全組み合わせについて可否を表にする。最初は方針が無いので全部「通る」になっている。db-allow-api を有効にすると、db の列だけが閉じて api からの1本が残る。api の列は方針が向いていないので何も変わらない。マスを押すと、そう判定された理由が出る。

デモNetworkPolicy通る経路 12 / 12
方針(クリックで有効・無効)
判定表(行=送り元、列=宛先)
→ web 既定のまま → api 既定のまま → db 既定のまま → batch 既定のまま
web →通る通る通る
api →通る通る通る
db →通る通る通る
batch →通る通る通る
方針が1つも無いので、全部が全部に繋がる。web が乗っ取られたら db まで直行できる

最初は方針が無く、判定表は全部「通る」になっている。これが既定で、そうでないと何も動かないからそうなっている。 db-allow-api を有効にすると、db の列だけが閉じて api からの1本だけが残る。許可を1つ書いた瞬間に、 書かなかった分がすべて閉じている。方針は受け側に付くので、db に方針を向けても db から出ていく通信は縛られない。 db-deny-all は規則を1つも持たない方針で、これを足しても他の許可は消えない。許可は足し算で、後から塞ぐことはできない。

設計の観点

  • 既定を反転させる操作として読む: 「許可を書く」でなく「その Pod への既定を拒否に落として、そのうえで許可を書く」と読むと、何が閉じるかを見落としにくい
  • 守られる側に置く: 送り側の善意に頼る制御は、乗っ取られた相手に効かない。受け側に置けば、送り側が何であっても効く
  • まず全部閉じてから開ける: 規則の無い方針で閉じ、必要な通信を1つずつ足す。閉じたいものを列挙するより、開けたいものを列挙するほうが漏れにくい
  • 足せても引けない: 許可は足し算なので、広い許可が1つ残っているとそれが効き続ける。塞ぐには広いほうを消すしかない
  • 段階的に閉じられる: 方針は Pod 単位で効くので、クラスタ全体を止めずに1つずつ絞れる。既存の通信を壊さずに進める道がある
  • Serviceとの層の違い: Service は「どこへ繋ぐか」を解決し、こちらは「繋いでよいか」を判定する。仮想 IP を経由しても、実際の宛先に対してこの判定が効く

対照と実例

送り側で制御する受け側で制御する(NetworkPolicy)
書く人呼ぶ側の担当者守る側の担当者
乗っ取られたら効かない効く
相手が増えたら呼ぶ側を全部直す受け側の許可を1つ足す
既定変わらない方針を向けると拒否に反転
漏れ方書き忘れた送り元が通る書き忘れた通信が止まる

裏どり:

  • NetworkPolicy: ラベルで守る対象と許可する相手を宣言する。方針が向いた Pod は既定が拒否に変わる
  • 既定は全通し: 方針が1つも無い namespace では、すべての Pod が互いに通信できる
  • 許可の合成: 複数の方針が同じ Pod を選んだ場合、許可は和になる。拒否を後から足すことはできない。そして通信が通るには、送る側の egress と受ける側の ingress の両方が許している必要がある。片方だけ開けても通らない
  • 実装はプラグイン側: NetworkPolicy 自体は宣言で、実際に遮断するのは Calico や Cilium などのネットワークプラグイン。プラグインによっては対応していない

簡略化したこと

  • 入ってくる側のみ: 実物は IngressEgress の両方を書ける。ここは入ってくる側だけ
  • namespace セレクタなし: 実物は別の namespace からの通信も条件にできる
  • IP 範囲なし: 実物はクラスタ外の IP 範囲も条件に書ける
  • プロトコルなし: TCP / UDP の区別は扱わない
  • 判定のみ: 実際の遮断はネットワークプラグインが行う。ここでは可否の計算だけ

参考資料