機械学習基盤をKubernetesで運用してきて

##　この記事について
この記事は[Kubernertes Advent calender 2018の8日目](https://qiita.com/advent-calendar/2018/kubernetes)の記事です。

普段からオンプレのKubernetesクラスタを使った研究開発に携わっており、LANケーブルの配線から新バージョンのKubernetesやエコシステムの検証、構築、運用保守、独自ツールの開発に加えてフロントエンドのGUIをmaterial-uiで作ったりとほぼ全レイヤをやっています。

今回は機械学習の実行基盤としてKubernetesを運用してきたなかで得られた知見について書いていきます。

## 運用中のKubernetesクラスタについて

運用しているKubernetesクラスタはオンプレミスのCPUサーバとGPUサーバの混合構成で、さらにGPUサーバは複数の世代のGPUが混ざっています。
データサイエンティスト向けに内製した機能を持つことが大きな特徴ではありますが、用途は機械学習に限定しておらず内部向けのサービスを動かしていたりBotを動かしたりと様々な目的で使われています。

内製した仕組みは独自のWebAPIインターフェースを持っており、Dockerを知っているけれどKubernetesを知らないエンジニアでもKubernetes上にコンテナを作ることができるようになっています。
この仕組みにより、わざわざ複雑なKubernetesのマニフェストを覚えなくてもデータサイエンティストはKubernetesのリソース管理の恩恵を受けることができます。
また、このインターフェースはKubernetesクラスタの運用側が開発しており、全体の利用状況を見て安定して利用できるように利用者のPodに様々な設定を追加、変更できる仕組みになっています。

この記事では運用中に実際にあったトラブルに対処してきた経験から得られた知見について書いて行きます。

## 知見

### Jupyterの扱いは難しい

データサイエンティストにとって試行錯誤しながら分析できるJupyterは必須のツールでしょう。

ただ、リソース管理者であるKubernetesの運用者としてはJupyterの扱いは頭を悩ませる原因の一つです。
業務時間中に次々と思いついたことをあれこれ試し続けて数日のうちにいい結果が得られるなんてことはまずなく、定例打ち合わせに参加したり雑務で数時間別のことをしたり、横から新しい仕事を振られた結果Jupyterコンテナを立てたことも忘れて貴重なGPUを確保したまま一週間放置されるなんてこともあります。

今はモニタリングしているPodのリソース状況から、GPUを使うPodを作りながらも一週間程度利用していないと思われるPodの作成者に必要がないならデータを退避して一度リソースを空けてもらうように直接相談するようにしています。
システマチックにPodを削除することも可能なのですが、人それぞれの事情があるので直近ではそういった仕組みを導入することはないと思います。


### PersistentVolumeをマウントしてチェックポイントを実施する
機械学習を扱うとGPUを使っても学習に1週間かかることが少なくありません。それなのに、あと少しで学習終わるというときにPodがkillされると悲惨です。

学習中のモデルはPersistentVolume上に定期的にチェックポイントしましょう。


### 自分だけではなく、他の人のためにもメモリの最大量（limits）を設定する
先ほどコンテナがkillされる可能性を挙げましたが、同一Node上のPodがメモリを使いすぎたためKubernetesによってkillされることが理由の半数以上を占めています。

KubernetesはPodをkillする優先度として各Podに[QoS](https://kubernetes.io/docs/tasks/configure-pod-container/quality-service-pod/)を与えています。
QoSは低い順に"Best Effort" < "Burstable" < "Guaranteed"となっており、雑に言うならばそれぞれ「リソースについてまったく宣言していないPod」「CPU、メモリのどちらか、または両方が必要な最少量だけが宣言されているか最少量と最大量に差があるPod」、「CPUとメモリの両方で必要な最少量と最大量が同じ値で宣言されているPod」になります。
分かりづらければ、リソースに対して何も設定しない"Best Effort"と厳密に設定する"Guatanteed"とそれ以外の"Burstable"と考えると覚えやすいです。

実際の環境で、メモリの最少量が宣言されているが最大量が宣言されていないQoSが"Burstable"のPodとQoSが"Best Effort"の学習用のコンテナが同じNode上で同居した際に"Burstable"のPodのメモリが膨らみ続けてしまった結果、"Burstable"のPodがNodeのメモリを全て使ってしまいQoSが"Best Effort"設定の学習用のPodを停止させてしまうことがありました。

これを防ぐには"Burstable"のPodがメモリの最大量（limits）を指定しておき、想定以上のメモリを使うようになればそのコンテナだけが停止するように振る舞うべきでした。

メモリのlimitsを設定するのは同じNodeで実行している他の人のコンテナを守ることにつながります。

### PodをGuaranteedの設定で起動する

先ほどの例では"Burstable"のPodがlimitsを設定すべきと書きました。
しかし、"Best Effort"の学習用のPodが大量のメモリを必要とする場合、"Burstable"のPodがlimitsを指定していたとしてもいずれ"Best Effort"の学習用のPodがNode上のメモリを食い潰すことになればQoSの低さから優先的にkillされてしまいます。

このような時はこの2つのPodが同じNodeにスケジュールされるべきではありません。スケジュールによって安全を確保するためにも全てのPodが"Guaranteed"の設定であることが理想です。


### limitsを指定できないなら、せめてbackoffLimitに0を指定したJobとして実行する

初めて実行するので必要なメモリの最大量の目安が分からないが何度も試行錯誤して見積もりをしている暇がないといった事情でどうしてもメモリのlimitsを指定できないこともあると思います。そういう時は実行に失敗してもRestartしないように`.spec.backoffLimit`を0に設定したJobとして実行しましょう。

経験上、機械学習でコンテナがメモリ不足になるのはこれまで何度も実行してきた学習用のプロセスに今までよりも大きなデータを与えたときに起こりやすいです。

何も考えずに極端に大きなデータを与えてしまうとそのサーバのメモリを食い潰し（同じNode上の"Best Effort"のPodを停止させてから）killされた後に他のNodeでリスタートします。このようなPodはたいてい次のNodeでもメモリ不足を起こし他のPodを巻き込みながらkillされることになり、例えるなら***Nodeを渡り歩く爆弾*** になります。
backoffLimitの設定を忘れると悲惨です。現時点の最新版であるv1.13では[API Reference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#jobspec-v1-batch)によると`backoffLimit`の初期値は6に設定されており、（初期の起動を含めると backoffLimit+1 回起動するので）最大で7つのNodeを渡り歩くことからその恐ろしさは推して知るべしです。

### GPUサーバにGPUを使わないコンテナを割り当てない
GPUの資源は希少なのでGPUが余っているのにCPUやメモリが不足しているという状況を作らないことが大事です。
我々の環境ではnodeSelectorを使ってGPUが必要ないPodはGPUサーバに割り当てないようにしています。

実際の運用では副次的な効果もありました。
ある時、GPUを必要としないDB系のPodが複数回リスタートする設定で作られた際にリソースの見積もりがあまかったために数百GBのメモリを要求した結果、Nodeを渡り歩く爆弾になりNode上のPodを殺してまわったことがありましたが、GPUを必要としないDB系のPodはGPUサーバにスケジュールされることがなかったため長時間モデルを学習プロセスを守ることができました。

### GPUの種類を指定できるようにする

複数の世代のGPUをまぜて運用すると、計算性能やメモリ量の違いからどうしても特定のGPU上で実行したいという要望が出てきます。
しかし、kubeletはサーバに積まれているGPUの数を認識するものの積まれているGPUの種類までは認識してくれません（要[DevicePlugin](https://github.com/NVIDIA/k8s-device-plugin)）。

そこで、GPUを持つNodeにGPUの種類を区別できるラベルをつけ、nodeSelectorでスケジューリングされるサーバを指定できるようにしています。
公式ドキュメントの[Clusters containing different types of NVIDIA GPUs](https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/#clusters-containing-different-types-of-nvidia-gpus)に具体的な方法が書かれている方法をそのまま採用しています。

### GPUの空き状況をモニタリングして公開する

機械学習でGPUが必要になる場合、同じ人でも試行錯誤を繰り返して特徴量を設計しているフェーズの時もあればパラメータ探索のために並行に大量の学習モデルを作りたいフェーズの時もあるのでユーザに対してQuotaを設けることはしていません。

その代わり、誰がどれだけGPUを使っているかをGrafanaで公開しており、ユーザが他のユーザの利用状況を確認できるようにしています。
自分が使いたいけれど特定の人が長期間GPUを占有している場合はユーザ間で調整をしてもらうようにお願いしていて、現状はそれでうまく回っています。



## まとめ

機械学習基盤としてKubernetesを運用してきた知見をまとめてみました。
振り返るとほとんどはPodのリソース関係で安定してPodを動かし続ける知見になりました。

Kubernetesが想定する障害への対処策である「コンテナをステートレスに作ったうえでレプリカを作っておき、Podがダウンしたら生き残っているPodが処理を継続しつつ他のNodeに新しくPodを作り直して復帰する」という戦略を、希少なハードウェア資源を使って単一のプロセスが長時間かけて実行するような機械学習の学習用コンテナに適用することはできません。一度実行が中断されてやり直すコストが高かったり、リソースが限られているためレプリカなどで投機的な実行をする余裕がないからです。

分散処理ならタスクを分割した上で失敗したタスクをリトライする方式をとれるのですが、単一のコンテナで長時間かかる学習をマルチテナントのKubernetesクラスタで安定して動かせるようにするのは難しいですね。