TECHSTEP

ITインフラ関連の記事を公開してます。

Kubernetes + Ansible = Kubespray ~KubesprayからAnsibleに入門する~

kubespray-logo

はじめに

Kubernetesクラスターをオンプレやクラウド上の仮想マシンの上に構築する場合、Kubernetes The Hard Wayのようにイチから全てを構築するか、構築用のツールを利用する方法があります。Kubernetesクラスターを構築するツールは複数存在し、代表的なものとしては以下のようなものがあります。

  • Kubeadm: Kubernetesが公式で提供するツール。
  • Kops: Kubernetes Operations。現在はAWSを公式にサポートするツール。
  • Rancher: Rancher Labsの提供するマルチクラスター構成ツール。
  • Kubespray: Ansibleを利用してKubernetesクラスターを構築する。

このうち今回紹介するのはKubesprayになります。

KubesprayはAnsibleを利用するクラスター構築ツールです。Ansibleは構成管理ツールとしてとても有名ですが、私はこれまで触れたことがありませんでした。今回はKubesprayを通じてAnsibleに触れ、合わせてKubesprayの特徴を紹介したいと思います。

Kubesprayとは

Kubesprayは以下のような特徴を備えたKubernetesクラスター管理ツールであり、本番環境で利用するクラスターを構築する場合にも利用できます。

各特徴について、もう少し見ていきます。

Ansibleを利用

ansible-logo

Ansibleは、いわゆる構成管理自動化ツールの一つです。AnsibleはPythonで書かれており、他の構成管理ツールと比べて以下のような特徴を備えています。

  • YAMLファイルを利用することでコンフィグファイルの可読性が高い
  • 対象の機器にSSHでアクセスできれば適用可能であり、エージェントレス
  • モジュールが豊富に備わっており、様々なレイヤーに適用させた自動化を実現できる
  • シンプルな処理を実行するのに向いている

※参考: Ansible実践ガイド第3版 (impress top gearシリーズ

幅広い環境にクラスターを構築できる

KubesprayはAnsibleを利用してKubernetesクラスターを構築します。Ansibleは各サーバーに対してSSH接続できる環境であれば適用可能なため、クラウドからオンプレまで幅広く適用できます。公式ドキュメントでは、利用できる環境について、以下のように紹介しています。

  • AWS
  • GCE
  • Azure
  • OpenStack
  • vSphere
  • Packet (bare metal)
  • Oracle Cloud (Experimental)
  • Bare Metal

HAクラスターの構築

Kubernetesクラスターを構築する場合、etcd・kube-apiserverを持つクラスターを複数台用意する必要があります。上記コンポーネントはMasterノードが保持する場合が多く、Kubesprayでは構築時に利用するYAMLファイルに必要なMasterノードを記載するだけで複数台のMasterノードを構築することができます。またetcdはMasterノードとは別に構築することも可能です。 ただし、kube-apiserverを複数用意する場合、Kubernetesコンポーネントがkube-apiserverにアクセスするためにロードバランサーを用意する必要があります。KubesprayではNginxベースのサポートを用意しており、デフォルトでそちらを利用することができます。

k8s-proxy

Kubeadmなどではkube-apiserver用のロードバランサーを構築者が用意する必要があるため、この辺りはKubesprayを利用することで得られるメリットと言えそうです。

様々なLinuxディストリビューションに対応

Kubesprayは、多くのLinuxディストリビューションに対応しています。Upstart/SysVinitベースのOSには対応していませんが、以下のLinuxディストリビューションをサポートしています。

複数のNetwork Pluginに対応

Kubernetesクラスターを利用する際、異なるノードに配置されたPod間で通信を行うため、Network pluginをデプロイする必要があります。Kubesprayでは複数のNetwork pluginをサポートしています。

  • flannel
  • calico
  • canal
  • cilium
  • contiv
  • weave
  • kube-ovn
  • kube-router
  • macvlan
  • multus

※参考リンク:

Kubernetes公式ドキュメント - Cluster Networking

Kubesprayと別ツールとの比較

最初に書いた通り、Kubernetesクラスターを自前で用意する方法はKubesprayだけではありません。Kubesprayと他のツールとの違いは何なのか、公式ドキュメントで紹介しています。ここではkopsとKubeadmに対する違いを説明しており、以下にその内容を紹介します。

Kopsとの比較

KubesprayはAnsibleを利用してクラスターの構築・構成管理を行うため、クラウドからベアメタルまで様々な環境に適用することができます。それに対してKopsは、kops自身が構築・管理を行い、Kubesprayに比べると適用できる範囲は狭くなってしまいます(この記事を書いている時点ではAWSを公式サポートとしています)。

Ansibleに慣れ親しんでいるユーザー、あるいはマルチプラットフォーム上でKubernetesクラスターを構築したいユーザーにとっては、Kubesprayの方が良い選択肢となるでしょう。一方、Kopsはサポートするクラウドプロバイダーとの連携がより密接なため、クラスター構築時の各種リソースのプロビジョニング、クラウドプロバイダーの提供サービスとKubernetesとの連携強化などを望めます。上記理由から、将来的に一つのプラットフォーム上でKubernetesクラスターを利用する場合、Kopsの方が良い選択肢となります。

※参考リンク:Kops公式ページ

Kubeadmとの比較

KubeadmはKubernetesクラスターのライフサイクルマネジメント(Kubernetes自身がKubernetesを管理するself-hosted layout、動的なディスカバリサービスなど)に関するナレッジを提供します。現在のKubernetes Operatorが普及した世界に当てはめれば、Kubeadmの事を「Kubernetes cluster Operator」と名付けることができるかもしれません。一方でKubesprayは「OS Operator」とも呼べるAnsibleを利用し、クラスターを構築する際はより一般的な構成管理タスクを実行します。

Kubesprayではver 2.3からKubeadmをサポートしており、クラスター構築の際はKubeadmの機能を利用しています。またver 2.8からはKubeadmを利用しないクラスター構築の機能を廃止する動きを進めつつ、最終的には両者からの恩恵を受けることを考えているようです。この記事を書いている時点ではすでにver 2.12になっており、Kubeadmへの統合はかなり進んでいると予想されます。

※参考リンク:

Creating a single control-plane cluster with kubeadm

Kubernetes Meetup Tokyo #8 Self-Hosted Kubernetesを調べてみた

Kubesprayを使ってクラスターを構築

ここからKubesprayを使ったクラスター構築を進めます。

Kubesprayを利用する条件

Kubesprayを実行するにあたり、実行の条件を確認します。 公式ドキュメントのこちらのページを参照すると、各種条件が記載されています。

Ansibleコマンド用マシン

  • Ansible: v2.7.8
  • python-netaddr
  • Jinja: 2.9以上

Kubernetesクラスター用マシン

  • Dockerイメージをpullするため、インターネットに接続できる
  • IPv4 Forwardingを許可
  • 操作用マシンとSSH通信を行うため、各サーバにSSH keyがコピーされている
  • Firewallが無効化されている(推奨)
  • rootユーザー以外でで実行する場合は権限の追加が必要
  • マシン性能
    • Masterノード: メモリ1.5GB以上
    • Workerノード: メモリ1.0GB以上

検証環境

検証環境のついての情報を紹介します。今回はAzure上に仮想マシンを構築し、そこにKubesprayを使ってKubernetesクラスターを構築します。

項目1 項目2 内容
仮想マシン
OS CentOS 7.7
インスタンスサイズ D2s v3
Kubernetesクラスタ
Masterノード数 1
Workerノード数 2
Network plugin Calico (デフォルト)
Ansible用サーバー
サーバー台数 1

構築手順

ここから実際の構築手順を紹介します。手順はこちらの公式ドキュメントを参照しながら進めます。

構築①:各ノードでの構築準備

Ansibleコマンドを実行する仮想マシン上で以下の作業を行います。

  • yum update
  • ssh-keyの発行 (ssh-keygenコマンドの実行)

クラスターを構築する仮想マシン上で以下の作業を行います。

  • yum update
  • IPv4フォワーディングの有効化
  • FireWallの無効化
  • SELinuxの無効化
  • 発行したsshキーの公開鍵情報をコピーする (ssh-copy-idコマンドなど)
    • 以降の手順では、Ansible実行サーバーから各マシンのrootユーザーにssh接続ができる必要があります

構築②:Ansible実行サーバーの準備

Ansibleコマンドを実行するサーバーの準備を行います。その前に、Ansibleを実行する上で何が必要かを紹介します。

Ansibleに必要なもの

Ansibleはコントロールノードからターゲットノードに対してSSHで処理内容を送り、実行します。今回の場合、コントロールノードはAnsibleコマンド実行用に用意したサーバー、ターゲットノードはKubernetesクラスター用のサーバーになります。

Ansibleを実行するには2種類のファイルを用意する必要があり、それぞれInventoryファイルとPlaybookファイルになります。

  • Inventoryファイル:ターゲットノードのリストを記載するファイル。ターゲットへの接続情報としてホスト名やIPアドレスなどを記載します。

  • Playbookファイル:ターゲットノードへの処理内容を記載するファイル。タスクを定義し、Inventoryファイルで定義したホストのグループと紐付けます。実行時、Playbookで定義されたタスクを、紐付けたホストグループに対して実行します。PlaybookはYAML形式で記述します。

  • Module:Playbookで定義するタスクは、モジュールを呼び出して実行します。モジュールは各作業内容を実行するために必要な処理をまとめたユニットです。Ansibleが公式に利用するモジュールに加え、ユーザーがモジュールを作成することもできます。

Ansible実行サーバーでの準備

ここからKubesprayを実行する上で必要なファイルを用意します。 まずはGitHubリポジトリから必要なファイルを取得し、Kubesprayの実行に必要なファイルを取得します。またAnsibleを実行するために必要なモジュールをインストールするため、リポジトリに含まれるrequirements.txtに書かれたモジュールをインストールします。

なお、検証時点ではCentOS7はデフォルトでPython2.7を利用しており、pipコマンドを実行できません。そのため、必要に応じてPython3のインストールを行います。

# git clone

[root@kubespray-ansible ~]# git clone https://github.com/kubernetes-sigs/kubespray.git

# pip install
[root@kubespray-ansible kubespray]# pip3 install -r requirements.txt 

(中略)

Successfully installed MarkupSafe-1.1.1 PyYAML-5.2 ansible-2.7.12 bcrypt-3.1.7 certifi-2019.11.28 cffi-1.13.2 chardet-3.0.4 cryptography-2.8 hvac-0.8.2 idna-2.8 jinja2-2.10.1 jmespath-0.9.4 netaddr-0.7.19 paramiko-2.7.1 pbr-5.2.0 pycparser-2.19 pynacl-1.3.0 requests-2.22.0 ruamel.yaml-0.15.96 six-1.13.0 urllib3-1.25.7

# インストール後の確認
[root@kubespray-ansible kubespray]# ansible --version
ansible 2.7.12
  config file = /root/kubespray/ansible.cfg
  configured module search path = ['/root/kubespray/library']
  ansible python module location = /usr/local/lib/python3.6/site-packages/ansible
  executable location = /usr/local/bin/ansible
  python version = 3.6.8 (default, Aug  7 2019, 17:28:10) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)]

Inventory/Playbookの内容確認・書き換え

次にクラスターインストール時に利用するInventory・Playbookを書き換えます。はじめに各ファイルの内容を確認します。

まずはInventoryとして利用するhosts.ymlファイルです。Kubesprayではinventory.pyを実行することで生成されます。以下にMasterノード・Workerノード1つずつの例を載せます。

all:
  hosts:
    node1:
      ansible_host: <node1のIPアドレス>
      ip: <node1のIPアドレス>
      access_ip: <node1のIPアドレス>
    node2:
      ansible_host: <node2のIPアドレス>
      ip: <node2のIPアドレス>
      access_ip: <node2のIPアドレス>
  children:
    kube-master:
      hosts:
        node1:
    kube-node:
      hosts:
        node2:
    etcd:
      hosts:
        node1:
    k8s-cluster:
      children:
        kube-master:
        kube-node:
    calico-rr:
      hosts: {}

上記ファイルを眺めると、以下のような階層構造になっていることがわかります。

all
  |
  --hosts
  |
  --children
        |
        |--kube-master
        |--kube-node
        |--etcd
        |--k8s-cluster
        |        |
        |        --children
        |                |
        |                --kube-master
        |                --kube-worker
        |--calico-rr

Inventoryではグループを階層構造で表すことができます。上位のグループが下位のグループを束ねることで階層構造を作り、複数のグループに共通のタスクを割り当てることができます。

hostsにはKubernetesのノードとして利用するマシンのIPアドレスを指定し、children以下で各hostに与えるタスクを指定します。

今回node1はMasterノードとして利用するのでkube-master etcdにて指定し、node2はWorkerノードとして利用するのでkube-workerにて指定します。

k8s-clusterではKubernetesのノードとして利用するホストをグループで指定します(ここではkube-master kube-worker)。

なおcalico-rrは通常利用することはありませんが、例えば大規模クラスターを構築し、一部ノード間での通信を許可したくない場合などに利用するようです。詳細は公式ドキュメントのCalicoのパートをご覧ください。

またInventoryとして利用できるファイルはhosts.ymlだけではありません。Kubesprayではあらかじめinventory.iniファイルを用意しています。以下にファイルの内容を載せます。

# ## Configure 'ip' variable to bind kubernetes services on a
# ## different ip than the default iface
# ## We should set etcd_member_name for etcd cluster. The node that is not a etcd member do not need to set the value, or can set the empty string value.
[all]
# node1 ansible_host=95.54.0.12  # ip=10.3.0.1 etcd_member_name=etcd1
# node2 ansible_host=95.54.0.13  # ip=10.3.0.2 etcd_member_name=etcd2
# node3 ansible_host=95.54.0.14  # ip=10.3.0.3 etcd_member_name=etcd3
# node4 ansible_host=95.54.0.15  # ip=10.3.0.4 etcd_member_name=etcd4
# node5 ansible_host=95.54.0.16  # ip=10.3.0.5 etcd_member_name=etcd5
# node6 ansible_host=95.54.0.17  # ip=10.3.0.6 etcd_member_name=etcd6

# ## configure a bastion host if your nodes are not directly reachable
# bastion ansible_host=x.x.x.x ansible_user=some_user

[kube-master]
# node1
# node2

[etcd]
# node1
# node2
# node3

[kube-node]
# node2
# node3
# node4
# node5
# node6

[calico-rr]

[k8s-cluster:children]
kube-master
kube-node
calico-rr

上記ファイルを眺めると、hosts.ymlと同じようなカテゴリーが並んでいることが分かるかと思います。それぞれのカテゴリーの関係性は上記階層構造と同様になります。

続いてPlaybookとして利用するcluster.ymlファイルは以下の通りです。

---
- hosts: localhost
  gather_facts: false
  become: no
  tasks:
    - name: "Check ansible version >=2.7.8"
      assert:
        msg: "Ansible must be v2.7.8 or higher"
        that:
          - ansible_version.string is version("2.7.8", ">=")
      tags:
        - check
  vars:
    ansible_connection: local

- hosts: bastion[0]
  gather_facts: False
  roles:
    - { role: kubespray-defaults}
    - { role: bastion-ssh-config, tags: ["localhost", "bastion"]}

- hosts: k8s-cluster:etcd
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  gather_facts: false
  roles:
    - { role: kubespray-defaults}
    - { role: bootstrap-os, tags: bootstrap-os}

- hosts: k8s-cluster:etcd
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/preinstall, tags: preinstall }
    - { role: "container-engine", tags: "container-engine", when: deploy_container_engine|default(true) }
    - { role: download, tags: download, when: "not skip_downloads" }
  environment: "{{ proxy_env }}"

- hosts: etcd
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - role: etcd
      tags: etcd
      vars:
        etcd_cluster_setup: true
        etcd_events_cluster_setup: "{{ etcd_events_cluster_enabled }}"
      when: not etcd_kubeadm_enabled| default(false)

- hosts: k8s-cluster
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - role: etcd
      tags: etcd
      vars:
        etcd_cluster_setup: false
        etcd_events_cluster_setup: false
      when: not etcd_kubeadm_enabled| default(false)

- hosts: k8s-cluster
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/node, tags: node }
  environment: "{{ proxy_env }}"

- hosts: kube-master
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/master, tags: master }
    - { role: kubernetes/client, tags: client }
    - { role: kubernetes-apps/cluster_roles, tags: cluster-roles }

- hosts: k8s-cluster
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/kubeadm, tags: kubeadm}
    - { role: network_plugin, tags: network }
    - { role: kubernetes/node-label }

- hosts: calico-rr
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: network_plugin/calico/rr, tags: ['network', 'calico_rr']}

- hosts: kube-master[0]
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes-apps/rotate_tokens, tags: rotate_tokens, when: "secret_changed|default(false)" }
    - { role: win_nodes/kubernetes_patch, tags: ["master", "win_nodes"]}

- hosts: kube-master
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes-apps/network_plugin, tags: network }
    - { role: kubernetes-apps/policy_controller, tags: policy-controller }
    - { role: kubernetes-apps/ingress_controller, tags: ingress-controller }
    - { role: kubernetes-apps/external_provisioner, tags: external-provisioner }

- hosts: kube-master
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes-apps, tags: apps }
  environment: "{{ proxy_env }}"

- hosts: k8s-cluster
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/preinstall, when: "dns_mode != 'none' and resolvconf_mode == 'host_resolvconf'", tags: resolvconf, dns_late: true }

cluster.ymlにはKubernetesクラスターを構築する手順を記載しています。各タスクはKubesprayのrolesフォルダにて定義されています。

# rolesフォルダの内容確認

[root@ansible-eastus roles]# pwd
/root/kubespray/roles

[root@ansible-eastus roles]# ls -l
total 4
drwxr-xr-x.  5 root root   47 Dec 18 12:23 adduser
drwxr-xr-x.  4 root root   36 Dec 18 12:23 bastion-ssh-config
drwxr-xr-x.  5 root root   65 Dec 18 12:23 bootstrap-os
drwxr-xr-x.  7 root root   85 Dec 18 12:23 container-engine
drwxr-xr-x.  6 root root   64 Dec 18 12:23 download
drwxr-xr-x.  7 root root   80 Dec 18 12:23 etcd
drwxr-xr-x.  9 root root  111 Dec 18 12:23 kubernetes
drwxr-xr-x. 17 root root 4096 Dec 18 12:23 kubernetes-apps
drwxr-xr-x.  5 root root   47 Dec 18 12:23 kubespray-defaults
drwxr-xr-x. 15 root root  189 Dec 18 12:23 network_plugin
drwxr-xr-x.  6 root root   71 Dec 18 12:23 recover_control_plane
drwxr-xr-x.  4 root root   43 Dec 18 12:23 remove-node
drwxr-xr-x.  4 root root   35 Dec 18 12:23 reset
drwxr-xr-x.  4 root root   45 Dec 18 12:23 upgrade
drwxr-xr-x.  3 root root   30 Dec 18 12:23 win_nodes
[root@ansible-eastus roles]# 

構築③:Inventory/Playbookファイルの準備

では、実際に利用するInventoryファイルを用意します。ここでは公式ドキュメントの手順に従います。

# サンプル用ファイルのコピー

[root@ansible-eastus kubespray]# cp -r inventory/sample inventory/mycluster

# inventory/myclusterの内容確認

[root@ansible-eastus kubespray]# ls -l inventory/mycluster/
total 4
drwxr-xr-x 4 root root  52 Dec 20 16:07 group_vars
-rw-r--r-- 1 root root 994 Dec 20 16:07 inventory.ini

# hosts.ymlファイルの用意
## ノードとして利用するマシンのIPアドレスを指定
[root@ansible-eastus kubespray]# declare -a IPS=(10.3.0.6 10.3.0.7 10.3.0.8)

## hosts.ymlファイルを生成するpythonコマンドの実行
[root@ansible-eastus kubespray]# CONFIG_FILE=inventory/mycluster/hosts.yml python3 contrib/inventory_builder/inventory.py ${IPS[@]}
DEBUG: Adding group all
DEBUG: Adding group kube-master
DEBUG: Adding group kube-node
DEBUG: Adding group etcd
DEBUG: Adding group k8s-cluster
DEBUG: Adding group calico-rr
DEBUG: adding host node1 to group all
DEBUG: adding host node2 to group all
DEBUG: adding host node3 to group all
DEBUG: adding host node1 to group etcd
DEBUG: adding host node2 to group etcd
DEBUG: adding host node3 to group etcd
DEBUG: adding host node1 to group kube-master
DEBUG: adding host node2 to group kube-master
DEBUG: adding host node1 to group kube-node
DEBUG: adding host node2 to group kube-node
DEBUG: adding host node3 to group kube-node

上記コマンドによって生成されたhosts.ymlファイルが以下になります。

all:
  hosts:
    node1:
      ansible_host: 10.3.0.6
      ip: 10.3.0.6
      access_ip: 10.3.0.6
    node2:
      ansible_host: 10.3.0.7
      ip: 10.3.0.7
      access_ip: 10.3.0.7
    node3:
      ansible_host: 10.3.0.8
      ip: 10.3.0.8
      access_ip: 10.3.0.8
  children:
    kube-master:
      hosts:
        node1:
        node2:
    kube-node:
      hosts:
        node1:
        node2:
        node3:
    etcd:
      hosts:
        node1:
        node2:
        node3:
    k8s-cluster:
      children:
        kube-master:
        kube-node:
    calico-rr:
      hosts: {}

上記ファイルを見ると、デフォルトでは指定したIPアドレスを持つマシンの多くがkube-master kube-worker etcdのすべてのタスクを実行するよう指定されています。

またホストを指定する際に利用する名前がnode1 node2 node3となっていますが、ここには各マシンのホスト名を指定します。デフォルトの状態でKubesprayを構築すると、指定したIPアドレスを持つマシンのホスト名がnode1 node2 node3となるため、正しいホスト名を指定します。

今回は10.3.0.6IPアドレスを持つマシンをMasterノード、それ以外をWorkerノードとして利用するため、以下のように書き換えます。

all:
  hosts:
    k8s-master:    # ホスト名の修正
      ansible_host: 10.3.0.6
      ip: 10.3.0.6
      access_ip: 10.3.0.6
    k8s-worker01:    # ホスト名の修正
      ansible_host: 10.3.0.7
      ip: 10.3.0.7
      access_ip: 10.3.0.7
    k8s-worker02:    # ホスト名の修正
      ansible_host: 10.3.0.8
      ip: 10.3.0.8
      access_ip: 10.3.0.8
  children:
    kube-master:
      hosts:    # Masterノードとして利用するホストのみ指定
        k8s-master:
    kube-node:
      hosts:    # Workerノードとして利用するホストのみ指定
        k8s-worker01:
        k8s-worker02:
    etcd:
      hosts:    # Masterノードとして利用するホストのみ指定
        k8s-master:
    k8s-cluster:
      children:
        kube-master:
        kube-node:
    calico-rr:
      hosts: {}

以上でInventoryの準備は完了です。

なお、上記手順で利用するinventory.pyは以下の通りです。

inventory.py

#!/usr/bin/env python3
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Usage: inventory.py ip1 [ip2 ...]
# Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5
#
# Advanced usage:
# Add another host after initial creation: inventory.py 10.10.1.5
# Add range of hosts: inventory.py 10.10.1.3-10.10.1.5
# Add hosts with different ip and access ip:
# inventory.py 10.0.0.1,192.168.10.1 10.0.0.2,192.168.10.2 10.0.0.3,192.168.1.3
# Add hosts with a specific hostname, ip, and optional access ip:
# inventory.py first,10.0.0.1,192.168.10.1 second,10.0.0.2 last,10.0.0.3
# Delete a host: inventory.py -10.10.1.3
# Delete a host by id: inventory.py -node1
#
# Load a YAML or JSON file with inventory data: inventory.py load hosts.yaml
# YAML file should be in the following format:
#    group1:
#      host1:
#        ip: X.X.X.X
#        var: val
#    group2:
#      host2:
#        ip: X.X.X.X

from collections import OrderedDict
from ipaddress import ip_address
from ruamel.yaml import YAML

import os
import re
import sys

ROLES = ['all', 'kube-master', 'kube-node', 'etcd', 'k8s-cluster',
         'calico-rr']
PROTECTED_NAMES = ROLES
AVAILABLE_COMMANDS = ['help', 'print_cfg', 'print_ips', 'print_hostnames',
                      'load']
_boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
                   '0': False, 'no': False, 'false': False, 'off': False}
yaml = YAML()
yaml.Representer.add_representer(OrderedDict, yaml.Representer.represent_dict)


def get_var_as_bool(name, default):
    value = os.environ.get(name, '')
    return _boolean_states.get(value.lower(), default)

# Configurable as shell vars start


CONFIG_FILE = os.environ.get("CONFIG_FILE", "./inventory/sample/hosts.yaml")
KUBE_MASTERS = int(os.environ.get("KUBE_MASTERS_MASTERS", 2))
# Reconfigures cluster distribution at scale
SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 50))
MASSIVE_SCALE_THRESHOLD = int(os.environ.get("SCALE_THRESHOLD", 200))

DEBUG = get_var_as_bool("DEBUG", True)
HOST_PREFIX = os.environ.get("HOST_PREFIX", "node")

# Configurable as shell vars end


class KubesprayInventory(object):

    def __init__(self, changed_hosts=None, config_file=None):
        self.config_file = config_file
        self.yaml_config = {}
        if self.config_file:
            try:
                self.hosts_file = open(config_file, 'r')
                self.yaml_config = yaml.load(self.hosts_file)
            except OSError:
                pass

        if changed_hosts and changed_hosts[0] in AVAILABLE_COMMANDS:
            self.parse_command(changed_hosts[0], changed_hosts[1:])
            sys.exit(0)

        self.ensure_required_groups(ROLES)

        if changed_hosts:
            changed_hosts = self.range2ips(changed_hosts)
            self.hosts = self.build_hostnames(changed_hosts)
            self.purge_invalid_hosts(self.hosts.keys(), PROTECTED_NAMES)
            self.set_all(self.hosts)
            self.set_k8s_cluster()
            etcd_hosts_count = 3 if len(self.hosts.keys()) >= 3 else 1
            self.set_etcd(list(self.hosts.keys())[:etcd_hosts_count])
            if len(self.hosts) >= SCALE_THRESHOLD:
                self.set_kube_master(list(self.hosts.keys())[
                    etcd_hosts_count:(etcd_hosts_count + KUBE_MASTERS)])
            else:
                self.set_kube_master(list(self.hosts.keys())[:KUBE_MASTERS])
            self.set_kube_node(self.hosts.keys())
            if len(self.hosts) >= SCALE_THRESHOLD:
                self.set_calico_rr(list(self.hosts.keys())[:etcd_hosts_count])
        else:  # Show help if no options
            self.show_help()
            sys.exit(0)

        self.write_config(self.config_file)

    def write_config(self, config_file):
        if config_file:
            with open(self.config_file, 'w') as f:
                yaml.dump(self.yaml_config, f)

        else:
            print("WARNING: Unable to save config. Make sure you set "
                  "CONFIG_FILE env var.")

    def debug(self, msg):
        if DEBUG:
            print("DEBUG: {0}".format(msg))

    def get_ip_from_opts(self, optstring):
        if 'ip' in optstring:
            return optstring['ip']
        else:
            raise ValueError("IP parameter not found in options")

    def ensure_required_groups(self, groups):
        for group in groups:
            if group == 'all':
                self.debug("Adding group {0}".format(group))
                if group not in self.yaml_config:
                    all_dict = OrderedDict([('hosts', OrderedDict({})),
                                            ('children', OrderedDict({}))])
                    self.yaml_config = {'all': all_dict}
            else:
                self.debug("Adding group {0}".format(group))
                if group not in self.yaml_config['all']['children']:
                    self.yaml_config['all']['children'][group] = {'hosts': {}}

    def get_host_id(self, host):
        '''Returns integer host ID (without padding) from a given hostname.'''
        try:
            short_hostname = host.split('.')[0]
            return int(re.findall("\\d+$", short_hostname)[-1])
        except IndexError:
            raise ValueError("Host name must end in an integer")

    def build_hostnames(self, changed_hosts):
        existing_hosts = OrderedDict()
        highest_host_id = 0
        try:
            for host in self.yaml_config['all']['hosts']:
                existing_hosts[host] = self.yaml_config['all']['hosts'][host]
                host_id = self.get_host_id(host)
                if host_id > highest_host_id:
                    highest_host_id = host_id
        except Exception:
            pass

        # FIXME(mattymo): Fix condition where delete then add reuses highest id
        next_host_id = highest_host_id + 1

        all_hosts = existing_hosts.copy()
        for host in changed_hosts:
            if host[0] == "-":
                realhost = host[1:]
                if self.exists_hostname(all_hosts, realhost):
                    self.debug("Marked {0} for deletion.".format(realhost))
                    all_hosts.pop(realhost)
                elif self.exists_ip(all_hosts, realhost):
                    self.debug("Marked {0} for deletion.".format(realhost))
                    self.delete_host_by_ip(all_hosts, realhost)
            elif host[0].isdigit():
                if ',' in host:
                    ip, access_ip = host.split(',')
                else:
                    ip = host
                    access_ip = host
                if self.exists_hostname(all_hosts, host):
                    self.debug("Skipping existing host {0}.".format(host))
                    continue
                elif self.exists_ip(all_hosts, ip):
                    self.debug("Skipping existing host {0}.".format(ip))
                    continue

                next_host = "{0}{1}".format(HOST_PREFIX, next_host_id)
                next_host_id += 1
                all_hosts[next_host] = {'ansible_host': access_ip,
                                        'ip': ip,
                                        'access_ip': access_ip}
            elif host[0].isalpha():
                if ',' in host:
                    try:
                        hostname, ip, access_ip = host.split(',')
                    except Exception:
                        hostname, ip = host.split(',')
                        access_ip = ip
                if self.exists_hostname(all_hosts, host):
                    self.debug("Skipping existing host {0}.".format(host))
                    continue
                elif self.exists_ip(all_hosts, ip):
                    self.debug("Skipping existing host {0}.".format(ip))
                    continue
                all_hosts[hostname] = {'ansible_host': access_ip,
                                       'ip': ip,
                                       'access_ip': access_ip}
        return all_hosts

    def range2ips(self, hosts):
        reworked_hosts = []

        def ips(start_address, end_address):
            try:
                # Python 3.x
                start = int(ip_address(start_address))
                end = int(ip_address(end_address))
            except Exception:
                # Python 2.7
                start = int(ip_address(str(start_address)))
                end = int(ip_address(str(end_address)))
            return [ip_address(ip).exploded for ip in range(start, end + 1)]

        for host in hosts:
            if '-' in host and not host.startswith('-'):
                start, end = host.strip().split('-')
                try:
                    reworked_hosts.extend(ips(start, end))
                except ValueError:
                    raise Exception("Range of ip_addresses isn't valid")
            else:
                reworked_hosts.append(host)
        return reworked_hosts

    def exists_hostname(self, existing_hosts, hostname):
        return hostname in existing_hosts.keys()

    def exists_ip(self, existing_hosts, ip):
        for host_opts in existing_hosts.values():
            if ip == self.get_ip_from_opts(host_opts):
                return True
        return False

    def delete_host_by_ip(self, existing_hosts, ip):
        for hostname, host_opts in existing_hosts.items():
            if ip == self.get_ip_from_opts(host_opts):
                del existing_hosts[hostname]
                return
        raise ValueError("Unable to find host by IP: {0}".format(ip))

    def purge_invalid_hosts(self, hostnames, protected_names=[]):
        for role in self.yaml_config['all']['children']:
            if role != 'k8s-cluster' and self.yaml_config['all']['children'][role]['hosts']:  # noqa
                all_hosts = self.yaml_config['all']['children'][role]['hosts'].copy()  # noqa
                for host in all_hosts.keys():
                    if host not in hostnames and host not in protected_names:
                        self.debug(
                            "Host {0} removed from role {1}".format(host, role))  # noqa
                        del self.yaml_config['all']['children'][role]['hosts'][host]  # noqa
        # purge from all
        if self.yaml_config['all']['hosts']:
            all_hosts = self.yaml_config['all']['hosts'].copy()
            for host in all_hosts.keys():
                if host not in hostnames and host not in protected_names:
                    self.debug("Host {0} removed from role all".format(host))
                    del self.yaml_config['all']['hosts'][host]

    def add_host_to_group(self, group, host, opts=""):
        self.debug("adding host {0} to group {1}".format(host, group))
        if group == 'all':
            if self.yaml_config['all']['hosts'] is None:
                self.yaml_config['all']['hosts'] = {host: None}
            self.yaml_config['all']['hosts'][host] = opts
        elif group != 'k8s-cluster:children':
            if self.yaml_config['all']['children'][group]['hosts'] is None:
                self.yaml_config['all']['children'][group]['hosts'] = {
                    host: None}
            else:
                self.yaml_config['all']['children'][group]['hosts'][host] = None  # noqa

    def set_kube_master(self, hosts):
        for host in hosts:
            self.add_host_to_group('kube-master', host)

    def set_all(self, hosts):
        for host, opts in hosts.items():
            self.add_host_to_group('all', host, opts)

    def set_k8s_cluster(self):
        k8s_cluster = {'children': {'kube-master': None, 'kube-node': None}}
        self.yaml_config['all']['children']['k8s-cluster'] = k8s_cluster

    def set_calico_rr(self, hosts):
        for host in hosts:
            if host in self.yaml_config['all']['children']['kube-master']:
                self.debug("Not adding {0} to calico-rr group because it "
                           "conflicts with kube-master group".format(host))
                continue
            if host in self.yaml_config['all']['children']['kube-node']:
                self.debug("Not adding {0} to calico-rr group because it "
                           "conflicts with kube-node group".format(host))
                continue
            self.add_host_to_group('calico-rr', host)

    def set_kube_node(self, hosts):
        for host in hosts:
            if len(self.yaml_config['all']['hosts']) >= SCALE_THRESHOLD:
                if host in self.yaml_config['all']['children']['etcd']['hosts']:  # noqa
                    self.debug("Not adding {0} to kube-node group because of "
                               "scale deployment and host is in etcd "
                               "group.".format(host))
                    continue
            if len(self.yaml_config['all']['hosts']) >= MASSIVE_SCALE_THRESHOLD:  # noqa
                if host in self.yaml_config['all']['children']['kube-master']['hosts']:  # noqa
                    self.debug("Not adding {0} to kube-node group because of "
                               "scale deployment and host is in kube-master "
                               "group.".format(host))
                    continue
            self.add_host_to_group('kube-node', host)

    def set_etcd(self, hosts):
        for host in hosts:
            self.add_host_to_group('etcd', host)

    def load_file(self, files=None):
        '''Directly loads JSON to inventory.'''

        if not files:
            raise Exception("No input file specified.")

        import json

        for filename in list(files):
            # Try JSON
            try:
                with open(filename, 'r') as f:
                    data = json.load(f)
            except ValueError:
                raise Exception("Cannot read %s as JSON, or CSV", filename)

            self.ensure_required_groups(ROLES)
            self.set_k8s_cluster()
            for group, hosts in data.items():
                self.ensure_required_groups([group])
                for host, opts in hosts.items():
                    optstring = {'ansible_host': opts['ip'],
                                 'ip': opts['ip'],
                                 'access_ip': opts['ip']}
                    self.add_host_to_group('all', host, optstring)
                    self.add_host_to_group(group, host)
            self.write_config(self.config_file)

    def parse_command(self, command, args=None):
        if command == 'help':
            self.show_help()
        elif command == 'print_cfg':
            self.print_config()
        elif command == 'print_ips':
            self.print_ips()
        elif command == 'print_hostnames':
            self.print_hostnames()
        elif command == 'load':
            self.load_file(args)
        else:
            raise Exception("Invalid command specified.")

    def show_help(self):
        help_text = '''Usage: inventory.py ip1 [ip2 ...]
Examples: inventory.py 10.10.1.3 10.10.1.4 10.10.1.5

Available commands:
help - Display this message
print_cfg - Write inventory file to stdout
print_ips - Write a space-delimited list of IPs from "all" group
print_hostnames - Write a space-delimited list of Hostnames from "all" group

Advanced usage:
Add another host after initial creation: inventory.py 10.10.1.5
Add range of hosts: inventory.py 10.10.1.3-10.10.1.5
Add hosts with different ip and access ip: inventory.py 10.0.0.1,192.168.10.1 10.0.0.2,192.168.10.2 10.0.0.3,192.168.10.3
Add hosts with a specific hostname, ip, and optional access ip: first,10.0.0.1,192.168.10.1 second,10.0.0.2 last,10.0.0.3
Delete a host: inventory.py -10.10.1.3
Delete a host by id: inventory.py -node1

Configurable env vars:
DEBUG                   Enable debug printing. Default: True
CONFIG_FILE             File to write config to Default: ./inventory/sample/hosts.yaml
HOST_PREFIX             Host prefix for generated hosts. Default: node
SCALE_THRESHOLD         Separate ETCD role if # of nodes >= 50
MASSIVE_SCALE_THRESHOLD Separate K8s master and ETCD if # of nodes >= 200
'''  # noqa
        print(help_text)

    def print_config(self):
        yaml.dump(self.yaml_config, sys.stdout)

    def print_hostnames(self):
        print(' '.join(self.yaml_config['all']['hosts'].keys()))

    def print_ips(self):
        ips = []
        for host, opts in self.yaml_config['all']['hosts'].items():
            ips.append(self.get_ip_from_opts(opts))
        print(' '.join(ips))


def main(argv=None):
    if not argv:
        argv = sys.argv[1:]
    KubesprayInventory(argv, CONFIG_FILE)


if __name__ == "__main__":
    sys.exit(main())

今回はMasterノードを1つ、Workerノードを2つ用意しましたが、HA構成を組む場合はMasterノードを「奇数」個分用意し、hosts.ymlに記載するだけで構築されます。

なお、Master(というかetcd)ノードを奇数個用意しなければならない理由は、こちらの記事に詳しく書かれています。etcdではRaftという分散合意アルゴリズムを利用しています。Raftの都合上、3台以上のノードを形成しない場合、障害が発生しても耐えられるノード数(failure tolerance)が0になってしまい、冗長構成の効果がなくなります。また偶数台の場合、アルゴリズムの性質上全断が発生してしまう場合があります。そのため、etcdノードを冗長化する場合は3、5、7・・・台用意することが推奨されています

KubesprayでMasterを偶数個指定してクラスターを構築しようとすると、以下のようなエラーメッセージが表示されます。

TASK [kubernetes/preinstall : Stop if even number of etcd hosts] ********************************************************************************************************************
Sunday 22 December 2019  08:54:42 +0000 (0:00:00.315)       0:00:15.956 ******* 
fatal: [ks-master01]: FAILED! => {
    "assertion": "groups.etcd|length is not divisibleby 2",
    "changed": false,
    "evaluated_to": false,
    "msg": "Assertion failed"
}
fatal: [ks-master02]: FAILED! => {
    "assertion": "groups.etcd|length is not divisibleby 2",
    "changed": false,
    "evaluated_to": false,
    "msg": "Assertion failed"
}

※参考リンク:

Raft - Kubernetes(etcd)のHA構成はなぜ3台以上?

クラスタに3ノード必要な理由

構築④:ansible-playbookコマンドの実行

それではAnsibleを実行してクラスターを構築します。

Kubesprayを実行する場合はansible-playbookコマンドを実行します。Ansibleを実行する際、ansibleコマンドの他にansible-playbookコマンドを実行することが可能です。前述の通りansible-playbookコマンドは、InventoryとPlaybookファイルを指定します。

一方ansibleコマンドでは、タスクを実行するホストとモジュールを個別に指定して実行します。開発したモジュールの試験や、一つの処理だけを実行したい場合に利用します。

ansible-playbookコマンドの実行

それではansible-playbookコマンドを実行します。

[root@ansible-eastus kubespray]# ansible-playbook -i inventory/mycluster/hosts.yml cluster.yml -b -v

各オプションは以下の用途で利用します。

  • -i:Inventoryファイルを指定
  • -b--become-method。接続ユーザー以外で対象ホストを操作するために使用する
  • -v:詳細な情報を出力

上記コマンドを実行し、15分ほど待つと完了します。

出力ログの内容

ansible-playbookコマンドの実行ログは大量に出力されますが、Kubeadmで実行する内容に加え、OS周りの処理やオプションの追加、各タスク実行前の確認などを行っているように見受けられます。

Kubeadmの処理内容については、以前私がQiitaでまとめたものなどがあるのでそちらをご覧ください。

最後にansible-playbook実行後に出力されるログのうち、処理結果の要約を表す「PLAY RECAP」の部分だけ載せておきます。

PLAY RECAP ***************************************************************************************************************************************
k8s-master                 : ok=613  changed=133  unreachable=0    failed=0   
k8s-worker01               : ok=442  changed=86   unreachable=0    failed=0   
k8s-worker02               : ok=406  changed=84   unreachable=0    failed=0   
localhost                  : ok=1    changed=0    unreachable=0    failed=0   

Friday 20 December 2019  16:41:25 +0000 (0:00:00.113)       0:12:13.186 ******* 
=============================================================================== 
container-engine/docker : ensure docker packages are installed --------------------------------------------------------------------------- 31.71s
kubernetes/master : kubeadm | Initialize first master ------------------------------------------------------------------------------------ 28.12s
kubernetes/kubeadm : Join to cluster ----------------------------------------------------------------------------------------------------- 22.46s
container-engine/docker : ensure service is started if docker packages are already present ----------------------------------------------- 22.26s
download : download_container | Download image if required ------------------------------------------------------------------------------- 12.58s
download : download_container | Download image if required ------------------------------------------------------------------------------- 11.65s
download : download_container | Download image if required ------------------------------------------------------------------------------- 10.55s
download : download_container | Download image if required -------------------------------------------------------------------------------- 9.68s
download : download_container | Download image if required -------------------------------------------------------------------------------- 9.43s
kubernetes-apps/ansible : Kubernetes Apps | Start Resources ------------------------------------------------------------------------------- 9.12s
etcd : Configure | Check if etcd cluster is healthy --------------------------------------------------------------------------------------- 8.69s
download : download_container | Download image if required -------------------------------------------------------------------------------- 7.26s
download : download_container | Download image if required -------------------------------------------------------------------------------- 7.10s
kubernetes/preinstall : Install packages requirements ------------------------------------------------------------------------------------- 6.22s
network_plugin/calico : Start Calico resources -------------------------------------------------------------------------------------------- 5.96s
download : download_container | Download image if required -------------------------------------------------------------------------------- 5.91s
etcd : wait for etcd up ------------------------------------------------------------------------------------------------------------------- 5.69s
download : download | Download files / images --------------------------------------------------------------------------------------------- 5.57s
kubernetes-apps/ansible : Kubernetes Apps | Lay Down CoreDNS Template --------------------------------------------------------------------- 5.35s
download : download_container | Download image if required -------------------------------------------------------------------------------- 4.74s

構築完了後の確認

ansible-playbookコマンドが完了したら、Masterノードにログインし、Kubernetesが構築されたことを確認します。

# kubectlコマンドによるノードの確認

[root@k8s-master ~]# kubectl get nodes
NAME           STATUS   ROLES    AGE     VERSION
k8s-master     Ready    master   6m11s   v1.16.3
k8s-worker01   Ready    <none>   5m7s    v1.16.3
k8s-worker02   Ready    <none>   5m7s    v1.16.3

# Podの確認

[root@k8s-master ~]# kubectl get pods --all-namespaces
NAMESPACE     NAME                                       READY   STATUS    RESTARTS   AGE
kube-system   calico-kube-controllers-86df5ff779-ptv75   1/1     Running   0          4m41s
kube-system   calico-node-77fpw                          1/1     Running   1          5m20s
kube-system   calico-node-jsmtg                          1/1     Running   1          5m20s
kube-system   calico-node-vsdx6                          1/1     Running   1          5m20s
kube-system   coredns-58687784f9-6264j                   1/1     Running   0          4m25s
kube-system   coredns-58687784f9-f7x2s                   1/1     Running   0          4m18s
kube-system   dns-autoscaler-79599df498-q8r7r            1/1     Running   0          4m20s
kube-system   kube-apiserver-k8s-master                  1/1     Running   0          6m20s
kube-system   kube-controller-manager-k8s-master         1/1     Running   0          6m20s
kube-system   kube-proxy-fnxdk                           1/1     Running   0          5m41s
kube-system   kube-proxy-tsm7b                           1/1     Running   0          6m26s
kube-system   kube-proxy-xx2zj                           1/1     Running   0          5m41s
kube-system   kube-scheduler-k8s-master                  1/1     Running   0          6m20s
kube-system   kubernetes-dashboard-556b9ff8f8-qrqpt      1/1     Running   0          4m16s
kube-system   nginx-proxy-k8s-worker01                   1/1     Running   0          5m41s
kube-system   nginx-proxy-k8s-worker02                   1/1     Running   0          5m41s
kube-system   nodelocaldns-5fmv7                         1/1     Running   0          4m18s
kube-system   nodelocaldns-njxmm                         1/1     Running   0          4m18s
kube-system   nodelocaldns-qrhk8                         1/1     Running   0          4m18s

上記結果を眺めると、Kubernetesコンポーネントの他にdns-autoscaler nginx-proxy nodelocaldnsなどのPodが確認できます。

dns-autoscalerKubernetes内部で利用するCoreDNSなどのDNSのHorizontal Autoscaleを自動的に行うために作成されるPodです。詳細はKubernetes公式ドキュメントをご覧ください。

nginx-proxyはWorkerノードに配置され、kube-apiserverとのヘルスチェックを行うPodです。

nodelocaldnsはCluster内部の名前解決を効率的に行うために作成されるDNSコンテナです。詳細はKubernetesの公式ドキュメントをご覧ください。

構築⑤:Workerノードの追加

最後に、構築済みクラスターにWorkerノードを追加します。KubesprayではInventoryを更新しPlaybookを実行するだけでノードを追加することができます。

まずはInventoryファイルであるhosts.ymlを以下のように更新します。

all:
  hosts:
    k8s-master:
      ansible_host: 10.3.0.6
      ip: 10.3.0.6
      access_ip: 10.3.0.6
    k8s-worker01:
      ansible_host: 10.3.0.7
      ip: 10.3.0.7
      access_ip: 10.3.0.7
    k8s-worker02:
      ansible_host: 10.3.0.8
      ip: 10.3.0.8
      access_ip: 10.3.0.8
    k8s-worker03:    # 追加ノード
      ansible_host: 10.3.0.9
      ip: 10.3.0.9
      access_ip: 10.3.0.9
  children:
    kube-master:
      hosts:
        k8s-master:
    kube-node:
      hosts:
        k8s-worker01:
        k8s-worker02:
        k8s-worker03:    # 追加
    etcd:
      hosts:
        k8s-master:
    k8s-cluster:
      children:
        kube-master:
        kube-node:
    calico-rr:
      hosts: {}

そしてansible-playbookコマンドを実行します。ここではPlaybookにscale.ymlというスケールアップ専用のファイルを指定します。scale.ymlの内容は以下の通りです。

---
- hosts: localhost
  gather_facts: False
  become: no
  tasks:
    - name: "Check ansible version >=2.7.8"
      assert:
        msg: "Ansible must be v2.7.8 or higher"
        that:
          - ansible_version.string is version("2.7.8", ">=")
      tags:
        - check
  vars:
    ansible_connection: local

- hosts: bastion[0]
  gather_facts: False
  roles:
    - { role: kubespray-defaults}
    - { role: bastion-ssh-config, tags: ["localhost", "bastion"]}

- name: Bootstrap any new workers
  hosts: kube-node
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  gather_facts: false
  roles:
    - { role: kubespray-defaults}
    - { role: bootstrap-os, tags: bootstrap-os}

- name: Generate the etcd certificates beforehand
  hosts: etcd
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: etcd, tags: etcd, etcd_cluster_setup: false }

- name: Target only workers to get kubelet installed and checking in on any new nodes
  hosts: kube-node
  any_errors_fatal: "{{ any_errors_fatal | default(true) }}"
  roles:
    - { role: kubespray-defaults}
    - { role: kubernetes/preinstall, tags: preinstall }
    - { role: container-engine, tags: "container-engine", when: deploy_container_engine|default(true) }
    - { role: download, tags: download, when: "not skip_downloads" }
    - { role: etcd, tags: etcd, etcd_cluster_setup: false, when: "not etcd_kubeadm_enabled|default(false)" }
    - { role: kubernetes/node, tags: node }
    - { role: kubernetes/kubeadm, tags: kubeadm }
    - { role: network_plugin, tags: network }
    - { role: kubernetes/node-label }
  environment: "{{ proxy_env }}"

それではansible-playbookコマンドを実行します。

[root@ansible-eastus kubespray]# ansible-playbook -i inventory/mycluster/hosts.yml scale.yml -b -v

こちらも処理結果の要約 (PLAY RECAP)を載せておきます。

PLAY RECAP **************************************************************************************************************************************************************************
k8s-master                 : ok=35   changed=3    unreachable=0    failed=0   
k8s-worker01               : ok=330  changed=21   unreachable=0    failed=0   
k8s-worker02               : ok=299  changed=20   unreachable=0    failed=0   
k8s-worker03               : ok=319  changed=81   unreachable=0    failed=0   
localhost                  : ok=1    changed=0    unreachable=0    failed=0   

Sunday 22 December 2019  02:01:56 +0000 (0:00:00.584)       0:07:11.674 ******* 
=============================================================================== 
container-engine/docker : ensure docker packages are installed -------------------------------------------------------------------------------------------------------------- 31.06s
container-engine/docker : ensure service is started if docker packages are already present ---------------------------------------------------------------------------------- 21.94s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 9.28s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 8.64s
kubernetes/kubeadm : Join to cluster ----------------------------------------------------------------------------------------------------------------------------------------- 7.54s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 7.45s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 7.21s
kubernetes/preinstall : Install packages requirements ------------------------------------------------------------------------------------------------------------------------ 6.41s
download : download | Download files / images -------------------------------------------------------------------------------------------------------------------------------- 4.57s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 4.24s
download : download_container | Download image if required ------------------------------------------------------------------------------------------------------------------- 3.93s
download : download | Sync files / images from ansible host to nodes --------------------------------------------------------------------------------------------------------- 3.75s
kubernetes/preinstall : Hosts | populate inventory into hosts file ----------------------------------------------------------------------------------------------------------- 2.92s
download : download_file | Download item ------------------------------------------------------------------------------------------------------------------------------------- 2.49s
download : download | Download files / images -------------------------------------------------------------------------------------------------------------------------------- 2.46s
download : download | Sync files / images from ansible host to nodes --------------------------------------------------------------------------------------------------------- 2.37s
kubernetes/preinstall : install growpart ------------------------------------------------------------------------------------------------------------------------------------- 2.30s
etcd : Gen_certs | Copy certs on nodes --------------------------------------------------------------------------------------------------------------------------------------- 2.10s
download : download_file | Download item ------------------------------------------------------------------------------------------------------------------------------------- 2.09s
container-engine/docker : check if container-selinux is available ------------------------------------------------------------------------------------------------------------ 2.02s

実行後にMasterノードからkubectlコマンドを実行し、確認します。

# ノードが追加されたことを確認

[root@k8s-master ~]# kubectl get nodes
NAME           STATUS   ROLES    AGE    VERSION
k8s-master     Ready    master   33h    v1.16.3
k8s-worker01   Ready    <none>   33h    v1.16.3
k8s-worker02   Ready    <none>   33h    v1.16.3
k8s-worker03   Ready    <none>   5m7s   v1.16.3

# Podが稼働しているかの確認

[root@k8s-master ~]# kubectl get pods -n kube-system -o wide
NAME                                       READY   STATUS    RESTARTS   AGE     IP             NODE           NOMINATED NODE   READINESS GATES
calico-kube-controllers-86df5ff779-ptv75   1/1     Running   0          33h     10.3.0.7       k8s-worker01   <none>           <none>
calico-node-77fpw                          1/1     Running   1          33h     10.3.0.7       k8s-worker01   <none>           <none>
calico-node-jsmtg                          1/1     Running   1          33h     10.3.0.6       k8s-master     <none>           <none>
calico-node-pk24d                          1/1     Running   2          5m22s   10.3.0.9       k8s-worker03   <none>           <none>
calico-node-vsdx6                          1/1     Running   1          33h     10.3.0.8       k8s-worker02   <none>           <none>
coredns-58687784f9-6264j                   1/1     Running   0          33h     10.233.111.1   k8s-master     <none>           <none>
coredns-58687784f9-f7x2s                   1/1     Running   0          33h     10.233.117.1   k8s-worker02   <none>           <none>
dns-autoscaler-79599df498-q8r7r            1/1     Running   0          33h     10.233.111.2   k8s-master     <none>           <none>
kube-apiserver-k8s-master                  1/1     Running   0          33h     10.3.0.6       k8s-master     <none>           <none>
kube-controller-manager-k8s-master         1/1     Running   0          33h     10.3.0.6       k8s-master     <none>           <none>
kube-proxy-868x2                           1/1     Running   0          5m22s   10.3.0.9       k8s-worker03   <none>           <none>
kube-proxy-fnxdk                           1/1     Running   0          33h     10.3.0.7       k8s-worker01   <none>           <none>
kube-proxy-tsm7b                           1/1     Running   0          33h     10.3.0.6       k8s-master     <none>           <none>
kube-proxy-xx2zj                           1/1     Running   0          33h     10.3.0.8       k8s-worker02   <none>           <none>
kube-scheduler-k8s-master                  1/1     Running   0          33h     10.3.0.6       k8s-master     <none>           <none>
kubernetes-dashboard-556b9ff8f8-qrqpt      1/1     Running   0          33h     10.233.125.1   k8s-worker01   <none>           <none>
nginx-proxy-k8s-worker01                   1/1     Running   0          33h     10.3.0.7       k8s-worker01   <none>           <none>
nginx-proxy-k8s-worker02                   1/1     Running   0          33h     10.3.0.8       k8s-worker02   <none>           <none>
nginx-proxy-k8s-worker03                   1/1     Running   0          5m22s   10.3.0.9       k8s-worker03   <none>           <none>
nodelocaldns-5fmv7                         1/1     Running   0          33h     10.3.0.6       k8s-master     <none>           <none>
nodelocaldns-5zsg9                         1/1     Running   0          5m22s   10.3.0.9       k8s-worker03   <none>           <none>
nodelocaldns-njxmm                         1/1     Running   0          33h     10.3.0.7       k8s-worker01   <none>           <none>
nodelocaldns-qrhk8                         1/1     Running   0          33h     10.3.0.8       k8s-worker02   <none>           <none>
[root@k8s-master ~]# 

最後に

今回はKubesprayについて紹介しました。

KubesprayではAnsibleを利用するため幅広い環境に適用することができます。またクラスター構築時に様々なオプションを選択できるため拡張性もあり、HAクラスターの構築も可能です。またクラスター構築後のノード追加も、少なくともWorkerノードについては簡単に実行することができます。クラスター構築後の追加作業が容易なことも考えると、オンプレ環境など自前でクラスター構築が必要な場面では、第一候補として利用を検討するべきツールではないかと感じました。

一方、Masterノードの追加方法については、この記事を書いている時点ではわかりませんでした。単純にInventoryファイルを更新してPlaybookを実行すれば良いわけではないようで、別の設定変更作業が必要なようです。以下のissueリンク等を参考にして引き続き検証したいと思います。

Document node adding/removing/restoring #1122

Unable to add new master/etcd node to cluster #3471

参考リンク

Kubespray公式ドキュメント

Ansible実践ガイド第3版 (impress top gearシリーズ

CentOS7にPython3系をインストールする手順