以前にラズパイ3(メモリ1GB)を3台使ってコンテナクラスタのオーケストレーション環境を作ったことがありました:
ラズベリーパイと鳩サブレ缶で docker swarm クラスタを構築する
ラズベリーパイと鳩サブレ缶で kubernetes クラスタを構築する

8ea25b3e


上記の上では docker swarm 環境、下では kubernetes 環境を構築しました。手順などの詳細はそれぞれのリンク先を参照していただきたいのですが、この時は kubernetes 1.8 を使ってクラスタを構築しました。kubernetes の環境としては作れたのですが、(案の定というか)メモリが圧倒的に足りなさすぎて実用にはほど遠い kubernetes 環境となってしまいました。マイナーな環境であることは理解しつつ、今でもラズパイ3でのオーケストレーション環境としては docker swarm が現実的なのかなあ、と感じています。


さて時は流れ、ラズパイ4が発売され、日本でも入手できるようになりました。CPU 等の強化もありますが、なんといっても4GBのメモリモデルを選ぶことができるようになったモデルです。上述のメモリ不足が解消される期待ができる上に CPU 等も強化されているわけなので、まともに動く(苦笑) kubernetes 環境構築にも期待できそうです。早速使ってみました。


【環境構築手順】
基本的には特別なことはしていないのですが、一応一通りの説明をしていきます。なお今回のクラスタ環境には計3台のラズパイ4を使って1台のマスターノードと2台のワーカーノードを構築しています。以下ではそれぞれ k8s-master-01, k8s-worker-01, k8s-worker-02 という名称で呼ぶことにします。

【3台共通で行う作業】
全てのノードに共通で行う作業は以下になります:
・Raspbian OS Buster 最新版の導入
・ネットワークなどを設定して再起動
・docker インストール
・kubectl などのコマンドをインストール

まず OS はラズパイ4向けにリリースされた Raspbian OS Buster を使います。こちらのサイトから "Raspbian Buster Lite" と書かれたイメージの最新版をダウンロード&展開して、MicroSD カードに焼き付けてください(私が試した時は "September 2019" バージョンが最新でした):
2019121601


起動後、ネットワークを有効にした上でターミナルから以下のコマンドを実行し、各種モジュールを最新版にしておきます。ここで少し時間がかかります:
$ sudo apt-get update -y

$ sudo apt-get upgrade -y

この後で一度再起動をかけるのですが、その再起動前に変更しておくべき項目がいくつかあります。まずは /boot/cmdline.txt を編集します。これは再起動の後 kubernetes 関連のコマンドを導入して動かす際に必要な cgroups の設定です:
$ sudo vi /boot/cmdline.txt

(cgroup_enable=cpuset cgroup_enable=memory cgroup_memory=1 を追加)

同様にして再起動前に以下のコマンドを実行してスワップメモリを無効&再起動後も無効に設定します。最近の kubernetes はスワップメモリがあると起動に失敗するので、予め無効にしておくための設定です:
$ sudo swapoff --all

$ sudo apt-get purge -y --auto-remove dphys-swapfile

$ sudo rm -fr /var/swap

また IP アドレスやホスト名の設定も行っておきましょう。以下の説明では3台のラズパイがそれぞれ以下のような固定 IP アドレス及びホスト名で動かす想定とします(IP アドレスなど設定が異なる場合は適宜読み替えてください):
ホスト名IPアドレスノードの用途
k8s-master-01192.168.1.200マスター
k8s-worker-01192.168.1.201ワーカー
k8s-worker-02192.168.1.202ワーカー


まず /etc/dhcpcd.conf を編集して以下の部分を追加し、固定 IP アドレスを取得するように変更します:
$ sudo vi /etc/dhcpcd.conf

(以下は無線 LAN(wlan0) で 192.168.1.200 に設定する場合)
interface wlan0
static ip_address=192.168.1.200/24
static routers=192.168.1.1
static domain_name_servers=192.168.1.1 8.8.8.8

次に /etc/hostname を編集して、raspberrypi と書かれた内容をそれぞれのホスト名で書き換えます:
$ sudo vi /etc/hostname

(k8s-master-01 の場合)
raspberrypi  k8s-master-01

また /etc/hosts を編集して、他の2ノードにも名前でアクセスできるように名前解決ルールを記載しておきます:
$ sudo vi /etc/hostname

(k8s-master-01 の場合)
127.0.1.1   raspberrypi  k8s-master-01

(以下は3台全てに追加する3行)
192.168.1.200  k8s-master-01
192.168.1.201  k8s-worker-01
192.168.1.202  k8s-worker-02

また以下は必須ではないのですが、固定の IP アドレスを設定する場合は SSH でアクセスできるようになるとこの後の作業が(直接ログインの必要がなくなり、リモートから SSH 接続後に作業できるようになって)楽なので、SSH 接続を有効にしておくと便利です。sudo raspi-config を実行してから、"Interfacing options" - "SSH" を選択して、SSH を有効にしておきます:
2019121602


ここまでの作業が済んだら一度再起動をかけます:
$ sudo shutdown -r now

再起動後、再度(SSH などで)ログインして、SSH 鍵を3台のラズパイ間で共有します。この作業は3台のラズパイが全て(上述の IP アドレスの再設定などを行った上での)再起動をして、全てネットワークに接続して稼働している状態で3台全てで行う必要があります:
$ ssh-keygen -t rsa

(いろいろ聞かれるけど、全て無指定のまま Enter でもOK)
$ ssh-copy-id k8s-master-01 $ ssh-copy-id k8s-worker-01 $ ssh-copy-id k8s-worker-02

次に docker を導入します:
$ curl -sSL https://get.docker.com/ | sh

$ sudo usermod -aG docker pi

(この後、一度ターミナルをログアウトして抜けて、再度ターミナルを開く)

次に kubelet, kubeadm, kubectl, kubenetes-cni などをインストールしますが、その準備として新しいリポジトリを登録する必要があります。まずはその準備として apt コマンドを https のリポジトリでも実行できるよう環境を用意しておきます:
$ sudo apt-get install -y apt-transport-https

そしてリポジトリのソースリストに kubernetes を追加します:
$ curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -

$ echo "deb http://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list

$ sudo apt-get update

ここまでの準備で kubectl などのコマンドを導入することができるようになったので、まとめて導入し、更にバージョンが変更されることがないよう固定します:
$ sudo apt-get update

$ sudo apt-get install kubelet kubeadm kubectl kubernetes-cni

$ sudo apt-mark hold kubelet kubeadm kubectl kubernetes-cni

3台共通で行う導入作業は以上です。


【マスターノード上で行う作業】
マスターノード上では flannel を使って kubernetes クラスタのコントロールプレーンを初期化します(このコマンド終了後に(kubeadm join)で始まる行が表示されるので、その内容を保存しておきます):
$ sudo kubeadm init --pod-network-cidr=10.244.0.0/16

上述コマンドの実行結果に含まれるコマンドを実行します:
$ mkdir -p $HOME/.kube

$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config

$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

最後にコンテナ間通信のためのモジュール flannel を導入します:
$ kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/62e44c867a2846fefb68bd5f178daf4da3095ccb/Documentation/kube-flannel.yml

これでマスターノード上の作業は完了です。以下の kubectl version コマンドを実行して正しく結果が返ってくることを確認しておきます:
$ kubectl version

Client Version: version.Info{Major:"1", Minor:"17", GitVersion:"v1.17.0", GitCommit:"70132b0f130acc0bed193d9ba59dd186f0e634cf", GitTreeState:"clean", BuildDate:"2019-12-07T21:20:10Z", GoVersion:"go1.13.4", Compiler:"gc", Platform:"linux/arm"}
Server Version: version.Info{Major:"1", Minor:"17", GitVersion:"v1.17.0", GitCommit:"70132b0f130acc0bed193d9ba59dd186f0e634cf", GitTreeState:"clean", BuildDate:"2019-12-07T21:12:17Z", GoVersion:"go1.13.4", Compiler:"gc", Platform:"linux/arm"}


【ワーカーノード上で行う作業】
続いて上述の kubeadm init コマンド正常終了時に出ていた、kubeadm join で始まるコマンドをワーカーノード上で実行すると、用意したマスターノードにワーカーノードが追加される形になります:
$ sudo kubeadm join 192.168.1.200:6443 --token XXXXXXXXXXXXXXXXX --discovery-token-ca-cert-hash sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

全てのワーカーノードが Ready ステータスになるまで少し時間がかかりますが、これでワーカーノードがマスターノードに紐付けられる形で追加されます。この状態になっていることを確認するためにマスターノード上で kubectl get nodes コマンドを実行して、2つのワーカーノードが追加されていることを確認します:
$ kubectl get nodes

NAME            STATUS   ROLES    AGE     VERSION
k8s-master-01   Ready    master   4d7h    v1.17.0
k8s-worker-01   Ready             2d23h   v1.17.0
k8s-worker-02   Ready             2d23h   v1.17.0

これで3台のラズパイ(4)を使った kubernetes クラスタ環境が構築できました。


【動作確認手順】
ではこの環境を使って実際にアプリケーションを動かしてみます。動作確認用にラズパイ(arm32v7)アーキテクチャ用のウェブアプリケーション docker イメージを以下に用意しました。よかったら使ってください:
https://hub.docker.com/repository/docker/dotnsf/hostname

↑このアプリケーションは実行している環境内のファイルシステムから /etc/hostname を読み込んで、そのまま text/plain で表示する(つまり VM やコンテナの内部的なホスト名を返す)だけのシンプルなアプリケーションです。なお特に指定しない場合は 3000 番ポートで待ち受けます。ソースコードに興味がある人は以下を参照ください:
https://github.com/dotnsf/hostname


ではこのイメージを構築した kubernetes 環境内で動かしてみます。以下は全てマスターノード内のターミナルで行う作業です。最初にアプリケーションを hostname という名前で deploy して、3000 番ポートを expose します。続けて pod の状態を確認して(下の例では hostname-86cfdc6cbf-psgdp という名前で動いています)、外部アクセス用のポート番号を確認します(下の例では 30652 番ポートで公開されています):
$ kubectl run hostname --image=dotnsf/hostname

$ kubectl expose deployment hostname --type="NodePort" --port=3000

$ kubectl get pods

NAME                        READY   STATUS    RESTARTS   AGE
hostname-86cfdc6cbf-psgdp   1/1     Running   0          2m12s

$ kubectl get services

NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
hostname     NodePort    10.96.124.229                 3000:30652/TCP   2m22s
kubernetes   ClusterIP   10.96.0.1                     443/TCP          4d7h

deploy された hostname-86cfdc6cbf の各 pod がどのワーカーノードで動いているかを確認します(下の例では k8s-worker-02 で動いていることが確認できます):
$ kubectl describe pod hostname-86cfdc6cbf

Name:         hostname-86cfdc6cbf-psgdp
Namespace:    default
Priority:     0
Node:         k8s-worker-02/192.168.1.202
Start Time:   Tue, 17 Dec 2019 07:55:38 +0900
Labels:       pod-template-hash=86cfdc6cbf
              run=hostname
Annotations:  <none>
Status:       Running
IP:           10.244.2.7
IPs:
  IP:           10.244.2.7
Controlled By:  ReplicaSet/hostname-86cfdc6cbf
Containers:
  hostname:
    Container ID:   docker://40fd3c5402c7617aa390ef73acdc6d29502788fd828d8307d1d06f9a00c3081c
    Image:          dotnsf/hostname
    Image ID:       docker-pullable://dotnsf/hostname@sha256:5052df05816b24f6b27da8e3ef75a912944747234118fe25d7bd054f841ee6f0
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Tue, 17 Dec 2019 07:56:23 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-6qsnh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-6qsnh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-6qsnh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:          <none>

アプリケーションにアクセスして、アプリケーションの動作を確認してみます。上述の手順で確認した IP アドレス(ホスト名)とポート番号を使って curl コマンドでアクセスしてみます:
$ curl http://k8s-worker-02:30652/

hostname-86cfdc6cbf-psgdp


期待通りの結果(/etc/hostname の中身)が返ってきました。どうやら構築したラズパイ4の kubernetes クラスタ環境が正しく動作していることを確認できました!


最後に、 deploy したアプリケーションがこの状態では1インスタンスで動作していますが、これを3インスタンスで動作するようにスケールさせてみます:
$ kubectl scale --replicas=3 deployment hostname

各 Pods の状態を確認して、3インスタンスでの動作に切り替わったことを確認します:
$ kubectl get pods

NAME                        READY   STATUS    RESTARTS   AGE
hostname-86cfdc6cbf-gh88s   1/1     Running   0          7m3s
hostname-86cfdc6cbf-h4x4z   1/1     Running   0          7m3s
hostname-86cfdc6cbf-psgdp   1/1     Running   0          16h

もともと hostname-86cfdc6cbf-psgdp 上だけで動いていたのですが、この Pod に加えて hostname-86cfdc6cbf-gh88s と hostname-86cfdc6cbf-h4x4z の2つの pods が追加されたことが確認できました。


改めて各インスタンスがどのワーカーノードで動いているのかを確認します:
$ kubectl describe pod hostname-86cfdc6cbf

Name:         hostname-86cfdc6cbf-gh88s
Namespace:    default
Priority:     0
Node:         k8s-worker-01/192.168.1.201
Start Time:   Wed, 18 Dec 2019 00:26:36 +0900
Labels:       pod-template-hash=86cfdc6cbf
              run=hostname
Annotations:  <none>
Status:       Running
IP:           10.244.1.9
IPs:
  IP:           10.244.1.9
Controlled By:  ReplicaSet/hostname-86cfdc6cbf
Containers:
  hostname:
    Container ID:   docker://923d7727f8c0fbcc7af3ee5119e60cc22bd2a0817e56e5230879df650edbdc0f
    Image:          dotnsf/hostname
    Image ID:       docker-pullable://dotnsf/hostname@sha256:5052df05816b24f6b27da8e3ef75a912944747234118fe25d7bd054f841ee6f0
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Wed, 18 Dec 2019 00:27:33 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-6qsnh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-6qsnh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-6qsnh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age        From                    Message
  ----    ------     ----       ----                    -------
  Normal  Scheduled  <unknown>  default-scheduler       Successfully assigned default/hostname-86cfdc6cbf-gh88s to k8s-worker-01
  Normal  Pulling    2m29s      kubelet, k8s-worker-01  Pulling image "dotnsf/hostname"
  Normal  Pulled     2m27s      kubelet, k8s-worker-01  Successfully pulled image "dotnsf/hostname"
  Normal  Created    2m3s       kubelet, k8s-worker-01  Created container hostname
  Normal  Started    117s       kubelet, k8s-worker-01  Started container hostname

Name:         hostname-86cfdc6cbf-h4x4z
Namespace:    default
Priority:     0
Node:         k8s-worker-02/192.168.1.202
Start Time:   Wed, 18 Dec 2019 00:26:36 +0900
Labels:       pod-template-hash=86cfdc6cbf
              run=hostname
Annotations:  <none>
Status:       Running
IP:           10.244.2.8
IPs:
  IP:           10.244.2.8
Controlled By:  ReplicaSet/hostname-86cfdc6cbf
Containers:
  hostname:
    Container ID:   docker://f2af91199fa35f6e64d717c3f101ca2e559d3136196519e73cad948a2708527a
    Image:          dotnsf/hostname
    Image ID:       docker-pullable://dotnsf/hostname@sha256:5052df05816b24f6b27da8e3ef75a912944747234118fe25d7bd054f841ee6f0
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Wed, 18 Dec 2019 00:27:28 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-6qsnh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-6qsnh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-6qsnh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age        From                    Message
  ----    ------     ----       ----                    -------
  Normal  Scheduled  <unknown>  default-scheduler       Successfully assigned default/hostname-86cfdc6cbf-h4x4z to k8s-worker-02
  Normal  Pulling    2m27s      kubelet, k8s-worker-02  Pulling image "dotnsf/hostname"
  Normal  Pulled     2m24s      kubelet, k8s-worker-02  Successfully pulled image "dotnsf/hostname"
  Normal  Created    2m2s       kubelet, k8s-worker-02  Created container hostname
  Normal  Started    119s       kubelet, k8s-worker-02  Started container hostname

Name:         hostname-86cfdc6cbf-psgdp
Namespace:    default
Priority:     0
Node:         k8s-worker-02/192.168.1.202
Start Time:   Tue, 17 Dec 2019 07:55:38 +0900
Labels:       pod-template-hash=86cfdc6cbf
              run=hostname
Annotations:  <none>
Status:       Running
IP:           10.244.2.7
IPs:
  IP:           10.244.2.7
Controlled By:  ReplicaSet/hostname-86cfdc6cbf
Containers:
  hostname:
    Container ID:   docker://40fd3c5402c7617aa390ef73acdc6d29502788fd828d8307d1d06f9a00c3081c
    Image:          dotnsf/hostname
    Image ID:       docker-pullable://dotnsf/hostname@sha256:5052df05816b24f6b27da8e3ef75a912944747234118fe25d7bd054f841ee6f0
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Tue, 17 Dec 2019 07:56:23 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-6qsnh (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-6qsnh:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-6qsnh
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:          <none>


この例では hostname-86cfdc6cbf-gh88s が k8s-worker-01 上で、それ以外の hostname-86cfdc6cbf-h4x4z 、 hostname-86cfdc6cbf-gh88s は k8s-worker-02 上でそれぞれ動作することがわかりました。

では今度は k8s-worker-01 に対してアクセスしてみます:
$ curl http://k8s-worker-01:30652/
hostname-86cfdc6cbf-gh88s

k8s-worker-01 ノードへも正しくアクセスすることができるようになりました。ここまで、無線 LAN を使ったことが原因(と思われる)パフォーマンスの遅さを感じることはありましたが、ラズパイ3の頃よりはかなり速くなっています! どうやらラズパイ・クラスタ環境の構築および動作確認ができました! あとはこれをクラスタ構築用の鳩サブレ缶にセットしてあげれば完成です:
2019121800


なお、作成した deployment や service を削除するには以下のコマンドを実行します:
$ kubectl delete deployment hostname

$ kubectl delete service hostname