fabricとcuisineでデプロイスクリプトを作って使いまわす

# はじめに
この記事はPython Advent Calendar 2016の2日目の記事になります。
http://qiita.com/advent-calendar/2016/python

# fabricとcuisineでデプロイスクリプトを作って使いまわす
株式会社LOGICAの松永と申します。
弊社ではもっと気軽に旅行に出かけられるようにしたいと思い、よりシンプルなホテルの横断検索サービスを開発しています。

現在、自社開発で２つのプロダクト（クローラーとメディア）をそれぞれDjangoで開発していますが、現在１人で開発しているうえ、サーバー台数も多いのでデプロイ作業はコマンド一発で終わらせたいと思っていました。

ただ、デプロイは自動化したいなと思いつつ、Ansibleは触ったことないしChefはチュートリアルをやった程度でよくわからなかったので、学習コストが低いらしいと聞いていたfabricを使ってデプロイスクリプトを作ることにしました。cuisineは冪同性を担保するためのものです（完璧ではないはず）
そして、クローラーとメディアの両方でPyenvやDjangoやNginxやgunicornを利用していたので、Chefのレシピっぽいものを作って使いまわそうと思いました。

# fabricとcuisineとは
fabricとcuisineの簡単な説明は下記の記事が分かりやすいと思います。
http://qiita.com/pika_shi/items/802e9de8cb1401745caa

ドキュメントへのリンクは以下の通りです。
[fabricのドキュメント](http://docs.fabfile.org/en/1.12/)
[cuisineのドキュメント](https://cuisine.readthedocs.io/en/latest/)

# ディレクトリ構成
※実際に使っているものを全部出すと長い＆アレなので、抜粋したものです。

ディレクトリは各プロジェクト（project1, project2）、recipes、ssh_keysになります。
各プロジェクトのtemplatesディレクトリ配下にdjangoの`settings.py`やNginxの設定ファイルなどといった、本番用のエンドポイントや設定を混ぜ込みたいファイルを置いておきます。これらの各テンプレートファイルにはsecrets.ymlの中に記述した変数をJinja2を使って入れたあとにサーバーにアップロードします。
`project1/files`内にはアップロードしたいファイルやバイナリを置いてます。
recipesの中には使いまわすスクリプトが入っています。
ssh_keysにはリモートでgithub上のレポジトリの中身を引っ張ってくるためのものです。

これらをgithubで管理しています。
もちろん、secrets.ymlとssh_keysディレクトリ内はgitignoreに追加しておきます。

```
├── project1
│   ├── fabfile.py
│   ├── files
│   │   └── phantomjs-2.1.1-linux-x86_64.tar.bz2
│   ├── secrets.yml
│   ├── secrets.yml.example
│   └── templates
│       ├── gunicorn_conf.py
│       ├── nginx.conf
│       └── settings.py
├── project2
│   ├── fabfile.py
│   ├── secrets.yml
│   ├── secrets.yml.example
│   └── templates
│       ├── gunicorn_conf.py
│       ├── nginx.conf
│       └── settings.py
├── recipes
│   ├── __init__.py
│   ├── django.py
│   ├── git.py
│   ├── gunicorn.py
│   ├── httpd_tools.py
│   ├── nginx.py
│   ├── phantomjs.py
│   ├── pyenv.py
│   ├── redis.py
│   ├── service_base.py
├── requirements.txt
└── ssh_keys
    └── github
        └── id_rsa
```


## 使いまわすレシピの例
amazon linuxを使っているのでパッケージ管理はyumを使っているのですが、`sudo service ◯◯ start`みたいにstartしたりstopしたりするものは全部次のスクリプトを親クラスにしておきます。インストール自体はcuisineの`package_ensure`を使えば確実にできますが、メソッドとしてわかり易い名前で生やしておきたかったので。

```py:recipes/service_base.py
# -*- coding: utf-8 -*-

from fabric.api import sudo
from fabric.utils import puts
from fabric.colors import green
from cuisine import package_ensure, select_package

select_package('yum')


class ServiceBase(object):
    def __init__(self, package_name, service_name):
        self.package_name = package_name
        self.service_name = service_name

    def install(self):
        package_ensure(self.package_name)

    def start(self):
        puts(green('Starting {}'.format(self.package_name)))
        sudo('service {} start'.format(self.service_name))

    def stop(self):
        puts(green('Stopping {}'.format(self.package_name)))
        sudo('service {} stop'.format(self.service_name))

    def restart(self):
        puts(green('Restarting {}'.format(self.package_name)))
        sudo('service {} restart'.format(self.service_name))

```

これを使ってnginxのインストール・起動・停止スクリプトは以下のように作ります。
### Nginx

```py:recipes/nginx.py
from service_base import ServiceBase


class Nginx(ServiceBase):

    def __init__(self):
        super(Nginx, self).__init__('nginx', 'nginx')
        self.remote_nginx_conf_path = '/etc/nginx/nginx.conf'
```
Nginxの設定ファイルのアップロードはあとで書きます。

その他、yumで管理しているもの以外、例えばPyenvやDjango（`python manage.py ~~`でコマンドを実行するので）やceleryなんかも共通のスクリプトを作ります。pyenvだけ載せておきます。

### Pyenv

```py:recipes/pyenv.py
class Pyenv(object):
    def __init__(self):
        pass

    def install(self):
        """pyenvと関連ツールをインストール"""
        pyenv_dir = '~/.pyenv'
        # pyenvのインストール確認
        if not dir_exists(pyenv_dir):
            run('curl -L https://raw.githubusercontent.com/yyuu/pyenv-installer/master/bin/pyenv-installer | bash')
            text = """
            # settings for pyenv
            export PATH="$HOME/.pyenv/bin:$PATH"
            eval "$(pyenv init -)"
            eval "$(pyenv virtualenv-init -)"
            """
            files.append('~/.bashrc', text)
            run('source ~/.bashrc')

    def install_python(self, py_version):
        """Pyenv上で指定したバージョンのpythonをインストールする"""

        # pyenvがインストールされていなければインストールする。
        if not dir_exists('~/.pyenv'):
            self.install()

        # Pythonのビルドに必要なパッケージがインストールされているか確認
        packages = ['gcc', 'python-devel', 'bzip2-devel', 'zlib-devel', 'openssl-devel', 'sqlite-devel', 'readline-devel', 'patch']
        for package in packages:
            package_ensure(package)

        if not dir_exists('~/.pyenv/versions/{}'.format(py_version)):
            run('pyenv install {}'.format(py_version))
            run('pyenv rehash')

    def make_virtualenv(self, py_version, env_name):
        """指定した名前の環境を作成"""
        self.install_python(py_version)

        if not dir_exists('~/.pyenv/versions/{}'.format(env_name)):
            run('pyenv virtualenv {} {}'.format(py_version, env_name))
            run('pyenv rehash')
            run('pyenv global {}'.format(env_name))
        else:
            run('pyenv global {}'.format(env_name))

    def change_env(self, env_name):
        run('pyenv global {}'.format(env_name))

```

## Jinja2を利用して変数を組み込んだ設定ファイルをアップロードする
以下のような関数を使ってます。fabricとcuisineはPython3に対応していないので先程書いた`recipes/pyenv.py`でアップロード前後でリモートのPythonのバージョン切り替えをやりますw　（つまり、リモートには２つのバージョンのPythonをインストールしています。）
ファイルのアップロードをやらなければリモートは3のままでも行けたんですが、file_writeをやったらリモートも2じゃないとエラーで落ちてしまったのでこういう面倒なことをしています。

```py
def upload_template(remote_path, local_template_path, variables={}, sudo=None):
    """
    jinja2のテンプレートに変数を入れてアップロード
    """
    # リモートのPythonを２系の環境に変更
    pyenv = Pyenv()
    pyenv.change_env(VIRTUALENV_NAME_FOR_FABRIC)

    local_template_name = local_template_path.split('/')[-1]
    local_template_dir = local_template_path.replace(local_template_name, '')
    jinja2_env = Environment(loader=FileSystemLoader(local_template_dir))
    content = jinja2_env.get_template(local_template_name).render(variables)
    file_write(remote_path, content.encode('utf-8'), sudo=sudo)

    # 元のPythonの環境に戻しておく
    pyenv.change_env(VIRTUALENV_NAME)
```

これを使ってNginxの設定ファイルなどをアップロードします。variablesにはsecrets.ymlから読み込んだデータが入っています。

```py
upload_template(nginx.remote_nginx_conf_path, 'templates/nginx.conf', variables, sudo=sudo)
```

例えば、`nginx.conf`のサーバーネームには下記のような記述をしておきます。

```
server_name  {{ end_point }};
```
`variables["end_point"]`にserver_nameに指定したいエンドポイントを入れておけばOKです。JinjaもしくはDjangoを普段使ってる方には見慣れた記述かと思います。

Djangoのsettings.py内のデータベース設定なら下記のようになります。

```:secrets.yml
django:
  settings:
    production:
      secret_key: シークレットキー
      databases:
        default:
          engine: django.db.backends.mysql
          name: DBの名前
          user: DBのユーザー名
          password: DBのパスワード
          host: DBのエンドポイント
          port: DBのポート
```

```py3:project1/templates/settings.py
DATABASES = {
    'default': {
        'ENGINE': '{{ databases.default.engine }}',
        'NAME': '{{ databases.default.name }}',
        'USER': '{{ databases.default.user }}',
        'PASSWORD': '{{ databases.default.password }}',
        'HOST': '{{ databases.default.host }}',
        'PORT': '{{ databases.default.port }}',
    },
}
```

```py:project1/fabfile.py
variables = secrets['django']['settings']['production']
upload_template(settings_file_path, 'templates/settings.py', variables)
```
## 実際のデプロイスクリプト
生のやつを載せるのはヤバイので、nginxのインストールとPyenvでPythonの環境構築だけをするスクリプトにしました（実際に使ってるものからの部分コピペなので動作確認はしてません。）

```py:project1/fabfile.py
# -*- coding: utf-8 -*-

import os
import sys
sys.path.append(os.pardir)

import yaml
from jinja2 import Environment, FileSystemLoader
from fabric.api import env, run, sudo, settings, cd
from fabric.decorators import task
from cuisine import package_ensure, select_package, file_write

from recipes.nginx import Nginx
from recipes.pyenv import Pyenv


# Pythonの情報
PYTHON_VERSION = "本番で使いたいバージョン"
VIRTUALENV_NAME = "本番で使う環境名"

# ファイルアップロード時のリモートのPython環境
PYTHON_VERSION_FOR_FABRIC = "2系で"
VIRTUALENV_NAME_FOR_FABRIC = "リモートfabric用の環境名"

# パッケージ管理方式の選択
select_package('yum')

# テンプレートに埋め込むための情報をロード
secrets = yaml.load(file('secrets.yml'))

# envの設定 デプロイ先のサーバーへのログインに使う情報
env.user = "ユーザー名"
env.group = "グループ名"
env.key_filename = "サーバーへのログインに使う鍵のパス"
env.use_ssh_config = True


def upload_template(remote_path, local_template_path, variables={}, sudo=None):
    pyenv = Pyenv()
    pyenv.change_env(VIRTUALENV_NAME_FOR_FABRIC)

    local_template_name = local_template_path.split('/')[-1]
    local_template_dir = local_template_path.replace(local_template_name, '')
    jinja2_env = Environment(loader=FileSystemLoader(local_template_dir))
    content = jinja2_env.get_template(local_template_name).render(variables)
    file_write(remote_path, content.encode('utf-8'), sudo=sudo)

    # 元のPythonの環境に戻しておく
    pyenv.change_env(VIRTUALENV_NAME)


@task
def deploy():
    # テンプレートアップロード用のPython環境構築（リモートも2系じゃないと落ちる）
    pyenv = Pyenv()
    pyenv.install_python(PYTHON_VERSION_FOR_FABRIC)
    pyenv.make_virtualenv(PYTHON_VERSION_FOR_FABRIC, VIRTUALENV_NAME_FOR_FABRIC)

    # 本番で使用するPythonの環境構築
    pyenv.install_python(PYTHON_VERSION)
    pyenv.make_virtualenv(PYTHON_VERSION, VIRTUALENV_NAME)

    # nginxの環境構築
    nginx = Nginx()
    nginx.install()
    variables = {
        'end_point': END_POINT,
    }
    upload_template(nginx.remote_nginx_conf_path, 'templates/nginx.conf', variables, sudo=sudo)
    nginx.stop()
    nginx.start()

```


# まとめ・感想
実際に使ってみた感想として、学習コストはほとんどありません（ただのshellのラッパーなので）
ただ、使い回しとかテンプレートファイルのアップロードまでやるとなるとAnsibleでいいんじゃないか感ありますね。（触ったことないのでわかりませんが）

うちはスタートアップなので、デプロイスクリプトを書くのは余計な工数かなと最初は悩みましたが、これらのデプロイスクリプト作成コストと手軽さを天秤にかけた結果、今はとても満足しています。うちは規模の割にサーバー台数が多いので、デプロイスクリプトは用意しておいて良かったです。めちゃめちゃ楽です。

もっといい方法がある、うちではこんな工夫をしているという方はぜひコメント欄で教えてくださいmm