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.5、10.244.0.6、10.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: 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 が自動で埋めてくれます。
kubectl get endpoints webNAME ENDPOINTS AGE
web 10.244.0.5:80,10.244.0.6:80,10.244.0.7:80 30sENDPOINTS カラムに Pod IP たちがそのまま並んでいます。Pod が1個死ぬとこの一覧からまもなく消え、新しく起動してきた Pod がラベルに合えば自動で合流します。
1.21+ からは EndpointSlice が推奨されます。1つの Service のバックエンドが多くなるとき1つのオブジェクトが大きくなりすぎる問題を解こうと導入された形です。大きな違いはなく、ユーザーからすると両方とも kubectl get で見られます。
kubectl get endpointslices -l kubernetes.io/service-name=webNAME 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 webNAME ENDPOINTS AGE
web <none> 1mENDPOINTS が空なら、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 にします。
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つです。
type—ClusterIP/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 と結果の確認 #
マニフェストをクラスタに反映します。
kubectl apply -f web-svc.yamlservice/web createdkubectl get svcNAME 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 を一度叩いてみましょう。
kubectl run tmp --rm -it --image=curlimages/curl -- sh--rm は終了するときに Pod を自動で消すオプションで、-it はインタラクティブ + TTY です。入って3つの形で呼んでみます。
/ $ 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行だけ追加すればよいです。
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 80
nodePort: 30080type: NodePort に変え、ports の下に nodePort: 30080 を追加しました。nodePort を書かないと K8s が 30000 ~ 32767 の範囲から自動で1つ選んでくれます。直接書くときはその範囲の中の値でなければなりません。
kubectl apply -f web-svc.yamlservice/web configuredkubectl get svc webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web NodePort 10.96.142.31 <none> 80:30080/TCP 5m変わった部分は2ヶ所です — TYPE が NodePort に、PORT(S) が 80:30080/TCP に変わりました。前の 80 が Service の port (クラスタの中で叩くポート)、後ろの 30080 がノードの NodePort です。これでクラスタの中では依然として web:80 で届き、クラスタの外からは <NodeIP>:30080 で届きます。
curl http://<NodeIP>:30080<NodeIP> の部分にはワーカーノードの外部 IP を入れればよいです。ローカル環境ごとに形が少し異なります。
- kind — ノードは docker コンテナの中なのでホストからは直接届きません。クラスタを作るとき
extraPortMappingsで 30080 をホスト側に公開するか、kubectl port-forwardで迂回します。 - minikube —
minikube 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 カラムに満ちてきます。
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80kubectl apply -f web-svc.yamlクラウド環境で #
EKS・GKE・AKS のようなマネージドクラスタで上のマニフェストを適用すると、普通は1 ~ 2分の間に外部 LB が作られます。
kubectl get svc webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.142.31 <pending> 80:31523/TCP 20sNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.142.31 a1b2c3d4.elb.. 80:31523/TCP 2mEXTERNAL-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 Service —
spec.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 コンポーネントとしてすでに一度登場したデーモンです。
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:80kube-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 ドメインが勝手に付いて解決されます。
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 を併せてきれいに消します。
kubectl delete -f web-svc.yamlservice "web" deletedkubectl delete deploy webdeployment.apps "web" deletedkubectl get svc,deploy,pods で空になっているか確認すれば出発点に戻ります。kubernetes Service だけ1行残っているのが正常です — それはクラスタが自前で持っている Service なので私たちが消す対象ではありません。
練習問題 #
- 上の本文どおり
web-svc.yamlのtypeをClusterIP→NodePort→LoadBalancerの順に1回ずつ変えながらkubectl applyしてみてください。各段階のkubectl get svc web出力でTYPE/CLUSTER-IP/EXTERNAL-IP/PORT(S)カラムがどう変わるかを表に整理します。LoadBalancer 段階でEXTERNAL-IPが<pending>にとどまっているか、実際のアドレスに変わるかによって環境 (ローカル vs マネージドクラウド) の違いがどこで分かれるかを一段落でメモします。 - Service の
spec.selectorラベルをわざと一文字変えてみてください (例:app: web→app: webb)。kubectl get endpoints webのENDPOINTSカラムがどう変わるか、kubectl run tmp --rm -it --image=curlimages/curl -- shの中でcurl http://webを叩いたときどんなエラーが出るかを記録します。§「Endpoints / EndpointSlice — selector の結果」のデバッグ出発点がどう適用されるかを整理します。 - 一時 curl Pod の中で
nslookup web、nslookup web.default.svc.cluster.local、nslookup 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章 シークレット運用 で本格的に扱います。