Kubernetes で実現する Phoenix/Elm アプリのホットデプロイ自動化完全詳解(2016年12月版)

今年の初頭に「Phoenixアプリのホットデプロイ完全自動化」の記事を書いてから一年が過ぎようとしている。この自動化は Elixir/Erlang の Hot swapping 機能を利用していて、git push から10分以内でデプロイが完了するという、当時としてはそこそこ満足のいく達成だったのだが、こんな不具合や、exrm (Elixir Release Manager) 作者の「hot upgrades はあんまりオススメ出来ない発言」などを見るにつけ、これを本番で使うのはちょっと辛いかもしれないと思うようになった。

今回、Cotoami プロジェクト を始めるに当たって、前々から気になっていた Google の Kubernetes(クバネテス)を試してみようと思い立った。そして実際に自動化の仕組みを構築してみて、その簡単さと仕組みの先進さに驚いた。言語に依存しないマイクロサービスのパッケージングと、それらを組み合わせて簡単にスケーラブルなWebサービスを構築できる仮想環境。これで本格的にコンテナの時代が来るんだなという新しい時代の訪れを感じずにはいられない。

というわけで、以下では Kubernetes を使った自動化の詳細について紹介したいと思う。この仕組みの全貌は Cotoami プロジェクトの一部として公開しているので、興味のある方は以下の GitHub プロジェクトを覗いて頂ければと思う。



 

Kubernetes とは何か?

Kubernetes が提供する仕組みは Container Orchestration と呼ばれている。Container Orchestration とは、Docker のようなコンテナ(アプリケーションを実行環境ごとパッケージングする仕組み)で実現されている小さなサービス(マイクロサービス)を組み合わせて、より大きなサービスを作るための仕組みである。

今では、Webサービスを複数のサービス(プロセス)の連携として実現することが当たり前になって来ている。次第に細かくなりつつあるこれらのサービスを扱う時の最大の障害が従来型の重い仮想化だ。例えば、Amazon Machine Images (AMI) のような従来型の仮想化技術を使ってサービスを更新する場合、イメージをビルドするのに20分から30分程度、更にそれを環境にデプロイするのに10分以上かかってしまう。自動化も容易ではない。サービスの数が多くなるほどに時間的なペナルティが積み重なってしまい、マイクロサービスのメリットを享受するのは難しくなる。なので、実際はマシンイメージをデプロイの単位にすることはせずに、言語やフレームワーク固有のパッケージに頼ったデプロイを行っている現場が多いのではないだろうか。

これらの問題を一挙に解決しようとするのが、Docker のような軽い仮想化と、それらをまるでソフトウェアモジュールのように組み合わせることを可能にする Container Orchestration 技術である。

 

Kubernetes を最短で試す

複数サービスの連携を、ローカルマシンで簡単に試せるというのも Kubernetes のようなツールの魅力だ。Kubernetes には Minikube というスグレモノのツールが用意されていて、ローカルマシン上に、お手軽に Kubernetes 環境を立ち上げることが出来る。

以下では、Mac OS X での手順を紹介する。

1. VirtualBox をインストールする

筆者の環境:

$ vboxmanage --version
5.1.8r111374

2. Minikube をインストールする

$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/v0.12.2/minikube-darwin-amd64 && chmod +x minikube && sudo mv minikube /usr/local/bin/

$ minikube version
minikube version: v0.12.2

3. Kubernetes を操作するためのコマンドツール kubectl をインストールする

$ curl -Lo kubectl https://storage.googleapis.com/kubernetes-release/release/v1.3.0/bin/darwin/amd64/kubectl && chmod +x kubectl && sudo mv kubectl /usr/local/bin/

4. Minikube を起動する

$ minikube start
Starting local Kubernetes cluster...
Kubectl is now configured to use the cluster.

以下のような情報を見れれば、準備は完了。

$ kubectl cluster-info
Kubernetes master is running at https://192.168.99.101:8443
KubeDNS is running at https://192.168.99.101:8443/api/v1/proxy/namespaces/kube-system/services/kube-dns
kubernetes-dashboard is running at https://192.168.99.101:8443/api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard

$ kubectl get nodes
NAME       STATUS    AGE
minikube   Ready     5d

5. サンプルプロジェクトをデプロイしてみる

Kubernetes には色んなサンプルプロジェクトが用意されているが、ここでは Guestbook という簡単なアプリを試してみる。

以下のファイル(guestbook-all-in-one.yaml)を適当な場所に保存して、

https://github.com/kubernetes/kubernetes/blob/master/examples/guestbook/all-in-one/guestbook-all-in-one.yaml

以下のコマンドを実行してデプロイする。

$ kubectl create -f guestbook-all-in-one.yaml 
service "redis-master" created
deployment "redis-master" created
service "redis-slave" created
deployment "redis-slave" created
service "frontend" created
deployment "frontend" created

これによって、以下の3つの Deployments と、

$ kubectl get deployments
NAME           DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
frontend       3         3         3            3           5m
redis-master   1         1         1            1           5m
redis-slave    2         2         2            2           5m

それぞれの Deployments に対応する3つの Serviceskubernetesはシステムのサービスなので除く)が出来上がっていることが分かる。

$ kubectl get services
NAME           CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
frontend       10.0.0.118   <none>        80/TCP     7m
kubernetes     10.0.0.1     <none>        443/TCP    6d
redis-master   10.0.0.215   <none>        6379/TCP   7m
redis-slave    10.0.0.202   <none>        6379/TCP   7m

簡単に説明すると、Deployment は一つのマイクロサービスのクラスタに対応し、Service はそのクラスタへのアクセス手段を提供する。

たったこれだけの手順で、冗長化された Redis をバックエンドにした、アプリケーションの環境が出来上がってしまった。構成の全ては guestbook-all-in-one.yaml というテキストファイルに定義されている。

早速ブラウザでアクセスして試してみたいところだが、デフォルトの設定だとサービスが Kubernetes の外部には公開されていないので、frontendサービスの設定をちょっと書き換えて(guestbook-all-in-one.yaml に以下のような感じで type: NodePort の行を追加する)、

apiVersion: v1
kind: Service
metadata:
  name: frontend
  labels:
    app: guestbook
    tier: frontend
spec:
  ports:
  - port: 80
  type: NodePort
  selector:
    app: guestbook
    tier: frontend

以下のコマンドを実行して設定ファイルの更新を環境に適用する。

$ kubectl apply -f guestbook-all-in-one.yaml 

更新が完了したら、以下のコマンドでアプリケーションのURLを知ることが出来る。

$ minikube service frontend --url
http://192.168.99.101:31749

以下のようなページが表示されただろうか?

guestbook

6. お片づけ

先ほどのサンプルプロジェクトで作ったリソースは、以下のコマンドで全部削除出来る。

$ kubectl delete -f guestbook-all-in-one.yaml

Minikube の停止は以下。

$ minikube stop

 

Phoenix/Elm アプリの Docker イメージを作る

さて、Cotoami の話に戻ろう。Cotoami では、以下のような構成で自動化を実現しようとしている。

cotoami-auto-deploy

CircleCI 上のビルドで Docker イメージをビルドして Docker Hub にリリース。その後、AWS上に構築した Kubernetes に更新の命令を出して、新しいイメージでアプリケーションの Rolling Update(無停止デプロイ)を行う。

この仕組みを構築するためには、まず Phoenix/Elm アプリケーションを Docker でパッケージングするための Dockerfile を用意する必要がある。しかし、ここで気をつけなければならないのは、パッケージングそのものよりも、CircleCI 上でどうやって Phoenix/Elm アプリケーションをビルドするかという問題である。

Elixirアプリケーションは、クロスコンパイル・ビルドが出来るという説明もあるが、実行環境とビルド環境は合わせておいた方が良いというアドバイスもよく見かけるので、Cotoami ではよりトラブルが少なそうな、環境を合わせるアプローチを取ることにした。

今回の例では、実行環境も Docker 上になるので、まずビルド用の Docker イメージを用意しておき、それを使ってアプリケーションのコンパイルとテストを行い、その後、そのイメージをベースにしてアプリケーションをパッケージングするという、docker build の二段構え方式でビルドを実施する。

まずは、以下の Dockerfile で Phoenix/Elm アプリのビルド環境を作る。

一度作ったイメージは、CircleCI のキャッシュディレクトリに入れておき、後々のビルドで使い回せるようにしておく。この辺の設定は全て circle.yml に書く。

アプリケーションのコンパイルとテストが終わったら、ビルド用のイメージをベースにして、アプリケーションのパッケージングを行う。そのための Dockerfile が以下である。

これらの組み合わせで、git push する度に、Docker Hub にアプリケーションのイメージがリリースされるようになる(Docker Hub に docker push するために、CircleCI に 認証用の環境変数を設定しておくこと: DOCKER_EMAIL, DOCKER_USER, DOCKER_PASS)。

参考: Continuous Integration and Delivery with Docker – CircleCI

 

AWS上に Kubernetes 環境を作る

アプリケーションの Docker イメージが用意出来たら、それを動かすための Kubernetes 環境を作る。今回は AWS 上に Kubernetes 環境を構築することにした。

Kubernetes から kops という、これまた便利なツールが提供されていて、これを使うと簡単に環境を構築出来る。

1. kops のインストール

Mac OS の場合:

$ wget https://github.com/kubernetes/kops/releases/download/v1.4.1/kops-darwin-amd64
$ chmod +x kops-darwin-amd64
$ mv kops-darwin-amd64 /usr/local/bin/kops

2. Kubernetes 用のドメイン名を用意する

ここが比較的厄介なステップなのだが、kops による Kubernetes 環境はドメイン名を名前空間として利用する仕組みになっている。具体的には、Route 53 内に Kubernetes 環境用の Hosted zone を作る必要がある。

例えば、立ち上げようとしているWebサービスのドメインが example.com だとすれば、k8s.example.com のような専用の Hosted zone を用意する(k8s は Kubernetes の略称)。

Cotoami の場合、AWS のリソースは出来るだけ Terraform を利用して管理することにしているので、Terraform で Hosted zone を設定する際の例を以下に置いておく。

resource "aws_route53_zone" "main" {
  name = "example.com"
}

resource "aws_route53_zone" "k8s" {
  name = "k8s.example.com"
}

resource "aws_route53_record" "main_k8s_ns" {
  zone_id = "${aws_route53_zone.main.zone_id}"
  name = "k8s.example.com"
  type = "NS"
  ttl = "30"
  records = [
    "${aws_route53_zone.k8s.name_servers.0}",
    "${aws_route53_zone.k8s.name_servers.1}",
    "${aws_route53_zone.k8s.name_servers.2}",
    "${aws_route53_zone.k8s.name_servers.3}"
  ]
}

主ドメインとなる example.com の Hosted zone について、サブドメイン k8s の問い合わせを委譲するような NS レコードを登録しておくのが味噌。

以下のコマンドを叩いて、DNSの設定がうまく行っているかを確認する。

$ dig NS k8s.example.com

上で設定した4つの NS レコードが見えれば OK。

3. kops の設定を保存するための S3 bucket を作る

kops は、Amazon S3 上に保存された構成情報に基づいて環境の構築・更新などを行う。というわけで、予めそのための S3 bucket を作っておき、その場所を環境変数 KOPS_STATE_STORE に設定する。

$ aws s3 mb s3://kops-state.example.com
$ export KOPS_STATE_STORE=s3://kops-state.example.com

これで、準備は完了。いよいよ Kubernetes の環境を立ち上げる。

4. Kubernetes の設定を生成する

新しい環境の名前を staging.k8s.example.com として、以下のコマンドで新規環境の設定を生成する。生成された設定は先ほどの S3 bucket に保存される。

$ kops create cluster --ssh-public-key=/path/to/your-ssh-key.pub --zones=ap-northeast-1a,ap-northeast-1c staging.k8s.example.com

Kubernetes ノードにログインするための ssh キーや、ノードを展開する Availability Zone などを指定する。細かいオプションについては、以下を参照のこと。

デフォルトでは、以下のような構成の環境が立ち上がるようになっている。

  • master (m3.medium)
  • node (t2.medium * 2)

5. Kubernetes 環境を立ち上げる

Kubernetes 環境を AWS 上に立ち上げる。単純に以下のコマンドを実行すれば良いのだが、

$ kops update cluster staging.k8s.example.com --yes

Terraform の設定ファイルを生成するオプションもあるので、Cotoami ではその方法を取ることにした。

$ kops update cluster staging.k8s.example.com --target=terraform

$ cd out/terraform
$ terraform plan
$ terraform apply

生成されたデフォルトの構成から、セキュリティグループなどをより安全な設定にカスタマイズすることもあると思われるが、これらのファイルは自動生成によって更新される可能性があることに注意する必要がある。ファイルを直接編集すると、新しく生成したファイルに同じ変更を施すのを忘れてしまう可能性が高い。なので、AWS のコンソール上で直接カスタマイズした方が良いかもしれない(新しい設定ファイルとの齟齬は terraform plan の時に気づける)。

どのようなファイルが生成されるか興味のある方は、Cotoami のリポジトリを覗いてみて欲しい。

環境を立ち上げる過程で、kop によって kubectl の設定も自動的に追加されている。以下のコマンドを実行すれば、AWS上の環境に接続していることが確認できるはずだ。

$ kubectl cluster-info
Kubernetes master is running at https://api.staging.k8s.example.com
KubeDNS is running at https://api.staging.k8s.example.com/api/v1/proxy/namespaces/kube-system/services/kube-dns

 

Kubernetes 上にアプリケーションをデプロイする

Kubernetes の準備は整ったので、後はアプリケーションをデプロイするだけである。Minikube のところでサンプルアプリをデプロイしたのと同じように、サービスの構成情報を YAML ファイルに定義しておき、kubectl create コマンドでデプロイを行う。

Cotoami の構成ファイルは以下に置いてある。

$ kubectl create -f deployment.yaml
$ kubectl create -f service.yaml

設定ファイルの仕様については Kubernetes のサイトを参照して頂くとして、内容自体は単純だということはお分かり頂けると思う。deployment.yaml では、アプリケーションの Docker イメージ名やクラスタを構成するレプリカの数、ポート番号などが指定されている。service.yaml では、そのサービスを外部にどのように公開するかという設定がされており、面白いのは type: LoadBalancer と書いておくと、AWS の ELB が自動的に作成されてアプリケーションのエンドポイントになるところだろうか。

 

デプロイ自動化をビルド設定に組み込む

最初のデプロイが無事に成功すれば、無停止更新の仕組みは Kubernetes 上に用意されている。後はそれを利用するだけである。

CircleCI から Kubernetes にアクセスするためには、以下のような準備が必要になる。

  1. kubectl のインストール
  2. kubectl の設定
    • ensure-kubectl.sh では、環境変数 S3_KUBE_CONF に設定された Amazon S3 のパスから kubectl の設定ファイルをビルド環境にコピーする。
    • Kubernetes on AWS を構築する過程でローカルに出来上がった設定ファイル ~/.kube/config を S3 にコピーして、その場所を CircleCI の環境変数 S3_KUBE_CONF に設定する。
      • この設定ファイルには、Kubernetes にアクセスするための credential など、重要な情報が含まれているので、取り扱いには注意すること!
    • CircleCI 側から S3 にアクセスするためのユーザーを IAM で作成して最低限の権限を与え、その credential を CircleCI の AWS Permissions に設定する。

これらの設定が完了すれば、ビルド中に kubectl コマンドを呼び出せるようになる。Cotoami の場合は、circle.ymldeployment セクションに、以下の二行を追加するだけで自動デプロイが行われるようになった。

https://github.com/cotoami/cotoami/blob/auto-deployment/circle.yml

- ~/.kube/kubectl config use-context tokyo.k8s.cotoa.me
- ~/.kube/kubectl set image deployment/cotoami cotoami=cotoami/cotoami:$CIRCLE_SHA1

長くなってしまったが、以上が自動化の全貌である。

Elixir/Phoenix と Elm による関数型 Web 開発環境の構築

前回は、Cotoami のアーキテクチャについて、コレオグラフィ型を採用するという話を書いた。しかし、開発の最初からコレオグラフィを前提にした構成にするのはスモールスタートとは言い難いので、まずは核となるWebアプリケーションを作るところから初めて、徐々にイベント駆動の箇所を増やしてく感じで進めたい。

このWebアプリケーションを実装する環境として選んだのが、Phoenix FrameworkElm である。両方とも関数型の言語なので、Webアプリケーション全体を関数型の枠組みで実装することになる。

Elixirの強みについてはゆびてくで何度か触れているのでここでは割愛するが、Elm を選択したのは何故だろうか?

大きな要因としては、Elmアプリのアーキテクチャを参考にデザインされたという JavaScript のライブラリ Redux での開発経験が挙げられる。その過程で、複雑化するフロントエンドを実装する技術として、全てのビジネスロジックを「変換の連鎖」へと落とし込む関数型の有効性を実感した(参考: 関数型つまみ食い: 関数型とはプログラミング言語ではなく、プログラムデザインの問題であることに気づく | ゆびてく)。Elm の場合は、Redux では冗長になりがちだったこの仕組みを簡潔に表現出来る上に、Static Typing があるというのも大きなアドバンテージだと考えた。

エディタ上で即座にフィードバックを受けることが出来る
プログラムの誤りについて、エディタ上で即座にフィードバックを受けることが出来る

Phoenix と Elm の相性については、最近 Elm 側で Phoenix のサポートが入ったというのが明るい材料ではあるが… こればかりは試してみないと分からない。


[2016/12/09追記]

素晴らしいツッコミを頂く。


 
以下に Phoenix/Elmアプリケーションのひな形を作るまでの手順をまとめてみた。

 

関連ツールのインストール

Node.js

以下を参考に nvm をインストールする。

creationix/nvm: Node Version Manager – Simple bash script to manage multiple active node.js versions

  • Phoenixのサイトに「Phoenix requires version 5.0.0 or greater.」とある。

筆者の環境:

$ node -v
v5.4.1

 

Elixir

Installing Elixir - Elixir

Mac OS X で Homebrew を利用している場合。

$ brew update
$ brew install elixir

筆者の環境:

$ elixir -v
Erlang/OTP 19 [erts-8.0.2]  [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Elixir 1.3.4

 

PostgreSQL

標準構成の Phoenix が利用するデータベース。環境によってパッケージも様々なのでインストール方法については割愛。データベースを使わないのであれば省略可。

以下のコマンドでデータベース一覧が取得出来ればデータベースのスタンバイは出来ている。

$ psql -l

筆者の環境:

# SELECT version();
                                                              version                                                              
-----------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 9.4.0 on x86_64-apple-darwin13.4.0, compiled by Apple LLVM version 6.0 (clang-600.0.56) (based on LLVM 3.5svn), 64-bit
(1 row)

 

Phoenix

Installation · Phoenix

$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez

筆者の環境:

$ mix phoenix.new -v
Phoenix v1.2.1

 

Elm

インストーラが用意されているので簡単。

Install · An Introduction to Elm

筆者の環境:

$ elm -v
0.18.0

 

Phoenix/Elm アプリケーションを作る

Phoenixアプリのひな形を作る

$ mix phoenix.new cotoami

依存関係の取得とデータベースの作成。

$ cd cotoami
$ mix deps.get
$ mix ecto.create   # PostgreSQLを使わなければ省略可
$ npm install

アプリを起動してブラウザでチェックしてみる。

$ mix phoenix.server

http://localhost:4000 にアクセスすると「Welcome to Phoenix!」のページが表示される。

 

elm-brunch をセットアップする

Phoenix に標準で付いてくる Brunch というJavaScriptのビルドツールがあるのだが、elm-brunch という Elm をビルドするための拡張があるのでそれをインストールする。

$ npm install --save-dev elm-brunch

brunch-config.js に elm-brunch の設定を追加。以下の二カ所を修正。

  1)
    ...
    watched: [
      "web/static",
      "test/static",
      "web/elm"
    ],
    ...

  2)
  ...
  plugins: {
    elmBrunch: {
      elmFolder: "web/elm",
      mainModules: ["App.elm"],
      outputFolder: "../static/vendor"
    },
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/web\/static\/vendor/]
    }
  },
  ...

 

Elmアプリのひな形を作る

$ mkdir web/elm && touch web/elm/App.elm
$ cd web/elm
$ elm package install elm-lang/html

App.elm の内容を以下のように編集。

module App exposing (..)

import Html exposing (Html, text)

main : Html msg
main =
  text "Hello Cotoami!"

 

ElmアプリをPhoenixアプリに配置する

Phoenixアプリのファイルをそれぞれ以下のように編集。

web/templates/layout/app.html.eex

web/templates/page/index.html.eex

<div id="elm-container"></div>

web/static/js/app.js に以下の二行を追記:

const elmDiv = document.querySelector("#elm-container")
const elmApp = Elm.App.embed(elmDiv)

これで準備は完了。ブラウザをリロードすると「Hello Cotoami!」と表示される。さらには、App.elm の内容を編集して保存すると、ブラウザが自動的にリロードされて即座に変更を確認出来るようになっているはずだ。

参考: Setting up Elm with Phoenix – Medium

Elixir試飲録 (7) – Erlangの軽量プロセスはどのように実現されているのか?

Elixir/Erlang の最重要コンセプトは「並行指向プログラミング」である、というのは既に書いた通りなのだが、この並行指向プログラミングを可能にする、いわゆる「軽量プロセス」がどのように実現されているのかという情報については、Erlang VM のソースコード以外にまとまった情報はなかなか無いようである。

The Erlang Runtime System』という書籍が来年発売予定らしいので、そこで体系的に解説される事になると思うが、とりあえず今回は筆者がWebで見つけた情報を簡単にまとめておきたいと思う。それぞれのコンセプトには分かる範囲で対応するソースコードへのリンクも貼っておいたので、興味のある方はそちらから詳細を追って頂ければと思う。

ちなみにこの情報の主な元ネタはredditに投稿された以下のコメントである。

 

Everything is a term

Erlangでは、全ての値が Term と呼ばれる固定長データ(32あるいは64bitの整数)の組み合わせで表現されている。

ソースコード上で見ると、Term は単にC言語の unsigned long 型へのエイリアスであることが分かる。

typedef unsigned long Eterm;

 
Term にはタグと呼ばれるメタ情報が含まれていて、そこを見れば Term に含まれるデータの種類が分かるようになっている。

term

ヒープやスタックといったデータ構造も内部的には Term の配列として実現されている。

 

A process is a C structure

軽量プロセスの正体は、process という名前の C の構造体である。

この構造体は以下のようなデータで構成されている。

  • ヒープ領域
  • スタック(Term の配列としてヒープ領域の末尾に配置されている
  • レジスタ(関数の引数)
  • インストラクション・ポインタ
  • メッセージ・キュー(受信したメッセージ本体はヒープ領域内にコピーされ、キューには本体へのポインタが格納される)
  • 親プロセスのPID

Erlangでプロセスを生成するとき、内部で行われているのは、メモリ領域を確保してこの構造体を初期化することと、その構造体へのポインタをスケジューリングのための実行キュー(Run Queue)に登録することだけである。そして、この時に必要なメモリ領域は、たったの 309 ワード(32ビット環境では1ワード4バイト)である。これが「Elixir/Erlangで一つのプロセスを作るのは一つのオブジェクトを作るのと同じぐらいの感覚」と言われる所以だ。

プロセスを生成するのに必要なメモリ 309 ワードの内、初期のヒープとして確保されている領域は 233 ワードである。このようにヒープの初期サイズを小さくしている理由は「Erlangのシステムが何十万、何百万というプロセス数をサポートをするために、極めて保守的に設定されているから」だと説明されている。

erlang-process-memory

 

Scheduler

Erlang が他の言語環境と比較して本当にユニークなのは、並行タスクを処理するプリエンプティブなスケジューリングシステムを、OSに頼るのではなくて、言語環境内に独自に持っているところである。

ここで言うスケジューリングとは、コンピュータ資源を複数のプロセスに割り当てる仕組みのことを言う。スケジューリングは OS などでマルチタスクを実現する際のコアとなる部品で、以下の二種類に分類される事が多い。

  1. プリエンプティブ(Preemptive)
    • スケジューラの側で資源の割り当てをコントロールする。制御を強制的に横取りするので「プリエンプション (preemption, 横取り) 」と呼ばれる。
  2. 協調的(Cooperative)
    • 各プロセスが資源の管理に責任を持つ。スケジューラの実装は楽だが、割り当ての公平性を保つのが難しく、素行の悪いプロセスがシステム全体を道連れにする恐れあり。

Erlang は、その出自上、高度なリアルタイム性と並行性が要求されていたので、より堅牢なマルチタスクを実現出来るプリエンプティブなスケジューリングを選択している。

Erlang におけるスケジューラは、内部的には一つのCPUコアに割り当てられた OS のスレッドである。そのスレッドの中でループ処理が走り、予め設定されたルールに基づいてプロセスを実行する。

scheduler

上図のように、スケジューラはキューからプロセスを一つ取り出し、インストラクション・ポインタが指している箇所から処理を再開させる。

プリエンプティブなスケジューリングで重要なのは、限られた資源をいかに公平に分配するかということである。Erlang では、計算コストに reduction という仮想の単位を導入してこの割り当てを行っている。例えば、一つの関数を呼び出すのは大体 1 reduction ぐらいのコストになる。プロセスには一度に消費出来る reduction 数の上限が設定されていて(バージョン R12B では 2000)、この上限に達する度に、スケジューラは実行するプロセスを切り替えて行く。

R11B より前の Erlang だと SMP(マルチコア)サポートがないために、Erlangランタイム全体で一つのスケジューラ(メインスレッド)しかなかったのだが、R11B 以降では、複数のスレッドを別々のCPUコアに割り当てることによって、複数のスケジューラが同時に動くようになった。

R11B から R13B にかけて、スケジューリングの仕組みは以下のように進化したようである。

1) 全体で一つのスケジューラ

scheduler

2) 複数のスケジューラが同じ実行キューを参照

scheduler2

3) 複数のスケジューラがそれぞれの実行キューを持つ

scheduler3

2) の方式だと一つの実行キューがボトルネックになってしまうということで、スケジューラごとにキューを持たせたのが 3) であるが、この方式でも複数のキューの間でサイズや負荷の偏りが出たらどうするのかという問題がある。今の Erlang ではこの偏りを均すために Migration Logic という仕組みを導入している。

一つ一つのスケジューラは、他のスケジューラを定期的にチェックして、自分より大きなサイズのキューを抱えているようだったら、そこからいくつか実行待ちのプロセスを自分のキューに移動してしまう。これが Migration Logic の仕組みである。

 

Message Passing

Erlang並行処理の要であるプロセス間のメッセージ送信はとてもシンプルな仕組みで実現されている。

  1. 名前あるいはPIDを指定して、送信先のプロセス構造体をレジストリから取得する
  2. プロセス構造体にあるメッセージ・キュー(mailboxと呼ばれる)をロックする
  3. 送信元プロセスのヒープ領域にあるメッセージ(Term)を送信先プロセスのヒープ領域にコピーする
  4. コピーしたメッセージへのポインタをメッセージ・キューに追加する
  5. メッセージ・キューのロックを解除する

もし、あるプロセスがメッセージ待ち(receive)状態になっているときは、新しいメッセージを受け取るまで実行キューから除外される。現実には多くのプロセスがこのメッセージ待ちの状態であることがほとんどである。つまり、この仕組みによって、何百万のプロセスを同時に立ち上げてもそれほどCPUを消費せずに済ませる事が出来るというわけだ。


[2016/09/29追記]

Erlang VM の仕組みについて、以下のような素晴らしいサイトが立ち上がっていた。


[2016/10/31追記]

Elixir/Erlang プロセスについての解説。コンパクトにまとまっていて分かりやすい。

Elixir試飲録 (6) – exrm で hot upgrade すると static assets のパスが古いバージョンのまま更新されない問題への対処

前回のElixir試飲録exrm (Elixir Release Manager) を利用した Phoenixアプリケーションのデプロイ自動化について紹介した。この仕組みの上で、手始めに簡単な Web API を開発している間は何の問題もなかったのだが、JavaScript と CSS を使って UI を作り始めるとすぐに表題の不具合に遭遇した。JavaScript (static assets) を更新してデプロイしても、稼働中のアプリケーションに変更が反映されないのである。

同じ問題に遭遇している人はやはりいて、Issue #206 で報告されていた。

Phoenixアプリケーションの config/config.exs を見ると以下のような設定がある。

config :example_app, ExampleApp.Endpoint,
  ...
  root: Path.dirname(__DIR__),
  ...

Issue #206 では、この Path.dirname/1 という関数が、前回紹介した「config内の環境変数参照がリリースパッケージをビルドする際に評価されてしまう問題(Issue #75 · bitwalker/exrm)」と同様、ビルド時に評価されてしまうので assetsへのパスが更新されないのではないか、と説明されている。

なので、config/prod.exs についてだけ、

config :example_app, ExampleApp.Endpoint,
  ...
  root: ".",
  ...

のように変更すれば問題は解決するとのこと。

しかし解せないのは、root のパスがビルド時のもの(しかも絶対パス)になってしまうのであれば、たまチームのようにビルド環境(CircleCI + Docker)と実行環境(AWS EC2)が異なる場合、そもそもこの設定が機能しないように思えるのだが…?

という疑問はさておき、とりあえず上のように修正して再びデプロイすると、無事 assets が更新されていることを確認できた….と思いきや、再び JavaScript を修正してもう一度デプロイを行うと、更新されない… その後は何度やっても更新されなかった。つまり、問題は解決されていない。

同じく Issue #206 に書かれている対策を施しても解決されないという Issue を投稿して放置されている人がいた(友よ)。

この問題がここまで放置されているということは、こちらのやり方がおかしいのか、あるいはそもそもこのツールを使って無停止デプロイをやっている人がほとんどいないのではないかという疑念が湧いてくるが、解決できなければ快速無停止デプロイ生活は終焉を迎えてしまう。

諦めてなるものかと試行錯誤を続けていると、どうやら root: "." の部分は、この問題とは関係がなく、単に :example_app, ExampleApp.Endpoint の設定がデプロイ時に更新されていれば、assetsのパスが正しく更新されることに気付いた。

試しに、以下のようなダミーの設定を追加して、

config :example_app, ExampleApp.Endpoint,
  ...
  version: "1",
  ...

デプロイの度にこのversionの値を更新すると、assetsのパスが正しく更新されることを確認できた。というわけで、今はこの方法で運用している。

具体的には、Conformの設定ファイルに以下のような項目を追加しておき、

example_app.conf

...
Endpoint.version = "default_version"
...

example_app.shema.exs(Conformの変換ルール)

    ...
    "Endpoint.version": [
      commented: false,
      datatype: :binary,
      default: "default_version",
      doc: "Workaround for https://github.com/bitwalker/exrm/issues/253",
      hidden: false,
      to: "example_app.Elixir.ExampleApp.Endpoint.version"
    ],
    ...

upgradeの際に以下のように値を置き換える。

upgrade.sh(このファイルの詳細についてはこちらを参照)

...
cp ${APP_HOME}/app.conf ${DEPLOY_TO}/${VERSION}/${APP}.conf
sed -i -e "s/default_version/${VERSION}/" ${DEPLOY_TO}/${VERSION}/${APP}.conf
...

この現象について Issue の方に報告したところ、開発者の Paul Schoenfelder氏の方でも問題に気付いて頂けたようである。近い将来に修正されるのを楽しみに待ちたい。

さて、以上のように、この問題は「Configに変更がない場合に、リフレッシュされずに古い状態が残ったままになる」という問題のようなので、keichan34氏が最初のIssueに投稿している以下の対策でも機能するのではないかと思う。

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 ファイルだ。

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 の簡単なサンプルだ。

最後の 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 に渡す、という流れである。

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

 

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

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

 

(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 が以下である。

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コンテナ内で実行される自動テストのシェルは以下のような簡単なものだ。

 

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

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

 

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

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

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)の例である:

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

 

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

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

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

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

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

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

自身の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)は以下の通り、

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

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

 

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

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

デプロイ対象のバージョンは、デフォルトでパッケージリポジトリの 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

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

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

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

Elixir試飲録 (4) – オブジェクト指向と関数型の違い

古いブックマークを漁っていたら、オブジェクト指向と関数型の違いに関する面白い記事を見つけた。

記事は Michael Feathers 氏の興味深いツイートから始まる。

  • 試訳
    • オブジェクト指向は、可変部分をカプセル化(隠蔽)することによって、コードを分かりやすくする
    • 関数型は、可変部分を最小化することによって、コードを分かりやすくする

筆者なりに解釈すれば、オブジェクト指向はインターフェースにフォーカスし、可変部分を捨象することによって複雑なシステムを単純化し、関数型は副作用のない関数を組み合わせることによってシステムの可変部分を最小限に保ち、システムの動的な性質を掴みやすくしようとする。

オブジェクト指向の「可変部分を捨象する」という考え方がシステムを単純化する一方、インターフェースの裏側で起こることは予測し辛くなる。インターフェースの「契約」に現れない状態変化、極端な例を挙げれば「重要なデータを削除しないこと」など、それら全てについて自動テストを用意するのは現実的ではないため、オブジェクト指向のシステムが提供出来る信頼性には自ずと限界があることになる。

そして興味深いのは、オブジェクト指向の上に関数型の仕組みを乗っけるのは止めた方が良いという主張だ。この記事を書いた John D. Cook 氏は、関数型のメリットを享受したいのなら、まずは関数型だけに集中し、プログラムの大きなパーツ(モジュールなど)を管理する時にオブジェクト指向的な枠組みを利用すれば良いのではないかと書く(「functional in the small, OO in the large」)。

コードの中に、関数型の部分とそうでない部分が混在してしまうと、全体としては関数型の恩恵を受け辛くなるのだろう。この話題はこれからもちょくちょく出てくると思うが、Scala と Elixir の比較という観点からも興味深い指摘である。Scalaは基本的にオブジェクトから出発して関数型の流儀を徐々に入れて行くの対し、Elixirはまず関数型から出発する。そして、そこから関数をグルーピングするための Module や、Moduleにインターフェースの仕組みを導入するための Behaviour、そしてポリモルフィズム(多態)のような仕組みを実現する Protocol などが出てくる。

James Hague 氏は、100%純粋な関数型を実現するのは非現実的であり、大体85%ぐらいを目指せばいいのではないかと主張する。そして、残りの15%は注意深く分離してコードの中に分散しないようにすべきであると。

副作用がある部分を切り離すデザインは、Phoenix Frameworkで採用されているデータアクセスのフレームワーク、Ectoの中にも見られた。Ectoでは副作用のない Model と、副作用を担当する Repository を分けるという、いわゆる Repositoryパターンが採用されている。Railsの Active Record が Model 自身に副作用を内包するのとは対照的である。

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

Elixir試飲録 (3) – マルチコア危機によるパラダイムシフト: オブジェクト指向から並行指向へ

マルチコアCPUの登場

ソフトウェアにおける並行性への根本的な転換」。この記事が書かれた2005年というのは、インテルが「Pentium D」「Pentium XE」、AMDが「デュアルコアOpteron 2xx」「Athlon 64 X2」という初めてのマルチコアCPUを発売した年である(参考: ASCII.jp:CPUの勢力図は変わるか? デュアルコアCPU4種対決!)。

それまでムーアの法則に従って指数関数的に向上していた半導体の性能は2003年に転機を迎える。以下のグラフは件の記事で紹介されているもので、CPUのトランジスタ数とクロック数の時系列変化を表している。

Intel CPU introductions (sources: Intel, Wikipedia)
Intel CPU introductions (sources: Intel, Wikipedia)

トランジスタ数は記事の書かれた2005年当時まで、さらには下の2011年までのグラフで分かるように、それ以降もムーアの法則に沿って向上しているが、クロック数の上昇については2003年を境に急激に減速している。クロック数が限界を迎えつつあるのは、それまで以上の高クロック数で動作させたときに発生する「熱」と「消費電力」が許容範囲を超えてしまうようになったからである。

2005年以前、CPUの性能を決定するのは、大きく分けて以下の三つの要因があると言われていた。

  1. クロック数
  2. 命令実行の最適化
  3. キャッシュ

クロック数は単位時間辺りの処理能力に直結する数字で、CPUメーカーは基本的にこの数字を上げることで指数関数的な性能向上を実現してきた。

しかし、クロック数には早晩限界が来ると分かっていたCPUメーカーの開発者は、それでも性能を向上させなければならないというプレッシャーの中、実行対象のプログラムの意味を変えてしまいかねないような大胆な最適化を実装したという。このような最適化を有効にしてプログラムを実行すると、速度自体は向上するものの、プログラマーの期待と異なる動作をする可能性があり、デバッグが困難になるため、通常は無効になっているようだ。

キャッシュの容量を増やすという方法は2005年以降も有効に機能しているようであるが、当然のことながら、それまでのクロック数のように性能を指数関数的に伸ばすというところまでは至らない。

2016年1月現在、インテルから発売されているCPUのクロック数を見てみると、一番高いもので 4.00GHz というのがあるが、これは2005年当時の3.4GHzと比較しても大幅な向上をしているとは言い難く、クロック数についてはほとんど横ばい状態になっていると言って良いだろう。

これまでと同じ方法では同じペースでの性能向上は見込めない。そこで生み出されたのが、マルチコアと呼ばれる、1つのプロセッサ・パッケージ内に複数のプロセッサ・コアを搭載する技術である。これによって、クロック数が頭打ちになっても、CPU全体のトランジスタ数は依然としてムーアの法則に従って向上させて行くことが出来るようになった。

A plot of CPU transistor counts against dates of introduction (sources: Moore's law, Wikipedia)
A plot of CPU transistor counts against dates of introduction (sources: Moore’s law, Wikipedia)

不可避となった並行指向プログラミング

  • マルチコア時代のCPU性能三大要因
    1. ハイパースレッディング
    2. マルチコア
    3. キャッシュ

1つのCPUの性能が頭打ちになったから、複数繋げて1つのCPUに見せかけるというのは、冷静に考えると安直な方法に思えなくもない。実際にインテルの最初のマルチコアCPUである Pentium D は、単純に2つのCPUのダイ (Die) を1つのパッケージに封入したマルチコア・マルチダイ形式だった。

重大な問題は、コアが2つ3つになったからと言って、性能がそのまま2倍3倍になるわけではないということだ。それどころか、それまでのパラダイムで書かれた一般的なプログラムの場合、どう頑張っても一つのコアしか利用することが出来ず、マルチコアの恩恵を受けることが出来ない。

これまではプログラム自体に特に工夫がなくても、時間が経てばCPUの性能が倍々で向上し、あらゆる問題が自然に解決されてきた。しかし、2005年以降、そのようなことを期待することは年々難しくなり、プログラムをマルチコアに合わせて設計し直さない限り、性能向上の恩恵を受けることが出来なくなっている。それどころか、マルチコアの時代においては、一つのコア(一つのスレッド)辺りの性能はむしろ低下する可能性さえあるという。

これまでの逐次的なプログラミングでは年々進歩するCPUの恩恵を受けることは難しくなった。そこで必要になってくるのが、件の記事の主題になっている「並行性」、つまり、マルチスレッドやマルチプロセスを利用したプログラミングである(ここでは、これらを総称して「並行指向プログラミング」と呼ぶことにする)。

今回の記事の著者である Herb Sutter氏は、マルチコア時代における並行指向プログラミングの登場は、1960年代に生まれ、1990年代以降に隆盛を極めたオブジェクト指向プログラミングの登場に匹敵する出来事なのだと主張する。

振り返ってみれば、オブジェクト指向の隆盛はCPUのクロック数が指数関数的に増えて行った時代と丁度重なっており、より複雑でよりリッチなソフトウェアを、よりリッチなハードウェア資源の上で実現するためには最適の手法だったと言えるのかもしれない。

マルチコア時代になって、CPU資源を最大限に活用するために並行性が必須となった。クラウド上で動くプログラムも分散・並行が基本であり、この流れは不可避である。しかし、並行指向プログラミングの最大の問題は「難しい」ということであった。並行処理の難しさは多くのプログラマーによって共有されるところで、安全に並行性を実現するためには、新しいプログラミングモデルが必要になる。そして、ここでオブジェクト指向が足かせになってしまう。オブジェクト指向は処理の前後で意図しない状態変化を引き起こす恐れがあり、安全に並行性を導入するには難がある。そこで注目を浴びているのが副作用のない処理を組み合わせる関数型プログラミングだ。

オブジェクト指向に慣れたプログラマーが並行指向プログラミングに移行するのは、数十年前、構造化プログラミングに慣れたプログラマーが手探りでオブジェクト指向を学んだ時と同じぐらいの試行錯誤があるだろうと、Sutter氏は言う。

今回のネタ帳 – https://oinker.me/room/marubinotto/multicore-crises

Elixir試飲録 (4) – オブジェクト指向と関数型の違い