Pod IPは信じられない — Serviceが解く問題
一言でいうと
Podはいつでも死に、新しいIPで生まれます。Serviceは死なない名前とアドレスを前面に立て、背後のPod一覧はラベルセレクターで自動更新します。ストレージも同じ考え方です。「どのディスク」ではなく、「どんな性質のストレージがどれだけ必要か」を宣言します。
なぜ必要なのか
前のモジュールで確認したとおり、Podを削除すると別の名前と別のIPのPodがやって来ます。では、そのPodに接続していた側はどうすればよいのでしょうか。毎回一覧を取得し直して、コネクションを張り直さなければならないなら、すべてのアプリケーションがKubernetesのAPIクライアントにならなければなりません。
Serviceはこの問題を間接層で解決します。安定した仮想IP(ClusterIP)とDNS名を1つ用意しておき、その背後につくPodの一覧は、セレクターで更新し続けます。クライアントは名前さえ知っていればよいのです。
どう動くのか
Kubernetesのネットワークモデルの3つの約束
- すべてのPodは、NATなしで互いに通信できます。
- ノードのエージェントは、そのノードのすべてのPodと通信できます。
- Podが見る自分のIPと、他者が見るそのPodのIPが同じです。
CNIプラグインは、この約束を守る方式が違うだけで、約束自体は同じです。
Serviceとエンドポイント
Serviceを作成すると、エンドポイントコントローラーが、セレクターに合う準備のできた(Ready)PodのIPを集めて、EndpointSlice(以前はEndpoints)に書き込みます。ここで重要なことが2つあります。
- ReadyのPodだけが入ります。readinessProbeが失敗するとトラフィックから外れるのは、このためです。
- セレクターがずれるとエンドポイントが0件になります。エラーは出ません。Serviceも正常で、ClusterIPも正常に発行されます。ただ、接続しても、どこにも行かないだけです。
kubectl get endpointsliceの結果が空かどうかを確認するのが、この系統の障害の最初の診断です。
なお、Kubernetes v1.33からEndpoints APIはdeprecatedとなり、EndpointSliceへ移行するよう案内されています。EndpointSliceは、大規模なクラスターで一覧を複数の断片に分割でき、デュアルスタックもサポートします。
Serviceのタイプ
| タイプ | 公開範囲 | 使う場面 |
|---|---|---|
| ClusterIP(デフォルト) | クラスター内部 | サービス間通信 |
| NodePort | すべてのノードの30000-32767ポート | 開発・簡易的な外部公開 |
| LoadBalancer | 外部ロードバランサー | クラウド/オンプレミスのLB連携 |
| ExternalName | DNS CNAME | クラスター外のサービスに名前を付ける |
ヘッドレスサービス(clusterIP: None)は例外です。VIPを作らず、DNSの問い合わせにPodのIPをそのまま返します。StatefulSetのように、個別のPodを直接指名しなければならないときに使います。
DNS名のルール
<서비스>.<네임스페이스>.svc.cluster.local
上のコードブロックのプレースホルダーは、順にサービス名とネームスペース名です。同じネームスペース内では<서비스>だけでも通じます(プレースホルダーはサービス名です)。このルール1つで「環境ごとにアドレスが違う」という問題がなくなります。ネームスペースを入れ替えるだけで、同じマニフェストが動くのです。
設定とシークレット
ConfigMapとSecretは、イメージと環境を分離するための仕掛けです。注入方法は2つです。環境変数として入れるか、ボリュームとしてマウントするか。ボリュームとしてマウントすると、値が変わったときにファイルが更新されますが、環境変数はプロセスの開始時点で固定されるため、更新されません。
Secretについて、KCNAレベルで必ず知っておくべき事実が1つあります。base64はエンコーディングであって、暗号化ではありません。デフォルト設定では、Secretはetcdに事実上平文で保存され、kubectl get secret ... -o jsonpathの結果にbase64 -dを一度かければ原文が出てきます。本当の暗号化は、EncryptionConfigurationを別途有効にする必要があります。(詳しくはKCSAで扱います。)
ストレージ
- PersistentVolume(PV): 実際のストレージです。管理者が作るか、プロビジョナーが作ります。
- PersistentVolumeClaim(PVC): ユーザーの申請書です。「1GiのRWOストレージが必要です」という内容です。
- StorageClass: 申請書を見てPVを自動で作るルールです。
PodはPVを直接指さず、PVCを指します。この間接層のおかげで、バックエンドがNFSでもブロックストレージでも、Podのマニフェストはそのままです。
アクセスモードも試験によく出ます。ReadWriteOnce(1つのノードで読み取り/書き込み)、ReadOnlyMany(複数のノードで読み取り)、ReadWriteMany(複数のノードで読み取り/書き込み)です。RWXをサポートするバックエンドは限られるため、NFSのようなファイルストレージを使うことになります。
現場での姿
筆者のホームラボは、kube-proxyをそもそもインストールせず、CiliumのeBPFでServiceのロードバランシングを代替しています。そして、それが実際に動いているかを確認しました。ノードのiptablesのKUBE-チェーンは0個で、代わりにeBPFマップに、10.96.0.10:53/TCP → 10.244.0.150:53/TCP、10.96.0.1:443/TCP → 10.0.0.120:6443/TCPのようなフロントエンド-バックエンドのマッピングが直接入っていました。
ここで面白いブートストラップ問題が出てきます。kube-proxyがないと、Cilium自身がAPIサーバーに接続するときに、kubernetesサービスのClusterIP(10.96.0.1)を使えません。そのVIPを作ってくれる主体が、Cilium自身だからです。そのため、インストール時にk8sServiceHost=10.0.0.120、k8sServicePort=6443で実際のアドレスを直接教えなければなりません。Serviceが「間接層」だという言葉の意味が、ここで鮮明になります。
同じクラスターのストレージは、csi-driver-nfsでNASを接続し、RWXを使っています。L4ロードバランサーはMetalLBで、アドレスプールは10.0.0.200-215です。Giteaが.200、ArgoCDが.201、Harborが.202、Grafanaが.203を受け取りました。LoadBalancerタイプのServiceが、実際に「プールからIPを1つ切り出して割り当てる」動作であることを、目で見て確認しやすい構成です。
次のラボですること
次のラボでバックエンドのDeploymentを立ち上げ、ClusterIP Serviceを付けて、エンドポイントが捕捉されるかを確認します。そのあと、わざとセレクターにタイプミスのあるServiceを作ってエンドポイントが0件になる状況を再現し、NodePort・ConfigMap/Secretの注入・PVCの申請・DNS名のルールまで、手で確認します。