TECHSTEP

ITインフラ関連の記事を公開してます。

O'Reilly IaC本を読んでIaCのテストについて学ぶ

今回はO'Reilly社から2020年に出版された Infrastructure as Code (2nd Edition) を読み、IaCに対するテストについて個人的に気になった箇所などを書き留めていきます。

www.oreilly.com

目的

これまで業務や勉強の中でクラウド、Infrastructure as Code (IaC) を扱うことは何度もありました。その中で IaCに対するテストってどう扱えば良いのかわからないなあ と感じており、ある程度網羅的にIaCのテスト体系を学びたいと思っていました。 IaC本といえば2017年に日本語訳されたものも出版されていますが、あちらは2016年に出版された第1版が原著なので、せっかくなら新しいほうにしようと思い、こちらを読むことにしました。

前提

IaCとは

IaCがどんなものかは色々と表現方法があると思いますが、本書では ソフトウェア開発のプラクティスに基づくインフラストラクチャ自動化のアプローチ と記載しています。IaCを使ったインフラ管理では、まずIaCコードに変更を加え、その変更をテストしてシステムに適用するために自動化プロセスを使用します。現代のクラウド全盛の時代 (本書では Cloud Age と記載) では、システムに変更を加え続けることがシステムの安定性につながるので、IaCを使うことは変更に適応するためにも適切なアプローチであるといえます。

また、Cloud Ageはリソースの用意までに数十秒から数分で済むものがほとんどのため、オンプレ全盛の時代に比べるとリソースを再作成しやすくなり、何か問題が発生しても問題解決しやすくなりました。そのため、リソースの設定や構成を変更することにかかるコストは以前と比べて大きく下がり、変更作業はシステムやリソースなどに対する学びと改善の機会を与えるイベントとなりました。このような状況では、何か問題が発生することを恐れて変更の機会を減らすより、変更を繰り返して改善速度を最大化するほうが適します。また変更の機会を増やすには、小さな変更を短期間に繰り返すことが重要となります。

こういった背景もあり、インフラにより安全に変更を加え続けるためにも、継続的に自動テストを実行することが重要であるといえるでしょう。

2つ目のCore Practice

本書ではインフラストラクチャをより簡単に、より安全に変更するための原則として、3つのCore Practiceをメインに紹介しています。3つのCore Practiceはそれぞれ以下の通りです。

  • Define everything as code
  • Continuously test and deliver all work in progress
  • Build small, simple pieces that you can change independently

これら3つのPracticeはいずれもテストに関わりがありますが、特に2つ目は直接関連するもののため、今回はここから多くの事項を取り上げています。このPracticeを実践するべき理由は、より早くシステムやコード上の問題を発見し、より少ない時間で問題に対応できるからです。また、継続的に問題を解消することで技術的負債の蓄積を避けることにもつながります。

IaCをテストするうえでの課題

上述した2つ目のCore Practiceでは、IaCコードに対して継続的にテストすることを掲げていますが、一方でこれを実践することが難しくなってしまう要因があります。

Challenge: Tests for Declarative Code Often Have Low Value

IaCを実現するツールは数多くありますが、本書では主にDeclarative Code / Imperative Code という2種類に注目しています。簡単に言えば、Declarative Codeはインフラリソースの種類や状態を宣言的に定義する、 CloudFormation/Terraform/Ansible/Chef/Puppetなどで定義するコードを指し、Imperative Codeは汎用的なプログラミング言語の中でリソースを定義する、 AWS CDK/Pulumiなどを指しています。

ここではDeclarative Codeでテストを実施するケースを考えます。Declarative Codeではインフラリソースの状態を宣言しますが、例えば以下のような形になります。

subnet:
  name: private_A
  address_range: 192.168.0.0/16

これに対する低レベルなテスト(ここではユニットテスト相当のもの)を実装すると、IaCで定義したものと同じ情報をテストコードにも書くことになります。

    assert:
      subnet("private_A").exists
    assert:
      subnet("private_A").address_range is("192.168.0.0/16")

※上記いずれも本書より引用

Declarative Codeに対してこのようなテストを実行することには、幾つかの課題があります。

まず、上記のようなテストを行おうとすると、IaCファイルを変更するたびにテストコードも修正しなければならず、テストが成功するよう帳尻を合わせる作業に時間を使うことになります。

次に、このテストをやるとどんなリスクが検知できるかを考えてみても、テストから検知できるリスク自体の価値が低く、そもそもこのテストをやらなくてもよい場合も多々あると主張しています。

  • 該当のコードが一度も環境に適用されていない : (実際にこういった問題はあるのかもしれませんが) 該当のコードの一部をテストすればこの問題は検出できるでしょうし、何度もテストをするものではありません。
  • コードは適用されたが、エラーを返すことなく失敗している : これはコードではなくツール側のバグです。ツール側のバグを検知するために、ここで細かくテストをする必要はないでしょう。
  • 誰かがインフラを修正したがテストを修正することを忘れていた : これはそもそもこのテストを実行しなければ除かれるリスクです。

一方で、Declarative Codeへのテストで有効なケースとして、input情報などによって作成されるリソースの種類や設定が異なる場合や、複数のリソースを組み合わせて複雑な構成のインフラを作成する場合などを挙げています。

※感想

この視点は理解ができる一方で、現実にはこれに近いことをやりたいケースも多々あると思っています。

例えばWAFやSecurity Groupなどは、ちょっとした設定ミスが即セキュリティインシデントにつながる可能性もあります。このようなリソースのパラメータに間違いがないか、目視のチェックだけでなくユニットテストまで実行できれば、より安全にチェックができる気もします。

こういった場合にどう対応するのがよいのかは気になりました。

Challenge: Testing Infrastructure Code is Slow

インフラリソースをテストするには、対象のリソースをクラウド環境などに作成し、起動中のリソースを用意する必要があります。この場合、テストを開始するには実際のリソースが用意されるまで待つ必要があるため、準備時間が長くなることを意味します (クラウドプラットフォームを利用する場合は特に長くなる傾向にあります)。結果としてテスト完了までの時間が長くなり、デプロイパイプラインやCI/CDを用意していても、自動テストから素早くフィードバックを得ることが難しくなります。

ただしこれを軽減する方法も本書では案内されており、ここでは2つの方法を取り上げます。

  1. 1つのファイルで管理するインフラリソース数を少なくする: テストのたびに複数のリソースをまとめて作成するとどうしても時間がかかるので、1つのファイルで管理するインフラの単位を小さくします。こうすると1度に作られるリソースが少ないので作成までの時間は短くなり、またテスト自体を書きやすくもなります。テスト対象のコードが別のリソースに依存する場合はTest doubleの利用も検討します。
  2. そもそもプロビジョニングしない: プロビジョニングに時間がかかるのなら、そもそもプロビジョニングしなくてもできるテストを考えます。本書では オフラインテスト という名称で紹介されており、例えばLinterなどの静的解析などがこれに当たります。

Challenge: Dependencies Complicate Testing Infrastructure

ひとつ前のチャレンジでも触れましたが、あるリソースを作成するために別のリソースが必要な場合があります。こういった依存するリソースも併せて作成するとなると、プロビジョニングの処理時間はさらに増加し、ますますテスト完了までの時間が長くなります。

ここでの有効な対策としては、依存関係をTest doubleに置き換えることです。mock/fake/stubといったTest doubleを利用することで、依存するリソースを作成することなく目的のリソースをテストできます。 なお、本書執筆時点では LocalStack / Moto などのツールが紹介されており、これらを使うことでローカルで幾つかのテストを実行できます。

※感想

IaCコードに対してTest doubleを使ってテストするケースとは、具体的にどのような場合かが気になりました。

よく見るケースはAWS Lambda関数から別のAWSリソースを操作する場合ですが、これ以外でTest doubleを使っている例があまり思い浮かばず。。。

IaCでのテスト項目

IaCのコードに問題がないかを確認するためには、様々な観点から、そのコードを適用することで生じるリスクをチェックする必要があります。この観点は幅広く、本書ではコードの品質、機能性、セキュリティ、コンプライアンス、パフォーマンス、拡張性、可用性、運用性について触れています。

そのうえで、前述のテストの難しさを踏まえ、それでも効率的なテストを実現するためにどんなことができるのか、本書では幾つかのポイントを挙げています。その一つが オフライン/オンラインテストを適切に実施すること です。

オフラインテスト

オフラインテストとオンラインテストは、実際にクラウドプロバイダーにリソースを作成するか否かで分かれます。実際にリソースは作成せずローカルで実施するものをオフライン、そうでないものをオンラインとしています。

オフラインテストは大きく3種類あります。

  • Syntax Checking: タイプミスや構文エラーなどを検知します。多くのIaCツールにはDry Run機能が備わっており、例えばこれを利用してテストを行います。

  • Static Code Analysis: 誤ったコードスタイルやポリシー、セキュリティの問題などを検知します。tflint などのlinterや tfsec / cfn_nagなどのセキュリティ系ツールを使用します。

  • Tesintg with a Mock API: Mockインスタンスを作成してStatic Code Analysisなどでは見つからないコードのエラーを検知します。LocalStack / Azurite などの一部ツールでしかこれは実現できず、またDeclarative Codeにはあまり有効なテストが実施できない傾向にあります。

オンラインテスト

オンラインテストにはより多くの種類がありますが、ここでは3つの観点からテストを紹介しています。

Preview: Seeing What Changes Will Be Made

実際にリソースを作成する前に、テスト対象のコードを適用することで、Stack (1つ以上のリソースを定義した集まり) にどんな変更が生じるかを確認します。 terraform plan はこの代表例ですが、変更予定の内容を適用前に確認することで、想定外のリソース削除が行われないか、などのリスクを管理できます。

なお、管理リソースが多いと terraform plan の実行結果が大量に出力されることもあり、この結果を人の目で全て確認するのが適切でない場合もあります。この場合は、出力結果に対して自動テストを実行することで、例えばポリシーの違反やリソースの誤削除などを検知するテストが実行できます。

※参考: SpeakerDeck - terraform plan 結果の検証を自動化するぞ! with Conftest

Verification: Making Assertions About Infrastructure Resources

コードを適用して実際にStackを更新した後は、各リソースに対するAssertionを通じて、そのリソースが問題なく動作することを確認できます。こういったテストはawspec / Chef InSpec / TaskCat / Terratest などのツールがサポートしており、例えばそのリソースが起動しているか、ヘルスチェックをパスするか、などをテストできます。

※参考:

ただし、Challenge: Tests for Declarative Code Often Have Low Value にあったようなシンプルなAssertionをテストしても、その多くはリソースの状態を再定義しているにすぎないため、あまり効果は得られません。本書では効果のあるテストの例として、仮想マシンの作成後に is_running passes_healthcheck などをテストすることを挙げています。これらのテストは、何らかの理由でVMが正常に起動しない場合をテストできるため、一定の効果が期待できます。

また、input情報によって結果の変わるような動的なコードの場合も、Assertionテストは効果があるでしょう。

Outcomes: Proving Infrastructure Works Correctly

Stackを更新してリソースを用意することで、テスト対象のリソースは想定通りに機能するか (いわゆる機能テスト) を確認できます。ただし機能テストは1リソースで完結しないことも多く、テスト実施のために専用のインフラリソース (Test Fixture) を作成する必要があります。

テストを構成するその他要素

これ以外にも、テストを効率よく実施するために検討すべき事項は様々あります。

テスト環境のライフサイクル

オンラインテストを実行するには、実際にリソースを作成する必要があります。そのため、多くのプロジェクトではテスト専用の環境を用意するでしょう。このテスト環境のリソースをどのようなライフサイクルで管理するかは、検討する余地があります。

Cloud Age以前は静的で長命なテスト環境を維持し続けていましたが、今ではテストのたびにまっさらな新しいインスタンスを作成することも簡単にできます。ただし毎回インスタンスを作り直すのもデメリットがあり、どんなライフサイクルで管理するかはトレードオフとの相談になります。

  • テスト用のリソースを常に起動する場合: 既存のリソースを更新するのでテスト準備時間は少なくなり、結果的にテスト実行時間は短くなります。ただしリソースが長い間起動し続けることで、起動中のリソース内に変更が蓄積される場合もあり、それが原因で更新時にリソースが壊れる場合があります。こういった事態が続く場合は、別パターンの採用を検討すべきでしょう。
  • テストのたびに新しいリソースを作る場合: 上記のような変更の蓄積はないので、それ以前のテスト実行結果を気にする必要はなくなります。ただしこちらはテスト開始までの時間が延びるため、フィードバックを得るまでの時間が長くなります。

こういったトレードオフを考慮しながら、オンラインテスト用のインスタンスのライフサイクルを決定すべきでしょう。

パイプライン設計

継続的にテストを実施するには、インフラストラクチャのデリバリーパイプラインの中にテストを組み込む必要があります。

Progressive Testing (段階的にテストを実施すること、後述) を意識すると、パイプラインの初期ステージでは、スコープが小さく完了までが短いテストを実施し、スコープの広く時間のかかるテストはステージ後半に実施します。

テスト対象のリソースの中で別のリソースに依存するものが含まれる場合、パイプラインの初期ステージでは依存するリソースが不要なもの、もしくはMockを利用できるものをテストします。

対象のリソースがクラウドプロバイダーなどに依存する場合も、パイプラインの前半ではプラットフォームに依存しないテストを行うよう意識します。

※デプロイパイプラインのステージとテストスコープ・テスト完了時間の関係性

テスト要素の組み合わせ

上記以外にもテストを構成する要素は様々ありますが、最終的にはこれら要素をまとめ、テスト環境を作る必要があります。本書では2つのガイドラインが記載されています。

Support Local Testing

1つ目はローカル環境でのテストをサポートすることです。インフラコードにかかわらずですが、コードを作成・変更する作業者は、共通のパイプラインや環境にコードを配置するより前に、作業者自身でテストを実行するべきです。

ローカル環境でテストをするには、テストに必要な環境を用意することが必要です。この方法は様々で、専用のスクリプトを使って各環境にツールなどをインストールする場合もあれば、コンテナや仮想マシンを使ってパッケージングされたテスト環境を作成する場合もあるでしょう。

またローカルでのテストを有効にするポイントとして、ローカル環境とパイプラインとで使うテスト用のスクリプトを揃えることを挙げています。こうすることで、どの場所でも一貫してテストのセットアップと実行が保障された状態を作ることができます。

Avoid Tight Coupling with Pipeline Tools

多くのCIやパイプラインツールは独自のプラグインを提供しており、それを使うことでテストの設定と実施が便利になるものもあります。しかしこういったプラグインを使うと、同じようなテストをパイプライン以外の環境で実行することが難しくなります。

こういったプラグインの使用は避け、どの環境からでも利用できるツールを採用します。

テストに向けた心得

本書ではテストをうまく実施するための心得が随所に記載されています。その中で大事と感じたものをいくつか取り上げます。

Core Practice: Define Everything as Code

本書の1つ目のCore Practiceでは、すべてをコードで定義することを挙げています。これを実践することで得られる利点は大きく3つ紹介されており、コードの再利用性・リソースの一貫性・透明性の向上があります。

何をコードで定義すべきかというと、IaC定義ファイルだけでなく、サーバーの設定ファイルやサーバーイメージ、デプロイに使うパイプラインの設定ファイル、自動テストに使うバリデーションルールファイルなど、インフラの作成や運用管理にかかわるすべての要素が対象となります。

またこのプラクティスを実現するためのポイントも紹介されています。

  • Choose Tools with Externalized Configuration: インフラの定義情報がコードで管理されず、特有のインターフェイス (GUI/API/CLIなど) からしか参照できないようなツールの利用を避けます。
  • Manage Your Code in a Version Control System: VCSに入れることで、追跡性・可視性などを獲得し、いざというときのロールバックもやりやすくなります。
  • Separate Declarative and Imperative Code: Declarative codeとImperative codeは性質が異なるため、2種類のコードが混在している状況は、ソースコードに何か重要な問題が潜んでいることを暗示しています。
  • Treat Infrastructure Code Like Real Code: コードをデザイン・管理して理解しやすいものに維持し、コードレビューやペアプロ・自動テストなどを利用してコードの品質を保つようにし、技術的負債に気づいたらそれを最小化するよう努めることが求められます。

Core Practice: Small, Simple Pieces

本書の最後のCore Practiceでは、システムを小さくシンプルなピースで構成することを挙げています。これはパフォーマンスの高いチームに共通で見られる特性で、こうすると変更しやすく、テストしやすいシステムを作ることにつながります。

本書では変更しやすいシステムを作るため、モジュラー性を持たせることを目指します。ほとんどのIaCツールは、モジュールやライブラリなどをサポートする機能がありますが、ここではモジュラー性を獲得するためのコツも紹介されています。

またこのPracticeでは自動テストを用意することの利点も挙げています。あるシステムの設計が不十分でモジュラー性に乏しい場合、このシステムに対して自動テストを作成し実行することは困難となります。つまり継続的なテストが実行できない場合、システム設計に何かしら課題があることを示唆しており、設計を見直し改善する機会を与えてくれます。

自動化の仕組みの導入をシステム構築作業に含める

IaCの導入などを進めようとするときに「システムの構築を先に終わらせてから自動化するべき」と主張する人もいますが、自動化を後回しにすると良くない理由がいくつかあります。

まず、自動化によって、構築したものに対する自動テストを簡単に書くことができるようになります。これによりシステムの抱える問題の検知と修正・再構築が容易になります。ビルドプロセスの一部としてこれを行うことで、より良いインフラを構築することができます。

また、既存のシステムを自動化するのは、とても難しいことです。自動化は、システムの設計と実装の一部であり、自動化なしで構築されたシステムに自動化を追加するには、そのシステムの設計と実装を大幅に変更する必要があります。これは、自動化されたテストとデプロイメントにも言えることです。

これらは検証としてシステムを作る時も同様です。検証で良さそうに見えると、すぐに次の段階に移ることを求められるため、自動化できないまま進んでしまうことがままあります。そのため、自動化の仕組みを導入することを検証の一部としていれるべきです。

Progressive Testingを意識する

すでに出てきたワードですが、シンプルなオフラインテストから始め、段階的にテストを行うことを意識します。こうすると最初にスコープの狭い範囲でのエラーを検知し、問題箇所を特定しやすいところから修正することができます。加えて異なるレベルのテストで同じ内容のテストを実施しないようにすることで、テストスイートを管理しやすくすることもできます。

なお、Progressive Testを行う上で、どのテストをどのステージで実施すべきかを判断する必要がありますが、これには通常テストピラミッドが役立ちます。ただしDeclarative Codeの場合、アプリケーションコードの時とは異なり、テストピラミッドがそれほど有効でない、とも指摘しています。

Declarative Codeツールで管理するStackは、ユニットテストを実施するにはサイズが大きく、かつそれはしばしばクラウドプラットフォームに依存します。またDeclarative Codeは Challenge: Tests for Declarative Code Often Have Low Value の通りユニットテストをする価値が低く、実際のインフラリソースがなければ有用なテストを実施するのが難しい傾向もあります。こういった理由から、たとえユニットテストがあるとしても、その総量は従来のテストピラミッドが示すほど多くはなく、どちらかというとダイアモンド状になるだろうと記載しています。

※本書中の図を一部改変

なお、これがImperative Codeの場合、Declarativeよりはもっと多くのユニットテストが必要になります。

テストツールは悩みの種

IaCコードに対するテストをする上で難しい点として、最後にツールの選定の難しさを挙げています。

例えばIaCテスト全体をオーケストレーションするために、多くのチームでは専用のカスタムスクリプトを作成しています。こういったテストをサポートするツールは現状あまりなく、本書でも Test Kitchen / Moleclue という2つを上げるにとどまっています。こういったツールは特定のパターンを想定していることが多く、自分たちのやりたいパターンに合わせてカスタマイズをしなければならない場合も多くあるため、結局は自分たちで一からスクリプトを作成した方が良いと考える人も多いとのことです。現状では使えるツールを探しつつ、自分たちがやりたいこととのギャップを埋めるため、カスタムスクリプトを用意するという過程を繰り返さなければならないでしょう。

またそれ以外の部分でも、IaCコードに対するテストに利用できるツール自体はいくつかあります (本書でもいくつか紹介されています) が、そもそもインフラストラクチャを効率的に開発する手法、例えばTDD (Test-Driven Development) ・CI・自動テストが (本書出版時点では) あまり確立されていません。今後の技術的発展などにより、こういった課題の改善されることが期待されます。

【メモ】PRを作成したら対象ブランチ名を変更してCodePipelineを起動する

以前CodeBuildでPull Requestごとに特定のCommit IDをターゲットにビルドを実行する方法を調べましたが、今回はAWS CodePipelineで同じことを実現しようとしました。

背景

AWS CodePipelineAWSサービスの一つでフルマネージドな継続的デリバリーサービス と紹介されます。CodePipelineからCodeBuildなどのサービスを呼び出し、一連の処理をパイプラインで実行するサービスです。

Pull RequestごとにCodePipelineの起動と実行ができると、例えばPull Requestごとに複数のCodeBuildを直列に実行したり、CodeBuildとLambda関数など別のリソースを実行することもできます。またCodePipelineから見ることで処理全体の見通しが良くなる効果も期待できます。

CodePipelineの機能を眺めると、Pull Requestのたびに実行するようなものはデフォルトで含まれていないように見えます。そのため、前回のCodeBuildと同様、何かしら別の方法を考える必要があります。

方法

CodePipelineを起動するStartPipelineExecution APIを見ると、指定できるパラメータが極端に少ない (clientRequestToken name のみ)ことがわかりました。一方、CodePipelineの中でCodeCommitをソースにする場合、対象のブランチ名を指定する必要があります。そこで今回はパイプラインを起動する前にCodePipelineの設定を更新し、PRの作成元となるブランチ名を指定することで、特定のブランチに対しパイプラインを実行できるようにしました。

パイプラインの更新には UpdatePipeline APIを利用しました。CodeCommitをソースに利用する場合は BranchName というパラメータに特定のブランチ名を渡すことで、ターゲットのブランチ名を変更できます。

今回の処理の大まかな流れは以下の通りです。ここではCodePipelineの設定を更新するためにCodeBuildプロジェクトを追加していますが、ここはLambda等でも実現可能だと思います。

CodeCommit -> EventBridge -> CodeBuild -> CodePipeline
                                                |
                                           CodeCommit -> CodeBuild

確認

今回は以下のリソースを用意しました。

  • Amazon EventBridge
  • AWS CodeCommit: テスト用の2つのファイルと buildspec.yml を用意
  • AWS CodeBuild:
    • パイプライン起動用: BRANCH_NAME という環境変数を設定
    • 実処理用: main ブランチをターゲットに設定
  • AWS CodePipeline: 上記CodeCommit/CodeBuild (実処理用) を指定

まずCodePipelineを実行するCodeBuildプロジェクトは start-pipeline-at-pr.yaml というファイルを使用します。pipeline-update.sh というスクリプトを用意し、引数にターゲットのブランチ名を渡します。

start-pipeline-at-pr.yaml

version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.9
    commands:
      - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"  > /dev/null
      - unzip awscliv2.zip > /dev/null
      - ./aws/install --bin-dir /root/.pyenv/shims --install-dir /usr/local/aws-cli --update > /dev/null
      - aws --version
  pre_build:
    commands:
      - echo Update Pipeline config
      - sh ./pipeline-update.sh $BRANCH_NAME
  build:
    commands:
      - echo Execute Pipeline
      - aws codepipeline start-pipeline-execution --name test-pipeline-20230504

CodePipelineを起動するCodeBuildが呼び出す pipeline-update.sh は以下の通りです。CodeCommitイベントは sourceReference というパラメータにPR作成元のブランチ名を含むのでこれを利用します。このブランチ名には refs/head/ という文字列が含まれており、このままブランチ名として指定することはできないため、これを取り除く必要があります。

pipeline-update.sh

#!/bin/bash
TARGET_BRANCH_PATH=$1
PIPELINE_NAME="test-pipeline-20230504"

echo "start updating codepipeline"
echo "get current pipeline config"

if [[ "$TARGET_BRANCH_PATH" == */* ]]; then
  TARGET_BRANCH_NAME=${TARGET_BRANCH_PATH##*/}
else
  TARGET_BRANCH_NAME=$TARGET_BRANCH_PATH
fi

aws codepipeline get-pipeline --name $PIPELINE_NAME --query pipeline > pipeline.json

echo "edit pipeline json file"
sed -i "s/\"BranchName\".*/\"BranchName\": \"$TARGET_BRANCH_NAME\",/" pipeline.json

echo "update pipeline config"
aws codepipeline update-pipeline --pipeline file://pipeline.json 2>&1 1>/dev/null

上記スクリプトに渡すブランチ名は、CodeBuildの環境変数である BRANCH_NAME を利用します。この変数はPRごとに変わるため、以前も利用したEventBridge Input Transformationを利用します。今回は以下のような設定を追加し、環境変数の上書きを行っています。

入力パス

{
  "sourceVersion": "$.detail.sourceReference"
}

入力テンプレート

{
  "sourceVersion": "<sourceVersion>",
  "environmentVariablesOverride": [
    {
      "name": "BRANCH_NAME",
      "value": "<sourceVersion>",
      "type": "PLAINTEXT"
    }
  ]
}

CodePipelineが実行するCodeCommit/CodeBuildの内容は以前と同様です。

CodeBuild buildspec.yml

version: 0.2

phases:
  build: 
    commands:
      - echo Check test01
      - cat test01.txt
      - echo Check test02
      - cat test02.txt

なお、CodePipelineをAWSマネージドコンソール画面から作成した場合、作成時のブランチをターゲットにしたEventBridgeが作成されます。これが残っていると対象のブランチ上に変更が走るたびにCodePipelineが起動するので、不要であればこれを無効化・削除してください。


この状態で以下のPull Requestを作成します。

Pull Requestを作成するとEventBridgeが検知し、CodeBuildを起動します。スクリプトにいろいろと足りていないのでわかりにくいですが、実行後のログを確認すると、スクリプトに記載された内容を実行し、最後にパイプラインの更新が行われている様子を確認できます。

CodePipelineのほうを見ると、直前にパイプラインが実行されている様子を確認できます。またCodeCommitの設定を見ると、先ほどのPRで指定したブランチ名を指していることが確認できます。

最後にCodeBuildのほうを見ても、目的のブランチを対象にビルドが実行された様子が確認できました。

【メモ】EventBridge Input Transformationを利用してCodeBuild起動時のCommit IDを指定する

背景

以前PR作成時にCodeBuildでCommit IDを対象にビルドを開始する方法を検証したのですが、Amazon EventBridgeのInput Transformationを使うと同じ事が簡単にできることを知ったので、今回はこちらを試してみます。

方法

Input Transformationとは、EventBridgeが情報をターゲットに渡す前に、イベントからの情報をカスタマイズする機能です。

以前はEventBridgeのイベントからsourceCommitというパラメータを取り出し、Lambda関数からCodeBuildを起動する際にこれを渡すことで実現しました。しかしこの時はCodeBuildを起動するためにLambda関数を利用しており、ここで余計なリソースを使っているとも言えます。

今回はLambda関数を介さずEventBridgeとCodeBuildを直接接続することができるため、余分なLambda関数を削除できます。

確認

今回は以下のリソースを用意しました。

  • Amazon EventBridge: 前回のイベントパターンに加え、後述するInput Transformationの設定を追加します。
  • AWS CodeBuild: デフォルトでは main ブランチをターゲットに設定します。
  • AWS CodeCommit: 事前にテスト用のファイルと buildspec.yml を用意します。buildspec.yml前回と同じものを使用しました。

今回はEventBridgeのInput Transformationに以下のような設定を入れています。設定画面にある 入力パス はイベントから取り出す変数を定義し、 入力テンプレート にはターゲットに渡す情報のテンプレートを定義します。ここではEventBridgeのイベントから sourceCommit の情報を取り出し、sourceVersion キーに対応する値としてCodeBuildに渡します。

この状態で以下のPull Requestを作成します。

Pull Requestを作成するとEventBridgeが検知し、CodeBuildを起動します。実行後のログを確認すると、Pull Requestの修正内容が表示されていることを確認できます。

最後にCodeBuildに記載されるCommit IDを見ると、Pull Request作成時のCommit IDが使われていることが確認できます。またCodeBuild画面の イニシエーター を見ると、前回はここがLambda関数が記載されていたのが、今回はEventBridgeのルール名に変わっています。ここからもEventBridgeからCodeBuildが直接呼ばれていることがわかります。

参考

【メモ】CodeCommit上でPR作成時に変更のあったファイルに合わせてCodeBuildの処理を変更する

背景

CI/CDを実現するために各種ソフトウェアを設定するなかで、変更のあったファイルに合わせて起動するジョブを変更したいときがあります。

例えばファイルによって適用するジョブを変更したい場合です。処理対象のファイルによって必要なコマンドライン引数が異なったり、ファイルごとに処理することでCI完了までの時間を短くしたいなどの理由から、変更するファイルによって起動するジョブを変えたいときがあります。

今回はAWS CodeCommit/CodeBuildを使った状況で、上記のようなファイルごとに処理を分ける方法を検証しました。

方法

CodeBuildでファイルごとに処理を分けるため、Pull Requestから変更のあったファイルを特定する対象のファイルを指定してCodeBuildのビルドを実行する、という2段階で処理することを考えました。

Pull Requestから変更のあったファイルを特定するには、例えば git diff コマンドを使う方法もあります。今回はAWSのサービスを使っているので、CodeCommitの GetDifferences というAPIを使うことにしました。このAPIは2つのCommit識別子 (ブランチ、タグ、Commit IDなど) を比較して、Responseとして差分を返します。この中には差分のあったファイルパスが含まれているので、これを後段の処理に使います。

また対象のファイルを指定してCodeBuildのビルドを実行するために、CodeBuildに環境変数を設定し、これを取得したファイルパスを上書きすることで実現しました。CodeBuildの StartBuild APIenvironmentVariablesOverride というパラメータで環境変数を上書きすることができます。ここに取得したファイルパスを指定し、CodeBuildの処理の中でこれを指定する形で処理しました。

確認

今回の動作確認のために以下のリソースを使用します。

  • Amazon EventBridge: 前回と同じイベントパターンを使用します。
  • AWS CodeBuild: main ブランチをターゲットに、TARGET_PATH環境変数に設定しました。
  • AWS CodeCommit: テスト用の2ファイルと buildspec.yml を用意しました。
  • AWS Lambda: IAMロールにCodeCommitの codecommit:GetDifferences 権限およびCodeBuildの StartBuild 権限を付与します。

またLambda関数および buildspec.yml は以下の通りです。

Lambda関数

import boto3

codecommit = boto3.client('codecommit')
codebuild = boto3.client('codebuild')

# 実行対象のCodeBuildプロジェクト名を指定
project = "test-codebuild-20230430"

def lambda_handler(event, context):
    response = codecommit.get_differences(
        repositoryName=event['detail']['repositoryNames'][0],
        beforeCommitSpecifier=event['detail']['destinationCommit'],
        afterCommitSpecifier=event['detail']['sourceCommit']
    )

    target_list = [difference['afterBlob']['path'] for difference in response['differences']]

    for target in target_list:
        build_response = start_codebuild(project, event['detail']['sourceCommit'], target)
        print('%s for %s is %s at %s' % (build_response['build']['projectName'], target, 
            build_response['build']['buildStatus'], build_response['build']['sourceVersion']))
    
    return {}
    
def start_codebuild(ProjectName, CommitID, target_path):
    response = codebuild.start_build(
        projectName=ProjectName,
        sourceVersion=CommitID,
        environmentVariablesOverride=[
            {
                'name': 'TARGET_PATH',
                'value': target_path,
                'type': 'PLAINTEXT'
            },
        ],
    )
    return response

buildspec.yml

version: 0.2

phases:
  build: 
    commands:
      - echo Check target file name
      - echo $TARGET_PATH
      - echo Print target file
      - cat $TARGET_PATH

まず1つのファイルに変更のあった場合を見るため、以下のPull Requestを作成します。ここでは test01.txt に文字列を追加します。

上記Pull Requestの作成をトリガーに、EventBridgeからLambda関数を実行します。Lambda関数の中で変更のあったファイルを取得して TARGET_PATH を設定し、CodeBuildのビルドを開始します。CodeBuildの実行結果を見ると、Pull Requiestで変更のあった test01.txt の内容だけが出力されます。

次に複数のファイルに変更があった場合を見るため、以下のPull Requestを作成します。

Pull Request作成後にCodeBuildの管理画面を見ると、以下のような2つのビルドが実行完了したことを確認できます。

それぞれの結果を見ると、 test01.txt test02.txt それぞれの内容が個別に出力されていることが確認できます。

test01.txt

test02.txt

最後にCodeBuildに記載されるCommit IDを見ると、Pull Request作成時のCommit IDが使われていることが確認できます。

参考

【メモ】CodeCommit上でPR作成時にLambdaからCodeBuildを起動して特定のコミットにビルドを実行する

背景

以前AWS CodeCommit/CodeBuildを用いたCI/CDを試した際、PRの作成をトリガーにCodeBuildを実行するために、Amazon EventBridgeを使用しました。この時にやりたかったことを並べると、以下のようになります。

  1. CodeCommitで特定のブランチ宛にPull Requestを作成すると、EventBridgeがそれを検知する
  2. EventBridgeはPR関連の情報をCodeBuildへ送る
  3. CodeBuildは受け取ったデータをもとに、PRのコミットに対してビルドを実行する

この時、EventBridgeはCodeCommitの pullRequestCreated というイベントをトリガーに、CodeBuildのビルドを開始するよう設定しておりました。これにより上記項目の1・2番は実現できています。

またCodeBuildは、Projectの設定を上書きしてビルドを実行できます。この時に特定のブランチやCommit IDを指定することで、Pull Requestに含まれる修正に対してCodeBuildを実行できます(項目の3番に該当)。

しかし以前の投稿時は、CodeBuildのほうは特に何も設定をしていなかったため、PRを作成しても想定通りには動かず、実際はデフォルトで設定された main ブランチを対象にビルドを実行していました。これにはだいぶ後になって気づいたのですが、今思うと「EventBridgeのほうでPull Requestを検知しているのだから、CodeBuildのほうもいい感じに処理してくれるだろう」と、なにか勘違いをしていたのでしょう。。。

ということで今回は、PR作成時にLambdaからCodeBuildを起動して特定のコミットにビルドを実行する、という方法を試してみました。

方法

大まかな処理の流れは以下になります。

CodeCommit -> EventBridge -> Lambda -> CodeBuild

すでに記載したとおり、CodeBuildはデフォルトの設定を上書きしてビルドを実行できます。CodeBuildの開始は StartBuild というAPIから実行でき、この時に対象のCommit IDを sourceVersion パラメータに渡せます。なのでこれを使えば今回やりたいことを実現できるのでは、と考えました。

またEventBridgeのターゲットはCodeBuildだけでなくAWSの多様なサービスに対応しており、その中にはAWS Lambdaも含まれます。今回はPythonを使ってLambda関数を定義し、上記 StartBuild APIを呼び出しました。

なお、Commit IDの情報はEventBridgeから渡されるデータから取り出します。EventBridgeがCodeCommitから渡されるデータには sourceCommit というパラメータがあり、ここにはPRの作成に使用されたソースブランチのCommit IDが含まれています。これを StartBuild 開始時に渡すことで実現しました。

確認

今回は以下のリソースを用意しました。

  • Amazon EventBridge: 後述のイベントパターンを使用します。
  • AWS Lambda: StartBuild のため、IAMロールにCodeBuildの実行権限を付与します。
  • AWS CodeBuild: デフォルトでは main ブランチをターゲットに設定しました。
  • AWS CodeCommit: 事前にテスト用のファイルと buildspec.yml を用意します。

なお、EventBridgeのイベントパターン、Lambda関数、 buildspec.yml はそれぞれ以下になります。

EventBridgeイベントパターン

{
  "detail-type": ["CodeCommit Pull Request State Change"],
  "resources": ["arn:aws:codecommit:ap-northeast-1:123456789012:test-codecommit-20230430"],
  "source": ["aws.codecommit"],
  "detail": {
    "pullRequestStatus": ["Open"],
    "destinationReference": ["refs/heads/main"],
    "event": ["pullRequestCreated"]
  }
}

Lambda関数

import boto3

# テスト用のCodeBuildプロジェクト名を指定
target_project_name = "test-codebuild-20230430"

def lambda_handler(event, context):
    client = boto3.client('codebuild')
    
    response = client.start_build(
        projectName=target_project_name,
        sourceVersion=event['detail']['sourceCommit']
    )
    
    return {
        'projectNameStaticAnalysis': response['build']['projectName'],
        'sourceVersionStaticAnalysis': response['build']['sourceVersion'],
        'buildStatusStaticAnalysis': response['build']['buildStatus']
    }

buildspec.yml

version: 0.2

phases:
  build: 
    commands:
      - echo Check test01
      - cat test01.txt
      - echo Check test02
      - cat test02.txt

PRを作成した場合の前に、試しにテスト用のCodeBuildプロジェクト上ビルドを実行します。特にパラメータ等を変更しない場合 main ブランチ上の最新のコミットに対してビルドを実行します。

次にPull Requestを作成した場合です。ここでは test01.txt に対して以下の修正を加えました。

Pull Requestを作成するとEventBridgeがそれを検知してLambdaが実行され、CodeBuildが開始されます。この時のCodeBuildのログを見ると、確かにPull Requestに含まれる修正内容が反映されています。

またこの時のビルドの情報を見ると、Pull Request作成時のCommit IDが指定されていることも確認できます。

以上の通り、想定通り動作することを確認しました。

Reloaderを試す

Kubernetes上のアプリケーションに外部から秘匿情報やパラメータを与える方法の一つとして、ConfigMap/Secretに情報を記載し、環境変数として渡すことができます。一方でこの方法を採用した場合、ConfigMap/Secretに記載する変数を更新すると、これをPodに反映するには、Podの再起動を実行する必要があります。

ここでReloaderというOSSを利用すると、ConfigMap/Secret更新時にPodの再起動を手動で行う手間を省くことができます。

Reloaderは、クラスター上にあるConfigMap/Secretを監視し、これらの更新を検知すると、特定のDeployment/DaemonSet/StatefulSetを更新します。これにより新規Podが作成され、自動的にPodに修正内容を反映してくれます。監視対象のConfigMap/Secret、更新対象のDeploymentなどは個別に指定することも可能なため、必要に応じて柔軟に設定することが可能です。

Reloaderの機能自体はとてもシンプルですが、ArgoCD/Argo Rollouts/Sealed SecretなどのOSSとも組み合わせて使えるようサポートされており、様々な場面で利用できることが期待できます。

なお、同じような機能を提供するOSSはいくつか存在しますが、今回は調べた時点で最もGitHubスター数の多いReloaderを使用しました。

Reloaderを動かす

ではReloaderを実際に使ってみます。今回はREADMEにも案内されている3通りの方法でPodに変更内容の反映を行います。

先に大まかな使い分け方を記載します。

Workloadへの指定 ConfigMap/Secretへの指定 対象のConfigMap/Secret
1 reloader.stakater.com/auto: "true" - Workloadの使用するすべてのConfigMap/Secretが対象
2 reloader.stakater.com/search: "true" reloader.stakater.com/match: "true" Workloadが利用するうち、Annotationの付与されたもののみ
3 configmap.reloader.stakater.com/reload: "<ConfigMap名>" secret.reloader.stakater.com/reload: "Secret名" - Workloadが利用しないものも含め、Annotationsで指定するConfigMap/Secretが対象

インストール

Reloaderをインストールする前に、Kubernetesクラスター上にテスト用のConfigMap/Secret/Deploymentリソースを作成しておきます。

# ConfigMap
$ kubectl get configmap
NAME               DATA   AGE
kube-root-ca.crt   1      14m
test-configmap     1      29s

# Secret
$ kubectl get secret
NAME                  TYPE                                  DATA   AGE
default-token-4cbzx   kubernetes.io/service-account-token   3      14m
test-secret           Opaque                                1      27s

# Pod
$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
reloader-test-7c55cb5568-6xqkf   1/1     Running   0          23s
reloader-test-7c55cb5568-knf2z   1/1     Running   0          23s
reloader-test-7c55cb5568-nb56f   1/1     Running   0          23s

# Podに反映されている変数
$ kubectl exec -it reloader-test-7c55cb5568-6xqkf -- printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=reloader-test-7c55cb5568-6xqkf
TERM=xterm
SECRET-ENV=secret1234

CONFIGMAP-ENV=configmap1234

(以降割愛)

Reloaderをインストールします。インストールには素のYamlファイルのほか、Kustomize・Helmチャートでの方法も案内されています。

$ kubectl apply -f https://raw.githubusercontent.com/stakater/Reloader/master/deployments/kubernetes/reloader.yaml
serviceaccount/reloader-reloader created
clusterrole.rbac.authorization.k8s.io/reloader-reloader-role created
clusterrolebinding.rbac.authorization.k8s.io/reloader-reloader-role-binding created
deployment.apps/reloader-reloader created


$ kubectl get pods
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          103s
reloader-test-7c55cb5568-6xqkf       1/1     Running   0          5m3s
reloader-test-7c55cb5568-knf2z       1/1     Running   0          5m3s
reloader-test-7c55cb5568-nb56f       1/1     Running   0          5m3s

1. Pod側にautoを指定

Reloaderを利用する際は、対象のDeploymentやConfigmap/Secretに特定のAnnotationsを付与し、Reloaderによる処理を指定することができます。例えばDeploymentにreloader.stakater.com/auto: "true"というAnnotationを付与すると、Reloaderが対象のDeploymentを検知し、Deploymentの参照するConfigMap/Secretが更新されれば、Deploymentの管理するPodを更新します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: reloader-test
  labels:
    app: nginx
  annotations:
    reloader.stakater.com/auto: "true"

(以降割愛)

Deploymentに上記Annotationを追加後、事前にアプライしておきます。

$ kubectl apply -f deployment.yaml
deployment.apps/reloader-test configured

ここではConfigMapの値を修正し、更新します。この時Podの状態を見ていると、新しいPodが作成されていることを確認できます。

# ConfigMapを修正
$ kubectl apply -f configmap.yaml
configmap/test-configmap configured

# Podが作成される
$ kubectl get pods -w
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          4m10s
reloader-test-7c55cb5568-6xqkf       1/1     Running   0          7m30s
reloader-test-7c55cb5568-knf2z       1/1     Running   0          7m30s
reloader-test-7c55cb5568-nb56f       1/1     Running   0          7m30s



reloader-test-74d4bfb55c-m75s9       0/1     Pending   0          0s
reloader-test-74d4bfb55c-m75s9       0/1     Pending   0          0s
reloader-test-74d4bfb55c-m75s9       0/1     ContainerCreating   0          0s
reloader-test-74d4bfb55c-m75s9       1/1     Running             0          3s
reloader-test-7c55cb5568-6xqkf       1/1     Terminating         0          8m32s
reloader-test-74d4bfb55c-dhwr9       0/1     Pending             0          0s
reloader-test-74d4bfb55c-dhwr9       0/1     Pending             0          0s
reloader-test-74d4bfb55c-dhwr9       0/1     ContainerCreating   0          0s
reloader-test-7c55cb5568-6xqkf       0/1     Terminating         0          8m33s
reloader-test-7c55cb5568-6xqkf       0/1     Terminating         0          8m33s
reloader-test-7c55cb5568-6xqkf       0/1     Terminating         0          8m33s
reloader-test-74d4bfb55c-dhwr9       1/1     Running             0          3s
reloader-test-7c55cb5568-nb56f       1/1     Terminating         0          8m35s
reloader-test-74d4bfb55c-28ww7       0/1     Pending             0          0s
reloader-test-74d4bfb55c-28ww7       0/1     Pending             0          0s
reloader-test-74d4bfb55c-28ww7       0/1     ContainerCreating   0          0s
reloader-test-7c55cb5568-nb56f       0/1     Terminating         0          8m36s
reloader-test-7c55cb5568-nb56f       0/1     Terminating         0          8m36s
reloader-test-7c55cb5568-nb56f       0/1     Terminating         0          8m36s
reloader-test-74d4bfb55c-28ww7       1/1     Running             0          3s
reloader-test-7c55cb5568-knf2z       1/1     Terminating         0          8m38s
reloader-test-7c55cb5568-knf2z       0/1     Terminating         0          8m39s
reloader-test-7c55cb5568-knf2z       0/1     Terminating         0          8m39s
reloader-test-7c55cb5568-knf2z       0/1     Terminating         0          8m39s

また更新後にDeploymentの状態を見ると、 STAKATER_TEST_CONFIGMAP_CONFIGMAP という変数が追加されていることがわかります。ReloaderはDeployment/DaemonSet/StatefulSetを更新するとき、デフォルトでは特定の環境変数を追加することで更新します。これ以外に、特定のAnnotationを追加することで更新する方法を選択することも可能で、ArgoCDなどを利用する場合はこちらが推奨されます。

$ kubectl exec -it reloader-test-74d4bfb55c-28ww7 -- printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=reloader-test-74d4bfb55c-28ww7
TERM=xterm
SECRET-ENV=secret1234

CONFIGMAP-ENV=configmap5678
STAKATER_TEST_CONFIGMAP_CONFIGMAP=9ff99bd578c45053d076640b122ec309c61d3b7e

(以降割愛)

※参考: GitHub - stakater/Reloader: Reload Strategies

2. Pod側にsearch, ConfigMap/Secret側にmatchを指定

reloader.stakater.com/auto というAnnotationはDeploymentの使用するConfigMap/Secretのいずれかが更新するとPodの作成を行いますが、監視対象とするConfigMap/Secretを特定することも可能です。ConfigMap/Secretを指定する方法の一つに、 reloader.stakater.com/match: "true" というAnnotationをConfigMap/Secretに、 reloader.stakater.com/search: "true" というAnnotationをDeploymentに付与する方法があります。

この設定は、searchmatch の両方が設定されており、かつConfigMap/SecretはDeploymentなどが利用しているものを指定しなければ動作しません。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: reloader-test
  labels:
    app: nginx
  annotations:
    reloader.stakater.com/search: "true"

(以降割愛)
apiVersion: v1
kind: Secret
metadata:
  name: test-secret
  annotations:
    reloader.stakater.com/match: "true"

(以降割愛)

ここでは、SecretにのみAnnotationを付与し、ConfigMap/Secret両方を更新したときの挙動を見てみます。

# 事前にAnnotationの変更を反映しておく
$ kubectl apply -f deployment.yaml
deployment.apps/reloader-test configured

$ kubectl apply -f secret.yaml
secret/test-secret configured

すると、Configmapを更新したときは何の変化もないのに対し、Secretを更新したときはPodが作成される様子を確認できます。

# configmapを更新してもpodは再起動しない
$ kubectl apply -f configmap.yaml
configmap/test-configmap configured

$ kubectl get pods -w
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          12m
reloader-test-74d4bfb55c-28ww7       1/1     Running   0          7m18s
reloader-test-74d4bfb55c-dhwr9       1/1     Running   0          7m21s
reloader-test-74d4bfb55c-m75s9       1/1     Running   0          7m24s



# secretを更新するとpodは再起動する
$ kubectl apply -f secret.yaml
secret/test-secret configured

$ kubectl get pods -w
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          12m
reloader-test-74d4bfb55c-28ww7       1/1     Running   0          7m18s
reloader-test-74d4bfb55c-dhwr9       1/1     Running   0          7m21s
reloader-test-74d4bfb55c-m75s9       1/1     Running   0          7m24s


reloader-test-58d84bcfd7-9vdnf       0/1     Pending   0          0s
reloader-test-58d84bcfd7-9vdnf       0/1     Pending   0          0s
reloader-test-58d84bcfd7-9vdnf       0/1     ContainerCreating   0          0s
reloader-test-58d84bcfd7-9vdnf       1/1     Running             0          3s

(以降割愛)

3. Pod側にConfigMap/Secretを指定

ConfigMap/Secretを指定する2つ目の方法として、 configmap.reloader.stakater.com/reload secret.reloader.stakater.com/reload を使う例になります。こちらはDeploymnetなどに対象のConfigMap/Secretの名称を指定する形式になります。そのため、例えばDeploymentが利用するConfigMapの1つだけを指定することで、それ以外のConfigMap/Secretが更新されてもPodの作成は発生しません。

ここでは以下のようなAnnotationをDeploymentに付与した状態で、ConfigMap/Secretの更新を行います。ConfigMapのほうは正しい名称ですが、Secretのほうは誤った名称を指定しています。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: reloader-test
  labels:
    app: nginx
  annotations:
    secret.reloader.stakater.com/reload: "test-secret-dummy"
    configmap.reloader.stakater.com/reload: "test-configmap"

(以降割愛)
# 事前にAnnotationの変更を反映しておく
$ kubectl apply -f deployment.yaml
deployment.apps/reloader-test configured

$ kubectl apply -f secret.yaml
secret/test-secret configured

この状態でConfigMap/Secretを更新します。すると、Secretを更新しても (Deployment Annotationで指定されていないため) Podの作成は発生しませんが、ConfigMapはPodの作成が発生します。

# secretは更新しても再起動しない
$ kubectl apply -f secret.yaml
secret/test-secret configured

$ kubectl get pods -w
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          30m
reloader-test-58d84bcfd7-9vdnf       1/1     Running   0          14m
reloader-test-58d84bcfd7-dlsvn       1/1     Running   0          14m
reloader-test-58d84bcfd7-nn6bk       1/1     Running   0          14m



# configmapを更新するとpodは再起動される
$ kubectl apply -f configmap.yaml
configmap/test-configmap configured

$ kubectl get pods -w
NAME                                 READY   STATUS    RESTARTS   AGE
reloader-reloader-69b9d44fc7-bq7b4   1/1     Running   0          30m
reloader-test-58d84bcfd7-9vdnf       1/1     Running   0          14m
reloader-test-58d84bcfd7-dlsvn       1/1     Running   0          14m
reloader-test-58d84bcfd7-nn6bk       1/1     Running   0          14m

reloader-test-d48cfc84b-k5h5k        0/1     Pending   0          0s
reloader-test-d48cfc84b-k5h5k        0/1     Pending   0          0s
reloader-test-d48cfc84b-k5h5k        0/1     ContainerCreating   0          0s
reloader-test-d48cfc84b-k5h5k        1/1     Running             0          3s

(以降割愛)

なお、こちらの方法で指定できるのは、Deploymentから利用しているConfigMap/Secretだけに限らないようで、試しにDeploymentの使用していないSecretなどをAnnotationに指定したところ、指定のSecretの更新に合わせてPodが作成される様子を確認しました。

# 不要なSecretを作成
$ kubectl apply -f secret-2.yaml
secret/test-secret-2 created

# Deployment Annotationを修正
$ head -10 deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reloader-test
  labels:
    app: nginx
  annotations:
    secret.reloader.stakater.com/reload: "test-secret-2"
    #configmap.reloader.stakater.com/reload: "test-configmap"
spec:

$ kubectl apply -f deployment.yaml
deployment.apps/reloader-test configured

$ kubectl get pods
NAME                                READY   STATUS    RESTARTS   AGE
reloader-reloader-bf77955f4-2x7fp   1/1     Running   0          62s
reloader-test-7c55cb5568-cfjqr      1/1     Running   0          10s
reloader-test-7c55cb5568-fq9xv      1/1     Running   0          10s
reloader-test-7c55cb5568-twwqh      1/1     Running   0          10s

# secret-2を更新
$ kubectl apply -f secret-2.yaml
secret/test-secret-2 configured

# Podが作成される
$ kubectl get pods -w
NAME                                READY   STATUS              RESTARTS   AGE
reloader-reloader-bf77955f4-2x7fp   1/1     Running             0          90s
reloader-test-7c55cb5568-cfjqr      1/1     Running             0          38s
reloader-test-7c55cb5568-fq9xv      1/1     Running             0          38s
reloader-test-c84db947-dhxlc        0/1     ContainerCreating   0          1s
reloader-test-c84db947-lfh7k        1/1     Running             0          4s
reloader-test-c84db947-dhxlc        1/1     Running             0          3s
reloader-test-7c55cb5568-fq9xv      1/1     Terminating         0          40s
reloader-test-c84db947-crc4l        0/1     Pending             0          0s
reloader-test-c84db947-crc4l        0/1     Pending             0          0s
reloader-test-c84db947-crc4l        0/1     ContainerCreating   0          0s

(以降割愛)

Kubernetes External SecretsからExternal Secrets Operatorへの移行方法を調査する

今回は、Kubernetes External SecretsからExternal Secrets Operatorへ移行する際のポイントを調査しました。

前回触れたExternal Secrets Operator (以降ESO) は、同様の機能を提供する Kubernetes External Secrets (以降KES) から移行先として推奨されているプロダクトです。そのため、すでにKESを使っているプロジェクトではESOへの移行を検討する必要があります。

今回は、KESからESOへ移行する際に注意するべき点や、実際の移行方法について調査・検証しました。なお、KESからESOへの移行は、すでにProduction環境で実施している例がいくつかあるため、そちらも参考にしてください。

※参考:

移行時のポイント

今回調査してわかった結論を先に書いておきます。

  • KESインストール時にリソース名を変更している場合、ESOのリソース名と重複する可能性があるため、必要に応じてESO側のリソース名を変更する
  • KESとESOの扱う ExternalSecretAPIが異なるため、同一クラスターにリソースを作成しても問題ない
  • KESとESOとで同じ名称のSecretを扱う場合、Secret中の metadata.ownerReferences を変更する必要がある。これを避けたい場合はKESとESOで異なるSecret名にする必要がある。

移行方法の概要

KESからESOへの移行を行う際のパターンはいくつかありそうですが、大きくは KESの管理する既存のSecretをESOが管理するよう移行する か、 ESO用の新しい名称のSecretリソースを使う かに分かれそうです。

ESOのドキュメントには、KESからESOへの移行をサポートする kes-to-eso というツールが紹介されています。このツールは、KES・ESOがどちらもインストールされていることを前提に、既存のKES ExternalSecret の設定をもとに、ESO SecretStore ExternalSecret のテンプレートを生成・デプロイします。そのため、このツールによる移行は、KESの管理するSecretをESOが管理するように移行する方法となります。

上記ツールによるAutomatic migrationを実行すると、以下のような動きで移行を実行します。


  • クラスター上のKES ExternalSecrets リソースの情報からマニフェストファイルを作成し、 kes_files フォルダに保存する
  • ESO replicasetのPod数をゼロにスケールインする
  • KES ExternalSecrets ファイルからESO SecretStore ExternalSecrets を生成し、 eso_files フォルダに保存する
  • ESO SecretStore ExternalSecrets リソースをクラスター上に作成する
  • KES replicasetのPod数をゼロにスケールインする
  • KESの管理するSecretリソースからKESのownershipを削除する
  • ESO replicasetのPod数を1にスケールアウトする

一方、ESOが管理するSecretは別名で用意し、アプリケーションは新しいSecretを見るように変更する方法もあります。こちらは最後のほうで少し触れます。

今回は kes-to-eso ツールをもとにKESからESOへの移行方法を検討し、以下のような流れでの作業を実施します。また作業前にいくつか疑問点が生じたので、そちらの解消も行います。

  1. ESOをKESと同じクラスター上にインストールする
  2. SecretStore ExternalSecret をデプロイする
  3. KESの扱う既存SecretからKESのownershipを削除する
  4. KES関連リソースを削除する

前提条件

前提として、今回は 移行前後で同じ名称のSecretリソースを更新し、既存のPodの設定に変更が入らないようにする こととしました。

また、KES/ESOがそれぞれAWSリソースにアクセスするための権限ですが、KESはあらかじめEKSノードにSecrets Managerへのアクセス権を付与して、ESOはAWSアカウント情報を持つSecretリソースを別に用意する形で付与しました。なお、IRSAを使用した場合については後述します。

検証環境

KESからESOへの移行を試すため、あらかじめKESをインストールしたKubernetesクラスターを用意します。

使用する環境は Amazon EKS ver 1.22になります。

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.0", GitCommit:"c2b5237ccd9c0f1d600d3072634ca66cefdf272f", GitTreeState:"clean", BuildDate:"2021-08-04T18:03:20Z", GoVersion:"go1.16.6", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"22+", GitVersion:"v1.22.16-eks-ffeb93d", GitCommit:"52e500d139bdef42fbc4540c357f0565c7867a81", GitTreeState:"clean", BuildDate:"2022-11-29T18:41:42Z", GoVersion:"go1.16.15", Compiler:"gc", Platform:"linux/amd64"}

また、KES/ESOが扱うAWS Secrets Managerは以下になります。

$ aws secretsmanager get-secret-value --secret-id test-secret
{
    "ARN": "arn:aws:secretsmanager:ap-northeast-1:111111111111:secret:test-secret-Rn6ufG",
    "Name": "test-secret",
    "VersionId": "93ed3aed-6048-41da-afec-48da94566aec",
    "SecretString": "{\"password\":\"1234\"}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2023-01-07T20:54:39.190000+09:00"
}

検証を始める前に、あらかじめKESをインストールしておきます。KESのインストールにはHelmパッケージを利用すればよいですが、今回は helm template から生成したマニフェストファイル群を利用しました。

# templateファイルの生成
$ git clone https://github.com/external-secrets/kubernetes-external-secrets.git
$ cd kubernetes-external-secrets
$ helm template --output-dir ./testdir ./charts/kubernetes-external-secrets/

# KESのインストール
$ kubectl apply -f ./charts/kubernetes-external-secrets/crds
$ kubectl apply -f ./testdir/kubernetes-external-secrets/templates/


# インストール後の確認
$ kubectl get crd
NAME                                         CREATED AT
eniconfigs.crd.k8s.amazonaws.com             2023-01-14T00:26:18Z
externalsecrets.kubernetes-client.io         2023-01-14T00:39:36Z
securitygrouppolicies.vpcresources.k8s.aws   2023-01-14T00:26:22Z

$ kubectl get pods
NAME                                                        READY   STATUS    RESTARTS   AGE
release-name-kubernetes-external-secrets-754cc56b8f-xg2dp   1/1     Running   0          13s

KESインストール後は ExternalSecret リソースに加え、生成されるSecretを扱うDeploymentも作成しておきます。

# ExternalSecretリソースの作成
$ kubectl apply -f externalsecret-kes.yaml
externalsecret.kubernetes-client.io/test-secret created

$ kubectl get externalsecret
NAME          LAST SYNC   STATUS    AGE
test-secret   3s          SUCCESS   12m

$ kubectl get secret
NAME                                                   TYPE                                  DATA   AGE
default-token-6fbtj                                    kubernetes.io/service-account-token   3      44m
release-name-kubernetes-external-secrets-token-wf77j   kubernetes.io/service-account-token   3      31m
test-secret                                            Opaque                                1      30m

$ kubectl get secret test-secret -oyaml
apiVersion: v1
data:
  password: MTIzNA==  # "1234"
kind: Secret
metadata:
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  ownerReferences:
  - apiVersion: kubernetes-client.io/v1
    controller: true
    kind: ExternalSecret
    name: test-secret
    uid: 3821d90f-02d2-454d-8fba-704efeab81f7
  resourceVersion: "5639"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque


# 上記Secretを利用するDeploymentの作成
$ kubectl apply -f kes-to-eso-deploy.yaml
deployment.apps/kes-to-eso-deploy created

$ kubectl get pods
NAME                                                        READY   STATUS    RESTARTS   AGE
kes-to-eso-deploy-5cfb75cd85-hrksm                          1/1     Running   0          13s
kes-to-eso-deploy-5cfb75cd85-mxlpl                          1/1     Running   0          13s
release-name-kubernetes-external-secrets-754cc56b8f-6q2gg   1/1     Running   0          30m


$ kubectl exec -it kes-to-eso-deploy-5cfb75cd85-hrksm -- printenv | grep password
password=1234

externalsecret-kes.yaml

apiVersion: kubernetes-client.io/v1
kind: ExternalSecret
metadata:
  name: test-secret
spec:
  backendType: secretsManager
  region: ap-northeast-1
  dataFrom:
    - test-secret

kes-to-eso-deploy.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kes-to-eso-deploy
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: kes-to-eso-container
        image: nginx
        env:
        - name: password
          valueFrom:
            secretKeyRef:
              name: test-secret
              key: password

ESO SecretStore ExternalSecret マニフェストファイルを作成する

今回使用したマニフェストファイルは以下の通りです。KESとESOの ExternalSecret は、リソース中で指定するパラメータが異なるため、移行前後で同じSecretの情報を扱うよう修正する必要があります。

※今回は試しませんでしたが、 kes-to-eso ツールでのManual migrationの手順を参照し、 kestoeso generate コマンドを使用すると、KESからESOに変換した SecretStore ExternalSecrets リソースが生成されるので、そちらを使うのもよさそうです。

secretstore-eso.yaml

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: secretstore-eso
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-1
      auth:
        secretRef:
          accessKeyIDSecretRef:
            name: awssm-secret
            key: access-key
          secretAccessKeySecretRef:
            name: awssm-secret
            key: secret-access-key

externalsecret-eso.yaml

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-secret
spec:
  refreshInterval: 5m
  secretStoreRef:
    name: secretstore-eso
    kind: SecretStore
  target:
    name: test-secret
    creationPolicy: Owner
  data:
  - secretKey: password
    remoteRef:
      key: test-secret
      property: password

※参考:

ESOをKESと同じクラスター上にインストールする

次にESOのインストールを行います。インストールを行う前に、KESとESOのリソースについて比較をしたうえで、ESOのインストールを行いました。

# templateファイルを生成
$ helm repo add external-secrets https://charts.external-secrets.io
$ helm template external-secrets external-secrets/external-secrets --output-dir .

# ESOのインストール
$ kubectl apply -f ./templates/crds/
$ kubectl apply -f ./templates/


# インストール後の確認
$ kubectl get pods
NAME                                                        READY   STATUS    RESTARTS       AGE
external-secrets-66dbbc7b45-xvmnh                           1/1     Running   2              5m59s
external-secrets-cert-controller-6c95b96976-8rzvf           1/1     Running   0              5m59s
external-secrets-webhook-647478f6dc-wkjq6                   1/1     Running   2 (112s ago)   5m59s
kes-to-eso-deploy-5cfb75cd85-hrksm                          1/1     Running   0              9m27s
kes-to-eso-deploy-5cfb75cd85-mxlpl                          1/1     Running   0              9m27s
release-name-kubernetes-external-secrets-754cc56b8f-6q2gg   1/1     Running   0              39m

疑問点: ESOとKESのリソース名は重複しないか

KESとESOをインストールするにはHelmパッケージが用意されています。 helm template コマンドでそれぞれ使用するファイル群を生成すると、以下のようになります。

# KES
$ tree .
.
├── crds
│   └── kubernetes-client.io_externalsecrets_crd.yaml
├── deployment.yaml
├── rbac.yaml
├── service.yaml
└── serviceaccount.yaml

# ESO
$ tree .
.
├── cert-controller-deployment.yaml
├── cert-controller-rbac.yaml
├── cert-controller-serviceaccount.yaml
├── crds
│   ├── acraccesstoken.yaml
│   ├── clusterexternalsecret.yaml
│   ├── clustersecretstore.yaml
│   ├── ecrauthorizationtoken.yaml
│   ├── externalsecret.yaml
│   ├── fake.yaml
│   ├── gcraccesstoken.yaml
│   ├── password.yaml
│   ├── pushsecret.yaml
│   └── secretstore.yaml
├── deployment.yaml
├── rbac.yaml
├── serviceaccount.yaml
├── validatingwebhook.yaml
├── webhook-deployment.yaml
├── webhook-secret.yaml
├── webhook-service.yaml
└── webhook-serviceaccount.yaml

上記のようにESOのほうがKESよりも多くのファイルを使用し、作成されるリソースの数もESOのほうが多いです。

また各リソース名を見てみると、KESにはHelmパッケージ利用時に指定できる release-name という文字列が含まれ、その後ろに kubernetes-external-secret と続きます。

※例: deployment.yaml

# Source: kubernetes-external-secrets/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: release-name-kubernetes-external-secrets
  namespace: "default"
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-8.5.5
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/managed-by: Helm

(以降割愛)

一方でESOのほうは release-name のような文字列はなく、KESと比較するとリソース名が重複するものはありませんでした。

※KESとESOのリソース名の比較表

リソースの種別 KESリソース名 ESOリソース名
Deployment release-name-kubernetes-external-secrets external-secrets
external-secrets-cert-controller
external-secrets-webhook
Service release-name-kubernetes-external-secrets external-secrets-webhook
Service Account release-name-kubernetes-external-secrets external-secrets
external-secrets-cert-controller
external-secrets-webhook
Secret external-secrets-webhook
Cluster Role release-name-kubernetes-external-secrets external-secrets-controller
external-secrets-view
external-secrets-edit
external-secrets-cert-controller
Cluster Role Binding release-name-kubernetes-external-secrets external-secrets-controller
release-name-kubernetes-external-secrets-auth external-secrets-cert-controller
Role external-secrets-leaderelection
Role Binding external-secrets-leaderelection
Validating Webhook Configuration secretstore-validate
externalsecret-validate
CRD externalsecrets.kubernetes-client.io externalsecrets.external-secrets.io
secretstores.external-secrets.io

このため、KESとESOを同じクラスター上にインストールする際、デフォルトではリソース名の重複などは気にする必要はなさそうです。ただKESのリソース名を修正している場合はこの限りではないため、必要に応じてESOのリソース名は修正が必要となります。

疑問点: ExternalSecret CRDは共存可能か

KESとESOは ExternalSecret という名称のCRDを使用します。実際にKESとESOを同じクラスター上にインストールすると、この2つのCRDが共存することは確認できます。

$ kubectl get crd
NAME                                                    CREATED AT
acraccesstokens.generators.external-secrets.io          2023-01-14T01:17:26Z
clusterexternalsecrets.external-secrets.io              2023-01-14T01:17:26Z
clustersecretstores.external-secrets.io                 2023-01-14T01:17:26Z
ecrauthorizationtokens.generators.external-secrets.io   2023-01-14T01:17:26Z
eniconfigs.crd.k8s.amazonaws.com                        2023-01-14T00:26:18Z
externalsecrets.external-secrets.io                     2023-01-14T01:17:26Z  # ESO
externalsecrets.kubernetes-client.io                    2023-01-14T00:39:36Z  # KES
fakes.generators.external-secrets.io                    2023-01-14T01:17:27Z
gcraccesstokens.generators.external-secrets.io          2023-01-14T01:17:27Z
passwords.generators.external-secrets.io                2023-01-14T01:17:27Z
pushsecrets.external-secrets.io                         2023-01-14T01:17:27Z
secretstores.external-secrets.io                        2023-01-14T01:17:27Z
securitygrouppolicies.vpcresources.k8s.aws              2023-01-14T00:26:22Z

それぞれの ExternalSecretリソースの定義を見ると、APIが異なっていることがわかります。このため、リソースの名称が同じでも、同時にCRDを作成することが可能となります。

なお、それぞれのリソースの状態を確認するコマンドは微妙に異なっているらしく、KES ExternalSecret リソースを見るには kubectl get externalsecret 、ESO ExternalSecret を確認するには kubectl get externalsecrets コマンドを使用します。

SecretStore/ExternalSecretをデプロイする

続いてSecretStore/ExternalSecretをデプロイします。

# AWSリソースにアクセスするための権限を付与
$ kubectl apply -f awssm-secret.yaml
secret/awssm-secret created


# Secret Storeのデプロイ
$ kubectl apply -f secretstore-eso.yaml
secretstore.external-secrets.io/secretstore-eso created

$ kubectl get secretstore
NAME              AGE   STATUS   CAPABILITIES   READY
secretstore-eso   7s    Valid    ReadWrite      True


# External Secretのデプロイ
$ kubectl apply -f externalsecret-eso.yaml
externalsecret.external-secrets.io/test-secret created

$ kubectl get externalsecrets.external-secrets.io
NAME          STORE             REFRESH INTERVAL   STATUS              READY
test-secret   secretstore-eso   5m                 SecretSyncedError   False

デプロイ後の状態を見ると、ESO ExternalSecret で以下のようなエラーが発生しています。これはKESが指定のSecretのownershipをすでに所有しており、ESOのownershipを追加できないために発生しています。

$ kubectl describe externalsecrets.external-secrets.io test-secret
Name:         test-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>
API Version:  external-secrets.io/v1beta1
Kind:         ExternalSecret
Metadata:

(一部割愛)

Events:
  Type     Reason        Age                From              Message
  ----     ------        ----               ----              -------
  Warning  UpdateFailed  0s (x15 over 83s)  external-secrets  could not set ExternalSecret controller reference: Object default/test-secret is already owned by another ExternalSecret controller test-secret

既存SecretからKESのownershipを削除する

ESO ExternalSecret のエラーを解消するため、Secretリソースの metadata.ownerReferences を修正します。今回はSecretリソース中の metadata.ownerReferences を削除しました。

$ kubectl edit secret test-secret
secret/test-secret edited


# 以下のようにmetadata.ownerReferencesの箇所を削除


# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving this file will be
# reopened with the relevant failures.
#
apiVersion: v1
data:
  password: MTIzNA==
kind: Secret
metadata:
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  resourceVersion: "5639"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque

ただし、KESが起動中に上記操作を行っても、KESがSyncすることで再び OwnerReferencesが追加されます

# 上記操作からしばらく後に状態を確認
$ kubectl get externalsecrets.kubernetes-client.io
NAME          LAST SYNC   STATUS    AGE
test-secret   1s          SUCCESS   51m

$ kubectl get externalsecrets.external-secrets.io
NAME          STORE             REFRESH INTERVAL   STATUS              READY
test-secret   secretstore-eso   5m                 SecretSyncedError   False

# ownershipが追加されている
$ kubectl get secret test-secret -oyaml
apiVersion: v1
data:
  password: MTIzNA==
kind: Secret
metadata:
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  ownerReferences:
  - apiVersion: kubernetes-client.io/v1
    controller: true
    kind: ExternalSecret
    name: test-secret
    uid: 3821d90f-02d2-454d-8fba-704efeab81f7
  resourceVersion: "13635"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque

これを避けるため、事前にKES Deploymentのレプリカ数をゼロにし、KESからSyncを行わないようにします。

$ kubectl get deploy
NAME                                       READY   UP-TO-DATE   AVAILABLE   AGE
external-secrets                           1/1     1            1           25m
external-secrets-cert-controller           1/1     1            1           25m
external-secrets-webhook                   1/1     1            1           25m
kes-to-eso-deploy                          2/2     2            2           29m
release-name-kubernetes-external-secrets   1/1     1            1           59m

# KESのレプリカ数をゼロに
$ kubectl scale deploy --replicas=0 release-name-kubernetes-external-secrets
deployment.apps/release-name-kubernetes-external-secrets scaled

$ kubectl get deploy
NAME                                       READY   UP-TO-DATE   AVAILABLE   AGE
external-secrets                           1/1     1            1           26m
external-secrets-cert-controller           1/1     1            1           26m
external-secrets-webhook                   1/1     1            1           26m
kes-to-eso-deploy                          2/2     2            2           30m
release-name-kubernetes-external-secrets   0/0     0            0           60m

再びSecretのownershipを修正します。これでしばらくすると、Secretリソースが更新され、ESOがOwnershipを持つようになります。ExternalSecretに発生していたエラーも解消されることが確認できます。

# Secretからmetadata.ownerReferencesを削除
$ kubectl edit secret test-secret
secret/test-secret edited


# Secretを修正してからしばらく後
$ kubectl get externalsecrets.external-secrets.io
NAME          STORE             REFRESH INTERVAL   STATUS         READY
test-secret   secretstore-eso   5m                 SecretSynced   True


$ kubectl describe externalsecrets.external-secrets.io test-secret
Name:         test-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>
API Version:  external-secrets.io/v1beta1
Kind:         ExternalSecret
Metadata:

(一部割愛)

Events:
  Type     Reason        Age                   From              Message
  ----     ------        ----                  ----              -------
  Warning  UpdateFailed  6m33s (x33 over 17m)  external-secrets  could not set ExternalSecret controller reference: Object default/test-secret is already owned by another ExternalSecret controller test-secret
  Normal   Updated       65s (x3 over 12m)     external-secrets  Updated Secret


# SecretにはESOのownershipが記載されている
$ kubectl get secret test-secret -oyaml
apiVersion: v1
data:
  password: MTIzNA==
kind: Secret
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"external-secrets.io/v1beta1","kind":"ExternalSecret","metadata":{"annotations":{},"name":"test-secret","namespace":"default"},"spec":{"data":[{"remoteRef":{"key":"test-secret","property":"password"},"secretKey":"password"}],"refreshInterval":"5m","secretStoreRef":{"kind":"SecretStore","name":"secretstore-eso"},"target":{"creationPolicy":"Owner","name":"test-secret"}}}
    reconcile.external-secrets.io/data-hash: aeb62059e67d377d0a0ff6afda1a82cf
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  ownerReferences:
  - apiVersion: external-secrets.io/v1beta1
    blockOwnerDeletion: true
    controller: true
    kind: ExternalSecret
    name: test-secret
    uid: 714a3d12-8869-4033-ad55-588e5789eabe
  resourceVersion: "15809"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque

ただし、ここで再度KESのレプリカ数を1に戻すと、今度はSecretリソースのownershipがKESに上書きされてしまうので、注意してください。

疑問点: ESOの creationPolicy: Merge は利用できるか

ESOは ExternalSecret リソースのパラメータで、Secretリソース作成時のポリシーを指定できます。このうち Merge を選択すると、Secretの新規作成は行わず、既存のSecretに値を追加する形での更新が可能となります。

当初、creationPolicy: Merge を利用すれば、KESとESOとが同じSecretリソースを管理する状態を実現できるのではないかと考えたのですが、ESOがデータを追加するのは異なるデータが存在する場合とのことだったので、残念ながら同じデータを参照している場合は利用できませんでした。

※参考: External Secrets Operator Doc - Lifecycle

creationPolicy: Merge の検証ログ

# ExternalSecretリソースを以下のように修正
$ cat externalsecret-eso.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: test-secret
spec:
  refreshInterval: 5m
  secretStoreRef:
    name: secretstore-eso
    kind: SecretStore
  target:
    name: test-secret
    #creationPolicy: Owner
    creationPolicy: Merge
  data:
  - secretKey: password
    remoteRef:
      key: test-secret
      property: password


$ kubectl apply -f externalsecret-eso.yaml
externalsecret.external-secrets.io/test-secret configured


# ExternalSecretのStatusはSyncできたと表示される
$ kubectl get externalsecrets.external-secrets.io
NAME          STORE             REFRESH INTERVAL   STATUS         READY
test-secret   secretstore-eso   5m                 SecretSynced   True


# metadata.ownerReferencesは更新されない
$ kubectl get secret test-secret -oyaml
apiVersion: v1
data:
  password: MTIzNA==
kind: Secret
metadata:
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  ownerReferences:
  - apiVersion: kubernetes-client.io/v1
    controller: true
    kind: ExternalSecret
    name: test-secret
    uid: 3821d90f-02d2-454d-8fba-704efeab81f7
  resourceVersion: "17653"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque

KES関連リソースを削除する

最後にKES関連のリソースを削除します。

$ kubectl delete -f ./testdir/kubernetes-external-secrets/templates/
$ kubectl delete -f ./charts/kubernetes-external-secrets/crds

削除後にESOの状態などを確認しますが、特に問題は発生していないように見えます。

KES削除後の状態確認ログ

$ kubectl get crd
NAME                                                    CREATED AT
acraccesstokens.generators.external-secrets.io          2023-01-14T01:17:26Z
clusterexternalsecrets.external-secrets.io              2023-01-14T01:17:26Z
clustersecretstores.external-secrets.io                 2023-01-14T01:17:26Z
ecrauthorizationtokens.generators.external-secrets.io   2023-01-14T01:17:26Z
eniconfigs.crd.k8s.amazonaws.com                        2023-01-14T00:26:18Z
externalsecrets.external-secrets.io                     2023-01-14T01:17:26Z
fakes.generators.external-secrets.io                    2023-01-14T01:17:27Z
gcraccesstokens.generators.external-secrets.io          2023-01-14T01:17:27Z
passwords.generators.external-secrets.io                2023-01-14T01:17:27Z
pushsecrets.external-secrets.io                         2023-01-14T01:17:27Z
secretstores.external-secrets.io                        2023-01-14T01:17:27Z
securitygrouppolicies.vpcresources.k8s.aws              2023-01-14T00:26:22Z


$ kubectl get deploy
NAME                               READY   UP-TO-DATE   AVAILABLE   AGE
external-secrets                   1/1     1            1           53m
external-secrets-cert-controller   1/1     1            1           53m
external-secrets-webhook           1/1     1            1           53m
kes-to-eso-deploy                  2/2     2            2           56m

$ kubectl get pods
NAME                                                READY   STATUS    RESTARTS      AGE
external-secrets-66dbbc7b45-xvmnh                   1/1     Running   2             53m
external-secrets-cert-controller-6c95b96976-8rzvf   1/1     Running   0             53m
external-secrets-webhook-647478f6dc-wkjq6           1/1     Running   2 (49m ago)   53m
kes-to-eso-deploy-5cfb75cd85-hrksm                  1/1     Running   0             57m
kes-to-eso-deploy-5cfb75cd85-mxlpl                  1/1     Running   0             57m

$ kubectl get externalsecret
Error from server (NotFound): Unable to list "kubernetes-client.io/v1, Resource=externalsecrets": the server could not find the requested resource (get externalsecrets.kubernetes-client.io)

$ kubectl get externalsecrets
NAME                                             STORE             REFRESH INTERVAL   STATUS         READY
externalsecret.external-secrets.io/test-secret   secretstore-eso   5m                 SecretSynced   True

NAME                                              AGE   STATUS   CAPABILITIES   READY
secretstore.external-secrets.io/secretstore-eso   42m   Valid    ReadWrite      True

$ kubectl get secret
NAME                                           TYPE                                  DATA   AGE
awssm-secret                                   Opaque                                2      45m
default-token-6fbtj                            kubernetes.io/service-account-token   3      104m
external-secrets-cert-controller-token-nlt7d   kubernetes.io/service-account-token   3      57m
external-secrets-token-nltx6                   kubernetes.io/service-account-token   3      57m
external-secrets-webhook                       Opaque                                4      57m
external-secrets-webhook-token-kkx8s           kubernetes.io/service-account-token   3      57m
test-secret                                    Opaque                                1      90m


$ kubectl get secret test-secret -oyaml
apiVersion: v1
data:
  password: MTIzNA==
kind: Secret
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"external-secrets.io/v1beta1","kind":"ExternalSecret","metadata":{"annotations":{},"name":"test-secret","namespace":"default"},"spec":{"data":[{"remoteRef":{"key":"test-secret","property":"password"},"secretKey":"password"}],"refreshInterval":"5m","secretStoreRef":{"kind":"SecretStore","name":"secretstore-eso"},"target":{"creationPolicy":"Owner","name":"test-secret"}}}
    reconcile.external-secrets.io/data-hash: aeb62059e67d377d0a0ff6afda1a82cf
  creationTimestamp: "2023-01-14T00:40:16Z"
  name: test-secret
  namespace: default
  ownerReferences:
  - apiVersion: external-secrets.io/v1beta1
    blockOwnerDeletion: true
    controller: true
    kind: ExternalSecret
    name: test-secret
    uid: 714a3d12-8869-4033-ad55-588e5789eabe
  resourceVersion: "20305"
  uid: 7319187e-49fa-4436-9a6b-c42f6d67bea4
type: Opaque

その他

別名のSecretを使う場合

今回はSecret名を同じと仮定しましたが、そもそもKESとESOで異なる名称のSecretを管理するようにすることも考えられます。

管理するSecret名を変更する場合は、今回発生したownershipの問題は発生せず、KESからESOへの切り替え自体はよりスムーズに実施できるはずです。冒頭に紹介した本番環境での例でも、別名のSecretに切り替える方法が紹介されています。

こちらの場合、ownershipの書き換えをする必要がない代わりに、Secretを利用するリソース側に変更が必要となります。具体的にはDeploymntなどが使用するSecret情報を更新する必要があります。

IRSAを使う場合

今回のAWSアカウントへのアクセス権限付与の方法は、KES・ESOともにセキュアとは言えない方法で行っており、本来はIRSAを使用することが推奨されます。

KES/ESOでIRSAを使用する方法は少し異なります。KESはExternal SecretコントローラーPodにIAM権限を付与したService Accountを紐づける必要があります。一方でESOはコントローラーに付与することもできますが、SecretStore ごとにService Accountを指定することもできます。

このため、KESで使用していたのと同じService Accountを SecretStore に指定したり、新しいService Accountを用意することもできます。