個人で運用するKubernetesクラスタ

Kubernetesの使用感に興味があってaws-workshop-for-kubernetesというのを先週やり、ちょうどEKSがGAになった直後だったのでEKSが試せたのだけど、まあ最初からマネージドだとあまり面白みがないし金もかかるので、個人のVPSで動かしてた奴を全部Kubernetes上で動かすようにしてみている。

まだ本番で運用した知見みたいなのが貯まってるわけではないのだが、公式のドキュメントを中心に読んでいても単に動かし始める段階で結構ハマって時間を消費したので、これから同じようなことをやろうとしている人向けに備忘録を兼ねて使用感や知見をまとめておくことにした。

Kubernetesは今でもalphaやbetaの機能が多く、今後この記事の内容も古くなることが予想されるので、なるべく公式のドキュメントへのリンクを置くのを意識して書いてある。

構成

現時点で、ConoHaで借りたサーバー (1GB 2コア x 2, 512MB 1コア x 1) で以下のような構成のものを動かしている。

f:id:k0kubun:20180621204251p:plain

Kubernetes周りの細かい用語は以降で説明するが、要は以下の5種類のものがKubernetesからDockerでデプロイされている。

コスト

このキャパシティだとメモリもCPUも割と余裕がない感じなのだが、落ちても困らない純粋な趣味*1で立てている赤字サーバーなので意図的に最小限のコストで走らせている*2

Ubuntu 16.04→Ubuntu 18.04に式年遷宮しておこうと思った関係で今回ついでにVPSも乗り換えていて、 雑に調べた中だとConoHaのメモリ1GBプランがメモリ/円, CPUコア数/円で見て最もコスパが良さそうなのでなるべくこれを並べることにしているが、元のVPSの費用が安かったので1台はケチって512MBのプランにしている。

もともとは1年課金で初期費用4000円 + 月2380円のVPS(4コア, 4GB RAM)で動かしていたのが、1時間課金で初期費用0円 + 合計月2430円になったので、コストはほとんど変わっていない。 試験的にDBもKubernetes上で動かしてみたけど、あとで月630円のサーバーを潰して月500円のDBサーバーに移そうと思ってるので最終的には安くなると思う。

お仕事ならクラスタを保守・運用する人件費の方が余裕で高くつくと思うのでEKSのFargate起動タイプが出るまでは大体GKEを選ぶのが良いのだろうと思っているが、真面目には調べていない。

Kubernetesクラスタの作成方法

AWSやGCEで立てるならkopsとかを使うのが普通なのだと思うが、まあその辺のVPSではそういうのは使えなさそうなのでいくつかのツールを検証した。最終的にkubeadmに落ちついたのだが、それ以外を選択しなかった理由についても書いておく。

conjure-up

https://kubernetes.io/docs/getting-started-guides/ubuntu/

上記のUbuntu向けのGetting Startedで使われている通り、多分Ubuntuではconjure-upで立てるのが公式のお勧めなのだろう。ターミナル全体を支配する感じのウィザードが立ち上がり、いくつか選択肢を選ぶだけでセットアップできる。

僕が使った時は普通にsnapでconjure-upを入れたところバグにヒットしたがこれはchannelをedgeにしたら直った。が、これはkubeadmと違ってDocker以外にKVM等の仮想化レイヤーも挟まることになっていて、conjure-upでmaster nodeをセットアップするとConoHaの3コア2GBのマシンでもリソースを使い尽くして死に、その1つ上の4GBのマシンはちょっと高くなってしまうので採用を見送った。

minikube

https://kubernetes.io/docs/setup/minikube/

これもVirtualBox等の仮想化ドライバの上でKubernetesが立ち上がる奴なのだが、使いやすくてよくできている。 手元で開発用に立ち上げるツールとしてはこれが一番いいのではなかろうか。

minikubeを使うためにはVT-xかAMD-vの有効化が必要で、手元のマシンではBIOSで有効にできたがConoHaでは無効だったのでこれも今回は採用できなかった。

kubeadm

https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/

kubeadmでKubernetesをセットアップすると、master nodeとworker nodeの両方でシングルバイナリのkubeletがsystemd等からホストで直に起動され、kube-apiserver, kube-controller-manager, kube-dns, kube-proxy等その他のコンポーネントはホストのdockerdにぶら下がる形で動く。

そのため余計なオーバーヘッドが少なめで、実際ConoHaのメモリ1GB 2コアのマシンでmaster nodeが割と安定して稼動してくれる。512MB 1コアのマシンだとkubeadm initでCPU使用率が爆発してセットアップが完了しないので、masterはそれ以上ケチることはできない。kubeadm joinするだけのworker nodeは上述の通り512MB 1コアのマシンでも動く。

kubeadm initでmaster nodeを立ち上げた後kubeadm resetで全て綺麗にできるので、色々いじりすぎて何かよくわからないけど動かない状態になってしまって最初からやり直したいという時に、マシンを作り直さなくてもkubeadm resetするだけで良かったのが便利だった。ただしmulti-masterに対応してないのが欠点っぽい。

kubeadmをmitamaeから叩く形でmaster nodeのセットアップは完全に自動化しているが、worker nodeに関してはkubeadm joinする部分だけ一時的なトークンが必要な都合自動化を保留している。

名前解決ができない問題

そもそも僕は最初にkubeadmを試していて、何故conjure-upやminikubeも触るはめになったのかというと、kubeadmでKubernetesクラスタを立ち上げると、何故かkube-dnsで外部のサービス(*.cluster.localとかじゃなくて例えばslack.com等)の名前解決ができなかったためである。

そのためにPodのネットワークアドオンもCalico, Canal, Flannel, Weave Netなど色々試し、最終的にkubeadmなしでmaster nodeをセットアップするのも挑戦することになった(死ぬほど面倒なのでやめた)。ConoHaのファイアウォールも疑ったけど手元の開発マシン(Ubuntu 18.04)でも再現した。minikubeではこの問題はなかった。

で、何日かハマった後、Customizing DNS Serviceというドキュメントをを参考に以下のようなConfigMapを作りkubectl applyすることでこの問題は解決した。upstreamNameserversの実際の値はConoHaコントロールパネルでDNSサーバー1, 2にあったものを使用している。

apiVersion: v1
kind: ConfigMap
metadata:
  name: kube-dns
  namespace: kube-system
data:
  upstreamNameservers: |
    ["XXX.XXX.XXX.XXX", "YYY.YYY.YYY.YYY"]

何故kubeadmだとこのような問題が起きるのかはまだちゃんと調べていないが、 *3 内部DNSもちゃんと名前解決できているし今のところ特に困ってはいない。

コンポーネントの動かし方

冒頭の分類別に、どうやって動かしているかを僕が実際に古いVPSから移してきた順に解説していく。 実際のYAMLをこの記事と一緒に読みやすいように名前等を変えたものを以下の場所に置いておいた。

https://github.com/k0kubun/misc/tree/kubernetes

あくまで参考用で、いくつかはsecretやnodeSelectorの都合そのままkubectl applyしても恐らく動かないことに注意されたい。 各見出しの先頭に、一番参考になると思われるドキュメントへのリンクを貼っておく。どう考えてもそっちを読んでもらった方がいいので、ここでは要点しか解説しない。

Worker

https://kubernetes.io/docs/tasks/run-application/run-stateless-application-deployment/

最初に練習用にRuby製のChatbotであるRubotyをSlack向けに立てた。 最近開発しているRuby 2.6.0-preview2のJITコンパイラで動かしているため、ネイティブコードで高速にレスポンスが返ってくる。

f:id:k0kubun:20180621204654p:plain

Railsアプリからの非同期ジョブを受けるワーカーも同じ方法で立てられる。 この類のアプリケーションはデプロイ時にダウンタイムがあってもそれほど困らないので、難易度が低い。

単にPodを作るだけでもデプロイは可能だが、Deploymentリソースを作ってそいつにPodを作らせることで、 OOMとかで殺されても再度勝手にPodが作られるようにすることができる。それ以外それほど考えることはなさそう。

別途Vaultとかを設定しなくとも、Secretリソースでetcdに保存しておいた秘匿値を参照できる。 ワーカーのYAMLの方に例を書いてあるが、他の平文と環境変数と並べて書けて便利。

また、次に解説するMySQLへは、"default"ネームスペースに"mysql"という名前のServiceを作っておくことで、mysql.default.svc.cluster.localという名前でアクセスできる。

Database

https://kubernetes.io/docs/tasks/run-application/run-single-instance-stateful-application/
https://kubernetes.io/docs/tasks/run-application/run-replicated-stateful-application/

上記は2つともMySQLの例だが、CassandraとかZooKeeperの例もドキュメントにあり、状態を持つアプリのデプロイもPersistentVolumeやStatefulSetsなどにより割と手厚いサポートがある。

まあ、普通の神経してたらDBはRDSとか使うのが普通だし、マルチテナントなコンテナクラスタに状態のあるアプリをデプロイしても実際はあまり嬉しくないと思うが、一応以下のようなYAMLでできる。

ホストの適当なパスを選んでPersistentVolumeというリソースを作っておくと、PersistentVolumeClaimというリソース経由でPodにボリュームをアタッチしてコンテナにマウントできる。 そのボリュームでのディスク使用量に制限をかけられるのが便利なところだと思う。 *4 MySQLの場合/var/lib/mysqlがコンテナを停止してもホストに残るようにしておけば良い。

Reverse Proxy

https://kubernetes.github.io/ingress-nginx/deploy/
https://kubernetes.io/docs/concepts/services-networking/ingress/

Serviceというリソースでも外部へのサービスの公開は可能だが、僕の場合はTLS terminationをやるためにIngressリソースを使う必要があった。 しかし、Ingressというのは"ingress controller"というのを設定しないと動いてくれないという特殊なリソースで、Serviceと違ってkube-proxy先輩は面倒を見てくれない。

例えばAWSだとALBをバックエンドにしたIngress Controllerを使うことになると思うが、その辺のVPSで立てるならnginx-ingress-controller等を使う必要がある。nginx-ingress-controllerの設定で結構ハマってなかなかパブリックIPからアクセスできる感じにならなかったのだが、最終的に以下のような設定をしたら動いた。

ホスト名とかがヒットしなかった時に使われる--default-backend-serviceは指定しないと起動に失敗する。

またKubernetesでRBAC(Roll-based access control)が有効になっているとnginx ingress controllerは様々な権限を要求してくるので、がんばって権限管理をする必要がある。RBACには権限のリソースにRoleとClusterRoleの2つがあり、それぞれRoleBindingとClusterRoleBindingによってnginx-ingress-controllerのPodのServiceAccountに紐付ける必要がある。

Podのspec.serviceAccountNameでServiceAccountは指定可能だが、デフォルトでは"default"というServiceAccountに紐付いていて、上記の例ではそいつに何でもできるcluster-adminというClusterRoleを紐付けている。良い子は真似しないように。

Application Server

https://kubernetes.io/docs/concepts/services-networking/service/

Ingressから参照するServiceリソースを立てておく必要があるところがWorkerと異なるが、他は基本的に同じ。

静的ファイルの配信はnginxとかに任せるのが定石だけど、まあ設定が面倒になるのでこの例ではRAILS_SERVE_STATIC_FILES環境変数をセットしてRailsにアセットの配信をやらせている。どうせクライアント側でキャッシュが効くのでカジュアルな用途ではこれでいいと思うが、真面目にやるならS3にアセットを上げておいてCDNから参照させるようにするとかがんばる必要がある。

Cron Job

https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/

CronJobというリソースでJobリソースを定期実行できる。Kubernetes 1.4だとScheduledJobという名前だったらしい。

僕はこれでLet's EncryptのTLSサーバー証明書の更新をやっている。公式のcertbotPythonなのが気にいらないのでRuby製のacmesmithというのを使っていて、これもJITを有効にしているため高速に証明書が更新できる*5

無駄に毎日叩くようにしてあるが、証明書が新しければacmesmith側で更新がスキップされるようになっているので特に問題はない。

実際の更新スクリプトが何をやるかはこのYAMLからは読み取れないようになっているが、curlhttps://${k8s_host}/api/v1/namespaces/default/secretsにPOSTしてnginx-ingress-controllerが参照しているSecretリソースを更新している。-H "Authorization: Bearer ${x8s_token}"もつけないとUnauthorizedになるが、このトークンの取得方法はこのYAMLのコメントにあるコマンドで見ることができる。

動かしてみてわかったこと

master nodeのエージェントが結構メモリを使う

master nodeにPodをスケジューリングするのはsecurity reasonsのためデフォルトで無効になっているが、まあコストのためにmaster nodeのリソースも普通に使うつもりでいた。

しかしKubernetesのmaster nodeに必要なプロセス達は結構なメモリを使ってしまうので、そのアテは外れた。特にkube-apiserverはメモリ1GBのマシンでコンスタントに25%くらいメモリを使うし、etcd, kube-controller-manager, kubeletもそれぞれ5%くらい使うし、他にも1%くらい使う奴がいっぱいある。設定でどうにかできるなら是非改善したい。

メモリ1GB 2コアのmaster nodeにChatbotを立てるくらいは流石にできたけど、master nodeの余剰リソースにはほとんど期待しない方がいい。 調子に乗ってmaster nodeのリソースを使い切るとkubectlが使えなくなってPodの停止もできなくなるので、マシンの強制終了くらいしかできなくなってしまう。それがsecurity reasonsなのかもしれない。

ちなみにworker nodeでもkubeletやdockerdが動いているため、worker node側も限界までリソースが使えるとは考えない方が良いと思う。

ある程度余裕を持ってキャパシティを見積る必要がある

割と当たり前の話だが、コンテナクラスタに余剰リソースがないと、rolling updateで新しいイメージをデプロイしようとした時に新たなPodのスケジューリングに失敗する。master nodeのリソース使用量が予想外に大きかったので結構ギリギリで今運用してるのだけど、停止時間を作りたくないデプロイをする時に普通に困る*6

Kubernetesの前身であるBorgの論文だと、バッチジョブとかのプライオリティを下げておき、よりプライオリティが高いタスクのスケーリングの際にそれを犠牲にする(Resource ReclamationやPreemptionと書かれている)運用戦略が語られているが、KubernetesにもPod Priority and Preemptionが存在するので、バッチジョブがある程度ある場合はそいつに融通を効かせるというのもできるかもしれないが、まだ試してない。

感想

普通はDockerコンテナ用意してちょろっとYAML書くだけでデプロイできる基盤があったら便利だけど、この規模の用途で使うなら、master nodeの運用の面倒さとかKubernetesがやたらリソースを食ってくるデメリットの方が高くつきそうなので、アプリはホストに普通に立ててリソース制限はsystemdとかでやるのがいいと思う。

*1:このRailsアプリは自作テンプレートエンジンや自作JITコンパイラに加えGraphQLやReactやwebpackerが使われていたり、RubyじゃなくてわざわざJavaでワーカーを書いたりと、色々好き放題やるために立てている。

*2:落ちても困らないのでkubeadmの推奨スペックを下回るマシンを使っている。Rubyを--with-jemallocでビルドし、UnicornをやめてPumaをスレッドベースで走らせることでメモリ使用量を抑えるみたいなこともやっている。

*3:Ubuntu固有の問題のようです https://inajob.hatenablog.jp/entry/2018/02/28/%E6%9C%8810%E3%83%89%E3%83%AB%E3%81%A7%E6%B5%B7%E5%A4%96VPS%E3%81%A7Kubernetes%E3%82%92%E8%A9%A6%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B%EF%BC%88kubernetes_v1.9%E7%89%88%EF%BC%89

*4:@nekop さんに訂正いただきましたが、hostPathではランタイムでQuotaの制限はないそうです https://twitter.com/i/web/status/1010037274694770688

*5:まあ当然これは冗談で、長い間走らせ続けるChatbotに比べたらむしろ実際には遅くなるくらいだと思われる

*6:僕のサーバーの現状に関してはMySQLをDBサーバーに追い出したら多分どうにかなると思っている