K8s 基礎 #5 Service — ClusterIP / NodePort / LoadBalancer

読了 16分

#4 Deployment と ReplicaSet で Pod 3 つの IP が毎回変わる点を確認しました。この記事ではその問題を解決する抽象である Service を扱います。安定した仮想 IP と DNS 名、selector が作るバックエンドのまとまり、そして ClusterIP / NodePort / LoadBalancer 3 種類の公開方式を整理します。

このシリーズは K8s 基礎 7 編です。

この記事の終わりには Pod の前面に安定した入口を置く最初のマニフェスト が整理されます。クラスタ内で Pod 同士が名前で互いを呼ぶ形も、外部ブラウザからノードポートで直接入る形も、クラウド環境で外部 LB が自動で付く形も、1 行の差で枝分かれします。

Pod IP の限界 — なぜ Service が必要か #

#4 の最後まで付いてきたなら頭の中の絵はこうです — app: web ラベルの付いた nginx Pod が 3 個立っていて、それぞれ 10.244.0.510.244.0.610.244.0.7 のようなクラスタ内部 IP を持っています。この状態で 1 つ更にやってみたくなります — 同じクラスタ内の別の Pod から、その 3 つに HTTP リクエストを送ったり、ノート PC のブラウザで一度開いてみたり。

ところがやってみると 4 つの問題が一度にかかります。

  • Pod IP は ephemeral (一時的) です。 Pod が再生成されると新しい IP が付きます。昨日メモした 10.244.0.5 は今日は無い IP かもしれません。クライアントコードに IP を固定して呼ぶ道は最初から閉じています。
  • 3 つの Pod 間に負荷分散がありません。 1 つの Pod IP を選んで呼ぶとその Pod だけが働き、残りの 2 つは遊んでしまいます。誰かがトラフィックを N 個の Pod に均等に撒いてくれる必要があります。
  • サービスディスカバリがありません。 クライアント Pod の立場から「あの web サービスの現在の IP は何か」を毎回どこに問い合わせればいいか曖昧です。IP ではなく名前で呼べる道が必要です。
  • 外部トラフィックの入口がありません。 クラスタ内部 IP はノート PC のブラウザからは見えません。外部の何かを内部の Pod に流す入口が別途用意されている必要があります。

この 4 つを一度に解く抽象が Service です。マニフェスト 1 枚を書けば K8s が安定した仮想 IP を取ってくれて、その IP が負荷分散器の役目を果たしながら selector でまとめた Pod たちにトラフィックを流し、同じクラスタの別の Pod から名前で呼べる DNS レコードまで自動で作ってくれます。

Service — 安定 IP + selector + DNS #

Service マニフェスト 1 枚が作り出す結果を 3 つに分けます。

  • 安定した仮想 IP (ClusterIP) — クラスタが生きている間は変わらない IP。Pod が死のうが立とうが同じ IP が維持されます。
  • selector でまとめられた Pod グループspec.selector のラベルにマッチする Pod がその Service のバックエンドになります。Pod が新しく立ってもラベルが合えば自動で参加、死ねば自動で除外。
  • DNS 名<svc>.<ns>.svc.cluster.local 形の FQDN が自動で作られます。同じ namespace の中では <svc> の短い名前だけでも呼べます。

頭の中の絵はこう置いておくと楽です。

Service 1 つとその後ろの Pod たち
   ┌──────────────────────────────┐
   │   Service: web               │  selector: app=web
   │   ClusterIP: 10.96.x.x       │  DNS: web.default.svc.cluster.local
   └──────────────┬───────────────┘
                  │ トラフィック分配
       ┌──────────┼──────────┐
       ▼          ▼          ▼
   ┌────────┐ ┌────────┐ ┌────────┐
   │ Pod-1  │ │ Pod-2  │ │ Pod-3  │  app=web
   │.0.5    │ │.0.6    │ │.0.7    │  (Pod IP は一時的)
   └────────┘ └────────┘ └────────┘

上の絵で肝心なのは — クライアントは真ん中の Service IP / 名前だけを見ていればよく、下の Pod たちが死のうが立とうが K8s が自動で更新してくれる、という点です。Service が持つ IP は安定で、その後ろの各 Pod IP は一時的。 両者が分離されていて初めて無停止運用が可能になります。

Endpoints / EndpointSlice — selector の結果 #

Service の selector がマッチした Pod たちの IP・ポートのリストは、K8s が別オブジェクトに整理しておきます。これが Endpoints (または 1.21+ から推奨される EndpointSlice) です。人が直接作ることはほぼ無く、Service を作れば K8s が自動で埋めてくれます。

Service のバックエンド一覧を見る
kubectl get endpoints web
出力例
NAME   ENDPOINTS                                     AGE
web    10.244.0.5:80,10.244.0.6:80,10.244.0.7:80     30s

ENDPOINTS 列に Pod IP がそのまま並んでいます。Pod が 1 つ死ねばこのリストからすぐ消え、新しく立った Pod がラベルに合えば自動で参加します。

1.21+ からは EndpointSlice が推奨です。Service 1 つのバックエンドが多くなったときに 1 オブジェクトが肥大化する問題を解くために導入された形です。大きな違いはなく、ユーザの立場では両方とも kubectl get で見られます。

EndpointSlice も一緒に
kubectl get endpointslices -l kubernetes.io/service-name=web
出力例
NAME         ADDRESSTYPE   PORTS   ENDPOINTS                          AGE
web-abc12    IPv4          80      10.244.0.5,10.244.0.6,10.244.0.7   30s

このオブジェクトがデバッグの第 1 の武器です。「Service にトラフィックが届いていないようだ」 という症状が出たら、最初に見るのがここです。

空いていないか
kubectl get endpoints web
空のときの出力例
NAME   ENDPOINTS   AGE
web    <none>      1m

ENDPOINTS が空なら、Service の selector がどの Pod にもマッチしていないという意味です。2 つのうち 1 つです — selector のラベルがタイポしている、またはマッチする Pod がその namespace に無い。kubectl get pods --show-labels で実際の Pod のラベルを確認し selector と合わせれば答えが出ます。

ClusterIP — クラスタ内部専用 #

最もよく使う既定タイプから始めます。Service の spec.type を書かなければ自動で ClusterIP です。クラスタ内部からだけ届く仮想 IP を取ってくれる形です。

#4 で立てた app: web Deployment がそのまま立っていると仮定して、その前面に Service を 1 つ付けてみます。ファイル名は web-svc.yaml にします。

web-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

マニフェストの背骨は #3 で見た 4 フィールドそのまま — apiVersion / kind / metadata / spec です。Service は apps/v1 ではなくコアグループの v1 という点だけ注意すれば良いです。Deployment とよく混同する部分です。

spec の中で新しく出会う部分は 3 つです。

  • typeClusterIP / NodePort / LoadBalancer / ExternalName のいずれか。書かなければ ClusterIP
  • selector — どんなラベルの Pod をバックエンドに取るか。上では app: web#4 の Deployment template ラベルと一致させたのが肝心です。
  • ports — ポートマッピングの一覧。1 つの Service が複数のポートを一度に公開することもでき、上のように 1 行だけ書いてもいいです。

port vs targetPort #

ports の下の 2 つのフィールドを 1 行ずつ押さえておきます。

  • port — Service が聞くポート。クライアントが叩く先。上のマニフェストなら web:80 で入って来ます。
  • targetPort — バックエンド Pod のコンテナが聞くポート。nginx コンテナが 80 番を聞いているので 80。

両者が同じ数字なので混同しやすいですが、別々にしている理由があります。例えばコンテナは 8080 を聞くようにして Service は標準 80 で公開したいなら port: 80, targetPort: 8080 のように違って書けばいいです。こうした分離があるので Service は一種の軽量ポートマッピング層の役割もします。

apply して結果を確認 #

マニフェストをクラスタに反映します。

Service を作る
kubectl apply -f web-svc.yaml
出力例
service/web created
Service 一覧
kubectl get svc
出力例
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP   2d
web          ClusterIP   10.96.142.31    <none>        80/TCP    10s

列名を 1 行で押さえると — NAME / TYPE / CLUSTER-IP / EXTERNAL-IP / PORT(S) / AGE です。シリーズの最後までよく出会います。kubernetes 行はクラスタが自前で持っている API サーバ用 Service なので気にしなくていいです。新しく見えるのは web 1 行です。CLUSTER-IP 10.96.142.31 が取られていて、EXTERNAL-IP<none> — クラスタ内部からだけ届くという意味です。

(IP の 10.96.0.0/12 範囲は kubeadm の既定サービス CIDR です。環境によって違って付きます。minikube・kind は似ていて、EKS・GKE のようなマネージドは独自の既定値があります。)

クラスタ内部から呼んでみる #

ClusterIP の核心の検証は 別の Pod からこの Service を呼べるか です。一時的なデバッグ用 Pod を 1 つ立てて中で curl を叩いてみます。

一時 curl Pod を立てて入る
kubectl run tmp --rm -it --image=curlimages/curl -- sh

--rm は終了するときに Pod を自動で消すオプション、-it はインタラクティブ + TTY です。中に入って 3 つの形で呼んでみます。

一時 Pod の中で
/ $ curl -s http://web | head -1
<!DOCTYPE html>
/ $ curl -s http://web.default.svc.cluster.local | head -1
<!DOCTYPE html>
/ $ curl -s http://10.96.142.31 | head -1
<!DOCTYPE html>

3 つの道がすべて同じところを指しています。

  • 短い名前 web — 同じ namespace (default) の中では Service 名だけで届きます。最もよく使う形。
  • FQDN web.default.svc.cluster.local — 別 namespace の Service を呼ぶとき、または曖昧さを無くしたいときに使う正式名。
  • ClusterIP 10.96.142.31 — 仮想 IP を直接叩いてもいいですが、この IP を覚えることはほぼありません。DNS で呼ぶのが正攻法。

同じコマンドを何度か叩いてみると毎回同じ nginx 歓迎ページが応答しますが、実際には K8s が リクエストごとにバックエンド Pod 3 個のうち 1 つを選んで流しています。 負荷分散は別途設定しなくても既定の動作です。実際にどの Pod が応答したか確認したいなら nginx の access log を一度開けばいいです — 3 つの Pod のログに均等にリクエストが落ちているのが見えます。

exit で一時 Pod を抜けると --rm のおかげで自動で片付けられます。運用でクラスタ内部の通信はほぼ常にこの ClusterIP の形です。バックエンド ↔ DB、バックエンド ↔ Redis、マイクロサービス間の呼び出し — すべて ClusterIP でまとめられます。

NodePort — ノード IP の特定ポートで公開 #

ClusterIP はクラスタ内部からだけ届くと言いました。外部から届くようにする最もシンプルな方法が NodePort です。クラスタの全ノードで同じポート (既定 30000–32767 範囲) を開けて、そのポートに入って来るトラフィックを同じ Service に流します。

マニフェストは ClusterIP に 2 行だけ追加すればいいです。

web-svc.yaml — NodePort 版
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080

type: NodePort に変え、ports の下に nodePort: 30080 を追加しました。nodePort を書かなければ K8s が 30000–32767 の範囲で自動的に 1 つ選んでくれます。直接書く場合はその範囲内の値であるべきです。

再適用
kubectl apply -f web-svc.yaml
出力例
service/web configured
再確認
kubectl get svc web
出力例
NAME   TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
web    NodePort   10.96.142.31    <none>        80:30080/TCP   5m

変わった部分は 2 か所です — TYPENodePort に、PORT(S)80:30080/TCP に。前の 80 が Service の port (クラスタ内部から叩くポート)、後ろの 30080 がノードの NodePort。これでクラスタ内部からは依然として web:80 で届き、クラスタ外からは <NodeIP>:30080 で届きます。

外部からノード IP で
curl http://<NodeIP>:30080

<NodeIP> の部分にはワーカーノードの外部 IP を入れればいいです。ローカル環境ごとに形が少し違います。

  • kind — ノードは Docker コンテナの中なので、ホストから直接は届きません。クラスタ作成時に extraPortMappings で 30080 をホスト側に公開するか、kubectl port-forward で迂回します。
  • minikubeminikube service web --url でアクセス URL を取得できます。
  • Docker Desktop k8s — ノード = ホスト自体なので localhost:30080 で直接届きます。

運用で NodePort を直接クライアントに公開することは稀です。ポート番号が 30000 番台で扱いづらく、ノードが追加・削除されるときに外部クライアントが IP リストを追わないといけないからです。普通は その上に LoadBalancer や Ingress が乗っていて、内側で NodePort を使う形 です。NodePort 自体はローカル開発で外部アクセスを素早く確認したり、デバッグ用に一時的に開けたりするのに便利です。

LoadBalancer — クラウド LB と統合 #

運用で外部に公開する一番よく見る形が LoadBalancer です。type: LoadBalancer の 1 行を書けば、K8s がクラウドプロバイダ (AWS ELB、GCP LB、Azure LB など) に外部 LB を自動で作るよう依頼します。作られた LB の外部 IP が Service の EXTERNAL-IP 列に埋まります。

web-svc.yaml — LoadBalancer 版
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
再適用
kubectl apply -f web-svc.yaml

クラウド環境で #

EKS・GKE・AKS のようなマネージドクラスタで上のマニフェストを適用すると、通常 1〜2 分の間に外部 LB が作られます。

作られている途中
kubectl get svc web
出力例 — 最初の 1 分
NAME   TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
web    LoadBalancer   10.96.142.31    <pending>     80:31523/TCP   20s
出力例 — LB が付いた後
NAME   TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)        AGE
web    LoadBalancer   10.96.142.31    a1b2c3d4.elb..   80:31523/TCP   2m

EXTERNAL-IP<pending> から実際の IP / DNS 名に変わります。そのアドレスが外部の入口です。AWS なら ELB の DNS 名、GCP なら IP アドレス、環境によって形が少し変わります。PORT(S) に NodePort 31523 も一緒に見えるのが興味深いです — LoadBalancer の内部では NodePort を自動で取り、クラウド LB がその NodePort にトラフィックを流す形 です。なので LoadBalancer は NodePort の上位概念に近いです。

ローカル・オンプレ環境で #

kind、単独の minikube、クラウドコントローラの無い一般のベアメタルクラスタでは、上のマニフェストを適用しても EXTERNAL-IP がいつまでも <pending> のままです。外部 LB を作ってくれる相手がいないからです。

ローカルでの出力例
NAME   TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
web    LoadBalancer   10.96.142.31    <pending>     80:31523/TCP   5m

この空白を埋めるために出てきたのが MetalLB (ベアメタル用) や cloud-provider-kind (kind 専用) のようなツールです。インストールするとそのツールがクラウドコントローラのように動作して EXTERNAL-IP を埋めてくれます。名前だけ押さえておき、深いインストールはこの記事の範囲外にします。

要点は 1 行です — 運用の外部入口はほぼ常に LoadBalancer または上に乗る Ingress です。Ingress は 1 つの LoadBalancer の後ろに複数の Service をホスト/パスでルーティングする上位抽象で、この K8s 基礎シリーズの範囲外なので中級シリーズで別途扱います。 この記事では LoadBalancer までが終点です。

Service タイプを 1 つの表に #

ここまでの 3 つとよく出会う 2 つを 1 つの表に整理します。

タイプ外部公開主な用途
ClusterIP (既定)無し (クラスタ内部専用)バックエンド ↔ DB、マイクロサービス間通信
NodePort<NodeIP>:<30000–32767>ローカル開発、デバッグ用外部アクセス、LB の内側実装
LoadBalancerクラウド LB の外部 IP / DNS運用外部入口。クラウドや MetalLB などが必要
ExternalName無し (DNS CNAME のみ)クラスタ内部名を外部ドメインに別名として置く
Headless (clusterIP: None)無し (仮想 IP 無し)StatefulSet のように Pod 個別 IP が必要なとき

最後の 2 行を 1 行ずつ押さえておきます。

  • ExternalName — マニフェストに type: ExternalName + externalName: db.example.com を書けば、<svc>.<ns>.svc.cluster.local を呼んだときに K8s 内部 DNS が外部ドメインの CNAME で応答します。selector もバックエンド Pod も無い特殊な形を扱います。外部システムをクラスタ内部名で呼びたいときに使います。
  • Headless Servicespec.clusterIP: None を書けば仮想 IP 自体を取らず、DNS 検索時にバックエンド Pod IP をそのまま返します。StatefulSet のようにクライアントが Pod 個別に直接届くべき場合の相方です。一般的な Web サービスではほぼ使いません。

kube-proxy — 結局誰がトラフィックを流すのか #

ここまで付いてきたら 1 つ少し気にかかります — Service の ClusterIP 10.96.142.31どのノードにも実際は付いていない IP です。ip addr でどのノードを覗いてもその IP がありません。それなのに Pod の中からその IP にパケットを送るとどこかに届きます。誰が流しているのでしょうか。

答えは各ノードで動く kube-proxy というシステムコンポーネントです。#1 のコントロールプレーンの絵には出ませんでしたが、すべてのワーカーノードに 1 つずつ立っているデーモンです。

kube-proxy の仕事
   Pod ─▶ 10.96.142.31:80 (仮想 IP)
              ▼  iptables/IPVS ルールで DNAT
   Pod IP 3 つのうち 1 つ ─▶ 10.244.0.5:80
                          10.244.0.6:80
                          10.244.0.7:80

kube-proxy が Endpoints/EndpointSlice を監視し、ノードの iptables (または IPVS) ルールを自動で敷きます。そのルールが「10.96.142.31:80 へ向かうパケットは 10.244.0.5:80、.0.6:80、.0.7:80 のうち 1 つに DNAT する」という内容です。Pod が Service IP に送ったパケットはノードを離れる前にこのルールに捕まり実際の Pod IP に書き換えられます。

なので Service はどこか 1 つのノードの LB ではなく、全ノードに分散された仮想 LB です。ノードごとに同じルールが敷かれていて、どの Pod がどのノードに立っていても同じ ClusterIP で呼べば等しく届きます。kube-proxy のモードは通常 iptables (既定) か ipvs で、より深い動作と eBPF ベースの代替 (Cilium など) は K8s 中級ネットワーキングのトピックに回します。

DNS — CoreDNS とサービス名 #

web のような短い名前がどうやって ClusterIP に解決されるかを 1 段落で押さえておきます。クラスタの kube-system namespace に CoreDNS という DNS サーバが立っています (通常 Pod 2 つで)。この CoreDNS がすべての Service について A レコードを自動で作っておきます。

既定ドメインは cluster.local、FQDN は <svc>.<ns>.svc.cluster.local。同じ namespace の中では短い名前 <svc> だけ書いても search ドメインが自動で付いて解決されます。

一時 Pod の中から DNS 確認
nslookup web
# Server:    10.96.0.10
# Address:   10.96.0.10#53
#
# Name:      web.default.svc.cluster.local
# Address:   10.96.142.31

応答 IP が私たちが見た ClusterIP と同じであることが肝心です。Pod の /etc/resolv.conf は K8s が自動で埋めてくれて、nameserver に CoreDNS の ClusterIP (10.96.0.10 のような値) が書かれており、search<ns>.svc.cluster.local svc.cluster.local cluster.local が書かれています。なので短い名前が自動で正式名に展開されます。

既定ドメイン cluster.local は変更可能です (クラスタインストール時のオプション)。ただしほぼすべての環境が既定値をそのまま使うので、マニフェストやコードに書くときは cluster.local を仮定しても問題ありません。

片付け #

今日作った Service と、#4 で立っていた Deployment を一緒にきれいに消します。

Service の片付け
kubectl delete -f web-svc.yaml
出力例
service "web" deleted
Deployment まで (あれば)
kubectl delete deploy web
出力例
deployment.apps "web" deleted

kubectl get svc,deploy,pods で空であることを確認すれば出発点に戻ります。kubernetes Service だけが 1 行残っているのは正常です — それはクラスタが自前で持っているもので私たちが消す対象ではありません。

まとめ #

この記事で押さえた流れ:

  • Pod IP は一時的。同じラベルの N 個の Pod に安定 IP・DNS・負荷分散を一度に乗せる抽象が Service
  • Service マニフェストの背骨は apiVersion: v1 / kind: Service / spec.type / spec.selector / spec.ports。selector は #4 の Deployment template ラベルと必ずマッチさせる。
  • selector の結果は Endpoints / EndpointSlice オブジェクトに自動で整理されます。kubectl get endpoints <svc> が空なら selector・ラベルがズレた最初の疑い点。
  • タイプは 3 つ — ClusterIP (既定、クラスタ内部専用)、NodePort (<NodeIP>:30000–32767 で外部公開)、LoadBalancer (クラウド LB 自動生成)。付加で ExternalName と Headless (clusterIP: None)。
  • Service の仮想 IP はどのノードにも実在しません。ノードの kube-proxy が iptables/IPVS ルールで DNAT して Pod IP に流してくれる — 分散仮想 LB。
  • 短い名前 <svc> が解決されるのは kube-systemCoreDNS が Service ごとに A レコードを自動で作るから。FQDN は <svc>.<ns>.svc.cluster.local

次 — ConfigMap / Secret #

ここまで来てもマニフェストの中に 1 つ依然として違和感が残っています — イメージタグ、ポート、ドメインのような値がマニフェストに 直接書かれたまま という点。環境 (開発/ステージング/運用) によって変わるべき値や、パスワードのように平文でマニフェストに置いてはいけない値をマニフェスト本体から切り出す作業が次のテーマです。

#6 ConfigMap / Secret では (1) ConfigMap に環境設定値をまとめて Pod に環境変数・ボリュームで注入する形(2) Secret が ConfigMap と何が違うか (そして base64 は暗号化ではないという 1 行)(3) この記事の web Deployment に設定値の一束を外部オブジェクトに切り出してみる 1 サイクル までを扱います。

X