BLOG

将来の夢はおもしろお兄さんです。

自動デプロイとアプリケーションの設定値と etcd

はじめに

こんにちは yosida95 です。 2015 年初めてのブログエントリです。 今年もよろしくお願いします。

ぼくが所属するゲヒルンでは CircleCI を使って CI を回しています。 以前はぼくが社内にたてた Jenkins を使っていて、ときどき Jenkins にバグ報告を上げたりもしていたのですが、ライブラリをオープンソースにする過程で TravisCI を使いこむようになって Jenkins のバグと運用から開放される喜びを知り、その後クローズドソースなプロダクトでも SaaS の CI を利用したく複数のサービスを検討した結果 CircleCI を契約しました。 最初のころは個人でのみ CircleCI を利用していたのですが、個人の契約に任意の Organization を Piggyback させられることを知り GehirnInc アカウントでも CircleCI を利用できるようにしました。 そして現在は社内で開発している多くのプロダクトで CircleCI が利用されるようになりました (というか、ぼくが参加するプロジェクトでは、ぼくが開発をしないリポジトリに対しても CircleCI を使うための Pull Request を投げています)。

CI と自動デプロイ

CI ではだいたいの場合単体テスト・結合テストまでが実施され、変更の粒度がある程度育ってきたらステージング環境にデプロイして実際に触ってみて機能テストを行うのではないかと思います。 Git-flow や GitHub-flow による開発をしている場合、 topic ブランチや feature ブランチを切って必要な変更とそれに対するテストを書いて CI を通したら元のブランチにマージしますが、この “元のブランチ” にマージされたタイミングで CI がキックされすべてのテストケースが通ったらそのままステージング環境に勝手にデプロイされてほしい訳です。

自動デプロイとアプリケーションの設定値

CI でアプリケーションをデプロイすることそのものは簡単なことです。 それこそ適当にシェルスクリプトを書いて、 rsync でアプリケーションを deliver したり、あるいはサーバーにログインして git pull すればいいだけのことです。 しかし現実的に、アプリケーションはデータベースに接続してユーザーデータを永続化しなければなりませんし、マッシュアップアプリケーションなら外部サービスの API をコールしなくてはなりません。 データベースにユーザーデータを永続化するためにはデータベースに接続するための情報、ホスト名やポート番号、それにデータベース名を、外部サービスの APIをコールするにはその API に接続するためのクレデンシャルを、それぞれアプリケーションが知っている必要があります。 多くの場合これらのパラメーターは YAML や INI 、あるいは JSON など任意の設定ファイルにまとめられ、アプリケーションのエントリーポイントがこれら設定ファイルを読み込んで起動する仕組みになっていることでしょう。

設定ファイルのこれまで

では自動デプロイをする場合において、それらの設定ファイルはどのように管理すればよいのでしょうか。 昨年頃から大きな流れとなっている Immutable Infrastructure の概念ではアプリケーションのデプロイは一度だけで、アプリケーションに変更があればサーバーごと捨ててしまえるので大きな問題とはならないのですが、その方法によって解決できない領域が存在します。 例えばぼくが所属しているゲヒルンが行っているレンタルサーバーサービス。 ユーザーは割り当てられたファイルシステムに自由にデータを書き込み、自由にデーモンを立ち上げ、その結果再現不可能なサーバーが誕生します。 レンタルサーバーサービスに新機能を追加すると、既存のサーバーの上に新たな、あるいは更新されたアプリケーションをデプロイする必要が生じます。 このようなアプリケーションの設定ファイルは、職人が都度書く、長い間伝承されてきた設定ファイルを scp で転送するなどは自動デプロイの文脈では問題外としても、リポジトリに平文で含む、 Ansible Vault の様なものを使って暗号化した上でリポジトリに含むなどがこれまで試みられてきた方法だと思います。 リポジトリに平文で含む方法の問題点は少し考えただけで簡単に見つけられますが、そこで妥協点として暗号化してリポジトリに含むという感じなのでしょうか。 しかしアプリケーションの実装と、それを運用する設定ファイルはできるだけ分離されているべきだと思います。

etcd を使った設定値の共有

そこでぼくが最近実験的に試している方法が、リポジトリには「どこにファイルを配置し、どこを読めば設定値を知ることができるか」という情報のみを含み、設定値自体は外部に切り出してしまうというものです。 この設定値の切り出し先として使っているサーバーが Core OS が開発している coreos/etcd です。 etcd は最近波に乗っている Docker のホストサーバー向け OS である CoreOS が利用しているということで一度は耳にしたことがあるのではないでしょうか。 設定値を共有するための高可用性分散 KVS ということで、早く言ってしまえば Go 言語で書かれた ZooKeeper です。 etcd 単体の機能として通信経路の TLS 暗号化と、クライアント証明書による認証が使えて大変便利です。

ぼくがゲヒルンで実験的に取り入れている etcd を使った具体的なデプロイのフロー以下の様なものです。

  1. 変更を staging ブランチにマージする
  2. 変更を検知して CircleCI がテストを実行する
  3. ステージング環境で
    1. git fetch && git reset --hard origin/staging を実行する
    2. 必要な依存パッケージのインストールを行う
    3. アプリケーションのビルドを行う
  4. リポジトリに含んだトリプル DES で暗号化された秘密鍵を復号する
  5. 復号した秘密鍵と証明書を使って etcd にアクセスして設定ファイルを生成する
  6. 設定ファイルをステージング環境の特定のパスに配置する

config.py
# -*- coding: utf-8 -*-

"""
Copyright (c) 2015, Kohei YOSHIDA <[email protected]>. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the copyright holder nor the names of its
      contributors may be used to endorse or promote products derived from
      this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""

import json
from urlparse import urlparse

import etcd


class ConfigRegistry(object):

    def __init__(self, parent, name):
        self.parent = parent
        self.name = name

    @property
    def root(self):
        if isinstance(self.parent, Config):
            return self.parent

        return self.parent.root

    @property
    def keyname(self):
        if isinstance(self.parent, Config):
            return '/'.join((self.parent.key_prefix, self.name))

        return '/'.join((self.parent.keyname, self.name))

    def read(self):
        return json.loads(self.root.client.read(self.keyname).value)

    def write(self, value):
        return self.root.client.write(self.keyname, json.dumps(value))

    def __getattr__(self, name):
        return ConfigRegistry(self, name)


class Config(object):

    def __init__(self, url, client_cert, client_key):
        self.url = url if url.endswith('/') else url + '/'
        self.__etcd_registry__ = {}

        parsed = urlparse(url)
        path_parts = parsed.path.split('/', 3)
        if len(path_parts) != 4\
                or path_parts[2] != 'keys':  # /v2/keys/appname/staging
            raise ValueError()

        self.key_prefix = '/' + path_parts[3]
        self.client = etcd.Client(host=parsed.hostname,
                                  port=parsed.port,
                                  protocol=parsed.scheme,
                                  cert=(client_cert, client_key))

    def __getattr__(self, name):
        if name not in self.__etcd_registry__:
            self.__etcd_registry__[name] = ConfigRegistry(self, name)

        return self.__etcd_registry__[name]
usage.py
config = PasteConfig('https://etcd.example.com:4001/v2/keys/appname/staging',
                     './etcd.cer',
                     './etcd.key')
config.foo.bar.read()  # https://etcd.example.com:4001/v2/keys/appname/staging/foo/bar

この config オブジェクトを任意のテンプレートエンジンに渡すことで、簡単に設定ファイルを生成できるようになります。

この方法ではリポジトリに設定ファイルを含まずに、アプリケーションのパラメーターを etcd に切り出して machine readable な形で提供しています。 こうすることによって設定ファイルを自動生成できるようになり、人の手を介さない完全自動デプロイが実現されています。 また etcd へはクライアント認証に成功した者、つまりトリプル DES のパスフレーズを知っている者のみがアクセス可能なため、センシティブな情報の保護も同時に実現しています。

おわりに

  • Immutable ではない Infrastructure 上に自動デプロイするアプリケーションの設定方法についてのベストプラクティスを知りたい
    • この方法はベターではあると思うけれど etcd という新しいものに飛びつきたかったという側面も否めない
  • ポエムを書こうとしたけれど文章がまとまらなかった
    • 世の中のポエマー各位すごい