チェシャ猫の消滅定理

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

July Tech Festa 2021 で IAM Access Analyzer と Zelkova について話してきました

こんにちは、チェシャ猫です。

先日開催された July Tech Festa 2021 で、AWS のアクセス制御検査ツール Zelkova について発表してきました。公募 CFP 枠です。

techfesta.connpass.com

講演概要

www.youtube.com

一般に、AWS 上のリソース設定を変更したときに何が起こるのかを事前に検査するのは難しい作業です。特に、複数の設定がマージされた結果として最終的なアクセス許可が決まるような場合、単独の項目をルールベースでチェックするだけでは限界があります。

今回の発表中では、インフラに対する検査の水準を以下の 3 つのレベルに分けて定式化しました。

  • Level 1:構文論的な検査。テンプレートが文法的に正しいかどうかを確かめる
  • Level 2ヒューリスティックな検査。意図した性質が成り立つかどうかを、特定の設定項目に関する条件として書き下した上で、その条件を確かめる
  • Level 3:意味論的な検査。意図した性質が成り立つかどうかを直接確かめる

ここで言う Level 3 の検査を実現するのが Automated Reasoning と呼ばれる技術です。設定ファイルの個別の項目をテストするのではなく、設定ファイルに記述された内容から最終的に意図した性質が成り立つかどうかを「自動的に推論」してくれます。

今回紹介した IAM Access Analyzer は、エンドユーザが Automated Reasoning を利用できる面白い機能です。例えば S3 BucketBucket Policy を変更したい場合、適用する前にプレビューの段階で、外部からの不用意なアクセスが許可されているなどのセキュリティ的な問題を検出できます。

IAM Access Analyzer の基礎になっているのは Zelkova と呼ばれるエンジンで、さらにその内部では SMT ソルバである Z3 が検査を行っています。発表中に述べた Zelkova の実装に関する情報は、以下の論文に基づきます。

ieeexplore.ieee.org

参考リンク

IAM Access Analyzer

IAM Access Analyzer 自体は無料で利用することができます。いくつかのリソースに対してアクセス権限の検査ができるようになっていますが、実験する場合は S3 BucketBucket Policy で試すのが最も簡単で、結果もわかりやすいと思います。

docs.aws.amazon.com

aws.amazon.com

例えば、Bucket Policy を用いてアクセス元として 11.22.33.0/24 を Allow し、11.22.33.44/32 を Deny した場合、Deny の方が CIDR の狭いため Access Analyzer はこれを過剰なアクセス許可としてプレビュー段階で検知します。しかしこれを逆にして 11.22.33.44/32 を Allow し 11.22.33.0/24 を Deny するよう書き換えると、今度は Deny の方が Allow を覆い隠す形になるため指摘は消えます。

このことから、Access Analyzer は CIDR 指定を単なる文字列として比較しているわけではなく、包含関係を考慮した上で最終的な権限の状態を推論していることがわかります。

f:id:y_taka_23:20210719070838p:plain
IAM Access Analyzer による意味論的検査 (スライド p.9)

SMT ソルバ

SAT ソルバや SMT ソルバの仕組みについては、以下のスライドがわかりやすく非常におすすめです。

www.slideshare.net

今回紹介した Zelkova には、SMT ソルバとして Z3 が組み込まれています。Z3 は Microsoft Research により開発されたソルバで、SMT ソルバとしては CVC と並んで非常にメジャーなものです。

github.com

オンラインで Z3 を実行できるチュートリアルサイトもあります。Zelkova で使用されている文字列やビットベクトルの理論もチュートリアルに含まれているので、実際に動作させてみると理解が深まるでしょう。

rise4fun.com

Zelkova / Tiros

発表中に名前を挙げた Zelkova や Tiros のような、数学的なバックグラウンドに立脚した AWS のセキュリティ検証の取り組みは、Provable Security というくくりで特設サイトにまとめられています。

aws.amazon.com

Zelkova に関連した記事はいくつかありますが、とりあえずのとっかかりとしてはこの記事から目を通すとよいです。

www.allthingsdistributed.com

文章よりも動画が好きだ、という人は以下のふたつをどうぞ。前者はビジネス層向けにポップにまとめられ CMクリップ、後者は re:Inforce 2019 のフルセッションです。

www.youtube.com

www.youtube.com

冒頭にも挙げましたが、今回の発表の後半部分は以下の Zelkova の論文が元になっています。発表中に盛り込めなかった SMT エンコーディングのディティールや具体例も載っています。検索すれば PDF も見つかります。

ieeexplore.ieee.org

まとめ

以上、簡単ですが July Tech Festa 2021 の登壇報告と、関連情報のリンクをまとめました。

AWS の検査エンジン Zelkova は、IAM Policy を SMT ソルバ用にエンコーディングすることで、実際のアクセスを伴わずに AWS リソースに対するアクセス権限の検査を行うことができます。またエンドユーザの立場では、IAM Access Analyzer を介して Zelkova に問い合わせることで、設定を変更する前にセキュリティ的な問題の有無を確認することが可能です。

今回は時間の都合もあって Zelkova をメインに据えましたが、Tiros も面白い技術です。SMT ソルバ(とその拡張)で完結している Zelkova と異なり、Tiros では SMT ソルバ MonoSAT 以外に、Datalog 互換のソルバ Soufflé と古典一階述語論理の証明器 Vampire を合わせた三種類を用途に応じて使い分けます。こちらも機会があれば是非どこかでお話ししたいのですが、それはまた別の話。

DevOpsDays Tokyo 2021 で Infrastructure as Code のテストについて話してきました

こんにちは、チェシャ猫です。

先日開催された DevOpsDays Tokyo 2021 で Infrastructure as Code のテストについて発表してきました。公募 CFP 枠です。

今回の発表は、昨年の CloudOperatorDays Tokyo 2020 での講演をほぼそのまま再現しています。内容については前回登壇時に詳細な解説記事を書いたので、こちらもご参照ください。

ccvanishing.hateblo.jp

オンサイト登壇

ここしばらく、どのカンファレンスもリモート開催がデフォルトになっていましたが、今回は久しぶりに会場まで行って登壇してきました。最後にオンサイトでプレゼンしたのが 2020 年の 3 月 26 日 なので、実に 1 年ぶりです。

f:id:y_taka_23:20210416164012j:plain

実際には会場で参加していたのはほぼ関係者オンリーで、各ホールに数人ずつといった程度の人数でした。ただ登壇する側としてはマイクを持って物理的に対面して話すのことの効果は大きく、久々に自分の本来のペースが出せたので非常に良かったです。個人的には、他のイベントでもせめて登壇側はオンサイトで話せる仕組みができると良いなと感じます。*1

前回からの差分

昨年 CloudOperatorDays の登壇からやや時間は経っていますが、主たるテーマである「IaC はなぜ辛いのか」「それを解決するために何をテストすべきか」という問題意識は今も変わっていないため、スライドも大部分は前回と同じものを使用しています。

ただし、CloudFormation Guard が 2020 年 10 月に一般利用可能 (GA) になったことを受けて記述をマイナーチェンジしました。前回の時点では開発プレビュー版だったこともあり、cfn-guard コマンドのインストールには Rust のソースコードからのビルドが必要でした。現在はバイナリリリース版が利用可能で、またセキュリティ用の設定が必要であればサンプルファイルも提供されています。

github.com

cfn-nag と CloudFormation Guard を比較する上で最も大きな差はルールのカスタマイズ性です。とりあえずセキュリティのベストプラクティスだけでも網羅的に当てておきたければ cfn-nag を、ルール追加やカスタマイズが必要であれば CloudFormation Guard を、という感じになるかなと思います。

なお、CloudFormation Guard は既存の Template からルールを生成する機能を持っていますが、これはあまり期待しない方がよいです。ポリシーとして記述したいであろう内容と、cfn-guard機械的に検出した条件はニュアンスが一致しないことも多いため、実用に足るポリシーはやはり人間が確認した上で書くことになりそうです。

まとめ

以上、簡単ではありますが DevOpsDays Tokyo 2021 での登壇報告でした。

余談ですが、講演後チャットで GCP + Terraform の場合はどうなるのかという質問を頂きました。AWS CDK の姉妹プロジェクトとして Hashicorp 社によって「Terraform 用の CDK」も開発されています。さらに、今回紹介した Conftest は、HCL や Dockerfile など YAML 以外のファイル形式もサポートしています。

github.com

というわけで、今回お話ししたような内容と Terraform との組み合わせもどこかで話せたら面白いかなと思っていますが、それはまた別の話。

*1:ただしその分、今回のように参加費が高くなるのも事実。

July Tech Festa 2021 winter で CockroachDB と TLA+ について話してきました

こんにちは、チェシャ猫です。

インフラ技術のカンファレンス July Tech Festa 2021 winter で、形式手法ツール TLA+ が CockroachDB の設計に使用された事例について発表してきました。公募 CFP 枠です。

www.youtube.com

今回の登壇は、昨年の CloudNative Days Tokyo 2020 と同じ題材を扱っています。ただ、前回より持ち時間が長くなった分、前回のスライドで説明不足だった部分を拡充してあります。特に、CockroachDB 以外の TLA+ 採用事例や、分散システムにおける Chaos Engineering の位置付けについて解説を追加しました。

なお、前回の登壇報告は以下の記事をご参照ください。

ccvanishing.hateblo.jp

当日出た質問

実時間を含む性質

今回の時相論理では「いつか成り立つ」という性質を記述していたが、時間制限をつけて「n 分以内に成り立つ」いう記述は可能ですか?

いいえ、TLA+ では「何らかの条件 B が成り立つより前に A が成り立つ」という記述はできますが、「n 分以内」のような実際の時間に言及するような記述はサポートしていません。

ただ TLA+ 以外だとこのような性質を記述・検査できるツールも存在します。実時間モデル検査と呼ばれる分野で、有名なツールとしては商用製品ですが UPPAAL が挙げられます。スライド中で「ベースとする数学的対象によってツールの性質が変わる」という話を出しましたが、UPPAAL は時間オートマトン (Timed Automata) をベースにしています。

事例の詳細

その他の TLA+ 採用事例についてもう少し具体的に知りたいです

スライド中に挙げた事例については、以下のリンクが参考になると思います。

AWS DynamoDB

cacm.acm.org

Azure Cosmos DB

github.com

TiDB / TiKV

github.com

Elasticsearch

github.com

学習リソース

形式手法の勉強をしようとすると情報が少なくて困るのですが、TLA+ を勉強する際に良い資料はありますか?

本家である Lamport が自身の Web ページ上にチュートリアルを公開しています。ご本人による動画による解説付きです。やや題材が硬派というか地味な感じですが、TLA+ の機能は最もしっかり解説されているように思います。

また、とりあえず動かしてみて雰囲気を掴みたいということであれば、以下のチュートリアルサイトも小綺麗で良い感じです。このサイトの作者 Hillel Wayne はCockroach Lab 社で TLA+ のセミナーを開催したとのことで、まさに今回の CockroachDB の事例のきっかけを作った人物です。

まとめ

以上、簡単ではありますが、July Tech Festa 2021 winter での登壇と、当日出た質問についてまとめました。

なお、今回の講演で解説した内容は、説明を簡略化するために実際の仕組みを省略している部分がいくつか存在します。もしオリジナル版に興味がある方は、GitHub で公開されている TLA+ 仕様記述を読み解いてみるのも面白いでしょう。

github.com

ところで、講演中に OSS の事例として挙げた TiDB と Elasticsearch は、「TLA+ だけでなく他の手法と組み合わせて検証を行なっている」という点でも興味深いプロジェクトです。TiDB が Chaos Mesh を開発していることはスライドに述べた通り、また Elasticsearch は Isablle というまた毛色の違う形式手法ツールを併用しています。

今回 CockroachDB を扱ったので、他のプロジェクトについてもいつかメインで取り上げたいと思っていますが、それはまた別の話。

Kubernetes: kube-scheduler をソースコードレベルで理解する

はじめに

Kubernetes において、Pod を配置するための Node を決定する手続きをスケジューリングと呼び、デフォルトのクラスタでは kube-scheduler がその責務を担っています。本記事ではこの kube-scheduler のソースコードを時系列に沿って追いつつ、どのようなロジックで Pod を配置する Node が決定されるのかを解説します。

なお、本記事は Kubernetes の内部実装について学ぶ勉強会 Kubernetes Internal #3 の補足資料を意図して執筆されました。本文中で参照しているソースコードのバージョンは v1.19.4 です。

スケジューラの概要

ソースコードを読むに先立つ予備知識として、スケジューリングの大まかな流れと Scheduling Framework の概要に触れておきます。

スケジューリングの流れ

ReplicaSet や Job によって作成されたばかりの Pod は、配置先の Node が未定の状態になっています。この Node を決定するのが kube-scheduler の役割であり、さらにその結果を各 Node で稼働している kubelet が見て、自 Node に割り当てられた Pod を実際に立ち上げる、というのが Kubernetes の動作原理です。

配置先が決まっていない Pod がキューに格納されていると考えてください。kube-scheduler のメイン部分は以下のように動作します。

  1. Pod を一つ、キューから取り出す
  2. 取り出した Pod が配置できる可能性がある Node をフィルタリングする
  3. 可能性のある Node のうち最も良いものを選ぶ
  4. API Server 経由で Bind リソースを作成する

1 から 3 までをスケジューリングサイクル、4 をバインディングサイクルと呼びます。スケジューリングサイクルは直列化されており常に Pod を一つずつ扱いますが、バインディングサイクルは Pod ごとに goroutine 上で実行されます。スケジューリングサイクルは 3 で Node が確定してバインディングサイクルの goroutine を起動させたあと、完了を待たずに次の Pod のスケジュールサイクルを開始します。

なお、2 で条件にあった Node のみフィルタリングする際、場合によっては一つも残らないことがあります。このような場合に Kubernetes は、 その Pod よりも Priority が低い稼働中 Pod を削除することで Node に空きを作ろうとします。この手続きを Preemption と呼びます。

f:id:y_taka_23:20201203044922p:plain
スケジューリングサイクルの模式図

Scheduler Extender

上で述べた流れに対して、追加ロジックを差し込むことができる機構が用意されています。あらかじめ kube-scheduler に設定を加えておくとスケジューリングの特定のポイントで HTTP リクエストが外部サーバに送信され、それにレスポンスすることで kube-scheduler の挙動を変えることができます。

拡張できるのは以下の 4 点です。

  • Filter: Node をフィルタリングした結果を受け取り、さらに候補を絞る
  • Prioritize: Node を選択する際の優先度付け関数を追加する
  • Bind: バインディングサイクル内で追加で他の処理を行う
  • Preempt: Preemption で犠牲となる予定の Pod を受け取り、その中に削除されたくない Pod があれば外す

Scheduling Framework

Extender を利用したスケジューリングロジックのカスタマイズには、いくつか問題点が指摘されていました。

まず単純に拡張点が少なく、カスタマイズの余地に制限があること。また、外部サーバに JSON Webhook を投げるという実装上パフォーマンスが落ちること。さらに、Webhook サーバが外部にあることで、拡張点をまたいだ情報の受け渡しやエラー時のハンドリングがスケジューラ側ではコントロールできないこと。

さらに kube-scheduler の実装自体も種々の拡張により肥大化が進んでおり、外部で新たなカスタムスケジューラを実装しようとするとかなりの部分が kube-scheduler の再実装にならざるを得ませんでした。

この問題に対して、コントロールフロー部分だけを提供しロジックを差し替えたスケジューラを作成できるようにする仕組みが Scheduling Framework です。Scheduling Framework では、以下の拡張点を定義しています。

  • スケジューリング待ちキューに作用するもの
    • QueueSort: 優先度付きキューの優先度関数を変更する
  • フィルタリングに作用するもの
    • PreFilter
    • FIlter
  • スコアリングに作用するもの
    • PreScore
    • Score
    • NormalieScore
  • バインドに作用するもの
    • Reserve: バインド前に Node や Volume などの確保をキャッシュに登録する
    • Permit: バインディングサイクルの先頭で Pod を一旦待機させる
    • PreBind
    • Bind
    • PostBind

今回のコードリーディングでは、これらの拡張点がどのように実装されているかも含め、スケジューリングの流れを追いつつ確認していきます。

f:id:y_taka_23:20201203041559p:plain
Scheduler Framework の拡張点 (https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/624-scheduling-framework)

プラグインの実装

Scheduling Framework のプラグインは、Name を返す Plugin interface の他、各拡張点に対応した interface を実装している必要があります。以下は Pod を配置できる可能性がある Node をフィルタリングする Filter プラグインの interface です。

type FilterPlugin interface {
    Plugin
    Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *NodeInfo) *Status
}

あらかじめ実装が提供されている Scheduling Framework のプラグインpkg/scheduler/framework/plugins に配置されています。

プラグインは一般には複数個の拡張点をサポートしています。例えば pkg/scheduling/framework/plugins/node_affinity.go にある NodeAffinity プラグインFilterScore の両方のメソッドを実装しており、両方の拡張点での挙動に影響を与えます。

var _ framework.FilterPlugin = &NodeAffinity{}
var _ framework.ScorePlugin = &NodeAffinity{}

また、上に示した Filter を見て取れるように、プラグインのメソッドは *CycleState を引数に取ります。この CycleState は排他制御付きの map であり、複数の拡張点やプラグインの間でデータを共有するために使用できます。ただし、CycleState はその名の通り、スケジューリングサイクルが開始されるごとにリセットされるため、データが共有できるのはあくまでも一つの Pod のスケジューリング内です。

f:id:y_taka_23:20201203053752p:plain
拡張点・プラグイン間の相互作用

スケジューリングサイクルの実装

スケジューリングサイクルの本体は pkg/scheduler/scheduler.go で実装されています。kube-scheduler は実行されると SchedulingQueue を起動させた上で、scheduleOne の無限ループに入ります。

func (sched *Scheduler) Run(ctx context.Context) {
    if !cache.WaitForCacheSync(ctx.Done(), sched.scheduledPodsHasSynced) {
        return
    }
    sched.SchedulingQueue.Run()
    wait.UntilWithContext(ctx, sched.scheduleOne, 0)
    sched.SchedulingQueue.Close()
}

この scheduleOne こそがスケジューラのロジックのコア部分であり、前述したスケジューリングの流れにほぼそのまま対応しています。以下、実装にい沿って詳細を見ていきましょう。

Pod の取り出し

冒頭、NextPod によりスケジューリングの対象となる Pod をキューからひとつ取り出します。後述しますが、このキューは Pod の Priority による優先度付きキューとして振る舞います。

podInfo := sched.NextPod()

次に、profileForPod で取り出した Pod に対応する Scheduling Profile を取得してどのようなロジックを採用するのかを決めます。また、削除中であったり、すでに配置先が決まっていた Node を再度見つけた場合は skipPodUpdate(pod) でスキップし、何も行いません。

pod := podInfo.Pod
prof, err := sched.profileForPod(pod)
(snip)
if sched.skipPodSchedule(prof, pod) {
    return
}

最後に、スケジューリングサイクルを始めるにあたって、プラグインのデータ置き場である CycleState をリセットしておきます。

state := framework.NewCycleState()

配置先 Node の決定

先に概要として述べた通りmPod を配置する Node を決定するにあたっては、

  1. 候補となる Node を絞る
  2. 残った Node に優先度をつける
  3. 一番優先度の高い Node を選択する

という手続きが行われます。これら一連の動作は pkg/scheduler/core/generic_scheduler.go で定義された Schedule メソッドで行われます。

scheduleResult, err := sched.Algorithm.Schedule(schedulingCycleCtx, prof, state, pod)

この中で、上記の「候補となる Node を絞る」を行うのが findNodesThatFitPod、「残った Node に優先度をつける」を行うのが prioritizeNodes、「一番優先度の高い Node を選択する」を行うのが selectHost です。

feasibleNodes, filteredNodesStatuses, err := g.findNodesThatFitPod(ctx, prof, state, pod)
priorityList, err := g.prioritizeNodes(ctx, prof, state, pod, feasibleNodes)
host, err := g.selectHost(priorityList)

Node のフィルタリング

ではさらに findNodesThatFitPod の中身を確認していきます。まず、PreFilter プラグインが呼び出されます。

s := prof.RunPreFilterPlugins(ctx, state, pod)

そして実際に Node をフィルタリングする処理はさらに二つの処理からなります。

まず一段階目は Filter プラグインによる findNodesThatFitPod です。

feasibleNodes, err := g.findNodesThatPassFilters(ctx, prof, state, pod, filteredNodesStatuses)

findNodesThatFitPod の内部では、各 Node に対して Pod との相性が並行してチェックされるようになっています。checkNode がチェックする処理で、さらにその内部で呼ばれている PodPassesFiltersOnNode が実際に Filter プラグインを呼び出す部分になっています。

parallelize.Until(ctx, len(allNodes), checkNode)
checkNode := func(i int) {
    // We check the nodes starting from where we left off in the previous scheduling cycle,
    // this is to make sure all nodes have the same chance of being examined across pods.
    nodeInfo := allNodes[(g.nextStartNodeIndex+i)%len(allNodes)]
    fits, status, err := PodPassesFiltersOnNode(ctx, prof.PreemptHandle(), state, pod, nodeInfo)
    //(snip)
}

冒頭でも提示しましたが、Filter プラグインFilter メソッドにより定義される interface になっています。PodPassesFiltersOnNode の内部では RunFilterPlugin が実行されており、これが各プラグインの Filter 結果を統合して一つでも失敗した Node は候補から外される仕組みになっています。処理の本体は pkg/scheduler/framework/runtime/framework.go にあります。

また、二段階目の findNodesThatPassExtenders では Extender による判定が行われます。

feasibleNodes, err = g.findNodesThatPassExtenders(pod, feasibleNodes, filteredNodesStatuses)

もし、この段階で候補となる Node が見つからなかった場合は、Preemotion の手続きに進みます。ここでは一旦、一つ以上の Node が見つかったとして話を続けましょう。

Node のスコアリング

prioritizeNodes でフィルタリングの結果生き残った Node に優先順序をつけます。Filter のときと同じく、ここでもまず PreScore プラグインが呼び出されます。

preScoreStatus := prof.RunPreScorePlugins(ctx, state, pod, nodes)

そしてやはり Filter の時と同じく、スコアリングもプラグインによるものと Extender によるものを合わせて考えます。まず、Score プラグインによるスコアの算出です。プラグイン名とそのプラグインによる Node の採点結果が map になって返されます。NormalizeScore プラグインによる正規化もここで行われます。

scoresMap, scoreStatus := prof.RunScorePlugins(ctx, state, pod, nodes)

そして、この結果に Extender によるスコアを足し込みます。Extender が複数登録されている場合、各 Extender ごとに並行して計算されます。

for i := range g.extenders {
    // (snip)
    go func(extIndex int) {
        // (snip)
        prioritizedList, weight, err := g.extenders[extIndex].Prioritize(pod, nodes)
        mu.Lock()
        for i := range *prioritizedList {
            host, score := (*prioritizedList)[i].Host, (*prioritizedList)[i].Score
            // (snip)
            combinedScores[host] += score * weight
        }
        mu.Unlock()
    }(i)
}

なお、プラグインによるスコアと Extender によるスコアの合算の際にはスケール調整を行っています。

result[i].Score += combinedScores[result[i].Name] * (framework.MaxNodeScore / extenderv1.MaxExtenderPriority)

Node の選択

最後に、selectHost が Node ごとのスコアの結果 priorityList を受け取って Node を一つ選択します。selectHost の中身は単純にループを回して最大値を選択する(同点の場合はランダム)だけです。

host, err := g.selectHost(priorityList)

バインディングサイクルの実装

Pod の配置先 Node が決定したあと、実際に Pod の Status を書き換えて配置を行う操作は Pod ごとに goroutine を発行することで行われます。これは、Volume のプロビジョニングの待ち時間や後述の CoScheduling によって Pod がすぐに起動できない場合であっても、先に次の Pod のスケジューリングサイクルを開始するためです。

まず、処理が goroutine として分岐する前に、Reserve プラグインと Permit プラグインが呼び出されます。

if sts := prof.RunReservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost); !sts.IsSuccess() {...}
runPermitStatus := prof.RunPermitPlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)

この次、Permit プラグインの呼び出しが終わったあとの処理を見ると、goroutine で無名関数を実行していることがわかります。

go func() {
    bindingCycleCtx, cancel := context.WithCancel(ctx)
    defer cancel()
    // (snip)
}()

この goroutine の中で最初に実行されるのが WaitOnPermit です。Permit プラグインによって許可が行われるまで Pod は待機状態になります。goroutine として分岐した後なので、ここで Pod が待たされている間も後続の Pod は次のスケジューリングサイクルに入ることができます。

waitOnPermitStatus := prof.WaitOnPermit(bindingCycleCtx, assumedPod)

Pod が待機状態から解放されると、まず PreBInd プラグインが呼び出されます。

preBindStatus := prof.RunPreBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)

そして実際のバインド処理は bind が実行されます。bind の中身は Filter や Score と同じくプラグインと Extender の両方が使用されますが、今回は Extender の実行が先で、かつその時点でバインドが成功すればプラグインには処理が渡らないようになっています。

func (sched *Scheduler) bind(ctx context.Context, prof *profile.Profile, assumed *v1.Pod, targetNode string, state *framework.CycleState) (err error) {
    // (snip)
    bound, err := sched.extendersBinding(assumed, targetNode)
    if bound {
        return err
    }
    bindStatus := prof.RunBindPlugins(ctx, state, assumed, targetNode)
    // (snip)
}

最後に PostBind プラグインを呼び出して一連の処理が終了です。

prof.RunPostBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)

なお、Reserve プラグイン呼び出し後の Volume 操作やプラグイン呼び出しでエラーが発生した場合、Reserve を取り消す必要があります。そのため、各エラーハンドリング内では Reserve プラグインを呼び出して取り消し処理を行うようになっています。

prof.RunReservePluginsUnreserve(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)

Preemption

ここまで、最初のフィルタリングの段階で少なくとも一つの Node が見つかった場合の流れについて解説してきました。しかし実際の運用中には必ずしも候補となる Node が存在するとは限りません。

では、条件に合う Node が全く見つからなかった場合の処理も見ていきましょう。このとき ScheduleFitError を返すことにより Preemption の手続きが開始されます。

scheduleResult, err := sched.Algorithm.Schedule(schedulingCycleCtx, prof, state, pod)
if err != nil {
    nominatedNode := ""
    if fitError, ok := err.(*core.FitError); ok {
        if !prof.HasPostFilterPlugins() {
            klog.V(3).Infof("No PostFilter plugins are registered, so no preemption will be performed.")
        } else {
            // Run PostFilter plugins to try to make the pod schedulable in a future scheduling cycle.
            result, status := prof.RunPostFilterPlugins(ctx, state, pod, fitError.FilteredNodesStatuses)
            if status.IsSuccess() && result != nil {
                nominatedNode = result.NominatedNodeName
            }
        }
    // (snip)
}

ここで、nominatedNode には、Preemption の結果 Pod が立ち退かされて空きができた Node の名前が記録されます。ただし、最終的に Pod がこの nominatedNode に配置されるとは限りません。再度スケジューリングサイクルを通過する間に他の Pod に埋められてしまう可能性もあり得ます。

Preemption 処理のロジックは PostFilter プラグインが担当します。

result, status := prof.RunPostFilterPlugins(ctx, state, pod, fitError.FilteredNodesStatuses)

デフォルトの状態では PostFilter は DefaultPreemption プラグインのみが実装しています。 ソースコードpkg/scheduler/framework/plugins/default/preemption/default_preemption.go です。

処理の本体は preempt メソッドであり、以下の処理が行われます。

  1. そもそもその Pod が Preempt 対象かどうか調べる
  2. 削除できる可能性のある Pod をリストアップする
  3. Extender に問い合わせて除外すべき Node がないかどうか調べる
  4. もっとも影響が軽い Node を選択する
  5. 実際に Pod を削除する

ロジックの中心となっているのは「候補となる Node をリストアップする」を行う selectVictimsOnNode です。大まかに

  1. まず、各 Node 上で、考えている Pod より Priority が低い Pod を全部削除した状態を考える
  2. そこから Pod を一つづつ戻してみる。PodDisruptionBudget に影響される Pod を優先して戻す
  3. それ以上戻せない、すなわち新しい Pod を配置するために必要な最低限の犠牲がどの Pod になるかを調べる

という順序で処理が行われます。

参考文献

過去のスライド

公式ドキュメント

まとめ

Kubernetes において、Pod を配置するための Node を選択する手続きをスケジューリングと呼びます。本記事では、デフォルトのスケジューラである kube-scheduler のソースコードを追うことで、Node がどのように選択されるのかの内部アルゴリズムを解説しました。特に最新の kube-scheduler では、Scheduling Framework を用いることでアルゴリズム中のロジックを差し替え可能になっているのが特徴です。

なお、今回大きくは扱いませんでしたが、運用上スケジューラのパフォーマンスに大きく影響する仕組みとして、スケジューリングキューとキャッシュが挙げられます。いつか機会があればこれらのトピックについても解説したいと思いますが、それはまた別の話。

安全性-活性分解定理とその関連研究

こんにちは、チェシャ猫です。先日行われた第 7 回 Web System Architecture 研究会で形式手法について発表してきました。

普段、形式手法について登壇する際は具体例な検証例を出すことが多いですが、今回は理論側に寄せたサーベイになっています。

はじめに

本セッションでは、安全性-活性分解 (safety-liveness decomposition) と呼ばれる一連の結果について解説する。安全性-活性分解は、システムの仕様が与えられた時、それを安全性 (safety) および活性 (liveness) と呼ばれる、よりシンプルな特徴付けを持つクラスに分解して扱うための方法論である。さらにセッションの後半では、安全性と活性の組み合わせ以外にも提案されている派生的な特徴付けについても述べる。

Web アプリケーションと形式手法

システムやプログラムの性質を何らかの数学的な対象を通して表現、検証する方法論を形式手法 (formal methods) と呼ぶ。

期待される入力と出力を人間が具体的に与える従来のテストと比較し、形式手法では理論的な背景に基づいた抜けや漏れのない検証が可能であり、また並行性のようなコントロールが困難な性質についても網羅的な検証を行うことができる。また、実装に依存せず設計段階でその意図を表現・検証できるというメリットもある。一方、形式手法を扱えるプログラマは多くなく、また一般には記述コストが通常のテストに比べて高くなるというデメリットも存在する。

伝統的には形式手法は、例えば航空宇宙関連の組み込みプログラムのような、高コストを支払ってでもそれを上回る信頼性が必要となる分野で用いられてきた。しかし近年では分散システムが身近になったことに起因し、Web アプリケーション開発において形式手法が採用される事例も見られる。一例としてここでは Amazon Web Service による S3 および DynamoDB のレプリケーションを検証した事例 [CRZ15]、および CockroachDB の分散トランザクションの最適化を検証した事例 [TSM20] を挙げておく。

安全性と活性

システムやプログラムの仕様を表現するにあたって、安全性と活性の二種類の仕様があることは古くから経験的に知られてきた。非形式的な表現で述べるなら、安全性とは「何か悪いことが決して起こらない性質」であり、活性とは「何か良いことがいつかは起こる性質」である。

シンプルな例として排他制御を考える。二つのプロセスが並行して動作し、各々が実行中にクリティカルセクションに入る必要があるとする。直感的にイメージされる性質は「二つのプロセスが同時にクリティカルセクションに入ることがない」であり、これは安全性に属する性質である。

しかし実はこの記述には不足があり、システムとして要求される性質を完全には表現していない。各プロセスが一度もクリティカルセッションに入らないようなシステムであってもこの安全性を満たすからである。したがって「各プロセスが実行中にいずれクリティカルプロセスに入る」という条件が必要であり、これが活性に相当する。

このことからもわかる通り、システムに要求される性質を記述する上では、安全性だけでなく活性を与えることが本質的に必要である。

分解定理

システムを検証するに当たって、前提としてその動作や期待する性質を何らかの形で記述する必要がある。停止するプログラムであれば入力-出力の関係としてその性質を記述することも考えられるが、システムサーバのような動き続けるシステムを記述するにはこの形式は不向きである。

このような場合、システムの状態を要素とする無限列を考えることが一般的である。例えば、システムに関与する各プロセスのプログラムカウンタの位置と各変数に代入された値の組はそのシステムの状態とみなすことができる。状態の無限列をパス (path) あるいは振る舞い (behaviour)と呼び、パス全体がなす集合の部分集合を性質 (property) と呼ぶ。

ここで、パスの定義は「実際にそのシステムが動作中に実現しうるもの」に限られない点に注意されたい。最終的な目標となる「システムが要求された性質を満たすことの検証」とはすなわち、「パスのうちシステムが実現しうるもの集合が要求したい性質の集合に含まれていること」に他ならない。

二つのパスに対して「最初 n 個目までの状態までが一致する」という関係はパス間の距離を定義する。したがって、パス全体の集合に対して、この距離が誘導する位相構造を考えることができる。

Alpern と Schneider [AS85] はこの位相構造を用いて、安全性と活性にエレガントな定義を与えている。特に活性については、これが最初の理論的な定義である。

定義:閉な部分集合を安全性 (safety property)、稠密な部分集合を活性 (liveness property) と呼ぶ

非形式的な表現に直すなら、安全性とは「要求に違反する無限列があったとしたら最初の有限個の状態ですでに違反している」性質であり、活性とは「最初の有限個がどんな状態であっても、その後続をうまく取れば全体としては要求を満たすことができる」性質である。これは前項で述べた安全性および活性の直感的な理解とよく一致する。

ところで、一般に位相空間において、任意の集合は閉集合と稠密集合の共通部分として表現することができる。このことから以下の定理が従うことがわかる [AS85]。

定理:任意の性質 P に対して、安全性 S と活性 L が存在して P = S \cap L

言い換えれば、システムに要求される任意の性質は、安全性に属する部分と活性に属する部分に分解できることがわかる。この定理は分解定理あるいは Alpern-Schneider 型定理と呼ばれ、以降に述べるような様々な派生的な研究のきっかけとなっている。

f:id:y_taka_23:20201114205820p:plain
安全性-活性分解の直感的証明

サブクラスに対する分解

上に示した分解定理は、任意の性質  P に対して成立するという意味では適用範囲が広いが、具体的に性質を記述したり判定を行ったりする目的には向かない。実用上はより具体的な記述の形式や分解の手続きが必要である。さらに、分解した結果を効率的に運用するためには、あるクラスに属する性質を分解した結果もそのクラスに閉じていること望ましい。ここでは知られている例として、Büchi オートマトンに対する結果および線形時相論理式に対する結果を取り上げる。

Büchi オートマトンに対する分解

Büchi オートマトンは有限状態オートマトンのある意味での拡張である。有限状態オートマトンが正規言語に対応するように、Büchi オートマトンは ω-正則言語と呼ばれる記号列のクラスに対応する。有限状態オートマトンを用いて記号の有限列が条件にマッチするかどうかを定義できるのと同様、Büchi オートマトンは与えられた記号の無限列が条件にマッチするかどうかを定義する。

非形式的な表現で述べれば、有限の記号列がある有限状態オートマトンに受理されるとは、記号に応じてオートマトンの遷移を辿っていった際、最終状態が受理状態であることを指す。これに対して無限の記号列がある Büchi オートマトンに受理されるとは、記号に応じてオートマトンの遷移を辿っていった際、受理状態を無限回通過することを指す。

今、考えているシステムのパスとは状態の無限列のことであり、システムの性質とはパス全体がなす集合の部分集合であった。つまり記号の集合として状態の集合を考えると、Büchi オートマトン m に対して m が受理するパスの集合 L(m) が定まり、これは何らかの性質を定義しているとみなすことができる。

Alpern と Schneider は先ほどの抽象的な分解に加えて、与えられた Büchi オートマトン m から二つの Büchi オートマトン Safe(m)Live(m) を構成する手続きを示した [AS87]。

定理:任意の既約 Büchi オートマトン m に対して、L(Safe(m)) および L(Live(m)) はそれぞれ安全性と活性となる。さらに L(m) = L(Safe(m)) \cap L(Live(m)) が成り立つ

この定理は先に述べた位相を利用して得られる定理と同様、性質を安全性と活性とに分解する記述を与えている。ただしその分解の対象は性質の中でも特に Büchi オートマトンにより定義できるものに限られ、その代わり分解のための具体的な手続きを提供する。

線形時相論理式に対する分解

線形時相論理 (linear temporal logic, LTL) は、時相論理と呼ばれる時間的な広がりを持った条件を記述することができる諸体系の一部である。 時相論理には複数のクラスがあり、LTL の他にも計算木論理 (computational tree logic, CTL) や、LTL と CTL の両者を内包する CTL* などの体系が知られている。木構造の分岐を扱うことができる CTL に対して、時間発展を直線的なモデル、すなわち状態列として扱うのが LTL の特徴である。

一般に時相論理おける論理式は、単一の状態に対する通常の命題論理に時相論理用の演算子を追加して記述される。どの記号がどの位置に出現することを許すかによって時相論理の中でも上記のバリエーションが生まれるが、LTL として妥当な演算子とその非形式的な意味は以下の二つである。

\bigcirc \phi:次の状態で \phi が成立する

\phi \mathbf{U} \psi:今後いつかは \psi が成立し、かつそれまでの間は \phi が成立する

実用上の文脈では、これらの演算子を用いてさらに定義される

 \Diamond \phi = true \mathbf{U} \phi

 \Box \phi = \neg \Diamond \neg \phi

が使用されることが多い。非形式的には  \Diamond \phi は「今後、いつかは \phi が成立する」 、 \Box \phi は「今後、常に \phi が成立する」といったような意味になる。

LTL 論理式は無限列に対してその真偽が判定できるため、やはり何らかの性質を表現しているとみなすことができる。Maretić らはこの LTL で表される性質のクラスに対する結果として以下を示した [MDB14]。前述の Büchi オートマトンに関する結果と同じく、与えられた性質を安全性と活性に分解する形式になっている。

定理:任意の LTL 論理式 \phi に対して、安全性を表現する LTL 論理式 \sigma と活性を表現する LTL 論理式 \lambda が存在して \phi = \sigma \land \lambda

なお、LTL 論理式で表現できる性質は Büchi オートマトンでも表現できるが、逆は成り立たない。LTL 論理式の表現力は Büchi オートマトンの中でも特に counter-free と呼ばれるクラスに属しているものに等しく [DG08]、これは Büchi オートマトン全体より真に狭い [W83]。つまり LTL 論理式に対する上記の定理は、LTL 論理式が分解について閉じていることを意味している。

性質に対する分類の拡張

ここまでに述べてきた結果は、対象とする性質のクラスに差はあるが、いずれも Alpern-Schneider により定式化された安全性と活性という分類に基づいたものであった。やや方向性が異なる研究として、従来とは異なる新たな分類手法を提案した文献を二つ取り上げる。

束論を用いた特徴付け

\langle L, \leq \rangle に対して、以下の条件を満たす作用素 cl: L \rightarrow L を閉包作用素と呼ぶ。

  1. a \leq cl(a)
  2. cl(a) = cl(cl(a))
  3. a \leq b ならば cl(a) \leq cl(b)

ある集合が与えられた時、その部分集合の全体に包含関係で順序を入れたものは束になり、位相の意味での閉包を取る操作は閉包作用素の条件を満たす。このため、位相を用いて定義された安全性と活性の概念は、束を用いて再定義することができる。

Manolios と Trefler はこの着想に基づき、束上で定義される安全性と活性の類似物について論じている [MR03]。具体的には以下のような定義である。

定義:可補モジュラー束 \langle L, \land, \lor, 0, 1 \rangle 上の閉包演算子 cl: L \rightarrow L が与えられたとする。a = cl(a) を満たすような元 acl-安全性 (cl-safety)、cl(a) = 1 を満たすような元  acl-活性 (cl-live) と呼ぶ

cl として位相的な閉包を取る操作を考えると、これらの定義は先に挙げたものの一般化になっている。さらにこの方針の面白い性質は、分解された各部分を特徴づける際に共通の cl を用いる必要がないという事実である。以下の定理からわかるように、特定の条件下さえ満たされれば、安全性と活性の定義に異なる cl を使用することができる。

定理 cl1cl2 を可補モジュラー束 \langle L, \land, \lor, 0, 1 \rangle 上の閉包作用素とし、任意の x \in L に対して cl1(x) \leq cl2(x) が成り立つとする。このとき cl1-安全性 y \in Lcl1-活性 z \in L が存在して x = y \land z が成り立つ

この一般化を利用して論文中では、分岐時間的 (branching-time) な性質、すなわちここまで見てきたような状態の直線的な無限列ではなく、状態をノードとするような木構造に対して定義された性質についても、従来と類似の分解定理が成立することが示されている。

定理:任意の分岐時間的な性質は、existentially safe な性質と existentially live な性質の共通部分、universally safe な性質と universally live の共通部分、existentially safe な性質と universally live な性質の共通部分、のすべてのパターンに分解可能である

ここで existentially および universally の具体的な定義は省略するが、それぞれ別の閉包作用素を通じて定義される性質である。非形式的な表現でいえば、状態がなす木構造において、existentially safe は「うまいルートの辿り方をすれば悪いことが起こらない」性質、universally safe は「どんなルートを辿ったとしても悪いことが起こらない」性質といった意味になる。

分岐時間的な分解定理は先に挙げた束上の分解定理の直接的な系であり cl1 が existentially、cl2 が universally に対応している。universally safe と existentially live の組み合わせだけが抜けているのは条件 cl1(x) \leq cl2(x) が満たされないためであり、実際にこの組み合わせでは分解できない反例が存在する。

またこの分岐時間の例は、束論による定義が位相による定義の真の拡張になっていることを示す例でもある。実際、universally を定義する閉包作用素は位相的な意味でも閉包を与えるが、existantially を定義する閉包作用素は位相的な意味では閉包にはならない。

階層的な特徴付け

また別の観点からの定式化を用いたシステムの性質の分類として、Chang らによる研究が知られている [CMP93]。従来の Alpern-Schneider 型の研究では性質を安全性と活性に二分していたが、Chang らは階層的なモデルを提案している。

既に述べた通り、Alpern と Schneider による分類は閉集合を安全性、稠密集合を活性とするものであった。Chang らの論文中では複数の視点から同値な定義が与えられているが、対応がわかりやすいように位相による定義を用いて彼らの分類を述べると以下のようになる。

定義閉集合、開集合、加算個の閉集合の和、加算個の開集合の共通部分として特徴付けられるクラスをそれぞれ Safety、Guarantee、Response、Persistence と呼ぶ

さらに、副次的なクラスとして Safety と Guarantee の両方に属するクラスを Obligation、Response と Persistence の両方に属するクラスを Reactivity として定義している。

面白いのは、理論的に自然な階層として定義されたこれらの性質と、システムに求められる要件の階層がよく対応しているように見えることである。例えば Persistence を定義する「加算個の開集合の共通部分」というクラスは、F_{\sigma} とも表され集合論では古くからよく知られた研究対象であるが、これは非形式的な表現では「いつかは成立し、かつ一度成立するとその後は成立したままになる」という状況に対応している。

なお、Chang らによる Safety の定義は Alpern-Schendier 型の安全性と共通しているが、他のクラスは活性とは直行する概念である。実際、Guarantee、Obligation、Response、Persistence、Reactivity のいずれかを \kappa で表すと、以下の分解定理が成立する。

定理:クラス \kappa に属する任意の性質 \Pi に対して、Safety に属する \Pi_{S}\kappaかつ活性に属する \Pi_{L} が存在して \Pi=\Pi_{S} \cap \Pi_{L}

参考文献

[AS85] B. Alpern, and F. B. Schneider. 1985. “Defining Liveness.” Information Processing Letters 21 (4): 181–85.

[AS87] B. Alpern, and F. B. Schneider. 1987. “Recognizing Safety and Liveness.” Distributed Computing 2 (3): 117–26.

[CMP93] E. Chang, Z. Manna, and A. Pnueli. 1993. “The Safety-Progress Classification.” In Logic and Algebra of Specification, 143–202. Springer Berlin Heidelberg.

[CRZ15] N. Chris, T. Rath, F. Zhang, B. Munteanu, M. Brooker, and M. Deardeuff. 2015. “How Amazon Web Services Uses Formal Methods.” Communications of the ACM 58 (4): 66–73.

[DG08] V. Diekert, and P. Gastin. 2008. “First-Order Definable Languages.” Logic and Automata 2: 261–306.

[MR03] P. Manolios, and R. Trefler. 2003. “A Lattice-Theoretic Characterization of Safety and Liveness.” In Proceedings of the Twenty-Second Annual Symposium on Principles of Distributed Computing, 325–33. PODC ’03. New York, NY, USA: Association for Computing Machinery.

[MDB14] G. P. Maretić, M. T. Dashti, and D. Basin. 2014. “LTL Is Closed under Topological Closure.” Information Processing Letters 114 (8): 408–13.

[TSM20] R. Taft, I. Sharif, A. Matei, N. VanBenschoten, J. Lewis, T. Grieger, K. Niemi, et al. 2020. “CockroachDB: The Resilient Geo-Distributed SQL Database.” In Proceedings of the 2020 ACM SIGMOD International Conference on Management of Data, 1493–1509. SIGMOD ’20. New York, NY, USA: Association for Computing Machinery.

[W83] P. Wolper. 1983. “Temporal Logic Can Be More Expressive.” Information and Control 56 (1): 72–99.

CloudNative Days Tokyo 2020 で CockroachDB と TLA+ について話してきました

こんにちは、チェシャ猫です。先日行われた CloudNative Days Tokyo 2020 で、形式手法ツール TLA+ が CockroachDB の設計に使用された事例について発表してきました。公募 CFP 枠です。

講演概要

CockroachDB は、Google Spanner の系譜に連なるいわゆる NewSQL データベースの一種です。

強い一貫性や ACID トランザクションといった従来の関係データベースが持つ「良い特徴」を残したまま、従来の関係データベースが苦手としていた水平スケーリングにも優れるのが特徴です。CockroachDB 自身は「地理分散 (geo-distributed) データベース」を標榜しています。

このような CockroachDB の特徴は、内部のデータ保持の方法に由来します。データは内部的には Key-Value レコードの形を取っていて、データ全体を一定のサイズで Range と呼ばれる単位に区切った上で、Range ごとに Raft 合意アルゴリズムを用いて Node 間複製を行います。

異なる Range 間では Raft による一貫性ははたらきません。したがって、複数の Key に対して同時に捜査する SQL をサポートするためには、分散トランザクションが必要になります。

当初の CockroachDB のトランザクションは、クライアントがトランザクションの完了通知を受け取るまでに Raft による合意のレイテンシが 2 往復分必要でした。新しく導入された Parallel Commit はこのレイテンシを減らし、1 往復で完了通知が出せるようにしたものです。

ところで、一般に分散アルゴリズムを正しく設計するのは難しい作業です。一般に複数のプロセスはそれぞれのタイミングで処理を進めており、実行順について正確に予測することができません。また、分散システムではプロセス間通信がネットワークを挟むため、パケットの遅延・入れ替わり・消失などについて全ての場合を考える必要があります。

結果として、非常に限られたパターンでしか発現しないバグが含まれていたとしても、それを通常のテストを通して発見するのは極めて困難です。

CockroachDB の Parallel Commit プロトコルも例外ではありません。そこで Cockroach Lab はこのプロトコルの正しさを検証するために形式手法 (formal methods)を利用しました。

形式手法は、対象となるシステムを数学的な定式化を用いて厳密に記述し、その性質を検査する方法群です。中でも今回利用されたツールは TLA+ と呼ばれるもので、CockroachDB 以外にも、Cosmos DB や TiDB など CloudNative として名前が挙がる OSS で使用されています。

Parallel Commit の具体的なプロトコル、および TLA+ による検査の内容について詳細はスライドを参照してください。

Parallel Commit に関する補足

講演中に解説した Parallel Commit の動作は簡略化されたものであり、説明のためにいくつかの要素が意図して省かれています。

Pipelining

今回の解説では、トランザクションに属するすべてのキーとトランザクションレコード自体を同時に書き込む形式になっていました。しかし実際には COMMIT コマンドが発行されるよりも前、すなわちトランザクションに含まれるキーが確定しないうちに Intent の書き込みリクエストが始まります。この最適化は Pipelining と呼ばれています。

この挙動は TLA+ の記述にも反映されており、キー確定前にリクエストを送信する pipelined_keys と、確定後にトランザクションと同時にリクエストを送信する parallel_keys が分けて扱われています。

Timestamp Cache と Epoch

今回の解説では、Transaction Recovery の途中でコンフリクトするインテントを発見し次第 Abort させる形式になっていました。このとき実際には、未達だった書き込みリクエストが Abort 後になって到着して書き込まれることがないよう、「Intent が存在しないことが観測された時刻」を Timestamp Cache として保持しておきます。また、書き込むとき Timesatmp Cache を悲観的にチェックした上で、衝突が発生した場合にはトランザクションの Epoch を増やして最初からやり直す処理が必要です。

この挙動も TLA+ の記述に反映されており、インテントを扱う際には tscache を確認する仕組みになっています。

参考資料

CockroachDB

TLA+

その他 TLA+ 事例

まとめ

今回の講演では、分散データベース CockroachDB のアーキテクチャを概説したのち、合意レイテンシを減らす最適化 Parallel Commit の必要性について述べました。また Parallel Commit の検証において、形式手法の一種 TLA+ が使われている様子について例を挙げて説明しました。

ところで、実は CockroachDB の TLA+ 仕様には正しくない部分があり、しかしそのミスは通常の検査では Transaction Recovery で覆い隠されてしまうため、非常に見つけにくい状態になっています。

github.com

チェシャ猫自身が今回の講演の準備中に気づいたので既に Issue として挙げておいたのですが、これをどうやって発見したのか、それはまた別の話。

【#CODT2020 解説】Infrastructure as Code の静的テスト戦略

こんにちは、チェシャ猫です。先日行われた Cloud Operator Days Tokyo 2020 で、Infrastructure as Code のテストについて発表してきました。公募 CFP 枠です。

Cloud Operator Days Tokyo 2020 は今回が初開催のイベントですが、昨年 CloudNative Days Tokyo と併設されていた OpenStack Days Tokyo が前身となっているようです。

今年は OpenStack に限らず広く運用がテーマにされていますが、セッションのラインナップを見る限り、他のイベントよりもオンプレミス・エンタープライズ的な色が強く出ているように感じられます。

ちなみに、チェシャ猫の発表は事前アンケートで割と人気があったようで、以下のプレスリリースでは 7 位につけています。どこの現場も、Infrastructure as Code を現実の方法論として取り込みつつやっぱり辛さも感じている、というぐらいのフェイズなのかもしれません。

f:id:y_taka_23:20200730133829p:plain
https://cloud.watch.impress.co.jp/img/clw/docs/1263/506/html/codt-23.png.html

目指したかったこと

今回発表するにあたって、自分の中の隠しテーマとして

  • 誰でも体感的には感じていることを自分なりの定式化で語る

という目標を設定していました。

普段チェシャ猫が登壇する際には、その場にいる人が知らないであろう新奇な情報の提供を狙っていることが多いです。例えばそれは Kubernetes のリリースされたばかりのアルファ機能や内部的な挙動に関する Deep Dive であったり、形式手法のような一般に馴染みの薄い要素技術だったりします。

それに対して今回の登壇では、技術的に目新しい情報はほとんど含んでいません。しかしもちろんご存知の通り、IaC について書かれた既存の資料はスライド冒頭にも出した O'Reilly 本をはじめとして山のように存在するわけで、どこかでスピーカーの独自色を出す必要があります。

そこで「予測可能性」というワードを中心に据えて、「IaC に対して静的テストを考える必然性」にどれだけ説得力を持たせられるか、という観点でプレゼンを組み立ててみました。

幸いにしてこの狙いはある程度成功したようで、Serverspec の作者 mizzy 氏からも以下のようなコメントを頂いています。

当日お話しした内容

以下、スライドの内容を追いつつ必要に応じて参考リンクを挙げていきます。

IaC における「静的」テスト

IaC は死せども IaC は永遠なれ

ここ数年、Infrastructure as Code はもはや特別なものではなく、誰もが存在は知っているし、少なくとも部分的には実践しているであろう共通認識の一部になりました。前述の mizzy 氏も、Infra Study Meetup #1 の基調講演において「IaC is dead. Long live IaC」という言い回しでそのことを表現しています。

f:id:y_taka_23:20200730145836p:plain
https://speakerdeck.com/mizzy/infra-study-meetup-number-1?slide=58

あなたの「辛さ」はどこから?

しかしいざ IaC を導入してみると、謳われているほど意外と楽にはならず、むしろ「IaC 疲れ」みたいな状態になってしまうというのはよく聞く話です。この辛さはなぜ、どのように発生するのか?

そもそも、ひとことで IaC と言っても人によってそのイメージは様々で意外と統一されていません。そこで本セッションではまず「今回考えたい IaC とは何か」を定義することにしました。

f:id:y_taka_23:20200730150613p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=13

O'Reilly 本では IaC を 4 領域に分類していますが、少なくともチェシャ猫の感覚だと、この分類は「辛さ」に対する説明力があまり高いようには思えませんでした。今回対策を立てるべき「辛くなりやすい領域」を明確化するため、本セッションでは「Local / Global」「Mutable / Immutable」を軸とした 4 象限で分類しています。

Local / Global が影響範囲による分類で、

  • Local:変更の影響範囲が特定のリソース内で閉じる(例えばサーバ内の設定ファイルを変更して再起動)
  • Global:変更がインフラ全体の広範囲にわたって影響する(例えばネットワークの設計や被依存性の高いリソースの変更)

Mutable / Immutable は更新方式による分類です

  • Mutable:既に存在するリソースに対して更新を重ねていく方式
  • Immutable:変更の際には一度削除して再作成する方式

より「モダンで素性の良いインフラ」は、コンポーネント間が疎に結合され、かつ変更時にはゼロから再作成する形式になっていることが多いように思います。上の分類で言えば Local + Immutable の枠ですね。逆に言えばこの対極である Global + Mutable の領域には「辛さ」が凝縮しがちです。

塩漬けインフラ負のサイクル

この「辛さ」の凝縮を説明するのが次の図です。

f:id:y_taka_23:20200730152655p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=20

インフラの設計や運用に無理が出てしまうと、IaC をデプロイした時の影響範囲が読めなくなり、それによって余計に IaC への移行が阻害されるため余計に無理が出る、という典型的な構図で、O'Reilly 本では「オートメーション恐怖症」という言葉で表現されています。

ではそのサイクルをどこで防止するかと言えば「影響範囲が読めるようにすればよい」ということになり、ここから本セッションのテーマである予測可能性を究極のゴールとして据えることができます。

f:id:y_taka_23:20200730153524p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=22

ちなみにこの「予測可能性」というワーディングは、Haskeller である igrep 氏の記事から着想を得ています。

デプロイの壁と静的テスト

そもそも同じ「ソースコードによる管理」を行なっているにも関わらず、インフラがアプリより辛くなりがちな気がするのはなぜでしょう? それを考えるのが以下のスライドです。

f:id:y_taka_23:20200730154238p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=28

アプリケーションの特にウォーターフォールモデルの開発において、開発と品質保証とのの関係を説明するために V 字モデルがよく取り上げられます。これをインフラに移し変えて考えてみると、デプロイ以前にテストする手段に乏しいこと気づくでしょう。Serverspec や awsspec のようなツールはありますが、これはいずれも「既にデプロイされたサーバをテストするもの」であって、「デプロイの壁」の向こう側にあるものです。

インフラにおいて「デプロイの壁」はアプリ以上に深刻です。それは金銭的コストの問題かもしれないし、社内統制の問題かもしれないし、あるいは部署間の組織的ハードルかもしれません。しかし何れにせよ、「新しくインフラを作成する」というのは「既にあるインフラにアプリをデプロイする」ことと比較して桁違いのコストを伴います。

以上が、今回のセッションで「Global + Mutable 領域のツールに対して、デプロイ前のテストを重視すること」に着目する動機付けです。

f:id:y_taka_23:20200730155010p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=30

AWS における予測可能性

では具体的に AWS で IaC を実践しようとしたとき、予測可能性はどのような要件として現れるでしょうか? 本セッションではそれを「再現性」「純粋性」「モジュール性」という三つの要素としてまとめました。

f:id:y_taka_23:20200730155439p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=32

IaC の文脈ではよく「冪等性」という言葉が使用されます。二度繰り返してデプロイしても最初と結果が変わらないことですね。しかしここでは予測可能性について考える上で、必要なのはむしろ「純粋性」である、という立場をとりました。

IaC のデプロイをある種の関数であると考えると、その引数は「IaC ツールに与えるテンプレートやパラメータ x」および「現在のインフラの状況 e」であり、戻り値は「更新後のインフラの状況 e'」です。数式で表すなら

e' = f(x, e)

のような形になるでしょう。この記法を用いると、冪等性と純粋性はそれぞれ以下で表される性質です。

  • 冪等性:任意の x, e に対して f(x, f(x, e)) = f(x, e)
  • 純粋性:任意の x, e1, e2 に対して f(x, e1) = f(x, e2)

この定義から、純粋性において e1 = f(x, e), e2 = e と取れば冪等性が得られるため、純粋性の方が冪等性より強い性質であることがわかります。

ちなみにテスタビリティを担保する上での純粋性という用語は、Haskell をはじめとする関数型プログラミング言語から着想を得たものです。

AWS リソース管理のステップ

実際に AWS 上のリソースを作成する方法としては、ナイーブなものから高度なものまで、以下の 4 段階を辿って発展するモデルを想定しました。

  • マネジメントコンソールから手作業で作成
  • AWS CLI を使用してシェルスクリプト
  • CloudFormation により宣言的な YAML を記述
  • Cloud Development Kit (CDK) で YAML を生成

このような流れで CDK 登場の経緯を説明する手法には前例があり、例えば以下の Black Belt Seminar も同様のストーリーで解説されています。

本セッションではそれに加えて、テーマとして掲げた「予測可能性のための三要素」がどのように反映されているか、という観点から各手法の比較を行っています。

f:id:y_taka_23:20200730163451p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=58

なお、この後のプレゼンでお見せした CDK のソースコードは、以下の公式チュートリアルの TypeScript 版をほぼ引用しています。CDK は TypeScript の他 PythonJava、.NET が使用できますが、後述のテスト関係の Jest 統合など、TypeScript が最も機能的に充実している印象です。

cdkworkshop.com

CDK に対するテストツール

CDK は作成したコンポーネント = Consutuct をテストするためのフレームワークが付属しています。元々は CDK 自体の開発の中で使用するために開発されたものですが、ユーザである我々が自作コンポーネントの出来をテストすることも可能です。

ドキュメント等では、テストは三種類に分類されています。ただし、内容を見てもらえればわかる通り、Validation は単なる TypeScript の例外系テストであって CDK 由来の特殊性はありません。

  • Snapshot Test:CDK から CloudFormation への生成結果が test/__snapshot__ に保存され、次回 CDK 変更時に回帰テストを行うことができる
  • Fine-grained Assertion:ライブラリ @aws-cdk/assert が提供されていて、CloudFormation の生成結果に関して属性ベースのアサーションが記述できる
  • Validation Test:Construct の生成時の変数(= Props)に与える値のバリデーションができる

このうち Fine-grained Assertion と Validation については先述の公式チュートリアルに記述があります。Snapshot Test については解説がないので、以下のドキュメントを合わせて読むと良いでしょう。

テスト戦略とツール

さて、CDK にはテストがあるからこれで十分かと言えば全くそんなことはありません。CDK のテストはあくまでも「CDK に対するテスト」であって、そのデプロイ結果が意図した挙動を示すかというのはまた別の手段で保証する必要があります。言葉を変えれば、CDK に備わっているテストは

生の CloudFormation と比較して、CDK を経由することで導入された非自明性を補償するもの

であると考えることができます。つまり生の CloudFormation を書く限りにおいてリソースの設定値は「そこに書いてある」以上のものではないわけですが、CDK を使用することで生成過程が隠蔽されてしまうため、その隠蔽の過程で何か意図しない変換が起こっていないかをテストしているわけです。

これを図式的に示したものが以下のスライドです。結局のところ、当初の問題であった予測可能性の担保、すなわち「デプロイしたら動かない」を防ぐためには、「YAML をデプロイした結果」についてデプロイ前にテストする必要があります。

f:id:y_taka_23:20200730170040p:plain
https://speakerdeck.com/ytaka23/cloud-operator-days-tokyo-2020?slide=97

本セッションでは、ツールとして cfn-nag、CloudFormation Guard および Conftest を紹介しました。

チュートリアルに沿って実装していくと、この時点ですでに cdk.out ディレクトリ内に CloudFormation に渡される JSON が作成されているはずなのでこれを検査対象とします。もし見当たらない場合には cdk synth コマンドを実行すると生成されます。

cfn-nag

cfn-nag はセキュリティ系ルールにフォーカスした検査ツールです。ベストプラクティスに沿った定義済みルールが多数用意されており、細かくカスタムルールを定義する前にとりあえずデフォルトで実行してみるといった使い方が可能です。

github.com

cfn-nag の実装は Ruby なので gem コマンド、あるいは Mac であれば brew コマンドで直接インストールすることも可能ですが、今回は Docker イメージとして配布されているものを使用してみます。

$ docker run --rm -v `pwd`/cdk.out:/tmp/cdk.out stelligent/cfn_nag /tmp/cdk.out/CdkWorkshopStack.teplate.json

------------------------------------------------------------
/tmp/cdk.out/CdkWorkshopStack.template.json
------------------------------------------------------------------------------------------------------------------------
| WARN W68
|
| Resources: ["EndpointDeployment318525DA5f8cdfe532107839d82cbce31f859259"]
| Line Numbers: [286]
|
| AWS::ApiGateway::Deployment resources should be associated with an AWS::ApiGateway::UsagePlan.
------------------------------------------------------------
(snip...)
------------------------------------------------------------
| WARN W58
|
| Resources: ["HelloHandler2E4FBA4D", "HelloHitCounterHitCounterHandlerDAEA7B37"]
| Line Numbers: [38, 157]
|
| Lambda functions require permission to write CloudWatch Logs

Failures count: 0
Warnings count: 9

意外と多数の警告が出ました。cfn-nag はあらかじめ定義されているルールの豊富さがメリットですが、現実の開発ですべてのルールが必ずしも有効とは限りません。特に開発環境では、コストや便宜性との兼ね合いにより、意図的にセキュリティ上のチェックを無効化する必要がしばしば生じます。

例として、今出た警告をすべて抑制してみましょう。policy/blacklist.yaml として以下のファイルを保存しておきます。

---
RulesToSuppress:
- id: W58
  reason: global blacklist example
- id: W59
  reason: global blacklist example
- id: W64
  reason: global blacklist example
- id: W68
  reason: global blacklist example
- id: W69
  reason: global blacklist example
- id: W73
  reason: global blacklist example
- id: W74
  reason: global blacklist example

このファイルを -b または --blacklist-path オプションに与えることで、該当するナンバーのエラーや警告が検知されなくなります。

$ docker run --rm -v `pwd`:/tmp/cdk-workshop stelligent/cfn_nag /tmp/cdk-workshop/cdk.out/CdkWorkshopStack.template.json -b /tmp/cdk-workshop/policy/blacklist.yaml

------------------------------------------------------------
/tmp/cdk-workshop/cdk.out/CdkWorkshopStack.template.json
------------------------------------------------------------
Failures count: 0
Warnings count: 0

CloudFormation Guard

CloudFormation Guard はこの 6 月に新しくリリースされたツールです。こちらはセキュリティに限らず、ユーザが自由にルールを定義して使用することが想定されています。

github.com

ルールの記述は、基本的には個々のプロパティの値をチェックする形式で書くことになります。等号・不等号による比較の他、配列に含まれているかどうかのチェックやワイルドカード正規表現の使用も可能です。可読性を上げるための変数も定義できます。

ただ、CloudFormation Guard はまだリリースから間もないこともあり、使い勝手の面はあまり整備されていません。今の段階で実戦投入は難しいのではないかという印象ですが、今後に期待しましょう。

Conftest

Conftest は、Rego と呼ばれる Prolog 派生の論理型言語を使用した検査ツールです。

github.com

もともと Conftest は、CNCF プロジェクトの一つである Open Policy Agent (OPA) から派生したツールです。OPA は CNCF が出自だけあり、Kubernetes コミュニティでの利用が盛り上がっており、最近ではカンファレンス等で実戦投入した事例も散見されます。OPA を直接使用するというよりは、Kubernetes の Admission Webhook と統合された Gatekeepr として導入されることが多いようです。

そこで Conftest の位置付けとしては、この Gatekeeper と共通の Rego による記述を採用することで、CI 上でも Admission Webhook と同様の検査を行うことを指向しています。なお Kubernetes の Manifest の検査に Conftest 使う例については、tkusumi 氏による以下の記事を読むとよいでしょう。

さて、Conftest の用途は Kubernetes に限ったものではなく、一般に YAMLJSON、あるいは Hashicorp 製品のための設定言語である HCL も検査の対象とすることが可能です。今回は CDK から出力されたテンプレートに Conftest を使用してみます。

まず、前述のチュートリアルを実行してエラーが出るところまで進めていると仮定します。ここまでの実装には、続く解説にも述べられている通り、

  • カウンタ用の Lambda Function から DynamoDB への読み込み・書き込み権限がない
  • カウンタ用の Lambda Function から後続の Hello World 用 Lambda Function を呼び出す権限がない

の二種類の不足点がありますが、ここでは例として前者を検知します。

Conftest は特に指定しない場合、policy ディレクトリ内の Rego ファイルを読みます。policy/mypolicy.rego として以下を保存してください。

package main

deny[msg] {

    rs        := input.Resources
    functions := [ [name, rs[name] ] | rs[name].Type = "AWS::Lambda::Function" ]
    tables    := [ [name, rs[name] ] | rs[name].Type = "AWS::DynamoDB::Table" ]
    roles     := [ [name, rs[name] ] | rs[name].Type = "AWS::IAM::Role" ]
    policies  := [ [name, rs[name] ] | rs[name].Type = "AWS::IAM::Policy" ]

    violations := [ [f, t] |
        f := functions[_];
        t := tables[_];
        needs_permission(f, t)
        not has_permission(f, t, roles, policies)
    ]

    count(violations) > 0
    msg := sprintf(
        "The function %v cannot access the table %v",
        [violations[_][0][0], violations[_][1][0]]
    )
}

needs_permission([_, function], [table_name, _]) {
    function.Type = "AWS::Lambda::Function"
    function.Properties.Environment.Variables["HITS_TABLE_NAME"].Ref = table_name
}

has_permission(f, t, roles, policies) {
    r := roles[_]
    p := policies[_]
    has_role(f, r)
    has_policy(r, p)
    allows(p, t)
}

has_role([_, function], [role_name, _]) {
    function.Type = "AWS::Lambda::Function"
    function.Properties.Role["Fn::GetAtt"][0] = role_name
}

has_policy([role_name, _], [_, policy]) {
    policy.Type = "AWS::IAM::Policy"
    policy.Properties.Roles[_].Ref = role_name
}

allows([_, policy], [table_name, _]) {
    policy.Type = "AWS::IAM::Policy"
    statements :=
        policy.Properties.PolicyDocument.Statement[_]
    statements.Effect = "Allow"
    statements.Resource[_]["Fn::GetAtt"][0] = table_name
    statements.Action[_] = "dynamodb:GetItem"
    statements.Action[_] = "dynamodb:PutItem"
    statements.Action[_] = "dynamodb:UpdateItem"
}

今回はこのチュートリアルの構成のために特化してルールを書いたためややハードコード気味ではありますが、一つ一つ読み下していけば難しくはありません。Rego のルールは定義の集合として記述され、自然言語で書くなら以下のような内容になっています。

  • エラーとは、違反の数が 0 を超えていること
  • 違反とは、権限が必要であるにも関わらず保持していない Function f と Table t の組が存在すること
  • Function f が Table t に対する権限を必要とするとは、f環境変数 HITS_TABLE_NAMEt を参照していること
  • Function f が Table t に対する権限を保持しているとは、ある Role r と Policy p の組みが存在し、fr を持ち、rp を持ち、pt へのアクセスを許可していること
  • Function f が Role r を持つとは、f が Role 属性で r を参照していること
  • Policy p が Role r を持つとは、p が Roles 属性で r を参照していること
  • Policy p が Table t へのアクセスを許可しているとは、p の Statement として t への Action を Allow していること

個々のルールは単純ですが、組み合わせることで複雑なルールを記述することができます。Rego が持つ単一化の仕組みを利用することで、具体的なリソース名に言及することなく「以下を満たすような ft の組が存在する」といった条件を探索できることに注目してください。

ちなみに、引数が [policy_name, policy] のようなタプルになっている点に違和感を覚えるかもしれませんが、これはエラーメッセージにリソース名を入れる際に便利だったという便宜的なものです。使わない部分では _ 値で捨てています。

チュートリアルを途中まで実装した状態(すなわち grantReadWriteData が追加されていない状態)で Conftest を実行すると、以下のようにエラーが表示されます。

$ conftest test cdk.out/CdkWorkshopStack.template.json

FAIL - cdk.out/CdkWorkshopStack.template.json - The function HelloHitCounterHitCounterHandlerDAEA7B37 cannot access the table HelloHitCounterHits7AAEBF80

そして、チュートリアルに従って CDK 側で権限を追加した後、再度 cdk synth で生成したテンプレートを検査させると、今度はエラーが解消することがわかります。

$ conftest test cdk.out/CdkWorkshopStack.template.json

1 test, 1 passed, 0 warnings, 0 failures

まとめ

以上、Cloud Operator Days Tokyo 2020 での登壇「Infrastructure as Code の静的テスト戦略」について解説と補足資料をまとめました。

今回のセッションでは、誰もが一度は感じたことがある「なぜ IaC は辛くなりがちなのか」という問いに対して「予測可能性」の観点から状況の整理を行い、AWS で IaC を実践する上でのテスト戦略とそのためのツールについて解説しました。特に、Conftest を用いて CloudFormation のテンプレートを検査する部分は、あまり他に類を見ない手法なのではないかと思います。みなさんが現場で感じている「辛さ」を改善するヒントになれば幸いです。

ところで、今回は Rego によるルールは一枚のファイルにまとめて記述しました。この記述をモジュール化して再利用可能にすることも可能なのですが、それはまた別の話。