あなたの知らないKubernetesのServiceの仕組み

2021年06月15日 火曜日


【この記事を書いた人】
田口 景介

社会人生活の半分をフリーランス、半分をIIJで過ごすエンジニア。元々はアプリケーション屋だったはずが、クラウドと出会ったばかりに半身をインフラ屋に売り渡す羽目に。現在はコンテナ技術に傾倒中だが語りだすと長いので割愛。タグをつけるならコンテナ、クラウド、ロードバイク、うどん。

「あなたの知らないKubernetesのServiceの仕組み」のイメージ

Kubernetesの主要なリソースの一つにServiceリソースがあります。ServiceリソースとはKubernetes上のPodへクラスタの外からアクセスするために使うもの、という理解をしている人が多いかもしれません。確かにそのような役割を担っているのですが、実際にはクラスタ内部に閉じた通信にも利用されていますし、実はもっといろいろな機能を持っています。

端的に説明すれば、Serviceとは「ロードバランサとDNSサーバを設定するためのリソース」です。意外に聞こえますか? もし意外に思えたなら、ぜひこのまま読み進めてみてください。

インターナルなロードバランサを制御する

Kubernetesにはクラスタ内部に閉じた通信を制御するロードバランサが内蔵されています。Kubernetesを利用するということは、ほぼ例外なくこのロードバランサを利用しているのですが、あまり意識せずに利用されていることが多いかもしれません。

たとえば、以下の操作でnginxをデプロイし、Service経由でアクセスできるようにします。この場合、クラスタの外からはアクセスできませんが、実はすでにロードバランサが構成されています。

$ kubectl create deploy nginx --image=nginx:1.19
$ kubectl expose deploy nginx --port=80 --target-port=80

作成されたPodリソース、Serviceリソース、Endpointリソース(Serviceリソースと対になり自動的に作成されます)を見てみましょう。注目してほしいのは以下2つのIPアドレスです。

  • ServiceのCLUSTER-IP: 10.102.97.154
  • EndpointのENDPOINTS: 192.168.76.141(=Podのアドレス)

$ kubectl get svc nginx
NAME    TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
nginx   ClusterIP   10.102.97.154   <none>        80/TCP    98s
$ kubectl get ep nginx
NAME    ENDPOINTS           AGE
nginx   192.168.76.141:80   106s
$ kubectl get pods --selector=app=nginx -o wide
NAME                     READY   STATUS    RESTARTS   AGE     IP               NODE          NOMINATED NODE   READINESS GATES
nginx-7b54d48599-2j79v   1/1     Running   0          3m14s   192.168.76.141   xxxxxxxxxxx   <none>           <none>

これは「10.102.97.154へアクセスすると、ロードバランサによって192.168.76.141へルーティングされる」ように設定されたことを意味します。つまり、service cluster networkとはロードバランサがサービスを公開するためのネットワークであり、Endpointリソースとはロードバランサがルーティングする宛先を管理するリソースということです。

 

図1 インターナルなロードバランサ

Podを2個にスケールアウトするとEndpointリソースには2つのIPアドレスが現れますが、これはロードバランサによって2つのPodへ負荷分散されるようになったことを意味します。

$ kubectl scale deploy nginx --replicas=2
$ kubectl get ep nginx
NAME    ENDPOINTS                             AGE
nginx   192.168.76.141:80,192.168.76.142:80   10m

このロードバランサはkube-proxyによって制御されていて、モードがいくつかあるのですが、ipvsモード(LVSなどで使われているLinuxのカーネルモジュールとして実装されたロードバランサ)を利用している場合は、ipvsadmコマンドで以下のようにロードバランサが設定されていることを確認できます。
$ sudo ipvsadm -L  -n
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  10.102.97.154:80 rr
  -> 192.168.76.141:80            Masq    1      0          0         
  -> 192.168.76.142:80            Masq    1      0          0

たったこれだけの操作でWebサーバがデプロイされ、ロードバランサによって負荷分散および冗長化されたシステムが作れるのはすごいと思いませんか?今回はヘルスチェックの設定を省略していますが、数行追加すれば障害が発生したノードを自動的にロードバランサから除外することも可能です。

Kubernetesの権威ネームサーバ

次にDNSの話をしましょう。Kubernetesはクラスタ内部に権威ネームサーバを持っています。このネームサーバはクラスタ外からアクセスされることはなく(できなくはありません)、主な用途はロードバランサが公開したservice cluster networkのIPアドレスに名前をつけることです。そして、その名前付けはServiceリソースを作成すると自動的に行われます。

前述の例であれば、ネームスペースdefaultにnginxという名前でServiceリソースを作成したので、「nginx.default.svc.cluster.local.」というFQDNでネームサーバへAレコードが登録されていることが確認できます。名前解決されたIPアドレスが前述のServiceのCLUSTER-IPと一致していることを確認してください。これによって、Serviceリソースを作成するとロードバランサが構成され、またそのロードバランサが公開したアドレスはネームサーバにAレコードが登録されるため、クラスタ内部では名前でアクセスできるということがわかりました。

$ dig  @10.96.0.10 nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN A       10.102.97.154

また、各Podの中では例えば以下のように複数のサーチドメインがresolv.confに設定されています。つまり、FQDNでは「nginx.default.svc.cluster.local.」ですが、単に「nginx」でも、「nginx.default」でも同じように名前解決できるということです。Pod間で通信する際にはServiceが登録したホスト名を使いますが、このときネームスペース名以降を省いて「nginx」だけで参照するようにしておけば、どのネームスペースにデプロイしても動くようになるので、必要以上にドメインを指定しない使い方がお勧めです。
# cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local 
options ndots:5

ヘッドレスサービス

Serviceリソースにはheadless serviceと呼ばれる使い方があります。これが何者なのか、DNS的な観点から説明します。

headless serviceとはcluster ipにNoneを指定したサービスであるとされています。つまり、ロードバランサがサービスを公開するアドレスがないということです。

$ kubectl expose deploy nginx --cluster-ip=None
$ kubectl get svc nginx
NAME    TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
nginx   ClusterIP   None         <none>        <none>    76s
$ kubectl get ep nginx
NAME    ENDPOINTS                       AGE
nginx   192.168.76.141,192.168.76.142   2m8s

察しのいい方はすでにおわかりかと思いますが、これはロードバランサを構成せず、DNSの設定だけを行うServiceリソースです。つまり、ロードバランサを使わずにDNSラウンドロビンしたいときに使うServiceということです。さきほどは「nginx.default.svc.cluster.local.」のAレコードにCluster IPが設定されていましたが、今度はEndpointのアドレス(=Podのアドレス)が直接Aレコードに設定され、Podが2個あるので2つ登録されていることがわかります。このように同一のドメイン名に複数設定されたAレコードは名前解決するたびに並び順が変わるため、返却された先頭のアドレスを使うことで、一種の負荷分散に利用することができます。
$ dig  @10.96.0.10 nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN A       192.168.76.141
nginx.default.svc.cluster.local. 30 IN A       192.168.76.142

どんなときにロードバランサを使わずに、DNSラウンドロビンを使うべきかという話についてはまたの機会に。

StatefulSetリソースとヘッドレスサービス

ヘッドレスサービスについて解説したので、ついでにStatefulSetリソースにも触れておきましょう。StatefulSetリソースを使うには、必ずペアになるヘッドレスサービスが必要です。理由は明快で、StatefulSetが生成するPodは通常それぞれにPersistentVolumeが割り当てられ、異なるデータを格納するからです。格納するデータが異なるということは、すべてのPodで同じプログラムが動いていても、どのPodへリクエストするかで結果が異なるということです。したがって、StatefulSetリソースでデプロイされたPodを利用する場合、ロードバランサあるいはDNSラウンドロビンによって任意のPodへルーティングされるという使い方だけではなく、どのPodへリクエストするのか宛先ホスト名を明示して使うことがあります。そのため、StatefulSetリソースを使うとPodごとに固有のFQDNが払い出され、ネームサーバに登録されるのです。

試しにStatefulSetを作ってみましょう。StatefulSetのくせにPVCを持っていませんが、サンプルなので気にしないように。

$ kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  clusterIP: None
  selector:
    app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nginx
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.19
EOF

今までと少し違うのは、普通のSerivceリソースの場合、「<Namespace名>.svc.cluster.local」というドメインにレコードが作成されますが、StatefulSetはService名でサブドメインを作り、そこにAレコードを登録していきます。この例では、FQDNが「<Pod名>.<Service名>.<Namespace名>.svc.cluster.local」になるということです。こうしないとネームスペース名とService名が重複した場合困りますからね。これでPodごとに固有のホスト名をつけることができるようになりました。

StatefulSetはPersistentVolumeを持つステートフルなワークロードを管理するためのものと説明されることが多いのですが、単にPodごとにDNSで名前解決できるホスト名をつけたい場合に使ってもよいでしょう(デフォルトでもありますが実用にはならないので)。

$ dig  @10.96.0.10 nginx-0.nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx-0.nginx.default.svc.cluster.local. 30 IN A 192.168.136.206
$ dig  @10.96.0.10 nginx-1.nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx-1.nginx.default.svc.cluster.local. 30 IN A 192.168.76.144

ちなみに、StatefulSetを使う場合でも普通のheadless serviceと同じようにDNSラウンドロビンされる設定もされています。
$ dig  @10.96.0.10 nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN A       192.168.76.144
nginx.default.svc.cluster.local. 30 IN A       192.168.136.206

ラベルセレクタのないサービス

ヘッドレスサービス以外にも、少し変わったServiceの使い方があります。まず、ラベルセレクタのないサービスです。通常Serviceリソースにはラベルセレクタを設定し、そのラベルセレクタにマッチしたPodがEndpointリソースに登録されるという働きになります。このラベルセレクタがないということは、自動的にEndpointリソースが管理されないということです。逆に言えば、Endpointに任意のIPアドレスを登録することができるということです。

これはPodではなくクラスタの外のアドレスに名前をつけたい場合に使うもので、以下のようにラベルセレクタのないサービスを作り、あわせてEndpointサービスを手作業で作ります。ここでは、202.232.2.180というグローバルアドレスを設定しています。

$ kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
  labels:
    app: nginx
  name: nginx
subsets:
- addresses:
  - ip: 202.232.2.180
EOF

これまでと同じように「nginx.default.svc.cluster.local」を名前解決すると、Endpointリソースに設定したグローバルアドレスを取得できることがわかります。この例ではクラスタの外であることを示すためにわかりやすくグローバルアドレスを使いましたが、実際にはクラスタの横で動いている仮想マシンなど、プライベートアドレスだがクラスタの外のアドレスを名前で解決するために利用することが多いと思います。
$ dig  @10.96.0.10 nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN A       202.232.2.180

ExternalName Service

まだあります。今度はExternalName Serviceです。これはIPアドレスを管理するEndpointリソースを使わず、代わりにホスト名を設定するというものです。

$ kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  type: ExternalName
  externalName: www.iij.ad.jp
EOF
$ kubectl get svc
NAME    TYPE           CLUSTER-IP   EXTERNAL-IP     PORT(S)   AGE
nginx   ExternalName   <none>       www.iij.ad.jp   <none>    68s

DNSに詳しい方でしたら、すでに説明は不要だと思いますが、externalNameを指定するとネームサーバにはCNAMEレコードが登録されます。ラベルセレクタがないサービスの例で登場したグローバルアドレスは実は弊社のコーポレートサイトのアドレスなのですが、本当にServiceリソースに登録したければexternalNameを使う方が100倍わかりやすくなりますね(そんなユースケースはないと思いますけれど)。
$ dig  @10.96.0.10 nginx.default.svc.cluster.local.
;; ANSWER SECTION:
nginx.default.svc.cluster.local. 30 IN CNAME   www.iij.ad.jp.
www.iij.ad.jp.          30      IN      A       202.232.2.180

いろいろ見てきましたが、ServiceがロードバランサとDNSサーバを管理するリソースであるということが理解できたのではないでしょうか。そして、Serviceリソースにはいろいろなオプションがありますが、それはDNSの設定に応じたものであり、それ以上の機能ではありません。

今回はservice type ClusterIP、ExternalNameの話だけでしたが、service type LoadBalancerやExternalIPにはまた違ったややこしくもおもしろいネタがあるので、いずれ取り上げたいと思います。そして、その後にはnew Service APIと呼ばれるGatewayリソースがやってきますので、そのあたりも次の機会に。

関連リンク

IIJの商用サービスのインフラ改善を目指し、IIJ用KubernetesディストリビューションIKE(IIJ Kubernetes Engine)の開発を進めるIIJのSREチーム。
メンバーがウォッチしたKubernetes業界の話題と、大規模サービスインフラに求められるSREの気づきを投稿しています。

田口 景介

2021年06月15日 火曜日

社会人生活の半分をフリーランス、半分をIIJで過ごすエンジニア。元々はアプリケーション屋だったはずが、クラウドと出会ったばかりに半身をインフラ屋に売り渡す羽目に。現在はコンテナ技術に傾倒中だが語りだすと長いので割愛。タグをつけるならコンテナ、クラウド、ロードバイク、うどん。

Related
関連記事