Elixir試飲録 (5) – Elixir/Phoenixのホットデプロイ完全自動化(2016年1月版)

いよいよ先月から、たまチームの新規プロジェクトとして Elixir/Phoenix によるWebサービスの開発が始まった。というわけで、今回はたまチームでの事例第一弾として、この半月で構築したホットデプロイ自動化の仕組みについて紹介したい。まだプロジェクトも初期段階なので未熟な部分も多いが、これまで Elixir/Phoenix に触れたことがない人でも、その雰囲気を感じて頂ければ幸いである。

 

自動化の全体像

開発を始めるに当たって、まず最初にやっておきたいのが CIとデプロイの仕組みを整備することである。これまでたまチームは Java系の言語で開発を行っていたのだが、サービスを停止させずにプログラムを更新する方法として Blue-Green Deployment 手法を採用していた。CIのビルドでマシンイメージを作り、デプロイの際はそれらのイメージから新しいサーバーを立ち上げて、ロードバランサーの向き先を旧バージョンのサーバー群から新バージョンに切り替えるという手順だ(「Immutable Infrastructureを導入する」)。この、マシンレベルで切り替える方法であれば、言語や環境に依存せずに無停止デプロイを実現できる。

blue-green

Immutable Infrastructureの恩恵もあり、これまでこの方法で比較的安全に運用出来てはいたのだが、マシンイメージのビルドやデプロイに時間がかかるのが悩みの種であった(ビルドからデプロイまでトータルで20分ぐらいかかる)。また、マシンイメージを使った Blue-Green Deployment を完全に自動化するのも難しかったため、誰でも気軽にデプロイというわけにもいかず、若干の手順を共有する必要があった。仮にUIを微調整するだけで20分かつ若干の手作業となると、そういった微調整的な更新を本番に適用する機会は先延ばしされることが多くなる。そして、そのようにしてデプロイの粒度が大きくなって行くと、プロジェクトは減速してしまう。

Elixir/Phoenix で是非とも試したいと思っていたのが、Hot Code-Swapping によるアプリケーションのホットデプロイ(無停止更新)である。アプリケーション(のラインタイム)にホットデプロイの仕組みが備わっているなら、わざわざビルドの度にマシンイメージを作ってマシンごと入れ替える必要もなくなるし、自動化も遥かにやりやすいだろう。

結論から言えば、半月ほど試行錯誤した結果、ホットデプロイ完全自動化の形はなんとか出来上がった。ビルドからデプロイまでにかかる時間も簡単なPhoenixアプリで5、6分程度にまで削減された。今のところステージング環境だけの運用でトラブル無く更新出来ているが、本番で同じように運用出来るかは開発を進めながら様子を見ないとなんとも言えないところである。

以下が今回の自動化の全貌である。

elixir-phoenix-deploy

Developer Machine(左下)でプログラムを更新して、git push すると、CircleCI上でビルドが行われ、最終的には App Server 上で稼働中のアプリケーションが停止すること無く更新されるところまで、全自動で行われる。

図中の赤文字の部分が今回の自動化を実現しているパーツである。以降のセクションでは、(1) から順番に、これらのパーツを一つ一つ仔細に見て行く。

(※) 今回の環境構築に利用した言語やフレームワークのバージョンは以下の通りである。

  • Elixir 1.2.1 (実行環境はUbuntu用の最新版パッケージが用意されてなかったので 1.2.0)
  • Phoenix 1.1.3
  • exrm 1.0.0-rc7
  • conform 1.0.0-rc8
  • Node.js 5.1.0 (CircleCIで利用出来る最新版)

 

(1) mix.ex – バージョン番号の自動生成と Elixir Release Manager の導入

Elixirには標準で Mix というビルドツールが同梱されている。色んなタスクコマンドが提供されていて、Elixirのコードをコンパイルしたり、依存関係を自動で取得してくれたりと、Java の Maven や、Node.js の npm に相当するツールである。プロジェクトをビルドするためには、Maven の POMファイル、あるいは npm の package.json のようなプロジェクト定義ファイルが必要となる。それが以下の mix.exs ファイルだ。

defmodule ExampleApp.Mixfile do
use Mix.Project
def project do
[
app: :example_app,
version: version(),
elixir: "~> 1.2.0",
elixirc_paths: elixirc_paths(Mix.env),
compilers: [:phoenix, :gettext] ++ Mix.compilers,
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
aliases: aliases,
deps: deps
]
end
defp version do
{{year, month, day}, {hour, minute, _}} = :calendar.local_time()
version = :io_lib.format("~4..0B.~2..0B~2..0B.~2..0B~2..0B", [year, month, day, hour, minute])
|> List.flatten
|> to_string
File.write! "VERSION", version
version
end
def application do
[
mod: {ExampleApp, []},
applications: [
:phoenix,
:phoenix_html,
:cowboy,
:logger,
:gettext,
:phoenix_ecto,
:postgrex,
:conform,
:conform_exrm
]
]
end
defp elixirc_paths(:test), do: ["lib", "web", "test/support"]
defp elixirc_paths(_), do: ["lib", "web"]
defp deps do
[
{:phoenix, "~> 1.1.3"},
{:phoenix_ecto, "~> 2.0"},
{:postgrex, "0.10.0"},
{:phoenix_html, "~> 2.3"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.9"},
{:cowboy, "~> 1.0"},
{:exrm, "~> 1.0.0-rc7", override: true},
{:conform, "~> 1.0.0-rc8", override: true},
{:conform_exrm, "~> 0.2.0"}
]
end
defp aliases do
["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"]]
end
end

view raw
mix.ex
hosted with ❤ by GitHub

mix.exs で定義されている内容は他言語の場合とほとんど変わりない。ここでは今回の自動化に関連する箇所に絞って説明して行く。

バージョン番号の自動生成

7行目で、このプロジェクト(:example_app という名前が付いている)のバージョンを定義しているのだが、通常 0.0.1 のように固定のバージョン番号を書くところを、自前のversionという関数(18行目)を指定して、ビルド時点でのタイムスタンプからバージョン番号を自動生成するようにしている。git pushする度にデプロイ可能な新しいパッケージをビルドするので、その都度手動でバージョン番号を書き換えるのはめんどうだし、ライブラリと違ってアプリのバージョンを人力で決定する意義は少ないように思う。

Elixirアプリのバージョン番号は、Semantic Versioning Specification (SemVer) に従わなければならないので、version 関数を見ると、タイムスタンプを無理矢理その形式に変換しているのが分かるだろう。このやり方で本当に問題ないか、いささか心許ないところもあるのだが、今のところは問題なく機能しているようだ。

バージョン番号を生成する時に、その番号を VERSION というファイルに書き出している(23行目)。このファイルはビルド用のシェルなどがバージョン番号を参照する時などに利用する。

Elixir Release Manager

47行目の deps 関数でプロジェクトが利用する外部モジュール(依存関係)を定義する。ここに指定されている :exrm が、今回のホットデプロイの主役を務める Elixir Release Manager だ。exrm を依存関係に追加して mix do deps.get, compile すれば、リリース用のパッケージをビルドするためのタスク mix release が利用出来るようになる。

$ mix help | grep release
mix release             # Build a release for the current mix application
mix release.clean       # Clean up any release-related files
mix release.plugins     # View information about active release plugins

実は、今回の自動化環境を作る際の苦労の多くは、この exrm に起因している。現時点ではまだ version 1.0 がリリースされてないので、成熟してないが故の制限がいくつかあった:

  • プロジェクトの依存関係を漏れなく application 関数にも指定しておかなければならない
    • exrm – Common Issues – https://hexdocs.pm/exrm/extra-common-issues.html
    • application に指定しておかないと、リリースパッケージをビルドする時に依存モジュールを知る術が無いらしい。しかし、既に書いたように deps にも依存関係を定義しているので、これはDRY原則に反する。
    • しかも困ったことに、推移的な依存についても application に指定しなければならないケースがある(依存先のモジュールの application に指定されてない場合)
  • configの内容を動的に決定することができない
    • 今回最も悩まされた問題がこれである。通常、Elixirでは設定ファイルの中で、System.get_env("HOGEHOGE") のような形で関数を使い、環境変数を参照することが出来る。ところが、exrmでビルドされたパッケージだとこれが実行時に評価されず、ビルド時に評価されてしまうという問題がある。この問題の対策については以降のセクションで説明する。

参考文献

Conform – 設定ファイルのテキストファイル化

deps 関数には、:conform:conform_exrm という依存モジュールが追加されている(57, 58行目)。この Conform は、exrm と同じく Paul Schoenfelder 氏によって開発されている Elixir のコンフィグレーションを支援するためのツールだ。

基本的にElixirプロジェクトの設定情報は config/config.exs というファイルに書くことになっている。拡張子から分かるように設定内容は Elixir のコードとして表現される。以下は config.exs の簡単なサンプルだ。

use Mix.Config
config :example_app,
key1: "value1",
key2: "value2"
import_config "#{Mix.env}.exs"

view raw
config.exs
hosted with ❤ by GitHub

最後の import_config で環境ごとの設定ファイルを読み込んでいる。具体的には、開発環境用の dev.exe と自動テスト実行時用の test.exs、そして本番用の prod.exs が、それぞれ config ディレクトリの中に用意されている。

設定内容をElixirコードで表現出来るので、単なる値だけではなく式などを指定することも出来る。Elixirのコーディングに慣れているプログラマーにとってはとても便利な仕組みであるが、 Elixirに慣れていないオペレーターがコンフィグレーションを行う場合、あまり理解しやすい形式とは言えないかもしれない。この問題に対処するためのツールが Conform である。

Conformには、以下の二つの機能がある。

  1. Elixirの設定ファイルを Key-Value 形式のシンプルなテキストファイルに変換する(その際、変換ルールを定義したスキーマファイルも同時に生成される)
  2. Key-Value 形式の設定ファイルをアプリケーション起動時に読み込んで Erlang の設定に変換する

:conformdeps に追加して、mix do deps.get, compile すれば、mixに以下のような関連タスクが追加される。

$ mix help | grep conform
mix conform.configure   # Create a .conf file from schema and project config
mix conform.effective   # Print the effective configuration for the current project
mix conform.new         # Create a new .schema.exs file for configuring your app with conform

 

Conformを利用して、アプリケーション起動時(あるいはアップグレード時)に設定を適用する

さて、今回のプロジェクトで Conform を採用したのは Key-Value 形式の設定ファイルが欲しいからではなく、exrm のセクションで言及した、設定内容を実行時に決定出来ない(環境変数を利用出来ない)問題に対処するためである。

exrm の作るリリース用パッケージというのは、結局のところ、Erlang用のパッケージである。リリースやデプロイの仕組みが Erlang によって提供されているので、当然と言えば当然のことなのだが、このパッケージをビルドする際に、Elixirの設定ファイルをErlang用の設定ファイル(sys.config)に変換する必要があり、このときにElixirの設定ファイルが評価されてしまうのである。

設定内容を環境変数で与えたいと思っても、それがビルド時に評価されてしまうため、本番デプロイ時に設定することができない。これはかなり重大な問題である。ビルド時に評価されるとなると、データベースのパスワードのような機密情報をElixirの設定ファイルに直接書かなければならず、しかもビルド対象とするためにそれを GitHub 上に上げなければならなくなる。

上記リンクの三番目、Issue #90 では exrmへの機能追加として、環境変数経由で設定出来るような仕組みを提供するという予告がなされているが、そこから一年が経過して、この Issue はまだ Open のままである。

Erlang用の設定ファイル(sys.config)の中では、Elixirのように関数などを利用することは出来ず、基本的に静的な値しか保持出来ない。ところが、Issue #90 の中で RELX_REPLACE_OS_VARS という環境変数を設定すれば、sys.config の中でも環境変数を参照出来るようになるよ、という情報を見つけ、これだ! と思って試してみると、確かにアプリケーションを起動した時には環境変数を参照出来ているが、ホットデプロイの時には参照出来ない…

最終手段として、デプロイ時にリリースパッケージ内にある sys.config を直接編集してしまえば良いのだが、そのままでデプロイできるパッケージ(*.tar.gz形式のアーカイブ)を展開して処理するのには抵抗があった。色々と調べ回って Conform の設定ファイルはリリースパッケージと同じディレクトリに置いておけば起動時に適用されることが分かり、最終的にこの Conform を利用する方法に辿り着いた。

コンフィグレーションの経路を図示すると以下のような感じになる。

elixir-config

設定内容は、アプリケーションサーバーを立ち上げる時に Cloud-init 経由で渡すようにし、新しいバージョンのリリースをデプロイする度にその設定を適用する。設定内容を変更したい場合は、直接 app.conf を編集するか、cloud-boothook.sh を変更してアプリケーションサーバーを作り直す(基本的に後者の方が好ましい)。cloud-boothook.sh は、Terraform の構成ファイルと共に同じ GitHub のリポジトリで管理されているが、リポジトリ内では暗号化されている。アプリケーションサーバーの更新をする際に、作業マシン上で復号化して Cloud-init に渡す、という流れである。

実際には以下のような内容のファイルを暗号化してリポジトリに入れている。

#cloud-boothook
#!/bin/sh
# Application config
cat <<EOF > /opt/example_app/app.conf
logger.level = info
Endpoint.url.host = "example.com"
Endpoint.url.port = 443
Endpoint.secret_key_base = "rRy7Q2mUhmcgYPJfN06DiQKaUM99CDvnQGVyP4b2Zm47bHalfjZbkCe/l2oHGzmC"
Repo.username = "dbuser"
Repo.password = "dbpass"
Repo.database = "exampledb"
Repo.hostname = "database-host.com"
Repo.pool_size = 20
EOF

view raw
cloud-boothook.sh
hosted with ❤ by GitHub

 

(2) circle.yml – ビルド全体の流れ

ビルド・デプロイ自動化の中心にあるのが CircleCI というCIサービスで、そこでのタスクを定義するのが circle.yml というファイルである。この circle.yml の内容を見れば、今回の自動化の全体的な流れが分かる。

machine:
timezone:
Asia/Tokyo
services:
docker
node:
version: 5.1.0
dependencies:
cache_directories:
~/docker
override:
cp ~/.ssh/id_circleci_github ci/docker/github.pem
if [[ -e ~/docker/image.tar ]]; then docker load -i ~/docker/image.tar; else cd ci/docker && docker build -t trusty .; fi
if [[ ! -e ~/docker/image.tar ]]; then mkdir -p ~/docker; docker save trusty > ~/docker/image.tar; fi
cd deploy && bundle install
test:
override:
docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'service postgresql start && cd /build && ./ci/test.sh'
deployment:
master:
branch: master
commands:
./ci/deploy-to-staging.sh # ensure that the latest version is running in staging
./ci/get-prev-rel.sh
docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'cd /build && ./ci/build-release.sh'
|
PACKAGE="./rel/example_app/releases/`cat VERSION`/example_app.tar.gz"
DEST="s3://example-app-packages"
aws s3 cp "${PACKAGE}" "${DEST}/`cat VERSION`.tar.gz"
aws s3 cp "${PACKAGE}" "${DEST}/latest.tar.gz"
aws s3 cp ./VERSION "${DEST}/VERSION"
./ci/deploy-to-staging.sh

view raw
circle.yml
hosted with ❤ by GitHub

 

(3) Dockerfile – ビルド環境と本番環境を合わせる

exrm が生成するリリースパッケージは、Erlangのランタイムを含む完全な All-In-One パッケージである。つまり、このパッケージファイルさえあれば、Erlang や Elixir の環境を用意しなくてもそのままアプリケーションを動かすことが出来る。これは環境構築の手間を省けて便利なのだが、一方でマルチプラットフォームには対応できない。例えば、MacでビルドしたものがLinuxで動かないのはもちろんだが、同じLinuxであってもディストリビューションやバージョンが異なると動かない可能性がある。試しに、CircleCIの環境(Ubuntu 12.04 (precise))でビルドしたものを Amazon Linux 上で動くかどうかテストしてみたが OpenSSLバージョンの違いが原因で動かなかった。

というわけで、exrm のリリースパッケージをビルドする環境は、アプリケーションが稼働する環境と合わせる必要がある。ビルドはCircleCIに任せたいが、本番環境をCircleCIに依存させるのは嫌なので、CircleCI上で本番環境に近いDockerコンテナを立ち上げ、その中でビルドを行うことにした。

今のところ、本番環境には Ubuntu 14.04 (trusty) を利用している。その本番向けのビルドを行うための Dockerfile が以下である。

FROM ubuntu:trusty
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update -q
RUN apt-get -y install language-pack-ja openssl libssl-dev ncurses-dev curl git
RUN update-locale LANG=ja_JP.UTF-8
# SSH
RUN mkdir -p /root/.ssh
RUN echo "Host github.com\n\tStrictHostKeyChecking no\n" >> /root/.ssh/config
ADD github.pem /root/.ssh/id_rsa
RUN chmod 700 /root/.ssh/id_rsa
# Node.js
RUN apt-get -y install rlwrap && \
curl -o /tmp/nodejs.deb https://deb.nodesource.com/node_5.x/pool/main/n/nodejs/nodejs_5.5.0-1nodesource1~trusty1_amd64.deb && \
dpkg -i /tmp/nodejs.deb && \
rm -rf /tmp/nodejs.deb
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
# Erlang and Elixir
RUN curl -o /tmp/erlang.deb http://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb && \
dpkg -i /tmp/erlang.deb && \
rm -rf /tmp/erlang.deb && \
apt-get update -q && \
apt-get install -y erlang-base=1:18.2 erlang-dev=1:18.2 erlang-eunit=1:18.2 erlang-parsetools=1:18.2 erlang-xmerl=1:18.2 elixir=1.2.0-1 && \
apt-get clean -y && \
rm -rf /var/cache/apt/*
RUN mix local.hex –force && mix local.rebar –force
# PostgreSQL
RUN apt-key adv –keyserver keyserver.ubuntu.com –recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get -y -q install python-software-properties software-properties-common \
&& apt-get -y -q install postgresql-9.3 postgresql-client-9.3 postgresql-contrib-9.3
# Alter postgres user for test
USER postgres
RUN /etc/init.d/postgresql start \
&& psql –command "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';"
USER root

view raw
Dockerfile
hosted with ❤ by GitHub

Ubuntu 14.04 のイメージをベースに、Erlang と Elixir、自動テストの際に必要となる PostgreSQL をインストールしている。9行目から13行目でSSHのキーをインポートしているが、これは開発中のプロジェクトが GitHub のプライベートリポジトリにあるモジュールを参照しているためで、そのような依存関係がなければ本来必要ない部分である。

このDockerコンテナをどのように利用するかは circle.yml の方を参照して欲しい。ビルドの度に Dockerfile からイメージをビルドしていたら時間がかかってしまうので、ビルドしたイメージを ~/docker ディレクトリにキャッシュして、二度目以降はそちらを利用するようにしている(11行目、14, 15行目)。

テストを実行する時のコマンドは以下のような感じである(20行目):

docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'service postgresql start && cd /build && ./ci/test.sh'

CircleCI側のプロジェクトディレクトリをDockerコンテナ内の /build にマウントして、service postgresql start でデータベースを起動しておき、./ci/test.sh でテストを実行する。/etc/localtime:/etc/localtime:ro でコンテナのタイムゾーンをホスト側(Asia/Tokyo)と合わせる。これでビルド時のタイムスタンプが日本時間になる。

リリースパッケージをビルドする時のコマンドは以下(28行目):

docker run -v `pwd`:/build -v /etc/localtime:/etc/localtime:ro -i -t trusty /bin/sh -c 'cd /build && ./ci/build-release.sh'

 

(4) test.sh – 自動テストの実行

Dockerコンテナ内で実行される自動テストのシェルは以下のような簡単なものだ。

#!/bin/bash
export MIX_ENV="test"
mix do deps.get, deps.compile, compile, test

view raw
test.sh
hosted with ❤ by GitHub

 

(5) get-prev-rel.sh – 直前のバージョンを取得する

exrm のタスク、mix release を実行すると、リリースパッケージがビルドされて rel ディレクトリ以下に保存される。このとき、rel ディレクトリに直前のバージョンがあると、そこからアップグレードを行うためのファイル(relup)も一緒に生成される。常に同じマシンでリリースパッケージのビルドを行っているのであれば問題なくアップグレードファイルが出来上がるが、CIのように、毎回リポジトリからコードを持ってきてビルドする方式だと、当然以前のリリースパッケージが含まれないのでアップグレードファイルが出来ない。これに対処するため、リリースパッケージのビルド前にパッケージリポジトリ(S3)から直前のバージョンを取得して、rel ディレクトリ以下に展開している。

#!/bin/bash
export APP="example_app"
aws s3 cp s3://example-app-packages/latest.tar.gz /tmp/${APP}.tar.gz
mkdir -p rel/${APP} && rm -rf rel/${APP}/*
tar zxvf /tmp/${APP}.tar.gz -C rel/${APP}

view raw
get-prev-rel.sh
hosted with ❤ by GitHub

 

(6) build-release.sh – リリースパッケージをビルドする

rel ディレクトリに前バージョンのパッケージを用意したら、リリースパッケージのビルドを行う。

#!/bin/bash
set -ex
mix do deps.get, deps.compile, compile
node -v
npm install
export MIX_ENV="prod"
mkdir -p priv/static
node node_modules/brunch/bin/brunch build
mix do phoenix.digest, compile, release –verbosity=verbose

view raw
build-release.sh
hosted with ❤ by GitHub

Production用のパッケージをビルドするため、brunchによるアセットファイルのビルドも合わせて行っておく必要がある。

リリースパッケージは以下の場所に出来上がる。

rel/example_app/releases/(VERSION)/example_app.tar.gz

このファイルと、同時に出来上がった VERSION ファイルをパッケージリポジトリにアップロードする(circle.yml – 30行目〜34行目):

PACKAGE="./rel/example_app/releases/`cat VERSION`/example_app.tar.gz" 
DEST="s3://example-app-packages"
aws s3 cp "${PACKAGE}" "${DEST}/`cat VERSION`.tar.gz"
aws s3 cp "${PACKAGE}" "${DEST}/latest.tar.gz"
aws s3 cp ./VERSION "${DEST}/VERSION"

これでめでたくリリースの完了である。

CircleCI用 IAM User の準備

パッケージリリースの処理にもあるように、ビルドの際、CircleCIからAWS上の各種リソースにアクセスする必要がある。そのために必要な最低限の権限を付与した IAM User を作成し、CircleCIのプロジェクトに設定しておく。

以下は今回のプロジェクト用に作成した、リリース・デプロイ担当の IAM User に付与したポリシー(Inline Policies)の例である:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "1",
"Effect": "Allow",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::example-app-packages/*"
]
},
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:revokeSecurityGroupIngress"
],
"Resource": "*"
}
]
}

view raw
deploy-policies.json
hosted with ❤ by GitHub

パッケージリポジトリであるS3へのアクセスと、デプロイ時に必要になるEC2に対する権限を設定している。

 

(7) deploy-to-staging.sh – リリースパッケージをアプリケーションサーバーにホットデプロイする

パッケージのリリースが完了したら、いよいよホットデプロイを行う。

デプロイには Capistrano という自動化ツールを使い、対象となるアプリケーションサーバーにSSHでログインして、アップグレード処理が書かれたシェルを実行する。

CircleCIからEC2インスタンスへのSSHアクセスを一時的に許可する

アプリケーションサーバーにSSHするためには、CircleCI側からアクセス出来るようにセキュリティグループ(ファイアウォール)の設定をしておかなければならない。CircleCIに対するアクセス許可を永続的に行うのには抵抗があるし、そもそもCircleCI側のIPは常に変わる。

そこで、ビルドの度にCircleCIコンテナのIPアドレスを調べて、そのIPアドレスに対してデプロイの時だけ一時的にSSHアクセスを許可するようにする。

#!/bin/bash
set -ex
export AWS_DEFAULT_REGION="ap-northeast-1"
SGID="sg-xxxxxxxx"
MYIP="`dig +short myip.opendns.com @resolver1.opendns.com`"
cd $(dirname $(readlink -f $0))
trap "aws ec2 revoke-security-group-ingress –group-id ${SGID} –protocol tcp –port 22 –cidr ${MYIP}/32" 0 1 2 3 15
aws ec2 authorize-security-group-ingress –group-id ${SGID} –protocol tcp –port 22 –cidr ${MYIP}/32
cd ../deploy && ./deploy-to-staging.sh

view raw
deploy-to-staging.sh
hosted with ❤ by GitHub

自身のIPアドレスを調べる方法として、curl -s ifconfig.me というやり方がよく見られるが、これがかなり遅い上に失敗することも度々あるので DNS を利用したやり方にしてある(8行目)。

aws ec2 authorize-security-group-ingress でSSH接続を許可して(13行目)、デプロイ後、aws ec2 revoke-security-group-ingress で許可を取り消すのだが(12行目)、許可取り消しを確実に実施するために trap コマンドを利用する方法はこちらを参考にさせて頂いた(感謝)。

Capistranoでデプロイを実施する

SSH接続の許可を行った後にまた別の deploy-to-staging.sh を実行しているが(14行目)、このシェルは以下のように単に Capistrano のタスクを実行しているだけである。

#!/bin/sh
bundle exec cap staging deploy:main $1

肝心の Capistranoタスクの内容(deploy/config/deploy.rb)は以下の通り、

require 'aws-sdk'
lock '3.4.0'
set :application, 'example_app'
set :pty, true
set :user, "ubuntu"
set :use_sudo, true
set :ssh_key, "~/.ssh/example_app.pem"
set :role_name, 'example_app'
set :env_name, 'staging'
REGION = 'ap-northeast-1'
Aws.config.update({
region: REGION
})
namespace :deploy do
task :main do
invoke "deploy:set_target_instances"
invoke "deploy:upgrade_to_latest"
end
task :set_target_instances do
count = 0
ec2 = Aws::EC2::Client.new
ec2.describe_instances(
filters:[
{ name: "tag:Environment", values: [fetch(:env_name)] },
{ name: "tag:Role", values: [fetch(:role_name)] },
{ name: 'instance-state-name', values: ['running'] }
]
).reservations.each {|r|
r.instances.each {|i|
puts "#{fetch(:role_name)}(#{count += 1}): #{i.public_dns_name}"
server i.public_dns_name, roles: %w(app), user: fetch(:user), ssh_options: {
keys: [File.expand_path(fetch(:ssh_key))]
}
}
}
end
task :upgrade_to_latest do
on roles(:app) do |host|
upload! "upgrade.sh", "/tmp/upgrade.sh"
execute "chmod +x /tmp/upgrade.sh && /tmp/upgrade.sh"
end
end
end

view raw
deploy.rb
hosted with ❤ by GitHub

処理の流れは、EC2インスタンスに付けたタグ Environment, Role の値と、稼働中であることを条件にしてインスタンスを検索し、それらのインスタンスにアップグレード用のシェル upgrade.sh をアップロードして実行するというものである。

開発用マシンからもデプロイが実施出来るように10行目でSSHキーの場所を指定しているが、CircleCIの場合は SSH Permission にキーを設定しておく必要がある。

 

(8) upgrade.sh – デプロイ処理の実際

Capistranoによって実行される upgrade.sh が実際のデプロイ(アップグレード)処理を行う。

#!/bin/bash
export AWS_DEFAULT_REGION="ap-northeast-1"
APP="example_app"
APP_HOME="/opt/${APP}"
DEPLOY_TO="${APP_HOME}/releases"
S3_FOLDER="s3://example-app-packages"
# Get the specified or latest version number
aws s3 cp ${S3_FOLDER}/VERSION /tmp/VERSION
VERSION=${1:-"`cat /tmp/VERSION`"}
echo "Trying to deploy ${APP} ${VERSION}"
# Deploy
if [ ! -d "${DEPLOY_TO}/${VERSION}" ]; then
mkdir -p ${DEPLOY_TO}/${VERSION}
aws s3 cp ${S3_FOLDER}/${VERSION}.tar.gz ${DEPLOY_TO}/${VERSION}/${APP}.tar.gz
cp ${APP_HOME}/app.conf ${DEPLOY_TO}/${VERSION}/${APP}.conf # Apply config
else
echo "${APP} ${VERSION} already exists."
fi
# Upgrade
if [ ! "${VERSION}" = "`curl -s http://localhost:8080/api/public/version`" ]; then
# Upgrade
${APP_HOME}/bin/${APP} upgrade ${VERSION}
# Set the current and prev versions
if [ -e "${DEPLOY_TO}/current" ]; then
mv ${DEPLOY_TO}/current ${DEPLOY_TO}/prev
fi
echo $VERSION > ${DEPLOY_TO}/current
else
echo "${APP} ${VERSION} is already running."
fi

view raw
upgrade.sh
hosted with ❤ by GitHub

デプロイ対象のバージョンは、デフォルトでパッケージリポジトリの VERSION ファイル(つまり最新版)を参照するが、シェルへの引数として指定することも出来る(12行目)。ただ、今のところは指定出来るというだけで任意のバージョンをデプロイ出来るわけではない。

20行目で、前述のConformについてのセクションで説明したアプリケーションの設定ファイル ${APP_HOME}/app.conf を、デプロイ対象のパッケージを置いた(19行目)ディレクトリにコピーしている。これでデプロイ時に本番用の設定を適用することが出来る。

稼働中のアプリケーションのバージョンを予め用意したAPI経由で調べて(26行目)、デプロイ対象のバージョンと同じでなければ、デプロイ処理を実行する(28行目)。現段階で対応しているのはアップグレード処理だけだが、同じ要領でダウングレードも実現出来るはずだ。

以上が、git push した後に、CircleCI上で行われる処理の全貌である。今のところ、たまチームの環境では、これら全ての処理が完了するまでにかかる時間はおよそ5、6分程度である。これからプロジェクトのサイズが大きくなるに従ってこの時間は長くなると思うが、それでも以前の環境とは比較にならないぐらい快適なデプロイ環境を実現することが出来たと思う。今回の改善の多くは、Elixir/Phoenixのホットデプロイ機能に負うところが大きいが、このホットデプロイがどこまで安定して運用出来るかはまだ未知数である。今回実現した完全自動化は今のところステージング環境で運用しているが、本番で利用する際には、安全のため、アップグレードのテストを行うためのリハーサル機を用意しようと考えている。

 

アプリケーションサーバーの構成管理

これまでのセクションでCircleCI上の自動化については一通り見てきたが、デプロイ対象となるアプリケーションサーバー内の構成がどうなっているかについては触れてこなかった。デプロイを自動化するためには、当然のことながら、アプリケーションサーバー側の設定も必要になってくる。というわけで、このセクションではその辺の設定について紹介して今回の記事の締めくくりとしたい。

今回のプロジェクトでは、アプリケーション用のプロジェクト(app)とは別に、アプリケーションサーバーのマシンイメージを作るためのプロジェクト(app-image)を別途 GitHub で管理している。このプロジェクトには、サーバープロビジョニング用の Ansible コードや、プロビジョニングの結果をテストするための serverspec コードが含まれており、git push されると、CircleCI上でそれらを実行してマシンイメージ(AMI)を作るようになっている。

既に書いたように、exrm によるリリースパッケージはランタイムを含む All-in-One パッケージなので、それらの環境を予め用意しておく必要はなく、プロビジョニングが比較的楽に行える。

アプリケーションサーバーのAnsibleタスクは以下のようになっている。

app-image/ansible/roles/app/tasks/main.yml

name: Download package
command: aws s3 cp "{{ package_url }}" /tmp/app.tar.gz
environment:
AWS_ACCESS_KEY_ID: "{{ lookup('env','AWS_ACCESS_KEY_ID') }}"
AWS_SECRET_ACCESS_KEY: "{{ lookup('env','AWS_SECRET_ACCESS_KEY') }}"
name: Make app directory
file: state=directory path={{ item }}
with_items:
"{{ app_home }}"
"{{ app_home }}/log"
name: Install app
unarchive: src=/tmp/app.tar.gz dest={{ app_home }}
name: Put app config
template: src=app.conf dest={{ app_home }}/
name: Ensure app directory belongs to ubuntu
file: "state=directory path={{ app_home }} owner=ubuntu group=ubuntu recurse=true"
name: Symlink log directory
file: src={{ app_home }}/log path=/var/log/example_app state=link
name: Put upstart config
template: src=example_app.conf dest=/etc/init/
name: Start and enable services
service: name={{ item }} state=started enabled=yes
with_items:
example_app
nginx
name: Wait for app to start
wait_for: port={{ app_port }} delay=10
name: Ensure app is running
uri:
url: http://localhost:{{ app_port }}/api/public/version
status_code: 200
timeout: 600

view raw
main.yml
hosted with ❤ by GitHub

パッケージリポジトリからリリースパッケージを取ってきてインストール、設定ファイルやログ関連の準備をして、サービスを起動するだけである。

マシンイメージのデフォルト設定

17行目のタスクで、アプリケーションの設定ファイル app.conf を配置している。「Conformを利用して、アプリケーション起動時に設定を適用する」で説明した通り、この設定ファイルは、マシンイメージからサーバーインスタンスを起動する時に Cloud-init 経由で実際の値に書き換えられるため、マシンイメージに含めるファイルの内容は、以下のようなデフォルトの設定にしてある。

app-image/ansible/roles/app/templates/app.conf

logger.level = info

Endpoint.url.host = "example.com"
Endpoint.url.port = 443

Endpoint.secret_key_base = "default_secret_key_base"

Repo.username = "postgres_user"
Repo.password = "postgres_pass"
Repo.database = "gyron_connect_dev"
Repo.hostname = "localhost"
Repo.pool_size = 20

実際の設定は Cloud-init で行われるとは言え、マシンイメージにあるデフォルトの設定でもアプリケーションを起動出来るようにしておきたい。そうしておくことで、マシンイメージの動作確認やテストがやりやすくなる。

アプリケーションを Upstart に登録する

マシンイメージから新しいサーバーを立ち上げるとき、あるいは何らかの理由でサーバーが再起動された場合に、アプリケーションも一緒に起動するようにしておきたい。そのためにはアプリケーションをサービスとして登録しておく必要があるが、Ubuntu 14.04 には Upstart という仕組みがあるのでそれを利用する。

上のAnsibleタスクでは、27行目でUpstart用の設定ファイル(/etc/init/example_app.conf)を配置している。

app-image/ansible/roles/app/templates/example_app.conf

description "Example App"
setuid ubuntu
setgid ubuntu
start on runlevel [2345]
stop on runlevel [016]
expect stop
respawn
env MIX_ENV=prod
export MIX_ENV
env PORT=8080
export PORT
env HOME={{ app_home }}
export HOME
pre-start exec /bin/sh {{ app_home }}/bin/example_app start
post-stop exec /bin/sh {{ app_home }}/bin/example_app stop

view raw
example_app.conf
hosted with ❤ by GitHub

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中