いよいよ先月から、たまチームの新規プロジェクトとして Elixir/Phoenix によるWebサービスの開発が始まった。というわけで、今回はたまチームでの事例第一弾として、この半月で構築したホットデプロイ自動化の仕組みについて紹介したい。まだプロジェクトも初期段階なので未熟な部分も多いが、これまで Elixir/Phoenix に触れたことがない人でも、その雰囲気を感じて頂ければ幸いである。
自動化の全体像
開発を始めるに当たって、まず最初にやっておきたいのが CIとデプロイの仕組みを整備することである。これまでたまチームは Java系の言語で開発を行っていたのだが、サービスを停止させずにプログラムを更新する方法として Blue-Green Deployment 手法を採用していた。CIのビルドでマシンイメージを作り、デプロイの際はそれらのイメージから新しいサーバーを立ち上げて、ロードバランサーの向き先を旧バージョンのサーバー群から新バージョンに切り替えるという手順だ(「Immutable Infrastructureを導入する」)。この、マシンレベルで切り替える方法であれば、言語や環境に依存せずに無停止デプロイを実現できる。

Immutable Infrastructureの恩恵もあり、これまでこの方法で比較的安全に運用出来てはいたのだが、マシンイメージのビルドやデプロイに時間がかかるのが悩みの種であった(ビルドからデプロイまでトータルで20分ぐらいかかる)。また、マシンイメージを使った Blue-Green Deployment を完全に自動化するのも難しかったため、誰でも気軽にデプロイというわけにもいかず、若干の手順を共有する必要があった。仮にUIを微調整するだけで20分かつ若干の手作業となると、そういった微調整的な更新を本番に適用する機会は先延ばしされることが多くなる。そして、そのようにしてデプロイの粒度が大きくなって行くと、プロジェクトは減速してしまう。
Elixir/Phoenix で是非とも試したいと思っていたのが、Hot Code-Swapping によるアプリケーションのホットデプロイ(無停止更新)である。アプリケーション(のラインタイム)にホットデプロイの仕組みが備わっているなら、わざわざビルドの度にマシンイメージを作ってマシンごと入れ替える必要もなくなるし、自動化も遥かにやりやすいだろう。
結論から言えば、半月ほど試行錯誤した結果、ホットデプロイ完全自動化の形はなんとか出来上がった。ビルドからデプロイまでにかかる時間も簡単なPhoenixアプリで5、6分程度にまで削減された。今のところステージング環境だけの運用でトラブル無く更新出来ているが、本番で同じように運用出来るかは開発を進めながら様子を見ないとなんとも言えないところである。
以下が今回の自動化の全貌である。

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 |
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でビルドされたパッケージだとこれが実行時に評価されず、ビルド時に評価されてしまうという問題がある。この問題の対策については以降のセクションで説明する。
参考文献
- Exrm Releases · Phoenix
- Deploying Phoenix Applications with Exrm — Medium
Conform – 設定ファイルのテキストファイル化
- bitwalker/conform: Easy release configuration for Elixir apps!
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" |
最後の import_config
で環境ごとの設定ファイルを読み込んでいる。具体的には、開発環境用の dev.exe
と自動テスト実行時用の test.exs
、そして本番用の prod.exs
が、それぞれ config
ディレクトリの中に用意されている。
設定内容をElixirコードで表現出来るので、単なる値だけではなく式などを指定することも出来る。Elixirのコーディングに慣れているプログラマーにとってはとても便利な仕組みであるが、 Elixirに慣れていないオペレーターがコンフィグレーションを行う場合、あまり理解しやすい形式とは言えないかもしれない。この問題に対処するためのツールが Conform である。
Conformには、以下の二つの機能がある。
- Elixirの設定ファイルを Key-Value 形式のシンプルなテキストファイルに変換する(その際、変換ルールを定義したスキーマファイルも同時に生成される)
- Key-Value 形式の設定ファイルをアプリケーション起動時に読み込んで Erlang の設定に変換する
:conform
を deps
に追加して、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 を利用する方法に辿り着いた。
- Release Configuration · exrm
コンフィグレーションの経路を図示すると以下のような感じになる。

設定内容は、アプリケーションサーバーを立ち上げる時に 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 |
(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 |
(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 |
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 |
(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} |
(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 |
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": "*" |
|
} |
|
] |
|
} |
パッケージリポジトリである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 |
自身のIPアドレスを調べる方法として、curl -s ifconfig.me
というやり方がよく見られるが、これがかなり遅い上に失敗することも度々あるので DNS を利用したやり方にしてある(8行目)。
- linux – How can I get my external IP address in bash? – Unix & Linux Stack Exchange
aws ec2 authorize-security-group-ingress
でSSH接続を許可して(13行目)、デプロイ後、aws ec2 revoke-security-group-ingress
で許可を取り消すのだが(12行目)、許可取り消しを確実に実施するために trap コマンドを利用する方法はこちらを参考にさせて頂いた(感謝)。
- CircleCI から deploy させる話 – scramble cadenza
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 |
処理の流れは、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 |
デプロイ対象のバージョンは、デフォルトでパッケージリポジトリの VERSION
ファイル(つまり最新版)を参照するが、シェルへの引数として指定することも出来る(12行目)。ただ、今のところは指定出来るというだけで任意のバージョンをデプロイ出来るわけではない。
20行目で、前述のConformについてのセクションで説明したアプリケーションの設定ファイル ${APP_HOME}/app.conf
を、デプロイ対象のパッケージを置いた(19行目)ディレクトリにコピーしている。これでデプロイ時に本番用の設定を適用することが出来る。
稼働中のアプリケーションのバージョンを予め用意したAPI経由で調べて(26行目)、デプロイ対象のバージョンと同じでなければ、デプロイ処理を実行する(28行目)。現段階で対応しているのはアップグレード処理だけだが、同じ要領でダウングレードも実現出来るはずだ。
- Upgrades and Downgrades – exrm v1.0.0-rc7
以上が、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 |
パッケージリポジトリからリリースパッケージを取ってきてインストール、設定ファイルやログ関連の準備をして、サービスを起動するだけである。
マシンイメージのデフォルト設定
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 |