このエントリーをはてなブックマークに追加

(翻訳): Ansibleを使ったデプロイに関する一考察

(訳注: この記事はThoughts on deploying with Ansibleの翻訳です。著者のRamon de la Fuente さんから許可を得て、翻訳・公開しています。元記事の公開は2014年6月ですが、2015年1月現在にも通用する話だと思います)

私たちのデプロイ手順を簡単にするために Ansible で roleを書きました(以前はCapistranoを使っていました)。このroleは今やかなり完璧で、本番環境に使い始めています。しかし作り始めた当初はいくつかの点で議論する必要がありました。今回みなさんとその議論を共有しようと考えたわけです。

デプロイとは?

最初に "デプロイ" を定義しましょう。デプロイするとき、ユーザーはすでに "Provisioning" を終えており権限なども適切に整っているものとします。

我々はデプロイを以下の5つの手順に分割しました。

  1. コードベースと設定の更新
  2. 依存性のインストール
  3. 共有リソースの保護Preserve shared resources
  4. ビルド
  5. 終了処理

また、以下のように、currentが最新のreleaseにシンボリックリンクされている、Capistranoのディレクトリ構造を持っているものとします。

.
├── releases
|   ├── 20140415234508
|   └── 20140415235146
├── shared
|   ├── sessions
|   ├── source
|   └── uploads
└── current -> releases/20140415235146

Role

taskを実行するroleを書くとき、Capistranoのモデルに従うのは難しくありません。Ansibleのモジュールにそのまま当てはめるとこのような感じになります。

  1. git か synchronize + copy か template
  2. command か shell
  3. file
  4. command か shell
  5. file

他にデプロイとは言えない仕事もあります。でも、これらは簡単に作成できます。

  • タイムスタンプの作成
  • 古いreleasesの掃除

タイムスタンプはcommandで作成し、registerでローカル変数に格納します。その値はyour_registered_variable.stdout内にあります(従ってどんな形式にでもできます)。

tasks:
  - command: date '+%Y%m%d%H%I%S'
    register: date_output

  - debug: msg={{ date_output.stdout }}

実際のところ、私達はタイムスタンプを使っていますが、別に必須なわけではありません。リリースごとに重複がなければどんな形式でも構いません(例えばコミットハッシュとかありますね。あるいは自分で同じバージョンを二回デプロイしないようにするとか)。

cleanupコマンドはもうちょっと複雑です。リモートのreleaseディレクトリの一覧を得る必要があります。もう一度registerを使い、最新n個のreleaseを保持して残りを消すようにループを回します。

tasks:
  - command: ls releases
    register: ls_output

  - debug: msg={{ item }}
    with_items: ls_output.stdout_lines

もちろん'var=ls_output.stdout_lines'でデバッグできる事は知っていますが、ここでのポイントは with_items でリストをループしている、ということです。

これらの仕事が複数のタスクに分散されていることと、Pythonで書くのはすごく楽なのとで、 "deploy" というモジュールを作成してroleに加えました。このモジュールを使うことにより、ディレクトリ構造が順番通りなことが保証され、タイムスタンプが返り値として返ってくるようになりました。

- name: Initialize
  deploy: "path={{ project_root }} state=present"

- debug: {{ deploy.new_release }}

古いディレクトリの削除はこうします。

- name: Remove old releases
  deploy: "path={{ project_root }} state=clean"

これでとても分かりやすくなりました。

問題はいつ始まったか

問題は、Capistranoから再利用というコンセプトを借りてこようとしたときに起こりました。例えば、Capistranoではcallbacksを記述できます。物事は 'before_X' や 'after_Y' のようになります。Capistrano はrollback(失敗した時に実行されます) にもコードを書けます。これはつまり、Capistranoはデプロイプロセスを変更し、他のプロジェクトで再利用できる、ということです。というかむしろ、Capistranoではデプロイ手順のどんな箇所にでも操作を付け加えることが出来ます。結局はRubyなのですから。計算されたデフォルト値を元にユーザーからの入力を行ったり、終了前に操作を行ったりなどはよくあることです。でも、Ansibleではそうはいきません。

なぜこのコンセプトが問題になるのか?なぜなら、Ansibleはプログラミング言語ではないからです。この言葉を繰り返して下さい。三回。大きな声で。「あなたはもうカンサスにはいない (訳注: You're not in Kansas Anymoreという曲)」のです (にやり)

rollbackはなにをしているのか

さて、なにか問題が起きた時、以前の状態に戻します。

有効なrelease: "A-OK" ➙ デプロイ失敗 "BORKED" ➙ rollback ➙ 有効なrelease: "A-OK"

でも、デプロイの最中のどのタイミングの失敗が問題になるのでしょうか?ええと…、破壊的なタスク、例えばDBスキーマの更新 (おそらく、ロールバックが必要な一番重要な理由だと推測してます)を行っている時です。とすると、もし良いDBのアップグレード/ダウングレードシステムをAnsibleに組み込んでいたら、これは問題になりません。

この場合、ロールバックはDB更新taskの一部分となります。従って、registerと一緒にignore_errors: Trueを使います。

tasks:
  - command: sh -c 'exit 1'
    register: task_output
    ignore_errors: True

  - name: Rollback
    command: echo 'rollback'
    when: task_output|failed
    failed_when: True

最後のfailed_when: Trueはロールバックが終わった時にデプロイを止めるためです。これはちょっと汚いですが、動きます。

他には、Ansible自体の返り値をチェックする、という方法もあります(しかし、これだけだとAnsibleがなぜ失敗したかは分かりません。実際にはこの方法はもう少し機能を付け加える必要があるでしょう)

ansible-playbook deploy.yml || ansible-playbook rollback.yml

ここで言いたいことは、適切なロールバックを適切な場所に (DBダウングレードのように) 置くことは、多くの場合やりすぎだ、ということです。私達は`Doctrine <https://github.com/doctrine/migrations>`_ のマイグレーションを使っていて、カラム名が変わるなど破壊的なDBの更新を防いでいます。カラムを追加してデータを移動させるなどの安全な処理を行った後に、次のイテレーションでもはやコードに'attach'していない古いカラムを消すなどの破壊的な操作を行うようにしました。

また、私達はrollbackというコンセプト自体を放棄することに決めました。rollbackなんて嘘だ!

私達に本当に必要な物は、デプロイ失敗とはなにかを決めることです。そのため、我々は "BUILD_UNFINISHED" というファイルをリリース中に置くtaskを作成しました。このファイルが存在している限り、リリースは終了していないとみなされます。もしも新しいビルドが始まった時に、releasesディレクトリ中のreleaseにまだこのファイルが有る場合、このリリースは失敗とみなされ、ディレクトリは削除されます。(deployモジュールが自動的に削除します)

この方式では、失敗したリリースの中身を検査できるというCapistranoを超える利点を得ることが出来ました。

有効なrelease: "A-OK" ➙ deploying "BORKED"  ➙ 失敗
(シンボリックリンクは置き換わらず、A-OKはまだ有効)

リリース中に "BORKED" ディレクトリの中身を見て、問題を見つけられます。

有効なrelease: "A-OK" ➙ deploying "FIXED"
(開始する前に "BORKED" はリリースフォルダーから削除されます)

callbackってある?

deploy roleをプロジェクト間で再利用するために、ほとんど同一のプロジェクトか、柔軟なメカニズムを保つ必要があります。任意のコマンドを "inject" できるようにしない限り、それぞれの変更ごとにrole全体をコピーする必要が出てきます。ここで試すことは、怪物を作り出さないようにするために、十分な柔軟性を持ったスイートスポットを探すことです。

私達にとっての解決方法は、コマンドのリストを変数として持つことでした。この変数のデフォルトの設定は空のリストです:project_post_build_steps: []

role内部のtaskはこうなっています。

- name: Run post_build_commands
  command: "{{ item }} chdir={{ deploy.new_release_path }}"
  with_items: project_post_build_commands

これは依存性マネージャーが走った後で、シンボリックリンクが作成された後に、どんなコマンドでも実行できるようになりました。これは完全なcallbackではありませんが、一連の操作の流れにおいて、主導権を貴方自身の手に取り戻すことにほかなりません。対照性のために、依存性マネージャーが走る前に実行される、project_pre_build_steps: []も追加しました。

追記: 注意以下は Ansible 1.6.8以降ではセキュリティ上の理由により動作しません。このアイデアは破棄しました。

We will be exploring the option of adding a task that uses the older action: notation of Ansible. This means you could run any Ansible module (pseudo code):

project_post_build_actions:
  - { module: '[some_module]', parameters: 'some_param=true some_other_param=some/value' }
  - { module: '[some_module]', parameters: 'some_param=false some_other_param=some/other/value' }

And the task would turn into something like:

- name: Run post_build_actions
  action: "{{ item.module }} {{ item.parameters }}"
  with_items: project_post_build_actions

This turns into a bit of meta-Ansible, but it's mighty useful if the rest of the role is a perfect fit. This technique could save you a lot of duplication.

追記: 注意以上は Ansible 1.6.8以降ではセキュリティ上の理由により動作しません。このアイデアは破棄しました。

デプロイする正しいバージョンを得るには?

これからデプロイされようとしているrelease(tag)を選択できるようにしたいです。常に最新のtagというわけではありませんが、最新以外のtagをリリースするのはたまになので、デフォルトとして最新tagを使うようにしたいです。

実はAnsibleではこれは驚くほど難しいのです。Ansibleではユーザーからの入力を得るにはvars_promptを使います。これにはデフォルト設定もできます。しかし、変数を使ったり値を検索したりしようとすると、質問の中では変換されないのです。そのため、

vars_prompt:
    - name: "release_version"
      prompt: "Product release version"
      default: "{{ lookup('pipe','latest_git_tag.sh') }}"

はこういうようになってしまいます。

$ ansible-playbook playbook.yml
Product release version [{{ lookup('pipe','latest_git_tag.sh') }}]:

"release_version" の実際の値はlatest_git_tag.shの出力値です。これはうまく動かないため、どのバージョンをデプロイすればいいのか分かりません。この問題はとても頑固です。playbookの中に複数のplayがあったとしても、2番目のplayのvars_promptの部分は最初のplayが実行する前に呼び出されます。

そのため、Ansibleをシェルスクリプトで囲むことにしました。このプロジェクトでは適切な質問を行い、Ansibleを--extra-varsオプションを付けて実行するbin/deployスクリプトを作ることにしました。これを使うとこうなります。

$ bin/deploy
Product release version [v1.2.3]:
Project environment (Staging/Production) [staging]:

Running: ansible-playbook deploy.yml --limit staging --extra-vars "environment=staging project_version=v1.2.3"

メンテナンスモードはどこ!

deploy roleで実装しなかった最後の一つは "メンテナンスモード" です。言うまでもなく、破壊的なタスクを実行しようとしてる時には必要です(訳注: 訳が怪しい)。私達はNginxで単にファイルの存在チェックをするようにしました。そのため、fileモジュールやcommand: touch maintenanceで十分なのです。

とはいえ、いつも起きるのは、デプロイ時に危険な可能性がある箇所は別のroleに分かれることです。(訳注: 訳が怪しい)これはstep 4(ビルドtask)の後で、step 5(終了処理)の前に起きます。

そのため、私達はroleを "open-end" にしています。 BUILD_UNFINSHED ファイルが削除されていて、現在のシンボリックリンクが置き換わっている、ということを保証するためのproject_finalizeという変数(デフォルトはTrue)を持つようにしています。もしこれがfalseにセットされていた場合、プロジェクトは独自のroleをdeploy手順に追加でき、そのroleの責任において、メンテナンスモードの設定/解除を行えるようにしています。

もしあなたがメンテナンスモードを設定することでデプロイを開始しようと考えているならば、pre_tasksをデプロイplaybookに設定するほうがいいでしょう。これはdeploy roleが始まる前に実行されます。もし、いつもメンテナンスモードを解除するならば、前述の 'exit-code' テクニックが使えます。 (訳注:post_tasksはplaybookが失敗した場合は呼び出されない)

Ansible.project_deployのplaybook例

我々のユーザーグループである SweetlakePHP をデプロイするplaybookの例を示します。これは Symfony2 のプロジェクトで、 Assetic を使っています。特別なことはなにもありません。すべての動作はroleの中で行われており、その同じroleを他のプロジェクト(Symfony2だけではありません)をデプロイするために使っています。プロジェクト間で異なるのはvarsだけです。

---
 - name: Deploy the application
   hosts: production
   remote_user: "{{ production_deploy_user }}"
   sudo: no

   vars:
     project_root: "{{ sweetlakephp_root }}"
     project_git_repo: "{{ sweetlakephp_github_repo }}"
     project_deploy_strategy: git

     project_environment:
       SYMFONY_ENV: "prod"

     project_shared_children:
       - path: "/app/sessions"
         src: "sessions"
       - path: "/web/uploads"
         src: "uploads"

     project_templates:
       - name: parameters.yml
         src: "templates/parameters_prod.yml.j2"
         dest: "/app/config/parameters_prod.yml"

     project_has_composer: yes

     project_post_build_commands:
       - "php vendor/sensio/distribution-bundle/Sensio/Bundle/DistributionBundle/Resources/bin/build_bootstrap.php"
       - "app/console cache:clear"
       - "app/console doctrine:migrations:migrate --no-interaction"
       - "app/console assets:install"
       - "app/console assetic:dump"

   roles:
     - f500.project_deploy

   post_tasks:
     - name: Remove old releases
       deploy: "path={{ project_root }} state=clean"