Skip to content

Ingress

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

Service はクラスタの中で使う宛先だった。外からの入口を Service ごとに出すと、口がサービスの数だけできて、証明書もドメインもその数だけ要る。Ingress は入口を1つに束ね、中でホスト名とパスによって振り分ける。扱う単位が IP とポートから1つ上がり、そのぶん規則が重なるので、どれを優先するかを決める必要が出てくる。

この章で作るもの

Serviceは、クラスタの中で使う宛先だった。仮想 IP を1つ用意して、後ろの Pod へ振り分ける。だが外から来る利用者は、仮想 IP を知らないし、そもそもクラスタの中に入れない。外からの入口が別に要る。

素朴には、Service を1つずつ外へ出せばよさそうに見える。実際そういう方法もある。だが Service の単位で外へ出すと、外から見える口がサービスの数だけできる。証明書も、ドメインも、その数だけ要る。10 個のサービスがあれば 10 個の入口を運用することになるし、クラウドのロードバランサを使っているなら課金もその数だけかかる。

Ingress は、入口を1つに束ねてから中で振り分ける。同じホスト名の下で /api は api サービスへ、/ は web サービスへ。ここで扱うのは IP とポートではなく、ホスト名とパスになる。層が1つ上がっていて、そのぶん何ができるかが変わる。

そして層が上がると、新しい問題が出る。規則が重なるのだ。/api/ のどちらも /api/users に当たってしまう。

   外からの利用者


   ┌── Ingress(入口は1つ、証明書も1つ) ──┐
   │                                     │
   │  shop.example/api/v2 → api-v2       │  ← 長いパスが勝つ
   │  shop.example/api    → api          │
   │  shop.example/       → web          │
   │  blog.example/       → blog         │
   └──────────────┬──────────────────────┘

             Service(ここから先は前章の仕事)

                 Pod
入口を1つに束ねる。振り分けの単位がホスト名とパスになるので、規則が重なる

先に押さえることが3つある。

  1. 入口を1つに束ねる: 証明書もドメインも1つで済む。サービスが増えても入口は増えない
  2. 扱う単位が上がる: IP とポートでなくホスト名とパス。だからパスで分けられる
  3. 特定度で優先を決める: 長いパスが勝つ。書いた順に依存させない

① ホスト名とパスで振り分ける

まず、規則の形を作る:

go

// Backend は振り分け先の Service。ここから先は Service の仕事になる。
type Backend struct {
	Service string
	Port    int
}

// Rule は「このホストのこのパスは、この Service へ」という規則1つ。
type Rule struct {
	Host string // 空ならすべてのホストに一致
	Path string // 前方一致。"/" はすべてに当たる
	Backend
}

// specificity は規則の特定度を返す。大きいほど優先される。
// ホストを指定しているほうが、パスが長いほうが、より特定的とみなす。
func (r Rule) specificity() (int, int) {
	host := 0
	if r.Host != "" {
		host = 1
	}
	return host, len(r.Path)
}

// matches は host / path がこの規則に当たるかを返す。
func (r Rule) matches(host, path string) bool {
	if r.Host != "" && r.Host != host {
		return false
	}
	return hasPrefix(path, r.Path)
}

Rule はホスト名とパスの条件と、行き先の Service を持つ。行き先が Pod でなく Service であるところが大事で、ここから先は前章の仕事になる。Ingress は「どの Service へ渡すか」までを決め、その Service の後ろに Pod が何個いるかは知らない。層がきれいに分かれている。

specificity が、この章の中心にある考え方になる。ホストを指定しているかどうかと、パスの長さ。この2つで規則の特定度を測る。ホストを書いた規則のほうが、パスの長い規則のほうが、より特定的とみなす。

② 特定度で優先を決める

規則が重なったときにどれを採るかを決める:

go

// Result は1回の振り分けの結果。
type Result struct {
	Matched bool
	Backend Backend
	Rule    Rule
	Reason  string
}

// Ingress は入口1つと、その下の振り分け規則を持つ。
type Ingress struct {
	rules   []Rule
	Default *Backend // どの規則にも当たらないときの行き先(nil なら 404)

	Routed   int
	NotFound int
	Log      []string
}

// New は規則を持たない入口を作る。
func New() *Ingress { return &Ingress{} }

// Add は規則を1つ足す。足すたびに特定度の高い順へ並べ直すので、
// 書いた順は結果に影響しない。
func (i *Ingress) Add(r Rule) {
	if r.Path == "" {
		r.Path = "/"
	}
	i.rules = append(i.rules, r)
	sort.SliceStable(i.rules, func(a, b int) bool {
		ha, la := i.rules[a].specificity()
		hb, lb := i.rules[b].specificity()
		if ha != hb {
			return ha > hb // ホスト指定があるほうが先
		}
		if la != lb {
			return la > lb // パスが長いほうが先
		}
		return i.rules[a].Service < i.rules[b].Service // 同点は名前順(決定的)
	})
}

// Rules は評価される順に規則を返す。
func (i *Ingress) Rules() []Rule { return append([]Rule(nil), i.rules...) }

// Route は host / path のリクエストをどの Service へ渡すかを決める。
//
// 上から順に見て、最初に当たった規則を採る。並びは特定度順なので、
// より特定的な規則が先に当たる。/api と / の両方がある状態で "/api/users"
// が来たら、長いほうの /api が勝つ。
func (i *Ingress) Route(host, path string) Result {
	for _, r := range i.rules {
		if !r.matches(host, path) {
			continue
		}
		i.Routed++
		return Result{Matched: true, Backend: r.Backend, Rule: r,
			Reason: "規則 " + label(r) + " に一致"}
	}
	if i.Default != nil {
		i.Routed++
		return Result{Matched: true, Backend: *i.Default, Reason: "どの規則にも当たらず既定へ"}
	}
	i.NotFound++
	i.logf(host + path + " に当たる規則がない")
	return Result{Reason: "当たる規則がなく、既定も無い"}
}

Add は規則を足すたびに特定度の高い順へ並べ直す。Route は上から順に見て、最初に当たったものを採る。結果として、より特定的な規則が先に当たる。

素朴な代替案は「書いた順」だ。上から順に評価し、先に書いたほうが勝つ。単純だが、規則の並び順が意味を持つので、足す場所を間違えると壊れる。/ を先頭に書いてしまうと、その下の規則が全部死ぬ。しかも死んだことに気づきにくい。

特定度で決めれば、足す順番を気にしなくてよくなる。/api/v2 をどこに足しても /api より先に評価される。テストで、規則を足す順を入れ替えても結果が変わらないことを固定した。宣言的な仕組みでは、書く順序に意味を持たせないほうが扱いやすい。この編で繰り返し出てきた「状態を書く」という形が、ここでも効いている。

同じ特定度のときは Service 名の順にした。実際にはこの状況はあまり起きないが、決めておかないと結果がぶれる。テストで、規則が特定度順に並ぶことを固定した。

当たる規則が無いときの扱いも決めておく。既定があればそこへ、無ければ見つからないとして数える。どこにも落ちない状態を作らないのが目的で、Default を置けば、想定していないホストで来たリクエストも黙って消えることはなくなる。

動かす

下のデモは、リクエストのホストとパスを選び、どの規則に当たるかを見る。規則の一覧は有効にした順ではなく特定度の高い順に並ぶ。/api/v2 を有効にすると、同じリクエストの行き先が /api から api-v2 へ移る。負けた規則には「当たるがより特定的なものに負けた」と出る。

デモIngressapi:8080
リクエスト
ホストshop.exampleblog.exampleother.example
パス//api/api/v2/items/static/logo.png
shop.example/api/v2/items
規則(クリックで有効・無効。並びは特定度の高い順で、書いた順ではない)
1shop.example/api → api:8080ここに当たった
2blog.example/ → blog:80
3shop.example/ → web:80当たるがより特定的なものに負けた
shop.example/api/v2/items → api:8080。ホスト shop.example / パス /api の規則に一致

規則の一覧は、有効にした順ではなく特定度の高い順に並んでいる。ホストを指定した規則が先に、同じなら パスの長い規則が先に来る。だから /api/v2 を有効にすると、同じリクエストの行き先が /api から api-v2 へ移る。 負けた規則には「当たるがより特定的なものに負けた」と出るので、なぜその行き先になったかが追える。 いちばん上の `*/` は全部に当たる規則で、有効にすると見つからないケースが消える代わりに、 意図しないホストまで受けてしまう。

設計の観点

  • 入口を束ねる動機は運用: 証明書、ドメイン、外向けの口。数が増えると運用の手数がそのまま増える。1つに束ねれば、増えるのは規則だけになる
  • 書いた順に意味を持たせない: 特定度で決めれば、規則を足す位置を考えなくてよい。順序に依存する設定は、足すたびに全体を読み直すことになる
  • 層を跨がない: 行き先は Service までで、Pod は見ない。どの Pod が生きているかは Service の担当で、Ingress はそこに踏み込まない
  • どこにも落ちない状態を作らない: 既定を置いておけば、想定外のリクエストも黙って消えない。無いと、来ていたことにすら気づけない
  • 上の層ほどできることが増える: IP とポートでは「どのサービスか」までしか分けられない。ホスト名とパスなら、同じサービスの中の一部だけを別へ回せる。そのぶん規則が重なり、優先順位を決める必要が出る
  • NetworkPolicyとの違い: あちらは繋いでよいかの判定で、こちらはどこへ繋ぐかの解決。同じ通信に対して、別の問いに答えている

対照と実例

Service を個別に外へ出すIngress で束ねる
外向けの口サービスの数だけ1つ
証明書口ごとに要る入口に1つ
分けられる単位ポートホスト名とパス
サービスが増えると口も増える規則が増えるだけ
費用口の数に比例入口ぶん

裏どり:

  • Ingress: ホスト名とパスで Service へ振り分ける規則の集まり。TLS の終端も入口で行う
  • pathType: Exact / Prefix / 実装依存。前方一致の境界の扱いが実装で違うことがある
  • Ingress コントローラ: Ingress は宣言で、実際に受信して転送するのは nginx や Traefik などのコントローラ
  • Gateway API: Ingress の後継として設計された仕様。役割ごとに資源を分け、Ingress では実装依存だった部分を標準化している

簡略化したこと

  • TLS なし: 実物は入口で証明書を持ち、TLS を終端する。入口を束ねる主な動機の1つ
  • 前方一致のみ: 実物は Exact / Prefix を選べる
  • 書き換えなし: パスの書き換えやヘッダの追加は扱わない
  • 重み付けなし: 同じ規則を複数の Service へ割合で分ける機能は扱わない
  • 判定のみ: 実際の受信と転送はコントローラが行う。ここでは行き先の計算だけ

参考資料