minikubeでDockerコンテナをクラスタ構成した運用とDockerコンテナを手作業で地道に管理の運用とどっちが楽か検証しながらまとめてみた

皆さまこんばんは、ikaです。ここ最近の仕事面では開発する時間も増えてきて、中々インフラの話で話題になれそうなものがあんまり出てこないなーという矢先の記事です。
なんだかんだ言って、弊社のインフラはまだ問題が山積みなため生々しい声(主に自分の運用の辛さ<-)も含めて書いています。

今日はその中でも今流行りの(?) Kubernetes によるDockerコンテナをクラスタ化の試験運用を行なっているので、その体験談、プラス自社の既存の運用方針と照らし合わせて結局Kubernetesってどうなの?と言う記事を書いてみたいと思います。それではいってみましょう。

注: 追記参照

コンテナ型仮想化について

Dockerコンテナと言っていますが、そもそもコンテナってなんでしょうか。Dockerの復習となりますが、一般的な仮想化と言えば、KVMXen と言ったようなハイパーバイザーがホストOSの中で動いています。このハイパーバイザーがホストOSの上でVM(仮想マシン)を動かせるように制御しています。そのため、VMのOSはホストOSとは違うOS(ゲストOS)を動かすことができ、またゲストOSの中で独立したアプリケーションを動かすことができます(ハイパーバイザーには他のOSで動くものと、専用のOSの上で動くものの二種類があります)。しかし、ハイパーバイザーの上でVMを作成するにはハードウェアのリソースを割り当てる必要があるので、VMの個数が多くなるとリソースの割り当てが難しくなってきます。

一方、Dockerのようなコンテナ型仮想化と呼ばれているものはホストOSの上にコンテナと呼ばれる仮装的なユーザ空間を提供します。ユーザ空間とは、ユーザがアプリケーションを実行するためのリソースが提供される空間です。そのため、ホストOSからはコンテナというものが単純な一つのプロセスに見えるため非常に管理がしやすくもあります。その反面、コンテナはホストOSに依存するユーザ空間が提供されるためゲストOSはホストOSのものと同じでなければいけません(コンテナの例としては他にも jailLXC、もっと言えば chroot から考えても歴史は古くからあります)。

サーバ仮想化とコンテナ型仮想化の使い分けの話ですが、ハードウェアのリソースが充分にあれば、従来通りVMによるサーバ運用や物理サーバでの運用で良いと思われます。
コンテナとして仮想化する場合は開発環境で使うのが良いかと思われます。案件や会社の予算にもよりますが、開発環境などのリソースを割くのが難しいものにはコンテナ型仮想化はビジネス的にも適しているかと思われますし、なおかつ今後の開発としても環境をコンテナで独立させられるので開発環境を使い回しする場合は使い回しによる影響が少ないはずです(逆に本番環境ではリソースは充分に割いて欲しいのでコンテナ型仮想化による本番環境の運用は基本的には反対しています)。

Kubernetesとは

さて、Dockerが発案されてコンテナ型仮想化が非常に普及してきたのは言うまでもないですが、コンテナを運用していくためには専用のプラットフォームが必要となります。ここで言うプラットフォームが Docker Engine になります。弊社では
Docker Engine (以下、Dockerホストと呼ぶ) が導入されている Linux OSを何台か運用しその上でたくさんのコンテナが動いています。しかし、Dockerホストの台数が大きくなり、その上で動くコンテナが多くなるに連れて、管理が煩雑化してきてしまっています。そのため、「VMと違ってリソースを割く必要性を感じなくなったのは良いが、コンテナ自体は比較しても多くなっているので結局管理の複雑さはVM運用の時と変わっていない、むしろ余計に複雑になってきている」感覚です。

そこで多少なりとも、現在のDockerホストの運用の手助けになればと思い私が着目したものが Kubernetes です。
Kubernetesとは、Linuxコンテナの操作を自動化するオープンソース・プラットフォームです。KubernetesはLinuxコンテナをまとめてクラスタ化できます。コンテナをクラスタ化することでアプリケーションのデプロイやスケーリングなどをより効率的に、容易に管理できます。

そのため、Kubernetesを用いることでそのような用途で利用する上では非常に役立つでしょう。

…と、ここまでは軽くKubernetesを用いることでできること、クラスタ化することによるメリットから着目した説明です。では、これが弊社のような「リソースを割くまでに至らないが必要なコンテナを手作業で運用」という用途でDocker Engineを利用している場合でも通用するのでしょうか。

私が一番注目したのはやはりコンテナのクラスタ化ができるという所です。これだけでも手作業での運用の管理と比較して、楽になるか調べていきます。

Kubernetes(Minikube)の導入

Kubernetesの導入のために、弊社で運用しているハイパーバイザーから一つVMを生成し、その上で Minikube を導入することにしました。
Minikube とは、Kubernetesを一つのコンピュータで容易に動かせるように設計された簡易版プラットフォームです。

説明不足でしたが、クラスタ化することでアプリケーションデプロイやスケーリングが容易となるというメリットに関して充分に活用しようとするのであれば、コンテナを動かすサーバとKubernetesを動かすサーバは最低限分けて構築する方がいいでしょう。`

いずれにせよ、そもそもそういう形でのKubernetesを使う予定がないため、Minikubeによる簡易版プラットフォームで「Docker管理ツール + Dockerコンテナ」の構成を行います。

Docker Engineの導入

Minikubeを動かすために事前にDockerをホストOSに導入します。

$ sudo apt update
$ sudo apt install apt-transport-https 
    ca-certificates 
    curl 
    software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo apt-key fingerprint 0EBFCD88
$ sudo add-apt-repository 
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu 
   $(lsb_release -cs) 
   stable"
$ sudo apt update
$ sudo apt install docker-ce
$ systemctl enable docker
 Synchronizing state of docker.service with SysV service script with /lib/systemd/systemd-sysv-install.
 Executing: /lib/systemd/systemd-sysv-install enable docker
$ systemctl restart docker

Minikubeの導入

$ sudo su -
$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
$ chmod +x minikube
$ mv minikube /usr/bin

$ curl -Lo kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl
$ chmod +x kubectl
$ kubectl /usr/bin

$ export MINIKUBE_WANTUPDATENOTIFICATION=false
$ export MINIKUBE_WANTREPORTERRORPROMPT=false
$ export MINIKUBE_HOME=$HOME
$ export CHANGE_MINIKUBE_NONE_USER=true
$ mkdir $HOME/.kube || true
$ touch $HOME/.kube/config

$ export KUBECONFIG=$HOME/.kube/config
$ minikube start --vm-driver=none

KubernetesのTOP画面は上記のようになります。KubernetesはGoogleエンジニアによって開発・設計されたため、すごくGoogle的な画面になっていますね。

さて、今度はクラスタ作成とコンテナ作成を行なっていきます。

TechRachoの開発環境をKubernetesで作ってみる

まずは単体のコンテナ一つでクラスタを作るようにします。試しにTechRachoの開発環境をここに作ってみましょう。
(なお、ここでは社内プライベートのDockerレジストリからimageを取得できるように事前にレジストリの設定を行なっております。
レジストリを簡単に作成できるようにコンテナで運用しています: https://hub.docker.com/_/registry/)

apiVersion: v1
kind: Pod
metadata:
  name: techracho-dev
  labels:
    run: techracho-dev
spec:
  containers:
  - name: techracho-dev
    image: "*******:5000/techracho-dev:ver1.0"
    command: [ "/usr/bin/supervisord" ]
    imagePullPolicy: IfNotPresent
    ports:
    - containerPort: 80
      name: http
    - containerPort: 22
      name: ssh
  restartPolicy: Never

上記は techracho-dev のコンテナを作成します。Kubernetesで作成するコンテナは、Podと呼ばれる単位でNode(Podが動作する物理もしくは仮想マシン)に配置されます。
そのため、依存関係のあるコンテナ同士は同一のPodに所属させて管理します。

今回は techracho-dev 単体なためPodは一つのコンテナだけです。また、コンテナで起動するサービスを後にポートフォワーディングで外部に公開させるようにしたいため、HTTPとSSHのウェルノウンポートを指定しておきます。また、Dockerコンテナの起動時に与えられるコマンドとして supervisord を与えます。

supervisordで、TechRachoの中で動いているサービスを一元管理しています。

上記のファイルをymlで保存して実行してみましょう。

kubectl apply -f techracho.yml
# kubectl get pods techracho-dev
NAME            READY     STATUS    RESTARTS   AGE
techracho-dev   1/1       Running   0          7m

techracho-devという名前のPodに一つコンテナが実行されていることがわかります。
より詳細的な情報が欲しい場合は、

# kubectl describe pods techracho-dev
Name:         techracho-dev
Namespace:    default
Node:         *******/*******
Start Time:   Tue, 13 Feb 2018 18:15:18 +0900
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"v1","kind":"Pod","metadata":{"annotations":{},"name":"techracho-dev","namespace":"default"},"spec":{"containers":[{"command":["/usr/bin/...
Status:       Running
IP:           172.17.0.4
Containers:
  techracho-dev:
    Container ID:  docker://4f06f4b907069cce848dae248ef42d213cc73ab70cfb864e83cf52b842da5a0c
    Image:         *******:5000/techracho-dev:ver1.0
    Image ID:      docker-pullable://*******:5000/techracho-dev@sha256:021bdd2c1ac9f1afeef42cea6935d2c9c70adf512c84cc9d0ed74f20e5f4fcf8
    Ports:         80/TCP, 22/TCP
    Command:
      /usr/bin/supervisord
    State:          Running
      Started:      Tue, 13 Feb 2018 18:15:19 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-5xkpk (ro)
Conditions:
  Type           Status
  Initialized    True
  Ready          True
  PodScheduled   True
Volumes:
  default-token-5xkpk:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-5xkpk
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     <none>
Events:
  Type    Reason                 Age   From                  Message
  ----    ------                 ----  ----                  -------
  Normal  Scheduled              10m   default-scheduler     Successfully assigned techracho-dev to ********
  Normal  SuccessfulMountVolume  10m   kubelet, ********  MountVolume.SetUp succeeded for volume "default-token-5xkpk"
  Normal  Pulled                 10m   kubelet, ********  Container image "*******:5000/techracho-dev:ver1.0" already present on machine
  Normal  Created                10m   kubelet, ********  Created container
  Normal  Started                10m   kubelet, ********  Started container

これだけではまだ、外部からサービスを公開できません。そのため、Kubernetesホストとコンテナとのポートをフォワーディングします。

Serviceの作成

Kubernetesの中で動作しているPodsですが、これを外部から公開するために Service を作成します。Serviceとは、Kubernetesの定義の一つで、PodsをKubernetes外からアクセスできるように中継したり、Pods間のロードバランサーの機能を持っているようです。

こちらもymlファイルから作成できるため、以下のものを作りました。

apiVersion: v1
kind: Service
metadata:
  name: techracho-dev
spec:
  selector:
    run: techracho-dev
  type: NodePort
  ports:
    - protocol: TCP
      port: 10080
      targetPort: 80
      nodePort: 30080
      name: http
    - protocol: TCP
      port: 10022
      targetPort: 22
      nodePort: 30022
      name: ssh

着目する部分は selector でServiceに対応するPodsを紐付けます。また、type: NodePortとすることでホストのIPとKubernetesのIPと紐付けができます。
portsは下記のように用途が分けられています。

  • port: localhostからPodへの接続用ポート
  • targetPort: コンテナ(Pod)内のポート
  • nodePort: Kubernetesホスト外部からPodへの接続ポート

簡単に設定するにはnodePorttargetPortさえ指定すれば、Kubernetes外からtechracho-devに接続できそうです(portだとどうしてもNAPT設定が必要になりますね)。
ただし、NodePortはあらかじめ利用できるポート番号が決まっているようです。

kubectl apply -f techracho_service.yml
The Service "techracho-dev" is invalid: spec.ports[0].nodePort: Invalid value: 10080: provided port is not in the valid range. The range of valid ports is 30000-32767

(30000-32767だけしか使えないのは少なすぎるような気もしますが、、、)

以上で、KubernetesのホストIPからtechracho-devへのアクセスが可能となります。

http://(kubernerntesが入ったホストのIP):30080/

さて、単体Podでの運用手順はこれくらいにして、今度はクラスタ配下に複数のPodを構築できるような運用をやってみましょう。

複数のPodを構築して運用してみる

弊社のサーバの中でクラスタにできそうなものとして、「同じRailsアプリだが、MySQLは案件毎に違うサーバ」という条件で手順方法を作ってみます。

この条件だと、Kubernetesの同じクラスタ名で所属させたとしても一つ一つは違うDockerイメージが必要になってきます。
そのため、Dockerイメージをその都度作成しないといけないため大変です。

そこで、「Railsアプリを動かす基盤となるシステムだけ先にイメージとして作成」しておき、後々イメージをコミットするようにします。
まずは、基盤となるイメージから同じPodsを二つ作成し、この二つに個々のデータをコピーし、イメージをコミットするようにします。

今回、ここで定義するのは ReplicaSetDeployment の二つです。
ReplicaSet とは、同じ仕様のPodの複製や指定された数のPodを常に起動できるように管理します。
DeploymentReplicaset の作成・管理を行います。

まず、Podを2つ作成するReplicaSetを作成するためのDeploymentを作成します。
設定ファイルは以下のようになります。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: example
spec:
  replicas: 2
  template:
    metadata:
      labels:
        run: example
    spec:
      containers:
      - name: example
        image: example:latest
        command: [ "/usr/bin/supervisord" ]
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
          name: http
        - containerPort: 22
          name: ssh

上記の設定ファイルでDeploymentを作成します。

# kubectl apply -f example_deploy.yml
deployment "example" created
# kubectl get deployments example
NAME       DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
example   2         2         2            2           49s
# kubectl get pods
NAME                        READY     STATUS    RESTARTS   AGE
example-59457cf5f9-6mpcc   1/1       Running   0          31m
example-59457cf5f9-f9xlb   1/1       Running   0          31m
techracho-dev               1/1       Running   0          3d

Podが二つ作成されていますね。そしてそのPodを管理するDeploymentも作成されています。
Podに応じて、データを反映させたらイメージを作成します。ここはKubernetesではなくDockerの方でcommitします。

# docker commit 31647b53184c example-dev:ver1.0
# docker commit 1d833a8cb373 example-dev2:ver1.0

commitでイメージを保存したら、最初に作ったdeploymentは削除してしまいましょう。
deploymentを削除すれば、配下にあるPodも自動的に消せます

# kubectl delete deployment example
deployment "example" deleted
# kubectl get pods
NAME            READY     STATUS    RESTARTS   AGE
techracho-dev   1/1       Running   0          3d

commitして更新したイメージでPodを作ります。

  • example-dev:ver1.0
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: example-dev
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: example-dev
    spec:
      containers:
      - name: example-dev
        image: example-dev:latest
        command: [ "/usr/bin/supervisord" ]
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
          name: http
        - containerPort: 22
          name: ssh
  • example-dev2:ver1.0
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: example-dev2
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: example-dev2
    spec:
      containers:
      - name: example-dev2
        image: example-dev2:latest
        command: [ "/usr/bin/supervisord" ]
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
          name: http
        - containerPort: 22
          name: ssh

2つの独立したPodを作成する事ができました。二つを PodではなくDeploymentで作っている理由は、replicas の部分で常に起動するPod数を指定するためです。これにより、Podが最低一つは常に起動される事が保証されます。
作成したPodをPort Forwardingで外部から公開しつつ、同じクラスタに配属させます。

apiVersion: v1
kind: Service
metadata:
  name: example-dev
spec:
  selector:
    app: example-dev
  type: NodePort
  ports:
    - protocol: TCP
      port: 10022
      targetPort: 22
      nodePort: 31022
      name: ssh
    - protocol: TCP
      port: 10080
      targetPort: 80
      nodePort: 31080
      name: http
apiVersion: v1
kind: Service
metadata:
  name: example-dev2
spec:
  selector:
    app: example-dev2
  type: NodePort
  ports:
    - protocol: TCP
      port: 10022
      targetPort: 22
      nodePort: 32022
      name: ssh
    - protocol: TCP
      port: 10080
      targetPort: 80
      nodePort: 32080
      name: http

一つ注意する点として、二つのPodで公開しているポート番号が 22, 80と重複していますので、ホストのポート番号と紐付けるために、selectorをPod毎に作成する必要があります。非常に複雑な設定をしていますがこれにより、コンテナポート毎にホストのポートが紐づけられます。
以上の設定で外部からPodにアクセスできます。

  • example-dev
http://(kubernerntesが入ったホストのIP):31080/
  • example-dev2
http://(kubernerntesが入ったホストのIP):32080/

更に、これらは管理画面より全てGUI上で把握できます。

http://(keubernetesが入ったホストのIP):30000/

こういうのが一目で見えるようになるのは良いですね。GUIからこれまでのステータスを再作成するとなると辛いですが。

まとめ

という訳でKubernetesを使ってみた感想も含めてですが、まずVMを構築・運用するような形で導入しようと考えたのが間違いでした。
まずKubernetesでちゃんと運用できるようにするのであれば、最低3台のサーバが必要になってきます。

  1. Kubernetesで運用するNodeサーバ
  2. KubernetesでNodeを制御するコントロールサーバ
  3. imageを取得するDockerレジストリサーバ(Docker Hubの有料プランでもOK)

正直、弊社のように急にリソースを増やすような提案が難しい場合には、Kubernetesを導入するのは逆に複雑になるような気がします。そのため、まずリソースを考慮するという点で手作業での運用の方が優越です。

更に単体のPodの運用の場合も、やはり手作業での運用の方が有利でしょう。一応、ReplicaSetで常時起動するPodを最低1台にするという運用も可能ですが、それなら docker update --restart=always で良いでしょう。

一方、同じ環境を複製して操作する事が多い場合は、クラスタ化は非常に有意義です。
今回のような基盤イメージからRailsアプリのイメージをその都度作っていくというような泥臭い事していますが、必要な分をあらかじめ決めて作成できるので精神的にも楽なところがあります。

ただ、今回の条件は正直に言うと凄くマイナー的と言いますか、本来同じ環境、同じデータが入ったものをクラスタ化して、スケーリング管理したいなどのクラスタ化する事で得られる一番メジャーな利点(?)のものを本件で試していないのがちょっと問題あります。
そのため、ここまで読んでいただいて申し訳ないのですが、最初の条件の時点でKubernetesを使うというスキーマにはならないような気もします(そもそもスケーリングしたいのであれば、AWSやGCPの機能で任せた方が良いのでは?という疑問もあったのですが)。

とは言え、今までのサーバ運用がDockerコンテナへ、そしてクラスタ運用がKubernetesでできる事になったため、弊社のような小さな会社でもコストをかけずに運用できるという意味では非常に有意義ではないでしょうか。
今後とも触っていく需要はありそうな気がします。

ところで、FreeBSDのコンテナ仮想化と言えばjailですが、jailのクラスタ化で検索したらこのような記事が出てきました。

ちょっと古い記事ではありますが、まだFreeBSD界隈は進んでいない気がする?…ただ、jailは標準の機能としてカーネルに入ったので今後発展していく可能性は大いにある…はず。

20180308 追記

後々、この設定だと致命的な問題があったので追記します。この設定だと Deploymentexample-dev:latestexample-dev2:latestの imageからDockerコンテナを作ろうと試みます。
しかし、この設定ではPodsに更新があった差分がimageに反映されないため、Minikubeでは常に古いDocker Imageでコンテナを起動してしまう問題が発生してしまいます(AWSでAMIからEC2を起動する際にもself-deployする手段がないと、古いコードでアプリが動いてしまいますね、感覚としてはそれに似ています)。

そこで、 私の方では cron で毎日 Docker imageを commitするようにしました。

#!/bin/sh
#
# docker image commit daily

set -e

if [ -x /usr/bin/docker ]; then
        container1=$(docker ps -q -f 'name=example_dev_example-dev')
        docker commit ${container1} example-dev:latest
        container2=$(docker ps -q -f 'name=example_dev2_example-dev2')
        docker commit ${container2} example-dev2:latest
fi

exit 0

docker ps -qでコンテナIDを標準出力として受け取り、変数に代入してcommitするという単純なcronです。
一先ず、これで日毎の差分バックアップは完了しているので、何時までも古い Docker imageでコンテナが起動するということはなくなりました。

ところで、こういうDockerイメージの差分バックアップって皆さんどうしているんでしょうね。
やはり、プライベートなレジストリサーバに毎日イメージ送るのが一番理想的なのでしょうか。

参考文献

関連記事

PrometheusでDockerホスト + コンテナを監視してみた

Dockerでsupervisorを使う時によくハマる点まとめ

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

ika

1990年滋賀県生まれ。以前は某大学の学内SE。 元々、Web業界に行きたかった希望があり、2015年10月にBPSへ入社。 北陸先端科学技術大学院大学(Jaist)情報科学研究科博士前期課程修了。 元々の専門はネットワークなので、ネットワークの話をすることもしばしば。

ikaの書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ