30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Docker Desktop を使って学ぶ Kubernetes の基本的な仕組み

この記事では Docker Desktop 上で Kubernetes クラスタを作り、実際に動かしながら、Kubernetes の基本的な仕組みについて説明していく。

動作確認は以下の環境で行った。

  • Docker Desktop 4.22.1
  • Kubernetes 1.27.2

事前準備

Kubernetes の有効化

Docker Desktop のダッシュボードから設定画面を開き、Enable Kubernetesのような項目を有効にすると Kubernetes を使えるようになる。

kubectlコマンドが使えるようになっていれば問題ないはず。

$ kubectl -h
kubectl controls the Kubernetes cluster manager.

コンテナイメージの用意

sampleという名前で、以下の内容のウェブサーバが動くコンテナイメージを作成しておく。

import http from "http";

http
  .createServer(function (_, res) {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Hello World\n");
  })
  .listen(3000);

Docker を使ったコンテナイメージの作り方は以下を参照。

numb86-tech.hatenablog.com

クラスタとリソース

Kubernetes は、コンテナ化されたアプリケーションのデプロイや運用を効果的に行うためのプラットフォーム。
複数(場合によってはひとつ)のコンピュータ(物理マシンや仮想マシン)上でコンテナを動かす。このコンピュータのことをノードといい、ノードの集合体のことをクラスタという。

Docker Desktop で Kubernetes を動かす場合docker-desktopという名前のクラスタが作られるが、このクラスタを構成するノードは物理マシンや仮想マシンではない。Kubernetes を動かすために必要な各種コンポーネントが Docker Desktop 上でコンテナとして作られ、それらがノードの役割を果たす。
そのため、ローカルマシン上にクラスタが存在するが、ローカルマシンそのものがノードというわけではない。

Kubernetes には「リソース」という概念があり、それを使ってクラスタの状態や動作を記述する。そうすることで Kubernetes は、記述された内容を実現・維持しようとする。
リソースには様々な種類があり、適切なリソースに対して適切な記述をすることで、クラスタを意図通りに動作させることができる。
具体的には、マニフェストファイルと呼ばれる定義ファイルにリソースの設定を記述し、それを Kubernetes に伝える。本記事ではkubectl applyコマンドを使ってマニフェストファイルの内容を Kubernetes に伝える。

早速manifestfile.yamlという名前のマニフェストファイルを作ってみる。
内容の説明は必要に応じて後から行うので、今は読まなくてもよい。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-dep # Deployment の名前
spec:
  selector: # Deployment が管理する Pod をどのように選択するか定義する
    matchLabels:
      app: node-app # app:node-app というラベルの Pod をこの Deploymentが 管理する
  replicas: 3 # 保ちたい Pod の数
  template: # 作成する Pod の情報を書いていく
    metadata:
      labels:
        app: node-app
    spec:
      containers:
      - name: my-pod
        image: sample:latest # 事前に作成した container image
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: my-ser # Service の名前
spec:
  type: NodePort
  ports:
  - name: my-ser-port
    port: 8099 # Service の port
    targetPort: 3000 # Pod の port
    nodePort: 32660 # ワーカーノードの port
  selector:
    app: node-app # Service が転送を行う Pod を指定

そして$ kubectl apply -f マニフェストファイルのパスを実行する。

$ kubectl apply -f manifestfile.yaml
deployment.apps/my-dep created
service/my-ser created

これでsampleから作られたコンテナが Kubernetes 上で動いているはずなので、curl で動作確認してみる。なぜポート番号が32660なのかは後述するので気にしなくてよい。

$ curl localhost:32660
Hello World

意図通りのレスポンスが返ってきた。
つまり、コンテナが動作しており、それに対してクラスタ外からアクセスできるようになっている。
そしてこれも後述するが、sampleから作られたコンテナは 3 つ存在しており、クラスタはその数を維持しようとする。

本記事ではこれ以降、このクラスタがなぜそのような動作をしているのか、そしてそれを実現させる上で各リソースがどんな役割を果たしているのか、といったことを見ていく。そしてそれを通して Kubernetes の初歩を学んでいく。

一旦、クラスタの状態を元に戻しておく。反映させたマニフェストファイルの設定を取り消すためには、$ kubectl delete -f マニフェストファイルのパスを実行する。

$ kubectl delete -f manifestfile.yaml
deployment.apps "my-dep" deleted
service "my-ser" deleted

Pod と Deployment

Kubernetes は、Pod というリソースでコンテナを管理する。
ひとつの Pod で複数のコンテナを管理することもできるが、この記事ではひとつの Pod はひとつのコンテナのみを扱うことにする。

さらに、Pod を管理するための Deployment というリソースがある。Deployment を使うことでコンテナの管理が容易になる。

マニフェストファイルに Deployment について記述してみる。その場合、Deployment の設定のなかに Pod の設定を記述する。
manifestfile.yamlというファイルを作成し、そこに以下の内容を書いていく。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-dep # Deployment の名前
spec:
  selector: # Deployment が管理する Pod をどのように選択するか定義する
    matchLabels:
      app: node-app # app:node-app というラベルの Pod をこの Deploymentが 管理する
  replicas: 3 # 保ちたい Pod の数
  template: # 作成する Pod の情報を書いていく
    metadata:
      labels:
        app: node-app
    spec:
      containers:
      - name: my-pod
        image: sample:latest # 事前に作成した container image
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 3000

このマニフェストファイルでは以下の設定を行っている。

  • my-depという名前の Deployment を作る
    • この Deployment はapp:node-appというラベルの Pod を管理する
    • Pod を 3 つ作りそれを維持する
  • my-podという名前の Pod を作る
    • この Pod のラベルはapp:node-app
    • コンテナイメージとしてsample:latestを使う
    • imagePullPolicy: IfNotPresentは、該当するコンテナイメージがないか、まずローカルを探すようにするための設定
    • コンテナは3000ポートで通信を受け付ける
      • 冒頭で示したようにsample3000ポートで通信を受け付けるので、それに合わせている

applyを実行して上記の設定をクラスタに反映させる。

$ kubectl apply -f manifestfile.yaml
deployment.apps/my-dep created

$ kubectl get deploymentsでクラスタ内の Deployment 一覧を見れるので、確認してみる。

$ kubectl get deployments
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
my-dep   3/3     3            3           34s

my-depが作られている。

同じ要領でクラスタ内の Pod 一覧も見れる。

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
my-dep-765c757fd8-4gxdp   1/1     Running   0          42s
my-dep-765c757fd8-8p7m9   1/1     Running   0          42s
my-dep-765c757fd8-nxsck   1/1     Running   0          42s

Pod が 3 つ作られている。

$ kubectl describe pod Pod名で Pod の詳細情報を得られる。
多くの情報が表示されるので一部を抜粋して載せるが、sample:latestから作られたmy-pod3000ポートを開けて動いていることが分かる。

$ kubectl describe pod my-dep-765c757fd8-4gxdp
(省略)
Containers:
  my-pod:
    Container ID:   docker://671805cbdd2a40895c135fd6e0d8d5438351d96fc2f1c0ea5e8f973610a4872a
    Image:          sample:latest
    Image ID:       docker://sha256:3ce5b6edbe20936ef2b9d495e107555f584760e1960d2257a4452ee4100230a1
    Port:           3000/TCP
    Host Port:      0/TCP
    State:          Running
(省略)

Pod の数は維持される

先程「Pod を 3 つ作りそれを維持する」と書いたが、Deployment は指定された数の Pod を維持しようとする。

試しに Pod をひとつ削除してみる。

$ kubectl delete pod my-dep-765c757fd8-4gxdp
pod "my-dep-765c757fd8-4gxdp" deleted

そのあとに$ kubectl get podsを実行すると以下の結果になる。

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
my-dep-765c757fd8-8p7m9   1/1     Running   0          14m
my-dep-765c757fd8-cr46q   1/1     Running   0          4s
my-dep-765c757fd8-nxsck   1/1     Running   0          14m

my-dep-765c757fd8-4gxdpは確かに削除されたが、その代わりにmy-dep-765c757fd8-cr46qが新しく作られ、Pod の数は 3 つに保たれている。

では、何らかの理由で数を変えたいときはどうすればよいのか。
マニフェストファイルのspec.replicasの記述を変え、再度 apply すればいい。

--- a/manifestfile.yaml
+++ b/manifestfile.yaml
@@ -6,7 +6,7 @@ spec:
   selector: # Deployment が管理する Pod をどのように選択するか定義する
     matchLabels:
       app: node-app # app:node-app というラベルの Pod をこの Deploymentが 管理する
-  replicas: 3 # 保ちたい Pod の数
+  replicas: 1 # 保ちたい Pod の数
   template: # 作成する Pod の情報を書いていく
     metadata:
       labels:
$ kubectl apply -f manifestfile.yaml
deployment.apps/my-dep configured

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
my-dep-765c757fd8-8p7m9   1/1     Running   0          17m

1 つになっている。

spec.replicas3に戻して apply すればまた 3 つになる。

$ kubectl apply -f manifestfile.yaml
deployment.apps/my-dep configured

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
my-dep-765c757fd8-68m8d   1/1     Running   0          2s
my-dep-765c757fd8-8p7m9   1/1     Running   0          20m
my-dep-765c757fd8-ss7gm   1/1     Running   0          2s

Pod の IP アドレス

Pod にはそれぞれ IP アドレスが振られている。
先程紹介した$ kubectl describe podでもその Pod の IP アドレスを見れるが、$ kubectl get pods -o wideを使うと各 Pod の IP アドレスを一覧で見れる。

$ kubectl get pods -o wide
NAME                      READY   STATUS    RESTARTS   AGE    IP           NODE             NOMINATED NODE   READINESS GATES
my-dep-765c757fd8-68m8d   1/1     Running   0          101s   10.1.0.152   docker-desktop   <none>           <none>
my-dep-765c757fd8-8p7m9   1/1     Running   0          22m    10.1.0.149   docker-desktop   <none>           <none>
my-dep-765c757fd8-ss7gm   1/1     Running   0          101s   10.1.0.151   docker-desktop   <none>           <none>

だがこの IP アドレスはクラスタ内部で通信するためのものであり、クラスタ外からこの IP アドレスを使って Pod と通信することはできない。
$ curl 10.1.0.152:3000を実行してもレスポンスは得られない。

クラスタ内部では通信できることを確認するため、Pod から Pod にリクエストを送ってみる。
kubectl exec -it Podの名前 -- 実行したいコマンドでコンテナ内でコマンドを実行できるので、それを使う。

$ kubectl exec -it my-dep-765c757fd8-8p7m9 -- curl 10.1.0.152:3000
Hello World

my-dep-765c757fd8-8p7m9からmy-dep-765c757fd8-68m8d(IP アドレス10.1.0.152:3000)にリクエストを送り、レスポンスを得られた。

しかし、いくらクラスタ内部で通信できたところで、外部からアクセスできないのでは、ウェブアプリケーションとしての実用性はない。
Service というリソースを使うことで、外部からアクセスできるようになる。

Service

Service は通信に関する様々な役割を担う。

Deployment のときと同様、マニフェストファイルに Service を記述する。
リソース毎にマニフェストファイルを用意することもできるが、この記事では全てのリソースについてひとつのマニフェストファイル(manifestfile.yaml)に書くことにする。

複数のリソースをひとつのマニフェストファイルに書くときは---で区切る必要があるので、その下に Service を書いていく。

# 既に説明した Deployment の設定がここに書かれてある
---
apiVersion: v1
kind: Service
metadata:
  name: my-ser # Service の名前
spec:
  type: NodePort
  ports:
  - name: my-ser-port
    port: 8099 # Service の port
    targetPort: 3000 # Pod の port
    nodePort: 32660 # ワーカーノードの port
  selector:
    app: node-app # Service が転送を行う Pod を指定

spec.typeとしてNodePortを設定した。spec.typeには他の設定もあるが、この記事ではNodePortである前提で話を進めていく。

apply してmy-serが作られていることを確認する。

$ kubectl apply -f manifestfile.yaml
deployment.apps/my-dep unchanged
service/my-ser created

$ kubectl get services my-ser
NAME     TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
my-ser   NodePort   10.96.104.45   <none>        8099:32660/TCP   3s

Service は自身にアクセスがあると、それを Pod に転送してくれる。
どの Pod に転送するのかは、spec.selectorで指定する。今回はapp: node-appというラベルの Pod に転送したいので、そのように書いた。
また、Pod のポート番号をspec.ports[].targetPortに書く必要があるので、これも Pod の設定に合わせて3000を書いている。

これで、Service にリクエストがあるとapp: node-appラベルの Pod、つまり先程 Deployment で作った Pod に転送されるようになった。

この設定が意図した通りに行われているか、$ kubectl get endpoints サービス名で確認できる。

$ kubectl get endpoints my-ser
NAME     ENDPOINTS                                         AGE
my-ser   10.1.0.149:3000,10.1.0.151:3000,10.1.0.152:3000   3m15s

my-serは、自身にリクエストがあったときにこのEndpointsのいずれかに転送してくれる。
そしてこれらのエンドポイントは、既に見た Pod の IP アドレスにspec.ports[].targetPortで設定したポート番号を組み合わせたものと一致している。

 $ kubectl get pods -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP           NODE             NOMINATED NODE   READINESS GATES
my-dep-765c757fd8-68m8d   1/1     Running   0          33m   10.1.0.152   docker-desktop   <none>           <none>
my-dep-765c757fd8-8p7m9   1/1     Running   0          53m   10.1.0.149   docker-desktop   <none>           <none>
my-dep-765c757fd8-ss7gm   1/1     Running   0          33m   10.1.0.151   docker-desktop   <none>           <none>

Service にリクエストを送ると Pod に転送してくれることは分かったが、そもそも Service へのリクエストはどのように行えばよいのか。

Service が作られると自動的に IP アドレスが割り振られるので、それを使って Service と通信を行うことができる。
kubectl get services サービス名で表示されるCLUSTER-IPが、その IP アドレスである。

$ kubectl get services my-ser
NAME     TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
my-ser   NodePort   10.96.104.45   <none>        8099:32660/TCP   10m

そしてspec.ports[].portで指定した値が Service のポート番号になる。

つまり今回の場合、10.96.104.45:8099で Service と通信できる。

Pod 間通信のときと同様、Pod から Service にリクエストを送ってみる。

$ kubectl exec -it my-dep-765c757fd8-8p7m9 -- curl 10.96.104.45:8099
Hello World

Hello Worldが返ってきた。
my-dep-765c757fd8-8p7m9という Pod から Service にリクエストを送り、それを受け取った Service がいずれかの Pod にリクエストを転送、Pod 内で動いているコンテナがレスポンスを返したため、このような結果になった。

ノードへのアクセス

わざわざ Pod 経由で Service にリクエストを送ったことで気付いている方もいるかもしれないが、Service にも、クラスタ外からアクセスすることはできない。
spec.typeNodePortの場合、ノードに対してリクエストを送ることで、外部からクラスタにアクセスできるようになる。

NodePortのときは全てのワーカーノード(ノードのうち、実際にその上でコンテナが動いているノード。他にマスターノードがある。)のポートが開くので、そこに対してリクエストを送ればよい。
具体的にはspec.ports[].nodePortで指定した値が開かれる。そのため今回は32660になる。

Docker Desktop の場合、Docker Desktop を動かしているローカルマシン上にノードがひとつ存在し、そのノードがマスターノードとワーカーノードも兼ねており、ローカルマシンのIPアドレス:32660で外部からアクセスできるようになる。

$ curl localhost:32660
Hello World

まとめると、「ノード -> Service -> Pod」という順番にルーティングされていくことで、Pod のなかで動いているコンテナがリクエストを受け取ることができるのである。