チェシャ猫の消滅定理

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

【#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 によるルールは一枚のファイルにまとめて記述しました。この記述をモジュール化して再利用可能にすることも可能なのですが、それはまた別の話。