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 を受け入れていないので繋がらない
└───────────────────────────┘順に見ていく。
- 分けたのは権限のため: 表現力が足りなかったのではなく、持ち主が違うものが同居していた
- 繋ぐには双方の同意: 指名と受け入れの両方が要る。片側だけでは相乗りできない
- 順序が仕様にある: どの規則が勝つかが決まっているので、実装を変えても結果が変わらない
① 役割で分ける
まず、置くものを分ける:
// 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つのオブジェクトである以上、権限が分けられないからだ。オブジェクトを分けることが、そのまま権限を分けることになっている。
Backend に Weight が入っているのも、Ingress との違いになる。Ingress では複数の振り分け先へ比率で流すのは仕様の外で、実装ごとの annotation で書いていた。書けはするが、実装を変えると書き直しになる。ここでは型の一部になっている。
② 指名と受け入れ
繋がるかどうかの判定がこうなる:
// 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 がその名前空間を受け入れていること。これは運用側の意思で、勝手に相乗りされない保証になる。既定が「同じ名前空間だけ」になっているのが大事なところで、開くには明示が要る。閉じているほうを既定にするこの形は、後の NetworkPolicy や RBAC の章にも繰り返し出てくる。
ホスト名が重なっていること。運用側が shop.example の入口だと言っているところに、admin.internal の規則を繋いでも意味が無い。
テストで、指名だけでは繋がらないこと、受け入れだけでも繋がらないことを別々に固定した。片側の意思では動かないことが、この設計の要点になる。
そして、繋がっていない Route の規則は振り分けに一切使われない。テストで、受け入れられていない名前空間の Route が /api により長く一致していても無視されることを固定した。一致の強さは、繋がっている中でしか意味を持たない。
③ 順序が仕様で決まっている
Ingress の悩みのもう1つが、どの規則が勝つかが実装ごとに違うことだった。同じ設定でも入れ替えると挙動が変わる。Gateway API は順序を仕様に書いた:
// 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
}上から順に見て、最初に差がついたところで決まる。Exact が PathPrefix に勝つのは長さと関係なく、/api の Exact は /api/v2/users の PathPrefix に勝つ。この2つは比べる目盛りが違う。テストで、この逆転を固定した。
そして最後まで同点なら、Route の名前順になる。実物では作成時刻の古いほうが先で、それも同じなら名前順になる。書いた順は結果に影響しない。
振り分けの実装がこうなる:
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 の振り分け先が変わるのが見える。リクエストを投げると、どの規則がどの目盛りで勝ったかが表示される。
1つの Gateway に2つのチームの Route を繋ごうとしている。team-a は受け入れられていて、 team-b は既定では受け入れられていない。切り替えると、同じ Route が繋がったり繋がらなくなったりする。 振り分けの表は、勝った規則とその目盛りを並べたもの。パスの長さで決まる行と、 ヘッダの一致で決まる行がある。いちばん下は重み付き分岐の実測で、乱数を使っていないので 宣言した比率がそのまま出る。
設計の観点
- オブジェクトの境界が権限の境界: 権限は種類に対して与えるので、分けたい単位でオブジェクトを分けるしかない
- 繋がりは双方向の同意で: 片側だけで成立する参照は、必ずどちらかの侵害になる
- 既定は閉じる: 名前空間をまたぐ受け入れは明示が要る。開いているほうが既定だと、増えるたびに事故が増える
- 順序を仕様に書く: 実装依存の挙動は、移植のたびに検証をやり直すことになる
- 拡張点を型で持つ: 重みもヘッダ一致も型の一部にある。annotation で足すと、実装を変えたときに落ちる
- Ingressは消えない: 単純な用途では Ingress のほうが短く書ける。分けることには手間の代償がある
対照と実例
| Ingress | Gateway 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 に書き戻す。ここでは問い合わせて返すだけ
- 作成時刻なし: 同点の解決は名前順だけで決める
参考資料
- Gateway API — 役割の分割と、繋がりの成立条件
- HTTPRoute の仕様 — 優先順位の規定と重み付き分岐
- 実装: orchestration/gatewayapi