Skip to content

Gateway API

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

Ingress は入口を1つに束ねたが、証明書やホスト名まで振り分け規則と同じ場所に書く形だった。運用側とアプリ側の持ち物が混ざっているので、権限を分けられない。Gateway API は入口と規則を別のオブジェクトにする。分けたぶん繋ぎ方が要る。ここで、規則が親を指名し、入口が受け入れる名前空間を宣言する形になった。

この章で作るもの

Ingress は入口を1つに束ねて、ホスト名とパスで中へ振り分けた。振り分けの仕組みとしては、あれで足りている。足りなかったのは別のところだった。

1つのオブジェクトに全部が入っている。証明書、公開するホスト名、待ち受けるポート、そして /api をどのサービスへ向けるかの規則。前半はクラスタを運用する側の持ち物で、後半はアプリを作る側の持ち物になる。持ち主が違うものが同じ場所に書かれている。

これが困るのは、RBAC で権限を切ろうとしたときになる。権限はオブジェクトの種類に対して与えるものなので、同じオブジェクトの中で「この項目だけ書いてよい」とは言えない。アプリ側に Ingress の編集を許せば、証明書もホスト名も触れてしまう。許さなければ、規則を1行足すたびに運用側への依頼が要る。

Gateway API はここを分ける。入口は Gateway、振り分けは HTTPRoute。別のオブジェクトなので、別々に権限を切れる。

分けたぶん、繋ぎ方が問題になる。そしてその繋ぎ方が、この章でいちばん面白いところになる。

  名前空間 infra (運用側)              名前空間 team-a (アプリ側)

  ┌─ Gateway "public" ──────────┐     ┌─ HTTPRoute "web" ─────────┐
  │ listener https :443          │     │ parentRefs: [public] ─────┼──┐
  │   hostname: shop.example     │     │ hostname: shop.example    │  │
  │   allowedRoutes:             │     │ /      → web:80           │  │
  │     namespaces: [team-a] ────┼──┐  │ /api   → api:8080         │  │
  └──────────────────────────────┘  │  └───────────────────────────┘  │
                                    │                                 │
                受け入れる ─────────┘         指名する ───────────────┘
                            両方そろって、はじめて繋がる

  名前空間 team-b (別のチーム)

  ┌─ HTTPRoute "steal" ───────┐
  │ parentRefs: [public]      │  指名はできる。だが Gateway 側が
  │ /api → attacker:80        │  team-b を受け入れていないので繋がらない
  └───────────────────────────┘
どちらか片方の意思では繋がらない。指名と受け入れが揃ってはじめて繋がる

順に見ていく。

  1. 分けたのは権限のため: 表現力が足りなかったのではなく、持ち主が違うものが同居していた
  2. 繋ぐには双方の同意: 指名と受け入れの両方が要る。片側だけでは相乗りできない
  3. 順序が仕様にある: どの規則が勝つかが決まっているので、実装を変えても結果が変わらない

① 役割で分ける

まず、置くものを分ける:

go

// Listener は Gateway が待ち受ける1つの口。運用側の持ち物になる。
type Listener struct {
	Name     string
	Port     int
	Hostname string // "" はすべて、"*.example.com" のような形も書ける
	// AllowedFrom は受け入れる Route の名前空間。空なら Gateway と同じ名前空間だけ。
	AllowedFrom []string
	// AllowAll はすべての名前空間から受け入れる。
	AllowAll bool
}

// Gateway は入口。どのポートでどのホスト名を待つか、誰の規則を受け入れるかを持つ。
type Gateway struct {
	Name      string
	Namespace string
	Listeners []Listener
}

// Backend は振り分け先。Weight は同じ規則の中での取り分になる。
type Backend struct {
	Service string
	Port    int
	Weight  int
}

// Match は1つの当たり判定。指定した項目がすべて一致したときだけ当たる。
type Match struct {
	// PathType は "Exact" か "PathPrefix"。空なら "PathPrefix" として扱う。
	PathType string
	Path     string
	Headers  map[string]string
	Query    map[string]string
	Method   string
}

// Rule は当たり判定と振り分け先の組。
type Rule struct {
	Matches  []Match
	Backends []Backend
}

// HTTPRoute は振り分けの規則。アプリ側の持ち物になる。
type HTTPRoute struct {
	Name      string
	Namespace string
	// ParentRefs は繋ぎたい Gateway の名前。指名した側の同意になる。
	ParentRefs []string
	Hostnames  []string
	Rules      []Rule
}

Listener に入っているのが運用側の持ち物になる。どのポートで待つか、どのホスト名を公開するか、そして誰の規則を受け入れるか。HTTPRoute に入っているのがアプリ側の持ち物で、どのパスをどのサービスへ向けるか。

この分割は Ingress でも書けたはずのものだが、書けなかった。1つのオブジェクトである以上、権限が分けられないからだ。オブジェクトを分けることが、そのまま権限を分けることになっている。

BackendWeight が入っているのも、Ingress との違いになる。Ingress では複数の振り分け先へ比率で流すのは仕様の外で、実装ごとの annotation で書いていた。書けはするが、実装を変えると書き直しになる。ここでは型の一部になっている。

② 指名と受け入れ

繋がるかどうかの判定がこうなる:

go

// Attachment は 1 つの Route が 1 つの Gateway に繋がったかどうかの結果。
type Attachment struct {
	Route    string
	Gateway  string
	Listener string
	Attached bool
	Why      string // 繋がらなかった理由
}

// attach は Route を Gateway に繋げられるかを判定する。
//
// 判定は3つで、どれも「双方が同意しているか」を見ている。Route が親を指名して
// いること、Listener がその名前空間を許していること、ホスト名が重なっていること。
// 片方だけの意思では繋がらないので、他人の入口に勝手に相乗りできない。
func attach(g Gateway, r HTTPRoute) Attachment {
	a := Attachment{Route: r.Namespace + "/" + r.Name, Gateway: g.Name}

	named := false
	for _, p := range r.ParentRefs {
		if p == g.Name {
			named = true
			break
		}
	}
	if !named {
		a.Why = "Route が この Gateway を親に指名していない"
		return a
	}

	var lastWhy string
	for _, l := range g.Listeners {
		if !l.allows(g.Namespace, r.Namespace) {
			lastWhy = "Listener " + l.Name + " が名前空間 " + r.Namespace + " を受け入れていない"
			continue
		}
		if !hostsIntersect(l.Hostname, r.Hostnames) {
			lastWhy = "Listener " + l.Name + " のホスト名 " + orAny(l.Hostname) + " と重ならない"
			continue
		}
		a.Listener = l.Name
		a.Attached = true
		return a
	}
	a.Why = lastWhy
	if a.Why == "" {
		a.Why = "受け入れる Listener が無い"
	}
	return a
}

// allows は Listener がその名前空間の Route を受け入れるかを返す。
// 既定は「同じ名前空間だけ」で、開くには明示が要る。
func (l Listener) allows(gatewayNS, routeNS string) bool {
	if l.AllowAll {
		return true
	}
	if len(l.AllowedFrom) == 0 {
		return routeNS == gatewayNS
	}
	for _, ns := range l.AllowedFrom {
		if ns == routeNS {
			return true
		}
	}
	return false
}

// hostsIntersect は Listener のホスト名と Route のホスト名が重なるかを返す。
func hostsIntersect(listener string, routes []string) bool {
	if len(routes) == 0 {
		return true // Route がホストを指定しなければ Listener に従う
	}
	for _, h := range routes {
		if hostMatch(listener, h) || hostMatch(h, listener) {
			return true
		}
	}
	return false
}

// hostMatch は pattern が host を含むかを返す。pattern が空ならすべてを含む。
func hostMatch(pattern, host string) bool {
	if pattern == "" || pattern == host {
		return true
	}
	if len(pattern) > 2 && pattern[0] == '*' && pattern[1] == '.' {
		suffix := pattern[1:] // ".example.com"
		return len(host) > len(suffix) && host[len(host)-len(suffix):] == suffix
	}
	return false
}

func orAny(h string) string {
	if h == "" {
		return "(指定なし)"
	}
	return h
}

見ているのは3つで、どれも「双方が同意しているか」を確かめている。

Route が親を指名していること。これはアプリ側の意思で、勝手に自分の規則を他人の入口へ押し込まれない保証になる。

Listener がその名前空間を受け入れていること。これは運用側の意思で、勝手に相乗りされない保証になる。既定が「同じ名前空間だけ」になっているのが大事なところで、開くには明示が要る。閉じているほうを既定にするこの形は、後の NetworkPolicyRBAC の章にも繰り返し出てくる。

ホスト名が重なっていること。運用側が shop.example の入口だと言っているところに、admin.internal の規則を繋いでも意味が無い。

テストで、指名だけでは繋がらないこと、受け入れだけでも繋がらないことを別々に固定した。片側の意思では動かないことが、この設計の要点になる。

そして、繋がっていない Route の規則は振り分けに一切使われない。テストで、受け入れられていない名前空間の Route が /api により長く一致していても無視されることを固定した。一致の強さは、繋がっている中でしか意味を持たない。

③ 順序が仕様で決まっている

Ingress の悩みのもう1つが、どの規則が勝つかが実装ごとに違うことだった。同じ設定でも入れ替えると挙動が変わる。Gateway API は順序を仕様に書いた:

go

// Request は入ってきた1件。
type Request struct {
	Gateway string
	Host    string
	Path    string
	Method  string
	Headers map[string]string
	Query   map[string]string
}

// Result は振り分けの結果と、そこに至った理由。
type Result struct {
	Backend  Backend
	Route    string
	Found    bool
	Why      string
	Priority Priority // 勝った規則の特定度
}

// Priority は仕様で定められた優先順位を数値にしたもの。
// 大きいほど優先される。比較は上から順に見る。
type Priority struct {
	ExactHost  int // ホスト名が完全一致なら 1、ワイルドカードなら 0
	ExactPath  int // Exact なら 1、PathPrefix なら 0
	PathLen    int // パスの長さ
	HeaderHits int
	QueryHits  int
	Method     int
}

// beats は p が q より優先されるかを返す。上から順に見て、最初に差がついた
// ところで決まる。ここが仕様で決まっていることが Ingress との一番の違いになる。
func (p Priority) beats(q Priority) bool {
	for _, d := range [][2]int{
		{p.ExactHost, q.ExactHost},
		{p.ExactPath, q.ExactPath},
		{p.PathLen, q.PathLen},
		{p.Method, q.Method},
		{p.HeaderHits, q.HeaderHits},
		{p.QueryHits, q.QueryHits},
	} {
		if d[0] != d[1] {
			return d[0] > d[1]
		}
	}
	return false
}

上から順に見て、最初に差がついたところで決まる。ExactPathPrefix に勝つのは長さと関係なく、/apiExact/api/v2/usersPathPrefix に勝つ。この2つは比べる目盛りが違う。テストで、この逆転を固定した。

そして最後まで同点なら、Route の名前順になる。実物では作成時刻の古いほうが先で、それも同じなら名前順になる。書いた順は結果に影響しない。

振り分けの実装がこうなる:

go

type winner struct {
	route string
	rule  Rule
	prio  Priority
}

// Route は1件のリクエストを振り分ける。
//
// 繋がっている Route だけを見る。繋がっていない Route の規則は、どれだけ
// 一致していても使われない。これが役割を分けたことの意味になる。
func (c *Cluster) Route(req Request) Result {
	var best *winner
	for _, g := range c.gateways {
		if g.Name != req.Gateway {
			continue
		}
		for _, r := range c.routes {
			if !attach(g, r).Attached {
				continue
			}
			if !hostsIntersect(req.Host, r.Hostnames) {
				continue
			}
			for _, rule := range r.Rules {
				p, ok := matchRule(rule, r, req)
				if !ok {
					continue
				}
				cand := winner{route: r.Namespace + "/" + r.Name, rule: rule, prio: p}
				if best == nil || cand.prio.beats(best.prio) ||
					(!best.prio.beats(cand.prio) && cand.route < best.route) {
					b := cand
					best = &b
				}
			}
		}
	}
	if best == nil {
		return Result{Why: "どの規則にも当たらない"}
	}
	return Result{
		Backend:  c.pick(best.route, best.rule.Backends),
		Route:    best.route,
		Found:    true,
		Priority: best.prio,
	}
}

// matchRule は規則が当たるかを判定し、当たったときの特定度を返す。
// 規則の中の Matches は「どれか1つ当たればよい」で、その中で最も特定度の
// 高いものを採る。
func matchRule(rule Rule, r HTTPRoute, req Request) (Priority, bool) {
	var best Priority
	found := false
	for _, m := range rule.Matches {
		p, ok := matchOne(m, r, req)
		if !ok {
			continue
		}
		if !found || p.beats(best) {
			best = p
			found = true
		}
	}
	return best, found
}

func matchOne(m Match, r HTTPRoute, req Request) (Priority, bool) {
	var p Priority

	if m.PathType == "Exact" {
		if m.Path != req.Path {
			return p, false
		}
		p.ExactPath = 1
	} else if !prefixOf(m.Path, req.Path) {
		return p, false
	}
	p.PathLen = len(m.Path)

	if m.Method != "" {
		if m.Method != req.Method {
			return p, false
		}
		p.Method = 1
	}
	for k, v := range m.Headers {
		if req.Headers[k] != v {
			return p, false
		}
		p.HeaderHits++
	}
	for k, v := range m.Query {
		if req.Query[k] != v {
			return p, false
		}
		p.QueryHits++
	}

	// ホスト名の特定度。完全一致で書かれているほうが強い。
	p.ExactHost = 0
	for _, h := range r.Hostnames {
		if h == req.Host {
			p.ExactHost = 1
			break
		}
	}
	return p, true
}

// prefixOf はパスの前方一致を、区切りを跨がない形で判定する。
// "/api" は "/api/users" に当たるが "/apiary" には当たらない。
func prefixOf(prefix, path string) bool {
	if prefix == "" || prefix == "/" {
		return true
	}
	if len(path) < len(prefix) || path[:len(prefix)] != prefix {
		return false
	}
	return len(path) == len(prefix) || path[len(prefix)] == '/'
}

// pick は重みに従って振り分け先を選ぶ。
//
// 乱数を使わず、その規則に何件目かを数えて割り当てる。同じ順で投げれば
// 同じ結果になるので、比率が本当に守られているかをテストで確かめられる。
func (c *Cluster) pick(key string, backends []Backend) Backend {
	if len(backends) == 0 {
		return Backend{}
	}
	if len(backends) == 1 {
		return backends[0]
	}
	sorted := append([]Backend(nil), backends...)
	sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Service < sorted[j].Service })

	total := 0
	for _, b := range sorted {
		total += b.Weight
	}
	if total <= 0 {
		return sorted[0]
	}
	n := c.hits[key] % total
	c.hits[key]++
	acc := 0
	for _, b := range sorted[:len(sorted)-1] {
		acc += b.Weight
		if n < acc {
			return b
		}
	}
	return sorted[len(sorted)-1]
}

重み付き分岐に乱数を使っていない。規則ごとに何件目かを数えて割り当てるので、同じ順で投げれば同じ結果になる。テストで、90 対 10 の設定に 200 件投げると 180 対 20 になることを固定した。乱数だと比率が守られているかを確かめにくいが、数えていれば数えられる。

動かす

下のデモは、2つのチームの Route を1つの Gateway に繋ぐ。「team-b を受け入れる」を切り替えると、同じ Route が繋がったり繋がらなくなったりする。繋がった瞬間、/api の振り分け先が変わるのが見える。リクエストを投げると、どの規則がどの目盛りで勝ったかが表示される。

デモGateway API繋がった Route 1 / 2
Gateway public(infra) ・ hostname shop.example ・ allowedRoutes team-a
繋がり
team-a/web指名と受け入れが揃った。繋がっている
team-b/adminListener が名前空間 team-b を受け入れていない
振り分け
リクエスト勝った Route振り分け先勝った目盛り
GET /team-a/webweb path 1
GET / (x-internal: yes)team-a/webweb-internal path 1 ・header 1
GET /api/usersteam-a/webapi-stable path 4
GET /api/admin/resetteam-a/webapi-stable path 4
GET /api/admin/reset: team-b/admin も一致したが使われない(Listener が名前空間 team-b を受け入れていない)
重み付き分岐(/api/users を投げた実測)
stable 90 : canary 10 と宣言している ・ 合計 100 件
api-canary10 件
api-stable90 件
team-b の Route は Gateway を指名しているが、Listener が受け入れていないので繋がらない。 /api/admin/reset により長く一致していても、繋がっていなければ使われない

1つの Gateway に2つのチームの Route を繋ごうとしている。team-a は受け入れられていて、 team-b は既定では受け入れられていない。切り替えると、同じ Route が繋がったり繋がらなくなったりする。 振り分けの表は、勝った規則とその目盛りを並べたもの。パスの長さで決まる行と、 ヘッダの一致で決まる行がある。いちばん下は重み付き分岐の実測で、乱数を使っていないので 宣言した比率がそのまま出る。

設計の観点

  • オブジェクトの境界が権限の境界: 権限は種類に対して与えるので、分けたい単位でオブジェクトを分けるしかない
  • 繋がりは双方向の同意で: 片側だけで成立する参照は、必ずどちらかの侵害になる
  • 既定は閉じる: 名前空間をまたぐ受け入れは明示が要る。開いているほうが既定だと、増えるたびに事故が増える
  • 順序を仕様に書く: 実装依存の挙動は、移植のたびに検証をやり直すことになる
  • 拡張点を型で持つ: 重みもヘッダ一致も型の一部にある。annotation で足すと、実装を変えたときに落ちる
  • Ingressは消えない: 単純な用途では Ingress のほうが短く書ける。分けることには手間の代償がある

対照と実例

IngressGateway API
オブジェクト1つGateway と HTTPRoute に分かれる
権限の分割できない種類ごとに切れる
名前空間をまたぐできない双方の同意があればできる
規則の優先順位実装依存仕様で決まっている
重み付き分岐annotation型の一部
ヘッダやメソッドでの分岐annotation型の一部
HTTP 以外扱えないTCPRoute、GRPCRoute などがある

裏どり:

  • 役割は3つに分かれる: GatewayClass(実装の提供者)、Gateway(クラスタ運用者)、HTTPRoute(アプリ開発者)
  • ReferenceGrant: 名前空間をまたいで Service を参照するときも、参照される側の同意が要る。同じ形が別の場所にも出てくる
  • 優先順位の規定: ホスト名の一致、パスの一致(Exact が優先、次に長さ)、メソッド、ヘッダ数、クエリ数の順。同点は作成時刻の古い順
  • Ingress は非推奨ではない: 併存する。Gateway API は上位互換ではなく、別の分け方を提供するもの
  • service mesh との統合: GAMMA イニシアチブで、mesh 内の通信にも同じ型を使う方向に進んでいる

簡略化したこと

  • GatewayClass なし: どの実装が担うかは扱わない。入口は常に1種類として動く
  • TLS なし: 実物の Listener は証明書を持ち、TLS を終端する。分けたい動機の中心の1つ
  • 書き換えなし: パスの書き換え、ヘッダの追加、リダイレクトといったフィルタは扱わない
  • ReferenceGrant なし: Service の参照は同意なしに通る
  • 状態を書き戻さない: 実物は繋がったかどうかを Route の status に書き戻す。ここでは問い合わせて返すだけ
  • 作成時刻なし: 同点の解決は名前順だけで決める

参考資料