アプリケーションのコンフィグレーションをどのように管理するのか、これまで数々の議論がなされてきた。ThoughtWorksの同僚であるTom Sulston氏と私は、アプリケーションの外部からコンフィグレーション管理を解決するひとつの方法として、ESCAPE プロジェクトを始めた。これは、複数の環境における複数のアプリケーションのコンフィグレーションを、RESTサービスとして提供するものだ。最近、ESCAPEには実活動がないが、死んでもいないし忘れているわけでもない。 – (たびたび)業務が忙しかっただけだ。
今日は、コンフィグレーション管理を楽にするため、そして、アプリケーションを運営、管理する必要のある人たちを楽にするために、コード内からできることについて見ていきたい。これらは私(たち)がThoughtWorksのプロジェクトで何度も使ってきて、その価値が証明されているものだ。
単一コンフィグレーションソース
実装固有の方法でコードベースのあちこちからコンフィグレーションにアクセスしているアプリケーションを、私はかなりたくさん見てきた。これはアプリケーション特有の設定をしているところに混乱を招くだけではない。置かれる場所によって異なる意味をもつ同名のコンフィグレーションパラメータ(database.hostなど)があると、混乱はさらに悪化する。他にも次のような副作用がある。
- 使用済みの、あるいは、不要なコンフィグレーションパラメータを見分けるのが困難になる。
- 同じコンフィグレーションソースに対して、コードのあちこちで異なる仕組みを使われる。
- 同じ値に対して、異なるコンフィグレーションソースが使われる。
運用面から見た最悪の副作用のひとつは、あるファイルではXMLを使い、別のファイルではキー/バリューペアを使うといった具合に、コンフィグレーションソースによって異なるフォーマットが使われることが多いということだ。こうした予期せぬ複雑さは、新しい環境へのアプリケーションのデプロイを非常に困難なものにする。
また、こうしたシステムでは、コンフィグレーションに関するコードにテストがなかったり、あったとしても不十分もしくは非現実的であることが多いこともわかった。
そのためにやったこと
格納されたコンフィグレーションに専念するため、実際のメカニズムをプロバイダにカプセル化して、値が必要になったところでこのプロバイダに投げるようにする。こうすることで、テスト専用のコンフィグレーションプロバイダを実装することも可能になる。また、システムの進化に合わせて、コンフィグレーションの格納方法を簡単に変更することができる。例えば、最初は文字列をハードコードすることから始めて、次にそれをファイルに移し、最終的に、いくつかの値を何らかのリポジトリに移すことも可能だ。
例として、次のシンプルなPythonクラスを見てみよう。これは値をハードコードしたディクショナリとして振る舞う。
class ConfigProvider(dict): def __init__(self): self['name'] = 'Chris'
class ConfigProvider(dict):
このシンプルなクラスを使うには、次のようにすればよい。
from ConfigProvider import ConfigProvider class ConfigProviderUser: def __init__(self, cfg): self.cfg = cfg print "Hello, my name is %s" % self.cfg["name"] if __name__ == "__main__": ConfigProviderUser(ConfigProvider())
その後、ハードコードはやめるべきだと判断して、.propertiesファイルから読むことにする。これにはConfigProviderのコードを少しだけ変更する必要があり、最終的には次のようなコードになる。
class ConfigProvider(dict): src = None prop = re.compile(r"([\w. ]+)\s*=\s*(.*)") def __init__(self, source = "config.properties"): self.src = source self.loadConfig() def loadFileData(self): data = "" try: input = open(self.src, 'r') data = input.read() input.close() except IOError: pass return data def loadConfig(self): for (key, val) in self.prop.findall(self.loadFileData()): self[key.strip()] = val
.propertiesファイルでは不十分で、.yamlファイルに切り替えたいとすれば、どんな作業をする必要があるだろうか。またしても、変更する必要があるのはConfigProviderのコードだけだ。ロード時に(ファイル拡張子に基づいて)2つのフォーマットを扱えるようにするところだ。
class ConfigProvider(dict): src = None prop = re.compile(r"([\w. ]+)\s*=\s*(.*)") def __init__(self, source = "config.properties"): self.src = source self.loadConfig() def loadConfig(self): if self.src.endswith(".properties"): self.loadPropertiesConfig() elif self.src.endswith(".yaml"): self.loadYamlConfig() def loadFileData(self): data = "" try: input = open(self.src, 'r') data = input.read() input.close() except IOError: pass return data def loadPropertiesConfig(self): for (key, val) in self.prop.findall(self.loadFileData()): self[key.strip()] = val def loadYamlConfig(self): entries = yaml.load(self.loadFileData()) if entries: self.update(entries)
単一コンフィグレーションルールセット
コンフィグレーションルールについてユーザに十分知らせることができていないアプリケーションが多すぎる。外部コンフィグレーションファイルがなく、コマンドラインからすべてのコンフィグレーションを取り込むような小規模でシンプルなものでさえもそうだ。コンフィグレーションルールには、以下のようなものがあるだろう。(ただし、これらに限られるわけではない)。
- セット可能なすべてのコンフィグレーションプロパティは?
- コンフィグレーションプロパティのどれが必須で、どれがオプションなのか?
- プロパティに指定した値が有効であるかをチェックできるのか?
- デフォルト値はあるのか、あるならどこにあるのか?
これは、コンフィグレーションルールがコードの挙動による暗黙の副作用にすぎないため、であることが多い。アプリケーションは通常通り起動して機能しているように見えても、コンフィグレーションされていなかったり、不正なコンフィグレーション値になっている機能をユーザが実行しようとすると、たちまち予想外の結果になる。このようなアプリケーションのデプロイメントがうまくいっているかを検証するのは時間がかかり、間違いを起こしやすい。
そのためにやったこと
上に挙げたポイントすべてを定義した単一のルールセットを定義する。単一の真実を語るものができると、アプリケーションによっては、コンフィグレーションのテンプレートを生成するのに使うこともできる。これはスキーマ検証をサポートするフォーマット(XMLなど)でもうまく機能するが、単にトークン化されたサンプルファイルを生成するだけのプロパティファイルのようなシンプルなシステムにも適用できる。
単一コンフィグレーションルールセットはデプロイメントコンフィグレーションのスモークテスト(Deployment Configuration Smoke Test)の一部として使うこともできる。もしアプリケーションの初期化時に必須のコンフィグレーション要素が欠けていれば、即座に失敗し(fail fast)、派手に失敗する(fail loudly)。アプリケーションがその値を読み出そうとするときまで待ってはいけない。指定された値が有効かどうかチェックする方法がわかっていれば(値が整数であるか、ファイルの存在、ソケットをオープンするべきホスト名やポートオプションをチェックするのは簡単だ)、これもここでテストしておく。
プロバイダはユニットテストをしなければならない。アプリケーションを設定する外部の人やシステムが使うテンプレートに対しても、これらのテストを実行するべきだ。デプロイメントコンフィグレーションのスモークテストは、開発者によるユニットテストと同じくらい早期に使われるべきだ。新しいコンフィグレーションオプションを追加したときには、そのオプションのユニットテストも追加するべきだ。誰かがコードベースを更新したとき、その値が定義されていないと失敗するテストがあれば、「コンフィグレーションエントリ sheep が定義されていることを期待していたが、定義されていない!」と大声で教えてくれる。
単一コンフィグレーションルールセットは単一コンフィグレーションソースを頻繁に利用せざるを得ないが、それらは別のものだということを覚えておこう。両者で漏れがないよう注意する必要がある。
先ほどのPythonのサンプルを続けよう。今や次のようなコンフィグレーションルールセットがある。
class ConfigRuleset(dict): defaults = { 'name': 'no name', } required = [ 'name', ] def __init__(self): self.update(self.defaults) def validate(self): missing = [] for key in self.required: if not self.has_key(key): missing.append(key) if len(missing): raise KeyError("The following required config keys are missing: %s" % missing)
またしても、変更するのはConfigProviderのコードだけだ。次のようになる。
class ConfigProvider(ConfigRuleset): src = None prop = re.compile(r"([\w. ]+)\s*=\s*(.*)") def __init__(self, source = "config.properties"): ConfigRuleset.__init__(self) self.src = source self.loadConfig() self.validate() ….
ConfigRulesetのdefaultsとrequiredというストラクチャは、デフォルト値が何であり、どのキーが必須なのかを探すための、コードにおける単一の真実を語るものになる。
コンフィグレーションビュー
動作中のアプリケーションにある問題を突き止めようとするときには通常、現在動作しているコンフィグレーション値が何であるかを調べる必要がある。単純に現在のコンフィグレーションソースを調べても、アプリケーションが最後にロードされてから変更されている可能性があるため、正確な情報はわからない。
そのためにやったこと
動作中のシステムがコンフィグレーションをどこからロードしたか、ロードされた値が何であるかがわかるよう、簡単で誰もがよく知っている方法を提供する。これは起動時のコンフィグレーションツリー(とソースロケーション)をプリントアウトするといったシンプルなものもあるだろう。しかし、これだと長時間動作しているシステムではすぐに無効になってしまう。もっとロバストなアプローチとしては、現在動作中のコンフィグレーションをその値がどこから読み込まれたかとともに(特に、コンフィグレーションソースに複数の可能性がある場合)返すような、ある種のWebページ/Aboutページ/リモートプロシージャコールだ。
また、システムのバージョン/ビルド/リリースといった情報を提供するビューがあると非常に役立つことが多い。その価値については、以前、私が書いた記事、Self Identifying Softwareに詳しく述べている。
これまで見てきたPythonのサンプルでは、その実装はConfigProviderの文字列表現を返すだけになるだろう。
DNSサービス名
今やサービスエンドポイントの設定に生のIPアドレスを使うのはバッドプラクティスだと一般的にも受け入れられている。DNS名の利用はもうほとんど一般的なものだ(残念ながら、すべてがそうではないが)。DNSエントリが特定のサーバホスト名を指しているにもかかわらず、依然として、システムを管理するのに問題を抱えている人たちがいる。最初にアプリケーションをデプロイしたときにはうまく動くかもしれないが、サービスにおけるハードウェアのひとつをアップグレードする必要があったとき、何が起こるだろうか? 次のような単純化したシナリオを考えてみよう。
クライアント情報のために中央データベースを利用している、かなりアクセスの多いWebサイトがあったとしよう。このデータベースはマーケティングチームによって、いくつかのアプリケーションやレポーティングツールでも利用されている。ビジネスはうまくいっているが、このサーバ(db02 と呼ぼう)はパフォーマンス問題を抱えており、もっと立派な新しいサーバ(>db04 と呼ぼう)にアップグレードする必要がある。これは長く苦痛を伴うプロセスになる。なぜなら、このデータベースを利用しているすべてのアプリケーションを見つけて、移行時に新しいサーバに再設定する方法について理解する必要があるためだ。
そのためにやったこと
すべてのサービスにDNSサーバ名を使う。最もシンプルな解決策は、サービスのエンドポイントにDNS CNAMEレコードを使うことだ。上のサンプルでは db02 を指した clientdb と呼ぶ CNAMEレコードを作成して、そのデータベースを使うアプリケーションはすべて、サービスのエンドポイントホスト名として clientdb を使うように設定すればよい。データベースを新しいサーバに移行するときには、移行計画の最終ステップは単にCNAMEエントリを更新して、db04 を指すようにするだけになる。これは、アプリケーション毎のコンフィグレーションを変更する必要をなくしてくれるだけでなく、お手軽なバックアウト戦略にもなる。もし新しい db04 サーバに何か問題があれば、問題が解決するまで db02 を指すようにCNAMEを戻せばよい。
DNSベースの環境解決
上記のようにDNSサービス名を使うと、少々副作用が発生するおそれがある。開発やテスト向けに異なるクライアントデータベースが複数あれば、少しだけ違うように見える大量のCNAMEエントリを持つことになるかもしれない。例えば、次のようになる。
- clientdb.example.com 製品用
- clientdb-perf.example.com パフォーマンステスト用
- clientdb-qa.example.com QA用
- clientdb-dev.example.com 開発用
たいていの場合、これはそれぞれの環境にコンフィグレーションファイルを拡散させることにつながる。
そのためにやったこと
サーバにDNSベースの環境解決を使う。トップレベルドメインを機能に基づいて複数のサブドメインに分割することから始めよう。そして、そのサービスに関連したサーバを指すよう、各サブドメインにDNSサービス名を作る。先ほどのリストは次のようになるだろう。
- clientdb.prod.example.com 製品用
- clientdb.perf.example.com パフォーマンステスト用
- clientdb.qa.example.com QA用
- clientdb.dev.example.com 開発用
こうすると、サーバはその機能に関連したサブドメインでエントリを解決することになる。つまり、QAサーバはすべて、最初にqa.example.comにあるエントリで解決され、もしそれが見つからなければ、次に example.com が試されることになる。こうすることで、すべての環境において正しく解決されるクライアントデータベースのホスト名(clientdb))のための単一のコンフィグレーションエントリを持つことができるようになる。このテクニックは共通のトップレベルドメインに定義されたグローバルサービスでも役に立つ。