Docker swarmで作る社内heroku: yadockeri

  • 70
    いいね
  • 0
    コメント

この記事はCrowdWorks Advent Calendar 2016 2日目の記事です.

みなさんDockerのswarmモード,使ってますか?
ここ最近変更が多くて,もうじき1.13が出ますね.

いろいろと便利機能が増えていますが,詳細についてはこちらで紹介されています.
Docker 1.13の気になる変更点と新機能

CrowdWorksでは社内の検証用サーバをDockerで構築していて,その部分でswarmを活用しています.
なお,この記事を書いている時点では1.13はまだリリースされておらず,これから書く話は全て1.12で構築しています.

やりたいこと

検証用サーバ

アプリケーションが動く環境として,

  • 開発者の手元環境
  • stagingと言われる確認環境
  • production,ユーザが実際に利用する環境

くらいを作ることはあると思います.

CrowdWorksではそれに加えて,「社内のエンジニア以外に機能確認をしてもらうための環境」を,stagingとは別に用意していました.
これは,

  • リリース前の機能なのでmaster以外のブランチをデプロイしたい
  • 複数人が同時に機能確認したい場合があり,stagingが一つだけでは足らない

というような事情があります.
実際にこういう環境があると,「なんか手元だと鍵の設定とかめんどくさいなー」とか「assets:precompileされるとこれってどうなるんだろう」みたいな,微妙な部分の変更で手元で確認しにくいようなことを確認するときに,すごく便利になってきます.

Dockerで作る理由

元々そういう「検証用サーバ」は存在していたんですが,メンテがツライ.

以前はChefで構築していて,1環境に1EC2インスタンスを割り当てる形でした.ただ,Chefで作っていたとしても,本番のインフラ構成を変更するたびに,検証用サーバの方にもChefを流し直さないといけないのは当然ですよね.

ところが,本番では成功したのに,検証用だとちょっと環境が違ってChefが上手いこと流れないということはよくありました.
また,Chefで設定されていた設定を手動で変更してしまっていて,既にChefを流し直せないサーバが生まれていました.

これをこのまま増やしていくのはツライ

というわけで新しく作り直すことにしました.

herokuではダメな理由

CrowdWorksが大きすぎました……単純にこれがクリティカルすぎて,もう自作するしかないという覚悟を決めていました.

Dockerの嬉しいこと

なんといってもこの場合, イミュータブルになる というのが最も嬉しかった.

コンテナを終了してしまえば,中身を手動でいじっていても消えてしまいます.
新しく起動する際には,必ずDocker image通りのものしか起動されません.

Chefでもインスタンスごと作り直せばいい話ですが,それを始めると圧倒的にDocker imageをベースに起動した方が早い.

というわけで初期の段階でDockerにすることは決まりました.

Docker swarmという選択

Dockerのオーケストレーションツールはいくつか候補があります.

やりたいこととしては,

  • 任意のブランチを指定してdocker buildし
  • そのimageを適当なホストにデプロイ
  • 分かりやすいホスト名でそこにアクセスできる

くらい.

CrowdWorks自体ではAWSをよく使っているので,ECSも候補ではあったんですが…….

swarmの良いところは,

  • コンテナがどのホストにデプロイされるかは適当に決まるということ(新しく増やす際にいちいちインスタンスを増やさなくて良い)
  • overlay networkにより,ホスト名の解決がいい感じにできそう

というのが大きかったです.特にホスト名のところはECSだと解決するのが難しそうだなーと悩んでいたところでした.

WebUIによる操作

これはオマケ程度の機能なのですが,やっぱりWebUIやAPIはあったほうがテンションあがります.

デプロイするたびに,swarm managerのホストに入ってコマンド叩いたり,シェルスクリプト実行したりするのは,あまりうれしくない……ということで基本的な操作をWebUIからできるようにしようという目標もありました.

ついでにAPIもあると,手元から叩けて更にテンション上がる!

yadockeri

こういうのはコードネームを付けると親しみやすいです.
いろいろと候補を募って,yadockeriという名前になりました.

ちなみに名前の由来は,

Yet Another Docker Infra

です.絶妙にDockerが全部入るようにしておきました.これで「ヤドカリ」と呼んでいます.
ホストにDockerコンテナ立てるヤドカリっぽさと,車輪の再発明感も漂う名前です.

Yadockeri(5).png

構成

yadockeri (3).png

だいたいこんな構成にします.
ここでは,akiraという名前のサービスを作り,akira.y.example.comというURLでアクセスできるようにしたいと思います.

ユーザが増えて,たとえばtoruという名前でサービスを作りたい場合には,router以外の,akiraと名前のついているネットワークやコンテナセットが増えて,toruの分ができる,という形ですね.

router

ここではoverlay networkを使って,要求されたコンテナにリクエストを振り分けられるようにします.

まず,router用のDockerを作ります.

Dockerfile
FROM nginx:stable-alpine

COPY nginx.conf /etc/nginx/

単純なnginxコンテナを使います.
ここに,

nginx.conf
user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile           on;
    keepalive_timeout  65;

    # resolverが未定だとエラーになるのでDockerコンテナ内のresolv.confに書いてあるIPを決め打ちで書いてみる
    resolver           127.0.0.11 valid=5s;

    server {
      listen             80;
      set_real_ip_from   '10.0.0.0/8';
      real_ip_header     X-Forwarded-For;
      proxy_set_header   X-Forwarded-Scheme $http_x_forwarded_proto;
      proxy_set_header   Host $host;

      server_name        _;

      # 例えばakira.y.example.comだと$subdomain=akiraになる
      # api.akira.y.example.comの場合も$subdomain=akiraにproxyする
      if ($host ~* ([^.]+)\.y\.(dev\.)?example\.com$) {
        set $subdomain $1;
      }

      location / {
        proxy_pass http://$subdomain:8080;
      }
    }
}

たとえば,akira.y.example.com というリクエストが来た場合には,http://akira:8080 にproxy_passされます.

そして,http://akira というリクエストは,swarmにより,akiraという名前で作ったserviceにリクエストを飛ばしてくれます.

そのため,後述するスタックを作るときに,akiraという名前で作ることにより,このrouterからakiraサービスにリクエストが飛ぶようになります.

そしてmanagerホスト内で,このDockerをbuildし,workerからでも参照できるようにECRにpushしてきます.

$ docker build -t hoge.ecr.ap-northeast-1.amazonaws.com/router .
$ docker push hoge.ecr.ap-northeast-1.amazonaws.com/router
$ docker network create --driver overlay --subnet=$ROUTER_SUBNET router
$ docker service create --network router --mode global --with-registry-auth --name router -p 80:80 hoge.ecr.ap-northeast-1.amazonaws.com/router

こうしてrouterというnetworkの属しているrouterコンテナ(nginx)を動かしておきます.

後述するスタックはこのrouterネットワークに所属するようにしてやり,サービス名をホスト名と同名にしておけば,同じネットワーク内なので名前解決され,nginxからのリクエストが飛んで来るというわけです.

スタックのデプロイ

overlay networkはできたので,次は実際にCrowdWorksが動くコンテナです.
CrowdWorksはRails以外にもmemcachedやElasticsearch等,複数のコンテナ群により一つのサービスを実現しています.このコンテナ群1セットをスタックと呼ぶことにします.

デプロイはdockerコマンドを叩くことが多いので,現状シェルスクリプトで実装されています.

この辺をgoで書き直そうと思ったんだけど,分量が多くて進んでない…….

スタックを作るあたりをちょっと紹介しましょう.
routerは既に作ってある状態です.

stack.sh
# 周辺のミドルウェアのコンテナを作る
create_stack()
{
    # 使用するイメージをpullしなおす
    docker-compose -f docker-compose-yadockeri.yml pull
    # Docker Distributed Application Bundleという現状experimentalな機能を使って
    # composeのymlからswarmにデプロイするためのメタデータを生成
    # .dabファイルはただのJSONなので何が出力されるかはファイルを見れば分かるけど
    # docker service createするための情報が入ってる
    # dabでは現状build命令に対応していないので、
    # デプロイする度にビルドしなおさないといけないアプリケーション本体はここには含めない
    # 基本的に一度デプロイしたら変更しない周辺のミドルウェアに使用
    docker-compose -f docker-compose-yadockeri.yml bundle -o ${STACK_NAME}.dab
    # dabファイルを元に新しいスタック用のoverlay networkとコンテナをデプロイ
    docker stack deploy --with-registry-auth $STACK_NAME
}

# 常駐プロセスのサービスを作る
create_service()
{
    local service=$1
    local entrypoint=$2
    # スタック内のネットワークはバックエンドのミドルウェア群との接続
    # routerのネットワークはフロントエンドのブラウザからの通信に使ってる
    docker service create \
        --network ${STACK_NAME}_default \  # akira_defaultみたいなネットワーク名を指定
        --network router \  # ここで先程作ったrouterネットワークを指定
        --replicas 1 \
        --with-registry-auth \
        --name $service \  # ここで指定される名前をakiraとすることで,http://akiraが名前解決される
        $IMAGE $entrypoint
}

memcachedやElasticsearch等の周辺サービスのコンテナはdocker-composeをベースに作成します.
ここではdabというファイルに変換していますが,1.13.0以降,docker-compose.ymlをそのまま使えるようになります.

yadockeri WebUI

WebUIもyadockeri内に

WebUIは規模としてかなり小さいし(上記のshを実行してくれれば良い),phoenix(elixir)で作りました.

このphoenixアプリケーションも,Dockerに詰め込んで,yadockeriの一部としてswarmのserviceとして管理します.

phoenix on Docker

phoenixのアプリケーションをDockerに乗せてデプロイするには,少しコツが要ります.

詳しくは,
phoenixアプリケーションをDockerでデプロイする

にまとめてあります.
ポイントとしては,config/prod.ex内で環境変数を使いたい場合には,ちょっと特殊な展開をしてやる必要があるということ.

config/prod.ex
config :sample_app, SampleApp.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "${DB_USER}",
  password: "${DB_PASSWORD}",
  database: "sample_app",
  hostname: "${DB_HOST}",
  port: 3306,
  pool_size: 5

という設定を作って,起動時にRELX_REPLACE_OS_VARS=true という環境変数をセットします.

$ docker run --name sample_app \
  -e DB_USER=hoge \
  -e DB_PASSWORD=fuga \
  -e DB_HOST=localhost \
  -e RELX_REPLACE_OS_VARS=true \
  sample_app:latest

これでアプリケーション実行時に環境変数を展開してくれます.

phoenixアプリケーションはmanager上で稼働させる

WebUIからスタックの操作をしたいため,前述のシェルスクリプトはphoenixアプリケーション内から叩く必要があります.
前述のシェルスクリプトのコマンドから分かる通り,スタックを操るコマンドは全てswarm manager上で実行する必要があるため,WebUIのコンテナだけは,swarm manager上で動くように制約を入れて起動します.

$ docker service create \
--name yadockeri \
-p 8080:8080 \
--replicas 1 \
--constraint "node.role == manager" \  # この指定によりmanager上で動くという制約が入る

コンテナ内から親ホストのdockerコマンドを使う

WebUIを作る上でのキモはおそらくこの辺ですが…….

create_service() 等のコマンドはmanagerホスト上のdockerコマンドが必要になります.
そのため,WebUIからも,managerホスト上のdockerコマンドを叩ける状態にしておく必要があります.

しかし,WebUI自体もmanager上で動いているコンテナです.コンテナ内から親ホストのdockerコマンドを叩くという,ちょっとトリッキーなことをしてやる必要があります.

これを実現するために,

  1. DockerのクライアントをWebUIコンテナにインストールする
  2. コンテナ内の/var/run/docker.sockにホストの/var/run/docker.sockをマウントして,コンテナ内からホスト側のDockerサーバを使えるようにしてやる

という準備をしておきます.

ENV DOCKER_CLIENT_VERSION=1.12.3
ENV DOCKER_API_VERSION=1.24
ENV DOCKER_COMPOSE_VERSION=1.8.1

RUN set -x && \
    curl -fsSL https://experimental.docker.com/builds/Linux/x86_64/docker-${DOCKER_CLIENT_VERSION}.tgz \
| tar -xzC /usr/local/bin --strip=1 docker/docker

RUN set -x && \
    curl -L "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && \
chmod +x /usr/local/bin/docker-compose

こんなDockerfileを書いておくと,Dockerのクライアントだけをコンテナ内に入れることができます.

あとは起動時のオプションとして,

$ docker service create \
--name yadockeri \
-p 8080:8080 \
--replicas 1 \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly=false

として,/var/run/docker.sockをマウントします.

まとめると,起動コマンドはこんな感じ.

docker service create \
--network router \
--with-registry-auth \
--name yadockeri \
-p 8080:8080 \
--replicas 1 \
--constraint "node.role == manager" \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly=false \
--mount type=bind,source=/home/devops,target=/srv,readonly=false \
--e DB_USER=hoge \
--e DB_PASSWORD=fuga \
--e DB_HOST=localhost \
--e RELX_REPLACE_OS_VARS=true \
crowdworks/yadockeri-web

デプロイ

このような状態になっていれば,WebUIからのデプロイでは,create_service() 等のコマンドを叩けば良いはずです.

というわけでelixirからは,

case System.cmd("sudo", ["-E", "stack.sh", "create", deployment.app_stack.name, deployment.branch]) do
  {result, 0} -> {:ok, result}
  {error, _} -> {:error, error}
end

こんな形で呼び出せるようにしておきます.

sudoが必要なのは,dockerコマンドだからですね…….そこはグループで管理してもよいのですが,普通にsudoersに入れたほうがラクだったので.

これで無事にデプロイが走るようになりました.

Yadockeri(6).png

まとめ

ECSでは実現できないような,柔軟な構成でアプリケーションをデプロイできるようになりました.
その分多少複雑ではありますが…….

今回,overlay networkで解決できたことが大きくて,これはかなりスマートな方法が取れたなぁと素直に思います.

WebUIを作ったのは,環境変数の設定を保存しておいて,デプロイ時に差し込めるようにしたかったりする意味もありました.が,環境変数設定まではまだたどり着いていません.
dockerコマンド的には,envオプションで渡すだけなのですが…….

あと,1.13.0になってから作ったほうが,docker-compose周りや,env-fileオプションが実装されるので,もう少し楽になったかなぁと思います.

この記事はCrowdWorks Advent Calendar 2016 の2日目の記事でした。明日は@ganta の「CrowdWorksのDocker開発環境」の予定です。

この投稿は クラウドワークス Advent Calendar 20162日目の記事です。