チェシャ猫の消滅定理

数学にプログラミング、素敵なもの何もかも。

Fun Fun Functional (1) で Haskell と Firebase を使ってライブコーディングしてきました

先日行われた Fun Fun Functional (1) で、Haskell と Firebase を使った Web アプリの作り方について発表してきました。

使用した要素技術は、GHCJS 上のフレームワーク Miso と、Fireabse SDK を呼び出すための DSL である JSaddle です。

GHCJSHaskellソースコードJavaScript に変換するコンパイラで、GHC をフォークすることによって開発されています。

github.com

Miso は GHCJS 上で The Elm Architecture を実装するためのフレームワークです。Miso では事実上サーバサイドで書けない Elm と異なり、サーバサイドとの Model の共有や、初回アクセス時の HTML をサーバ側で構築するサーバサイドレンダリング (SSR) の仕組みが提供されています。

github.com

ccvanishing.hateblo.jp

JSaddleHaskell から JavaScript の関数を呼ぶための DSL です。Lens インタフェースに対応した JSM モナドが提供されており、GHCJS でコンパイルした場合は単に IO モナドに変換されます。

main :: IO ()
main = do
    -- console.log('Hello, JSaddle!');
    jsg "console" ^. js1 "log" (val "Hello, JSaddle!")

github.com

これら要素技術の簡単な解説に加えて、LT の後半ではライブコーディングのパフォーマンスを披露しました。

作成したのは、簡単なコメントが残せるゲストブックを模した、以下のようなアプリです。バックエンドとして Firebase Realtime DB を利用しているため、複数のブラウザから開いた場合でも書き込みはリアルタイムで同期されます。

f:id:y_taka_23:20190529193951p:plain

前掲のスライドは公開用にソースコードを補足してありますが、当日は文字通り、その場で段階的にコードを追記・変更することで機能が画面に反映されていく様子を紹介しました。

  1. まず動きのない HTML だけの View をレンダリングする
  2. ユーザからの入力に反応して Action を発行し、Update で Model に反映する
  3. Effect を利用してコメントを Realtime DB に保存する
  4. Subscription を利用して Realtime DB の変更を検知する

LT としてはあまり見ない形式ですが、Twitter 上で見る限り割と好評だったようです。こんな風に、実装の結果が目で見て分かりやすいのはフロントエンドの強みだなと改めて感じました。

(ちなみに @u_taka_23 ではなく @y_taka_23 です)

ところで今回のライブコーディング、ハンズオン形式のチュートリアルとして仕立て直して公開しようかなとも思っているのですが、それはまた別の話。

Docker Meetup Tokyo #28 で Scheduler のカスタマイズについて話してきました

先日行われた Docker Meetup Tokyo #28で、Kubernetes Scheduler の挙動をカスタマイズする方法について発表してきました。

なお Scheduler のカスタマイズについては、つい最近 Kubernetes Meetup Tokyo #16 でも発表しています。ドキュメント類へのリンクも含めてまとめたものが以下の記事です。

ccvanishing.hateblo.jp

両方のスライドを見比べて頂ければ分かる通り、内容としてはオーバラップしている部分がかなりあります。

ただし、前回はあくまでも Scheduling Framework の解説であったのに対し、今回は Scheduler のカスタマイズ全体を俯瞰する形で差別化を図っています。時間的にも前回 5 分に対して今回 9 分とやや余裕があったため、Amazon ECS の binpack 配置戦略やポリシの記述方法など、前回端折った具体的な設定についても少し触れてみました。

ちなみに、スライド中で挙げた 3 種類のカスタマイズ方法それぞれについて、ハンズオン形式で実装しながら学べるチュートリアルを作成するプランもあるのですが、それはまた別の話。

Kubernetes Meetup Tokyo #16 で Scheduling Framework について話してきました

先日行われた Kubernetes Meetup Tokyo #16 で、現在 Scheduling SIG で進められているプロジェクト Scheduling Framework について発表してきました。

Kubernetes では、Pod をどの Node に配置するかを決める手続きをスケジューリングと呼びます。

古典的な Kubernetes の用途、すなわち通常の long-running なサーバ群の管理においては、Pod のスケジューリングは比較的シンプルな問題でした。すなわち、Node の障害時でも可用性が保てるように Pod を複数の Node に散らし、一度立ち上がった Pod は基本的に動き続ける、というシナリオです。

しかし、Kubernetes は既にコンテナスケジューラのデファクトを獲得し、様々な性質を持ったアプリケーションがデプロイされる基盤となりました。この流れの中で、デフォルトの kube-scheduler ではカバーできないような複雑なスケジューリングの需要も生まれています。

この問題を解決するためのプロジェクトが Scheduling Framework です。本記事では、スライドに登場した用語や概念について、Scheduling SIG その他から提供されているドキュメントやサンプルを紹介します。

Scheduling SIG によるサブプロジェクト

kube-batch

github.com

kube-batch はバッチ処理向けのスケジューラで、かつて kube-arbitrator と呼ばれていたプロジェクトが改名しました。たった 1 行だけの Wiki が味わい深いです。

PodGroup を指定することで複数の Pod を組にして扱う機能を備えているのが特徴で、この仕組みは Kubernetes機械学習用基盤として使用する kube-flow プロジェクトにも導入されるようです。

Poseidon

github.com

Poseidon は、グラフ理論を利用して複雑かつ効率的なスケジューリングを目指す新機軸のプロジェクトです。

元になっているのは Firmament Scheduler と呼ばれる汎用のタスクスケジューリングの仕組みで、配置の制約を最小費用流問題に帰着させて最適化を行うようです。Kubernetes の Pod と Node に対して Firmament を実装したものが Poseidon という関係になっています。

正直なところ、この記事を書いている時点ではまだ論文を充分読み込んでいないので何とも言えませんが、内容がきちんと把握できたら改めてどこかのイベントで発表したいと思います。

Scheduler Extender

github.com

スケジューリング処理の一部分を JSON Webhook で外部サーバに委譲するための仕組みが Extender です。

Extender が設定できる処理は以下の 4 か所で、Scheduler 起動時の設定ファイルに外部サーバのエンドポイントなどを記述することで有効になります。

  • Filter - 後段の順序付けフェイズに候補として残す Node を絞ることができます。リクエストはデフォルトのフィルタリングプロセスを通過した Pod、レスポンスはフィルタリング後の Pod です。
  • Prioritize - 最良の Node を選択するためのスコアリング関数を追加できます。引数はフィルタリングで残った Pod、レスポンスは Node ごとのスコアです。
  • Preempt - Pod の Preemption が発生する際に、犠牲となる Pod を消極的に選択することができます。リクエストは Node ごとに犠牲となる予定の Pod、レスポンスは実際に犠牲にする Pod です。
  • Bind - Pod を Node に配置する前の準備などを行うことができます。ただしその場合、Pod と Node を結びつけているBinding リソースの作成まで含めて自分で行う必要があります。Pod の情報と Node 名がリクエストされます。

なお、上記のドキュメントは古いためか Preempt Extender の記載がありませんが、ソースコードを見ると実装されていて実際に動きます。

もし Extender を使用したい場合は、ドキュメントよりもむしろ、構造体の定義および @everpeace さんによる実装サンプルを参考にするとよいでしょう。

Scheduling Framework

github.com

Scheduling Framework のプロポーザルです。今回のスライドは基本的にこのプロポーザルに沿って説明しています。

上でも挙げたような特定用途のために作られたスケジューラは、基本的にスクラッチから開発されています。というのも kube-scheduler の実装は歴史的事情から抽象化が十分でなく、拡張性に限界があるためです。

そこで、Scheduling Framework では設計を刷新して新しい拡張点を定式化します。それぞれの拡張点では、Go のインタフェースとして実装されたプラグインを登録し処理を差し込めるようにするとともに、プラグイン同士が情報をやり取りできる仕組みが構築される予定です。

そう、あくまでも予定です。 プロポーザルには多数の拡張点が述べられていますが、現状で以下の二点のみ実装されています。

  • Reserve - Node が確定した後、Pod ごとの goroutine が生成される前
  • Pre-Bind - Pod ごとの goroutine に入った後、API Server に Binding が登録される前

この二点は、Pod を配置する Node に前もって何かを準備する処理を想定したもので、典型的な用途はネットワークストレージをボリュームとして確保するというものです。

従来、ボリュームの確保とバインドは assumeVolumebindVolume という専用の関数で行われています。一方 GPU などボリューム以外のネットワークリソースについてはユーザが Bind Extender という形で各自実装する必要があります。

そこで、assumeVolume に相当するものとして Reserve プラグインを、bindVolume に相当するものとして Pre-Bind プラグインを実装し、リソース関連の処理を統合して抽象化することが Scheduling Framework の第一弾として意図されていました。が、今のところ拡張点が準備されただけで止まっており、assumeVolumebindVolume もそのまま残っています。まあ、今後の展開を見守りましょう……。

まとめ

本記事では、Kubernetes スケジューラの再設計を目的としたプロジェクト Scheduling Framework と、その周辺トピックについて紹介しました。Scheduling SIG のマンパワー不足もあって、当初の予定ほど華々しい進展が見られないのは残念ですが、それはまた別の話。

Kubernetes 1.13: SIG Scheduling の変更内容

はじめに

本記事では、Kubernetes 1.13 の CHANGELOG からスケジューリングに関する内容をまとめました。

主な変更点

1.13 における SIG Scheduling の取り組みは主に安定性に焦点を当てており、いくつかの大きな機能の導入は次のバージョンまで延期することになりました。特記すべき変更として次に挙げる 2 点があります。

#69824: Taint based Eviction の有効化

TaintBasedEvictions がベータに移行し、デフォルトで有効になりました。この機能が有効になっている場合、Node には自動的に条件 Taint が付加され、Pod は必要であれば Toleration を使用することができます。

Taint based eviction は、Node に問題が発生した際、その内容に応じて Node Controller が以下のような Taint を自動的に付加する仕組みです。

  • node.kubernetes.io/not-ready
  • node.kubernetes.io/unreachable
  • node.kubernetes.io/out-of-disk
  • node.kubernetes.io/memory-pressure
  • node.kubernetes.io/disk-pressure
  • node.kubernetes.io/network-unavailable
  • node.kubernetes.io/unschedulable
  • node.cloudprovider.kubernetes.io/uninitialized

今まで Pod のスケジューリングには「Not Ready な Node を避ける」といったロジックが入っていました。1.13 からこの TaintBasedEvictions がデフォルトで有効になったことにより、障害時の Pod 退避は Taint による管理に統一されます。

Taint と Tolaration によるスケジューリングに統一されることで、Node 障害時の挙動をユーザがより柔軟にコントロールできるようになります。例えば Pod に tolerationSeconds を指定することで「Node に問題 X が発生した際は n 秒以内に回復しなければ移動」といった挙動の調整が可能です。

tolerations:
- key: "node.kubernetes.io/unreachable"
  operator: "Exists"
  effect: "NoExecute"
  tolerationSeconds: 6000

ちなみに、tolerationSeconds が設定されていない場合、Admission Control により not-readyunreachable に 300 秒の tolerationSeconds が設定されます。要するに何も設定していない場合は Node の障害から最大 300 秒待って Pod が削除される、ということです。

#70298: critical-pod アノテーションが非推奨に

Pod に対するクリティカルアノテーションが非推奨になりました。アノテーションの代わりに Pod の優先度を使用すべきです。

DNS や Metrics Server といった死なれるとクラスタ全体の動作に影響するような Pod のために、従来 scheduler.alpha.kubernetes.io/critical-pod というアノテーションが用意されていましたが、今回から非推奨になりました。

代わりに、デフォルトで定義されている優先度クラス system-cluster-criticalsystem-node-critical を使用します。両者の定義は以下のようになっており、Node の移動が許容できるかどうかで用途が分かれています。

Name:           system-cluster-critical
Value:          2000000000
GlobalDefault:  false
Description:    Used for system critical pods that must run in the cluster, but can be moved to another node if necessary.
Annotations:    <none>
Events:         <none>
---
Name:           system-node-critical
Value:          2000001000
GlobalDefault:  false
Description:    Used for system critical pods that must not be moved from their current node.
Annotations:    <none>
Events:         <none>

ただしこれらの優先度クラスは、1.11 以降 kube-system Namespace 内でしか使えないことに注意が必要です。

また、この件とは直接関係しませんが、優先度による Preemption の動作原理については半年ほど前に書いた記事があるのでよければこちらもご笑覧ください。

ccvanishing.hateblo.jp

#70040: 要対応

1.13 では kube-scheduler の設定ファイルの apiVersioncomponentconfig/v1alpha1 ではなく kubescheduler.config.k8s.io/v1alpha1 になります。

API グループ componentconfig は解体されつつあり、Scheduler 以外にも例えば kubeproxy.config.k8s.io が 1.9 から導入されています。

しかし、そもそもこの設定ファイルの書式はドキュメントに記載されていないし、サンプルファイルのようなものも提供されていません。対応する構造体が以下に定義されているので、ファイルの記載項目を確認することは一応可能です。

SIG Scheduling リリースノート

メトリクス追加を除き、内部ロジックの修正のみです。

#59529

ボリュームをスケジューリングする操作にメトリクスが追加されました。

以下のメトリクスが登録されるようになっています。

  • binder_cache_requests_total
  • scheduling_duration_seconds
  • scheduling_stage_error_total

#65350

Toleration を含む大量の Pod を処理する際のメモリ使用量とパフォーマンスが向上しました。

#69758

ゾーン内の Node がすべて削除された際、スケジューラが無限ループに陥るバグを修正しました。

#71212

Pod のバインディングでエラーが発生した際、古いキャッシュが使用されないよう削除するようにしました。

#71063

スケジューラ内部のキャッシュが不整合になった際、panic になる挙動を修正しました。

#71085

kube-scheduler のリーダ選出がデッドロックに陥った際、unhealthy を報告するようになりました。

#70898

必要のない Pod まで preemption してしまう潜在的なバグを修正しました。

まとめ

ユーザ側にとってさほど大きな変更点はありませんが、これまで Pod の優先度を導入しないまま運用していた場合、critical-pod アノテーションの件で影響を受ける可能性があります。まだ非推奨になっただけで廃止ではありませんが、早めに変更しておきましょう。

なお、このところ機能追加という意味では SIG Scheduling はやや低調で、実際マンパワーが足りていない印象がありますが、水面下では興味深い動きもいくつか見られます。

  • スケジューラに拡張点を設けてカスタマイズ可能にする Scheduling Framework
  • 複数の Pod を同時に All or Nothing でスケジューリングする coscheduling(旧称 Gang Scheduling)

あたりが目下のところ予定されている大きな機能追加ですが、それはまた別の話。

We Are JavaScripters! @26th で Elm と Firebase の連携について話してきました

先日行われた We Are JavaScripters! @19th で Elm と JavaScript ライブラリの連携について発表してきました。

Elm の初心者向けの解説としてよく Msg, Model, update からなるアーキテクチャが挙げられていますが、今回の発表ではもう一歩だけ進んで、Cmd と Sub を使って Elm から JavaScript のライブラリを呼ぶ方法について解説しました。

サーバとしての JS ライブラリ

他の AltJS では JavaScript を呼び出す際、ソースコードの内部に埋め込む形になるのが普通です。

例えば HaskellJavaScriptコンパイルする GHCJS の場合、JSaddle という DSL を利用して次のように呼び出すことになります。

store :: Int -> IO ()
store n = runJSaddle () $ do
    ref <- jsg "firebase" ^. js0 "database" ^. js1 "ref" (val "/counter")
    ref ^. js1 "set" (val n)
    return ()

Firebase SDK を呼び出して Realtime DB に値をセットする部分です。JavaScript 側の関数を文字列で指定することで、直接 Haskellソースコード内に IO アクションとして JS の呼び出しが定義されていることがわかります。この場合、Haskell とは別に JavaScript 側を自分で実装する必要はありません。

一方、Elm で同様の呼び出しを実装する場合、「Port」「Command」「JavaScript 側での subscribe」という 3 つの部分に分割されます。

まず、JavaScript 側へのインタフェースとなる Port を次のように定義します。

port module RealtimeCounter.Port exposing (store)

import Json.Encode as E

port store : E.Value -> Cmd msg

JSON を受け取って、Cmd を発生させる関数 store が定義されています。port に指定された関数は型シグネチャだけが存在し、中身は記述しません。

次に、実際に update 関数の中でこの Cmd を発生させるために、Elm の Int 値を JSON エンコードしてこの port に流し込む関数を定義します。

import Json.Encode as E

storeCount : Int -> Cmd msg
storeCount =
    store << E.int

最後に、Elm 側から発生した Cmd を受け取るための JavaScript を実装します。

const { Elm } = require('./Main.elm');
const app = Elm.Main.init({
    node: document.getElementById('app')
});

app.ports.store.subscribe((count) => {
    firebase.database().ref('count').set(count);
});

上に挙げたコールバックによる実装を見ても分かる通り、JavaScript 側はあたかも Web サーバのコントローラのように subscribe で待ち受けており、Elm から Cmd によって JSON が渡されると実際に Realtime DB に値をセットします。

逆に、JavaScript 側から Elm 側に値を戻す必要がある場合には、JavaScript 側で send 関数を使用すると Elm 側からは Sub となって観測されます。具体的には下記のサンプルレポジトリを参照してください。

github.com

なお、実際に JS 連携する部分をコーディングする際には、boiyaa さんが書かれている詳しい記事も参考になると思います。

boiyaa さんの記事は Elm v1.18 時点で書かれているので起動時に Html.programWithFlags を使用していますが、Elm v1.19 ではここに相当する関数は Browser.element に変更になっています。

動作サンプル

実際に触って動かせるデモもデプロイしておきました。先に挙げたソースコードと合わせて、よかったら参考にしてみてください。

f:id:y_taka_23:20181124221326p:plain デモページ

プレゼンの際にお見せしたデモは単にテキストを置いただけの画面でしたが、公開版はちょっと凝った CSS を付けてみました。カウンタ自体の Elm 側のロジックは非常にシンプルで行数も少ないので、GitHub からは Elm ではなく CSS のレポジトリ扱いされてしまっていますが、それはまた別の話。

猫でもわかる Vertical Pod Autoscaler

先日行われた Kubernetes Meetup Tokyo #13 で、Vertical Pod Autoscaler (VPA) について発表してきました。

VPA は、各コンテナの Resource Request の値を自動的に調整してくれるコンポーネント群です。必要とするリソース(CPU、メモリ)量があらかじめ推測しにくいアプリケーションに対して、実績に基づいてそれらしい値を決めたい場合に効果を発揮します。

本記事ではスライドの補足として、VPA が動作する流れをクラスタ上での実際の挙動を通じて確認し、また内部実装についても踏み込んで解説します。

なお、本記事中で引用している仕様やソースコードは執筆時点で最新のコミット ab9c27e を基準にしています。

github.com

はじめに

Kubernetes の Pod には、コンテナごとにリソース使用量を指定する機能があります。指定できる項目は Request と Limit の二種類がありますが、今回のテーマである VPA に関係するのは Request の方です。

Kubernetes Scheduler は、Node の利用可能リソースと Pod の Resource Request を比較してどの Node に Pod を配置するか決定します。

Managing Compute Resources for Containers - Kubernetes

重要なのは、Request の値はあくまでもユーザが指定した値であり、実際のリソース使用量とは無関係であるという点です。そのため、Request が小さすぎると実際の動作時にリソースが不足してアプリケーションの挙動に悪影響を与える可能性がある一方、大きすぎるとリソースが余っているのにもかかわらず Pod が配置されないスペースが増えるため、集積度が下がり無駄なコストを抱えることになります。

この Request と実績値の乖離という問題を解決するのが VPA です。VPA は、リソース使用量の実績値をモニタして、過去の履歴から算出した推奨値を自動で Request として設定してくれます。結果として、Scheduler はその Pod をより実態に合った Node に配置することができ、クラスタ全体のリソースを有効活用することができます。

コンポーネント群とその役割

ここからは、実際にクラスタ上に VPA をデプロイしtて、Resource Request が変更される際の各コンポーネントの挙動を確認してみましょう。手元のクラスタ必要な条件を満たしていることを確認しておいてください。

なお、VPA は amd64 用のバイナリしか提供していません。Raspberry Pi で試したい場合、ARM 用の差分をフォークしたので参考にしてください。対応した Docker イメージも Docker Hub に push してあります。

起動の準備

VPA は、次に挙げた三つのコンポーネントがそれぞれの役割を分担することで成り立っています。

  • Recommender : 実際のリソース使用量データから Request の推奨値を算出する
  • Updater : 推奨値に合致しない Pod を見つけて evict する
  • Admission Controller : evict された Pod が再作成される際に Request の値を差し替える

これらのコンポーネントを作成するための YAML deploy ディレクトリ以下にありますが、証明書の作成などの作業とひとまとめにしたスクリプト vpa-up.sh が提供されているのでこれを使用します。

$ mkdir -p $GOPATH/src/k8s.io
$ cd $GOPATH/src/k8s.io
$ git clone https://github.com/kubernetes/autoscaler.git
$ cd autoscaler/vertical-pod-autoscaler
$ git checkout -b ab9c27e

ただし今回は、起動スクリプト実行前にちょっとした工夫を加えます。コンポーネント群を生成する *-deployment.yamlreplicas を 0 に書き換えておきましょう。

$ find deploy -name *-deployment.yaml | xargs sed -i.bak "s/replicas: 1/replicas: 0/"
$ ./hack/vpa-up.sh

こうすることで、必要な準備は整えた上で、コンポーネント群はまだ起動していない状態をつくることができます。以下、各コンポーネントをひとつづつ起動し、それぞれのコンポーネントの役割を確認します。

VPA CustomResource

VPA の設定および現在の状態は CustomResource として保持されます。

VPA の作成

先ほど実行した vpa-up.sh によって VPA 用の CustomResourceDefinition が作成されているはずなので確認してみましょう。

$ kubectl get crd | grep verticalpodautoscaler
verticalpodautoscalercheckpoints.poc.autoscaling.k8s.io   2018-10-01T08:17:20Z
verticalpodautoscalers.poc.autoscaling.k8s.io             2018-10-01T08:17:20Z

二種類の CustomResource が作成されていますが、VPA の状態を保持するのは verticalpodautoscalers です。

提供されているサンプル hamster.yaml から Deployment とそれを管理する VPA を作成します。

$ kubectl create -f examples/hamster.yaml
verticalpodautoscaler.poc.autoscaling.k8s.io/hamster-vpa created
deployment.extensions/hamster created

Pod が 2 個作成されますが、この段階では Pod の Resource Request は hamster.yaml で定義されている通り CPU 100m、メモリ 50 Mi です。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-46g4g   1/1       Running   0          6s
hamster-6db596f5b4-9hlw2   1/1       Running   0          7s

$ kubectl describe pods/hamster-6db596f5b4-46g4g
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

同時に作成された VPA についても確認しておきましょう。出力に Status の項目がないことに注意してください。Status を埋める役割を担っているのは Recommender ですが、この段階ではまだ起動していないためです。

$ kubectl describe vpa/hamster-vpa
...

以上の状態はいわば「初期状態」です。必要なコンポーネント群がまだ起動していないので、このまま待っても状態は変化しません。ここから一つずつコンポーネントを立ち上げていって、VPA の変化を観察します。

VPA の設定項目

今回のサンプルでは最低限の項目のみ定義されていますが、可能な設定をすべて明示すると以下のようになります。

apiVersion: "poc.autoscaling.k8s.io/v1alpha1"
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
spec:
  selector:
    matchLabels:
      app: my-app
  updatePolicy:
    updateMode: Auto
  resourcePolicy:
    containerPolicies:
    - containerName: my-app
      mode: Auto
      minAllowed:
        cpu: 200m
        memory: 100Mi
      maxAllowed:
        cpu: 1000m
        memory: 500Mi

各項目はおおむね見た通りですが、updatePolicy は少し説明が必要かもしれません。可能な値は AutoRecreateInitialOff で、それぞれ次のような挙動になります。

Auto はデフォルト値です。現時点では Kubernetes が Pod を起動させたまま Resource Request の値を変更する機能を提供していないため、暫定的に Recreate と同じ挙動になります。

Recreate の場合、Resouce Request が推奨値のレンジに収まらない Pod が存在したとき、その Pod は evict されます。

Initial の場合、Pod が最初に作成されるときのみ Resource Request の変更を行いますが、すでに起動状態の Pod を能動的に evict しようとはしません。

Off の場合、Resource Request の推奨値の算出だけを行うのみで Pod には影響を与えません。

なお resourcePolicies[].mode には Auto もしくは Off のみが指定できます。また resourcePolicies[].containerName* とすると、その Pod に属するコンテナの内 containerName が指定されていないものすべてに適用されるデフォルト値になります。

Recommender

Recommender は Metrics Server 経由で Pod のリソース使用実績を取得し、Resource Request の推奨値を算出して VPA に保存します。

Recommender の起動

まず、先ほど 0 に差し替えていた replicas を 1 に戻して Recommender を起動させます。

$ mv deploy/recommender-deployment.yaml.bak deploy/recommender-deployment.yaml
$ kubectl apply -f deploy/recommender-deployment.yaml
serviceaccount/vpa-recommender configured
deployment.extensions/vpa-recommender configured

$ kubectl -n kube-system get pods | grep recommender
vpa-recommender-6d8cddc856-rw7m2   1/1       Running   0          6s

これで Recommender が立ち上がりました。数分待つと、算出された推奨値が VPA の Status として書き込まれているのが確認できます。

$ kubectl describe vpa/hamster-vpa
...
Status:
  Conditions:
    Last Transition Time:  2018-10-01T08:52:56Z
    Status:                True
    Type:                  RecommendationProvided
  Recommendation:
    Container Recommendations:
      Container Name:  hamster
      Lower Bound:
        Cpu:     115m
        Memory:  53677237
      Target:
        Cpu:     688m
        Memory:  319572800
      Upper Bound:
        Cpu:     991408m
        Memory:  460504404800
...

Recommender は Target、Lower Bound、Upper Bound の三種類の値を提示します。もし現に起動している Pod の Resource Request がこの Lower Bound を割っている場合、その Pod が実際に動作するためには想定よりも多くのリソースが必要です。また Upper Bound を超えている場合、Node の選択時に必要以上に余裕を見ていることでクラスタ全体のリソースに無駄が生じていることになります。

ただし、Recommender は推奨値を算出して VPA の Status に記録するだけで、実際の調整作業は行いません。例えば先ほど作成した Pod の Request は CPU 100m、メモリ 50 Mi だったので推奨値の範囲からは外れていますが、しばらく放置しても再作成されたりはしないはずです。

推奨値を算出するアルゴリズム

アルゴリズムについて解説する前に、もう一度 VPA の状態を確認してみましょう。

$ kubectl describe vpa/hamster-vpa
...
Status:
  Conditions:
    Last Transition Time:  2018-10-01T08:52:56Z
    Status:                True
    Type:                  RecommendationProvided
  Recommendation:
    Container Recommendations:
      Container Name:  hamster
      Lower Bound:
        Cpu:     529m
        Memory:  237494649
      Target:
        Cpu:     712m
        Memory:  319572800
      Upper Bound:
        Cpu:     114632m
        Memory:  51451220800
...

先ほどと値が変わっています。もちろん、Recommender はリアルタイムの実績値を使用しているのでタイミングによって多少のブレはありますが、Lower Bound と Upper Bound が Target に近づいている、言い換えれば許容される値の幅が狭くなっているのが特徴的です。

推奨値の算出ロジックは、estimator.go に定義された ResourceEstimator インタフェースとして抽象化されています。

type ResourceEstimator interface {
    GetResourceEstimation(s *model.AggregateContainerState) model.Resources

内部で使用されている ResourceEstimator の実装はいわゆる Decorator パターンになっていて、ベースとなる Estimator を次々にラップすることで算出ロジックを組み立てていきます。

例として、実装の一つである minResourceEstimator を見てみましょう。

type minResourcesEstimator struct {
    minResources  model.Resources
    baseEstimator ResourceEstimator
}

func (e *minResourcesEstimator) GetResourceEstimation(s *model.AggregateContainerState) model.Resources {
    originalResources := e.baseEstimator.GetResourceEstimation(s)
    newResources := make(model.Resources)
    for resource, resourceAmount := range originalResources {
        if resourceAmount < e.minResources[resource] {
            resourceAmount = e.minResources[resource]
        }
        newResources[resource] = resourceAmount
    }
    return newResources
}

構造体の内側にベースとなる baseEstimator を保持していて、ベースが返す値がもし許容される最小値 minResources より大きい場合はそのまま、小さい場合には切り上げた値を返すようになっています。

実際に Lower Bound、Target、Upper Bound を算出している Estimator は、recommender.go 内の CreatePodResourceRecommender 関数で構築されています。過去データのパーセンタイル値をベースとして、最小値と安全係数を加味した Estimator です。

推奨値 リソース 基準パーセンタイル 最小値 安全係数
Lower Bound CPU 50% 25m *1.15
memory 50% 250Mi *1.15
Target CPU 90% 25m *1.15
memory 90% 250Mi *1.15
Upper Bound CPU 95% 25m *1.15
memory 95% 250Mi *1.15

Lower Bound と Upper Bound については、ここからさらに「信頼区間」を加味するためのスケーリング操作が入ります。

Lower Bound の計算式は

 \displaystyle
  scaledResource = originalResource \left(1 +\frac{0.001}{N}\right)^{-2}

Upper Bound の計算式は

 \displaystyle
  scaledResource = originalResource \left(1 +\frac{1}{N}\right)

で与えられます。N は算出に使用する過去データのサンプルサイズを表すパラメータで、デフォルトのサンプリング間隔(1 回/分)の場合はデータを使用する期間の日数に一致します。

計算式から、使用する過去データの期間が長ければ長いほど scaledResource は originalResource に近くなり、また Upper Bound よりも Lower Bound の方が急激に収束することがわかります。このことは、先ほど VPA をしばらく放置した際、値の幅が狭くなっていた観察結果とも一致します。

なお、このサンプリング間隔は Recommender 起動の際に --recommender-interval オプションで指定できますが、なぜか無駄に細かくナノ秒単位で指定する仕様になっています。

Updater

Updater は、Recommender が VPA に書き込んだ推奨値と実際に起動中の Pod に指定されている Resource Request を比較し、推奨値のレンジから外れている場合はその Pod を evict します。

Updater の起動

Recommender に引き続き、Updater も起動させてみましょう。

$ mv deploy/updater-deployment.yaml.bak deploy/updater-deployment.yaml
$ kubectl apply -f deploy/updater-deployment.yaml
serviceaccount/vpa-updater configured
deployment.extensions/vpa-updater configured

$ kubectl -n kube-system get pods | grep updater
vpa-updater-664996d6dd-sjrz4       1/1       Running   0          6s

これで Recommender + Updater が立ち上がった状態になりました。数分待つと Pod が evict され、Deployment の作用で再作成されます。しかし、この新しい Pod の Resource Request は元の Pod と同様、CPU 100m、メモリ 50Mi のままです。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-9hlw2   1/1       Running   0          12m
hamster-6db596f5b4-wls85   1/1       Running   0          4s

$ kubectl describe pods/hamster-6db596f5b4-wls85
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

Updater の担当はあくまでも Pod を evict することまでであり、Resource Request の値を変更することはありません。したがって当然ながら Pod は Deployment で定義されている通りの設定で再作成されます。すると新しい Pod もやはり推奨値のレンジからは外れているので、結果として evict と再作成が繰り返されるはずです。

ちなみに、Updater が delete ではなく evict API を使用するという事実は地味ですが有用です。というのも、あらかじめ Pod Disruption Budget を指定しておくことで、すべての Pod が同時に再作成されて一時的にサービス不能になる事態を避けることができるからです。

evict される Pod の割合

ところで、今回は特に Pod Disruption Budget が設定されていないにもかかわらず、evict される Pod は必ず一個ずつであることに気づいたかもしれません。これは、Updater 自身が evict する Pod を制限する仕組みを持っているためです。

Pod を evict できるかどうかの判定は、pods_eviction_restriction.go 内で定義されたインタフェース PodsEvictionRestriction によって行われます。インタフェースは切られていますが実装は事実上podsEvictionRestrictionImpl のみで、判定を行う関数 canEvict は以下のような内容になっています。

func (e *podsEvictionRestrictionImpl) CanEvict(pod *apiv1.Pod) bool {
...
    shouldBeAlive := singleGroupStats.configured - singleGroupStats.evictionTolerance
    if singleGroupStats.running-singleGroupStats.evicted > shouldBeAlive {
        return true
    }
...
}

ここで、configured は設定上存在する Pod の個数、例えば今回の場合なら Deployment に指定された replicas の値が格納されています。条件判定の内容から、evictionTolerance は evict したくない Pod の個数であるらしいことが見て取れます。

evictionTolerance の値を実際に計算しているのは、同じファイル内の NewPodsEvictionRestriction 関数です。

func (f *podsEvictionRestrictionFactoryImpl) NewPodsEvictionRestriction(pods []*apiv1.Pod) PodsEvictionRestriction {
...
    singleGroup.evictionTolerance = int(float64(configured) * f.evictionToleranceFraction)
...
}

この evictionToleranceFraction のデフォルト値は 0.5 なので、 Updater は一度に replicas の半分まで evict しようとします。今回、2 個ある Pod が 1 個ずつ evict されていたのはこのロジックが原因です。

なお evictionToleranceFraction の値は Updaer 起動の際に --eviction-tolerance オプションで指定できますが、VPA ごとに Pod の用途に合わせて設定することはできないため注意しましょう。

Admission Controller

Admission Controller は、Updater により evict された Pod が再作成される際、API Server への Pod 作成リクエストに割り込んで Resource Request の値を書き換えます。

Admission Controller の起動

最後のコンポーネントとして、Admission Controller を起動させます。

$ mv deploy/admission-controller-deployment.yaml.bak deploy/admission-controller-deployment.yaml
$ kubectl apply -f deploy/admission-controller-deployment.yaml
deployment.extensions/vpa-admission-controller configured
service/vpa-webhook configured

$ kubectl -n kube-system get pods | grep admission-controller
vpa-admission-controller-76744566cc-t6m9d   1/1       Running   0          9s

これで Recommender + Updater + Admission Controller の全コンポーネントが揃った状態になりました。先ほど Updater のみ起動した状態では元と同じ設定で Pod が再作成されるだけでしたが、Admission Controller の起動によって状況が変わったことを確認しましょう。

$ kubectl get pods | grep hamster
hamster-6db596f5b4-7xm84   1/1       Running   0          1m
hamster-6db596f5b4-9dn55   1/1       Running   0          22s

$ kubectl describe pods/hamster-6db596f5b4-9dn55
...
    Requests:
      cpu:        712m
      memory:     319572800...

ここで hamster-6db596f5b4-9dn55 は Admission Controller 起動後に作成された Pod です。Resource Request の値が VPA に保持された Target と同じ値に差し替えられていることがわかります。

以上で VPA が推奨値を算出して Pod に適用する一連の流れが確認できました。

Request を差し替える仕組み

ところで、なぜ Pod の Resource Request が差し替えられたのでしょうか? Pod を再作成している Deployment と、そこから作られる ReplicaSet の状況を確認してみましょう。

$ kubectl describe deploy/hamster
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

$ kubectl get rs | hamster
hamster-78568d5fbc   2         2         2         29m

$ kubectl describe rs/hamster-78568d5fbc
...
    Requests:
      cpu:        100m
      memory:     50Mi
...

Deployment と ReplicaSet はいずれも元の設定である CPU 100m、メモリ 50 Mi から変化していません。すなわち他には影響を与えず、Pod を作成するリクエストだけが差し替えられていることがわかります。

ここまで VPA の動作に必要な特定のコンポーネントを Admission Controller と呼んできましたが、実はこれは固有名詞ではありません。もっと広く Kubernetes API へのリクエストに割り込んで特殊な操作を行う機能の総称です。

Using Admission Controllers - Kubernetes

KubernetesAPI にはいわゆる認証・認可の後にこの Admission Control のフェイズがあり、リクエストの内容が特定の条件を満たさない場合にエラーを返したり、あるいはリクエストの一部を書き換えたりすることができます。

Admission Contoller はあらかじめ API Server に組み込まれているものの他に、JSON Webhook の形でユーザが定義することもできます。

Dynamic Admission Control - Kubernetes

VPA の Admission Controller は、その名の通りこの仕組みを利用しています。先ほど立ち上げた VPA Admission Controller の実体は Webhook を提供する HTTP サーバで、API Server は Pod 作成リクエストを受け取った際、この Webhook サーバに問い合わせることでリクエスト内容の書き換えを行います。

それでは VPA Admission Controller の実装を確認してみましょう。

まず Admission Controller は、API Server に対して自分自身を Webhook サーバとして登録し、Pod の作成リクエストがあったとき自分に問い合わせが来るようにします。ソースコード上では config.go 内の selfRegistration 関数が相当します。

func selfRegistration(clientset *kubernetes.Clientset, caCert []byte) {
...
        Webhooks: []v1beta1.Webhook{
            {
                Name: "vpa.k8s.io",
                Rules: []v1beta1.RuleWithOperations{
                    {
                        Operations: []v1beta1.OperationType{v1beta1.Create},
                        Rule: v1beta1.Rule{
                            APIGroups:   []string{""},
                            APIVersions: []string{"v1"},
                            Resources:   []string{"pods"},
                        },
                    },
...
                ClientConfig: v1beta1.WebhookClientConfig{
                    Service: &v1beta1.ServiceReference{
                        Namespace: "kube-system",
                        Name:      "vpa-webhook",
                    },
                    CABundle: caCert,
                },
            },
        },
    }
}

VAP Admission Controller がこの設定を API Server に登録しておくことで、API Server は Pod に対する Create リクエストを受け取った際、Namespace kube-system 内の Service vpa-webhook にアクセスするようになります。

そしてアクセスされた VAP Admission Controller 側では、recommendation_provider.go 内の getContainersResources 関数によって、VPA が保持している Target が Resource Request の値として設定されます。

func getContainersResources(pod *v1.Pod, podRecommendation vpa_types.RecommendedPodResources) []ContainerResources {
    resources := make([]ContainerResources, len(pod.Spec.Containers))
    for i, container := range pod.Spec.Containers {
        resources[i] = newContainerResources()
...
        resources[i].Requests = recommendation.Target
    }
    return resources
}

ここで取得した resources を元にして、最終的には server.go 内の getPatchesForPodResourceRequest 関数が JSON Patch を組み立てて API Server に送り返す流れになっています。

まとめ

本記事では、Kubernetes Pod の Resource Request を実績値から判断し、自動で変更する仕組みである Vertical Pod Autoscaler (VPA) の実装について解説しました。

VPA を使用することで、リソースの使用実績が Pod の配置にフィードバックされるため、クラスタのリソースを有効に活用することができます。

より具体的には、実績から推奨値を算出する Recommender、推奨値に合わない Pod を evict する Updater、Pod の再作成時にリクエストを書き換える Admission Controller の三つのコンポーネントがそれぞれの役割を果たすことで、最終的に Pod の Resource Request が更新されます。現時点では稼働中 Pod の Resource Request を動かしたまま(In-Place で)変更することができないという制限のため、一度 evict する手法が取られています。

ところで、この稼働中の In-Place な変更という問題も深入りすると実はなかなか興味深いのですが、それはまた別の話。

elm/time の使い方

はじめに

先日、Elm v0.19 がリリースされました。公式ライブラリのリポジトリelm-lang から elm に変更され、その中身も大きく再構成されています。

本記事では、これらの変更のうち特に時刻や日付の扱いに関する部分について、新しい API の使い方を含めて簡単に解説します。

v0.18 における時刻の扱い

v0.18 では、時刻を扱う機能は標準パッケージ elm-lang/core の中で提供されていました。時刻を扱う Time モジュールと日付を扱う Date モジュールで、それぞれデータ型や関数が定義されているのが特徴です。

なお、旧バージョンのライブラリは現在 Elm Packages の検索にはヒットしない ので、中身を確認するためには直接 URL にアクセスする必要があります。

旧 Time モジュール

Time - core 5.1.1

時刻を扱う Time 型を提供します。Time 型の実体は Float 型のエイリアスで、Unix Epoch からの経過ミリ秒数を表します。

メインの関数は現在時刻を取得する now と指定した時間間隔で Msg を送出する every です。その他、every と組み合わせて使用する単位として second : Timeminute : Time が定義されており、例えば毎秒何かを行う Subscription は次のように書けます。

type Msg =
    DoSomethingAt Time.Time

subscriptions : Model -> Sub Msg
subscriptions _ =
    Time.every Time.second DoSomethingAt

旧 Date モジュール

Date - core 5.1.1

日付を扱う Date 型を提供します。また Time 型との変換用の関数もこちらで定義されています。

その他、紛らわしいですが Date モジュールでも now 関数がエクスポートされており、こちらは現在の日付を取得します。

特徴的なのは、Time 型と Date 型の変換においてタイムゾーンを指定する機能がないことです。したがって API 定義上は

toTime : Date -> Time
fromTime : Time -> Date

の変換は純粋な関数に見えますが、実際にはシステムのタイムゾーンに依存する副作用を持っていることになります。

v0.19 における時刻の扱い

さて、今回新しくなったバージョンでは、旧 TimeDate に相当する機能が再編成されてひとつのモジュール Time になり、さらに別ライブラリ elm/time として切り出されました。

新 Time モジュール

Time - time 1.0.0

大きな変更点は、Unix 時間を表す Posix 型に加えて、タイムゾーンを表す Zone 型が陽に導入されたことです。

現在時刻を取得するには今まで通り now で、現在のタイムゾーンを取得するには here を使用します。例えば初期化の際、両者を同時に取得する Cmd は以下のように書けます。

type Msg
    = SetSystemTime (Time.Zone, Time,Posix)

setSystemTime : Cmd Msg
setSystemTime =
    Task.perform SetSystemTime <| Task.map2 Tuple.pair Time.here Time.now

Date モジュールにあった日付への変換も Time の中にまとめられました。Unix 時間が同じでも実際の日付はタイムゾーンに依存するため、変換には Zone 型が必要になっているのが分かります。新しい変換関数名には toXXX で統一されており、Day 型は Weekday 型に、dayOfWeektoWeekday に変更されました。

toYear : Zone -> Posix -> Int
toMonth : Zone -> Posix -> Month
toWeekday : Zone -> Posix -> Weekday
toHour : Zone -> Posix -> Int
toMinute : Zone -> Posix -> Int
toSecond : Zone -> Posix -> Int
toMillis : Zone -> Posix -> Int

また、旧 Time モジュールにあった時間の単位 minutesecond は外されました。ミリ秒単位で直接指定する必要があります。

サンプル:デジタル時計

以上をまとめると、Elm v0.19 対応の簡単なデジタル時計は次のように実装することができます。

  • 初期化時に setSystemTime で現在のタイムゾーンと時刻を取得
  • それ以後 1000 ミリ秒ごとに setCurrentTime で現在時刻を更新
  • 表示の際は toHourtoMinuteタイムゾーン依存の時刻に変換

という流れになっています。

module Main exposing (main)

import Browser
import Html exposing (..)
import Task
import Time


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }


type alias Model =
    { zone : Time.Zone
    , posix : Time.Posix
    }


type Msg
    = SetSystemTime ( Time.Zone, Time.Posix )
    | SetCurrentTime Time.Posix


init : () -> ( Model, Cmd Msg )
init _ =
    ( { zone = Time.utc, posix = Time.millisToPosix 0 }, setSystemTime )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SetSystemTime ( zone, time ) ->
            ( { zone = zone, posix = time }, Cmd.none )

        SetCurrentTime time ->
            ( { model | posix = time }, Cmd.none )


view : Model -> Html Msg
view model =
    let
        h =
            Time.toHour model.zone model.posix

        m =
            Time.toMinute model.zone model.posix
    in
    div [] [ text <| String.fromInt h ++ ":" ++ String.fromInt m ]


setSystemTime : Cmd Msg
setSystemTime =
    Task.perform SetSystemTime <| Task.map2 Tuple.pair Time.here Time.now


subscriptions : Model -> Sub Msg
subscriptions _ =
    Time.every 1000 SetCurrentTime

Time.Extra モジュール

ところで、実際に時刻を扱うアプリを書いてみると、elm/time はかなり非力であることがわかります。特に以下のようなケースは問題になりそうです。

  • 時刻 + タイムゾーンから Unix 時間に変換できない(例:特定の日付と現在時刻を比較したい)
  • 時刻の和や差が取れない(例:ちょうど 1 か月後の日付が欲しい)
  • 時刻を丸めることができない(例:次に 00 秒になるタイミングで Msg を発生させたい)

このような問題を解決するために、justinmimbs/time-extra が使用できます。

time-extra 1.0.1

Time.Extra モジュールでは旧 Date 型に代わるものとして Parts 型を定義しており、Zone 型を組み合わせることで各タイムゾーンにおけるその時刻の Unix 時間を得ることができます。

partsToPosix : Zone -> Parts -> Posix

-- UTC における 2018/09/26 11:23.45.00
time1 : Posix
time1 =
    partsToPosix utc <| Parts 2018 Sep 26 11 23 45 0

また、旧 Time モジュールの minutesecond や代わる時間単位として Interval 型を定義しており、これを使って「1 か月後の日付」や「次に 00 秒ちょうどになる時刻」が取得できるようになっています。

add : Interval -> Int -> Zone -> Posix -> Posix
ceiling : Interval -> Zone -> Posix -> Posix

-- UTC における 2018/09/26 11:23.45.00 の 1 か月後
time2 : Posix
time2 =
    add Month 1 utc <| partsToPosix utc <| Parts 2018 Sep 26 11 23 45 0

-- UTC における 2018/09/26 11:23.45.00 以降、最初に 00 秒になる瞬間
time3 : Posix
time3 =
    ceiling Minute utc <| partsToPosix utc <| Parts 2018 Sep 26 11 23 45 0

まとめ

本記事では Elm v0.19 で刷新された elm/time について、旧バージョンとの違いや使い方について簡単に解説しました。

  • 時刻パッケージは elm-lang/core から elm/time に移動
  • 原則 Posix を操作し、通常の時刻表示に変換した時は Zone と合わせる
  • 時刻を操作するには justinmimbs/time-extra ライブラリが使える

ところで、今回紹介した新しい API を使ってちょっと面白いサンプルアプリを作ってみたので、GIF アニメにしたものを貼っておきます。

https://raw.githubusercontent.com/y-taka-23/elm-clockclock24/master/demo.gif

元ネタは Humans since 1982 の作品 ClockClock 24 です。ソースコードは以下にあるので elm/time の使い方の参考に。

github.com

ちなみにこのサンプル、実際には elm/time ではなくそれ以外の部分の実装のほうが大変だったのですが、それはまた別の話。