目次
5 章

Service

Pod IP が一時的だという問題を解く抽象 — Service。安定した ClusterIP・selector・Endpoints / EndpointSlice・そして ClusterIP・NodePort・LoadBalancer の3タイプの選択基準、kube-proxy の DNAT、CoreDNS の短い名前の解決までを一連の流れで扱います。

第4章 Deployment と ReplicaSet で Pod 3個が自動で生きて起動している形までは作りました。ただ、その3個の IP が毎回変わるという点が気にかかっています。本章ではその問題を解決する抽象である Service を扱います。安定した仮想 IP と DNS 名、selector が作ったバックエンドのまとまり、そして ClusterIP / NodePort / LoadBalancer の3つの公開方式を併せて整理します。

本章の終わりには Pod の前段に安定した進入点を置く最初のマニフェスト が手に入ります。クラスタの中で Pod 同士が名前で互いを呼ぶ形も、外部のブラウザからノードポートで直接入ってくる形も、クラウド環境で外部 LB が自動で付く形も、一行の違いで分かれます。

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

第4章 の最後までついてきたなら頭の中の図はこうです — app: web ラベルの付いた nginx Pod 3個が起動していて、それぞれ 10.244.0.510.244.0.610.244.0.7 のようなクラスタ内部 IP を持っています。この状態でもう1つやってみたくなります — 同じクラスタの中の別の Pod からあの3個に HTTP リクエストを送ったり、ノートパソコンのブラウザで一度開いてみたりすることです。

ところが実際にやってみると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 はノートパソコンのブラウザから見えません。外部の何かを中の 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 が自動で生まれます。同じネームスペースの中では <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 が推奨されます。1つの Service のバックエンドが多くなるとき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

このオブジェクトが Service デバッグの一次出発点です。「Service にトラフィックが行っていないようだ」 という症状が出たら、最初に見る場所がここです。

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

ENDPOINTS が空なら、Service の selector がどの Pod にもマッチしていないという意味です。2つのうちどちらかです — selector ラベルがタイプミスか、マッチする Pod がそのネームスペースにない場合です。kubectl get pods --show-labels で実際の Pod のラベルを確認して selector と合わせてみれば答えが出ます。診断ツリーの完成版は 第27章 kubectl デバッグパターン で整理します。

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 のうち1つです。書かないと ClusterIP です。
  • selector — どんなラベルの Pod をバックエンドにつかむかを決めます。上では app: web にしました。第4章 の Deployment の template ラベルと一致させたのが核心です。
  • ports — ポートマッピングの一覧です。1つの Service が複数のポートを一度に公開することもでき、上のように1行だけ書いても大丈夫です。

port vs targetPort #

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

  • 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

カラム名を一行で押さえておくと — 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 — 同じネームスペース (default) の中では Service 名だけで届きます。最もよく使う形です。
  • FQDN web.default.svc.cluster.local — 別のネームスペースの 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 の一行を書けば 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 の上位概念に近いです。

この形は4部 EKS 実戦 (第22章 アプリ配備の骨格) で ALB と一緒に本格的に扱います。

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

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 を埋めてくれます。名前だけ押さえておいて、深いインストールは本章の範囲の外に置いておきます。

要点は一行です — 運用の外部進入点はほぼ常に LoadBalancer またはその上の Ingress です。Ingress は1つの LoadBalancer の後ろに複数の Service をホスト・パスでルーティングする上位抽象であり、第10章 Ingress と Ingress Controller で本格的に扱います。本章では LoadBalancer までが終点です。

Service タイプを一表で #

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

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

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

  • 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 たちをそのまま返します。第8章 StatefulSet のようにクライアントが Pod ごとに直接届かなければならない場合の相棒です。一般的な Web サービスではほとんど使いません。

kube-proxy — ではいったい誰がトラフィックを流すのか #

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

答えは各ノードで動く kube-proxy というシステムコンポーネントです。第1章 の worker node コンポーネントとしてすでに一度登場したデーモンです。

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 の3つのうち1つへ DNAT する」という内容です。Pod が Service IP へ送ったパケットはノードを離れる前にこのルールに捕まって実際の Pod IP に変わります。

だから Service はどれか1つのノードの LB ではなく、すべてのノードに分散された仮想 LB です。ノードごとに同じルールが適用されているので、どの Pod がどのノードに起動していても同じ ClusterIP で呼べば同じように届きます。kube-proxy のモードは普通 iptables (デフォルト) か ipvs で、より深い動作と eBPF ベースの代替 (Cilium など) は 第15章 CNI の深層 で扱います。

DNS — CoreDNS とサービス名 #

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

デフォルトドメインは cluster.local で、FQDN は <svc>.<ns>.svc.cluster.local です。同じネームスペースの中では短い名前 <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行残っているのが正常です — それはクラスタが自前で持っている Service なので私たちが消す対象ではありません。

練習問題 #

  1. 上の本文どおり web-svc.yamltypeClusterIPNodePortLoadBalancer の順に1回ずつ変えながら kubectl apply してみてください。各段階の kubectl get svc web 出力で TYPE / CLUSTER-IP / EXTERNAL-IP / PORT(S) カラムがどう変わるかを表に整理します。LoadBalancer 段階で EXTERNAL-IP<pending> にとどまっているか、実際のアドレスに変わるかによって環境 (ローカル vs マネージドクラウド) の違いがどこで分かれるかを一段落でメモします。
  2. Service の spec.selector ラベルをわざと一文字変えてみてください (例: app: webapp: webb)。kubectl get endpoints webENDPOINTS カラムがどう変わるか、kubectl run tmp --rm -it --image=curlimages/curl -- sh の中で curl http://web を叩いたときどんなエラーが出るかを記録します。§「Endpoints / EndpointSlice — selector の結果」のデバッグ出発点がどう適用されるかを整理します。
  3. 一時 curl Pod の中で nslookup webnslookup web.default.svc.cluster.localnslookup kubernetes.default.svc.cluster.local の3つを順に叩いてみてください。各応答 IP がどこから来たか (CoreDNS・ClusterIP・kubernetes システム Service) を表に整理し、Pod の /etc/resolv.conf の中の search ドメイン一覧が短い名前の拡張にどう作用するかを一段落でメモします。

一行まとめ: Service は一時的な Pod IP の限界を解く抽象で、安定 ClusterIP と selector でまとめた Pod グループ、CoreDNS の自動 A レコードを一まとめで提供する。外部公開は NodePort (ノードポート) と LoadBalancer (クラウド LB) の2つに分かれ、実際のトラフィックは各ノードの kube-proxy が iptables・IPVS で DNAT して Pod IP へ流す。

次の章 #

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

第6章 ConfigMap と Secret では、ConfigMap に環境設定値をまとめて Pod に環境変数・ボリュームで注入する形、Secret が ConfigMap と何が違うのか (そして base64 は暗号化ではないという一行)、本章の web Deployment に設定値の一まとまりを外部オブジェクトに切り離してみる一連の流れまで扱います。シークレットの production 運用 (sealed-secrets・external-secrets・IRSA) は 第29章 シークレット運用 で本格的に扱います。

X