2016年3月30日水曜日

Dockerを使って1サーバで複数Webサービスを運用するためのマイベストプラクティス

はじめに

エンジニアやっていると色んなサービスを作りたくなると思うのですが、Herokuのフリープランが使えなくなってしまった影響で無料でのサービス運営は難しくなってきています。
もちろん、Google App Engineなど無料で運用できるものもあるのですが、サービスにロックインされてしまうのが多くちょうど良い物が見つかりませんでした。
ということである程度安く色々やろうとすると、1台のサーバでいい感じに複数サービスを立ち上げるという昔ながらの構成になるのですが、Dockerを使うことで環境セットアップなどサーバ管理の手間を最小限にしていこう、というのがこの記事の趣旨となります。

方針

要件
  • 安い
  • サービスにロックインされない
  • スケーラブル(もしサービスのアクセス量が増えたとしてスケールさせられる)
  • インフラ管理が容易
    • セキュリティとかなるべく気にしたくない
以上のことを踏まえた結果ConoHaのVPSを使う構成となりました。費用としては、
  • ドメイン1つ(4000円/year)
  • サーバ1台(900円/month)
  • RDB1台(500円/month)
  • S3 (数十円/month)
という構成で月2000円以下に抑えられています。

検討したこと

PaaSとか考えたのですが結局VPSが一番安いよねという話。

検討1 自宅サーバ

データ消えないような仕組みづくりとかそういうところに時間を使う時代じゃなくなってきてるので自分の中ではこれは無いかな。

検討2 PaaS

HerokuはFreeプランでできることが少なくなったので他のサービスを探しましたが、複数サービスを無料に近い金額でできるのがありませんでした。OpenShiftとかは複数アカウント作ればできるんだろうけどその管理がめんどくさい。調べた時は見つけられなかったけどIBM Bluemixは無料枠多いのでもしかしたら結構良いかもしれない。

検討3 AWS、GCPなど

価格が高いということでやめました。
特にDBサーバの管理をしたくないのでマネージド型のRDBが欲しかったのですが、コストが大分高くなってしまう感じでした。NoSQL系は安いのですが使いたくなかったのがあります。
#ちなみにAmazonとGoogleは調べたのですが他は調べてないのでもっと良いのがあるかも

検討4 VPSを使う

結局安く運用するならVPSだなという結論に。マネージド型のRDBサービスについては、ConoHaクラウドがDBサーバを500円という格安料金で提供していて、LBなども追加できるIaaS的なサービスでちょうど自分の要望を満たすことができたのでこちらにしました。DBがMariaDBだったりおそらく多くのユーザで共有しているのでパフォーマンスが期待できないなどの問題が気にならなければこれで良さそう。
自分の中ではDBサービスのあるVPSが無かったらたぶんAWS上でEC2+RDSの構成にしていたと思いますが(そのくらいDBの管理をしたくない)、自分でDBサーバを立てるのであればどこのVPSでも良いと思います。

構成

ということでこんな構成になりました。サーバの構成管理は1ファイルで完結します。Dockerのイメージ作成もDockerfileとnginx,supervisordの設定ファイルのみで簡単にできるようになっていて、必要な管理はそれだけなのが特徴です。
構成図.png
順に構成要素を説明していきます。

CoreOS

Dockerだけあれば良いのでCoreOSにしました。CoreOSの本質は分散システムを容易に構築できるところだと思うのですが、今回は
  • 必要十分な機能をもつOS
  • コンポーネントが少なくセキュリティリスクが少ない
  • 自動アップデートの仕組み
  • 構成を cloud-config.yml の1ファイルで管理できる
という点でこれしかないなと思って選びました。
特に cloud-config.yml でサービスの立ち上げなどが管理できるため、その1ファイルだけ Dropbox にいれておけばファイルの管理としては十分で、プライベートリポジトリも必要とせずに構成の管理ができてしまうのが個人的には気に入りました。

サービスのDockerイメージ

Dockerの考え方的にはおそらくアプリケーションサーバとnginxは別コンテナにするべきなのですが、nginxとrailsを別コンテナで管理するコストが高いのでシンプルさという点で避けることにしました。
そのため supervisord で nginx と rails を立ち上げるような構成にしています。
docker-composeを使うということも考えましたがCoreOSに入っていないのと、あくまでDevelopment向けに開発されているものなので使っていません。
Rails上のsecretやDB接続先はDocker実行時の環境変数として入れることで環境に依存しないイメージを作成するようにしています。

nginx-proxy

複数のサービスはそれぞれのコンテナで動いているので、各リクエストを振り分けるリバースプロキシが必要です。
通常だとnginxやapacheの設定でVirtual hostやディレクトリ毎の振り分け先を書いて、みたいなことをするわけですが、nginx-proxyを使うと立ち上がっているDockerコンテナに自動的に振り分けを行うことができます。
これによりサービスの投入が非常に楽になります。

docker-letsencrypt-nginx-proxy-companion

SSLが使えないサービスは今時ありえないので、それへの対応です。
Let's Encryptは最近注目の無料でSSL証明書を発行できるサービスです。Facebookを始めとした名高い企業がスポンサーとなっています。
このコンテナを動かすだけで自動でSSL証明書の発行・更新を行ってくれます。

New Relic

サーバ監視は最低限あったほうが良いと思うのでNew Relicを入れています。これもコンテナを動かすだけなので管理コストは少ないです。

Docker Registry

Dockerのイメージはパブリック領域ならDocker Hubを使えば良いのですが、いくら個人のクソサービスとはいえ全部が全部公開できるものではないと思います。しかしDocker Hubのプライベートリポジトリはお金がかかるので小さいサービスを量産していく形だと非常にお金がかかってしまいます。そのため別途 Docker Registry を立てることにしました。
ただ、外部からアクセスできる Docker Registry を立てるにはベーシック認証を入れたりSSL証明書を導入したりの手間が必要で(調べるのが)めんどくさかったので、S3のストレージを共有してPushはローカルのマシンで立ち上げたDocker Registryから、PullはVPS上で立ち上げたDocker Registryから、という構成にしています。
この構成は正直あまりイケてないので今後直したいです。CircleCIから自動でPushするとかもできないですし。ちなみにAWSやGCPにすると、各サービスが提供しているDocker Registryが使えるのでこんなコンポーネントは要らなくなります。とはいえConoHaだと1500円で済むサーバ構成がRDSだけで2000円を超えてしまうので、そのコストとDocker Registryを自前で立てる複雑さのどちらが良いかという選択でした。

環境セットアップ手順

以上を踏まえて環境セットアップ手順です。

1. CoreOSのインストール

VPSにCoreOSをインストールします。
ConoHaのサーバにインストールした時のメモがこちら。
http://qiita.com/miyasakura_/items/4d81dc5fe6f9de0f0dd5
基本となる cloud-config.yml は次のような感じです。
設定箇所としては
  • nginx-proxy と letsencrypt-nginx-proxy-companion の証明書のディレクトリパス
  • newrelic の NEW_RELIC_LICENSE_KEY
  • s3のAPIのアクセスキーやバケットの情報
があります。NewRelicとS3のアクセス情報はあらかじめ準備しておいてください。
cloud-config.yml
#cloud-config

ssh_authorized_keys:
  - ssh-rsa AAAAB3Nza...(sshする時の公開鍵)

coreos:
  update:
    reboot-strategy: best-effort
  units:
    - name: docker.service
      command: start

    - name: timezone.service
      command: start
      content: |
        [Unit]
        Description=timezone
        [Service]
        Type=oneshot
        RemainAfterExit=yes
        ExecStart=/usr/bin/ln -sf ../usr/share/zoneinfo/Japan /etc/localtime

    - name: nginx-proxy.service
      content: |
        [Unit]
        Description=nginx-proxy

        [Service]
        Type=simple
        Restart=always
        ExecStartPre=-/usr/bin/docker stop nginx-proxy
        ExecStart=/usr/bin/docker run \
                    --rm \
                    --name="nginx-proxy" \
                    -p 80:80 \
                    -p 443:443 \
                    -v /home/core/certs:/etc/nginx/certs:ro \
                    -v /etc/nginx/vhost.d \
                    -v /usr/share/nginx/html \
                    -v /var/run/docker.sock:/tmp/docker.sock \
                    jwilder/nginx-proxy
        ExecStop=/usr/bin/docker stop nginx-proxy

        [Install]
        WantedBy=multi-user.target

    - name: letsencrypt.service
      content: |
        [Unit]
        Description=letsencrypt
        Requires=nginx-proxy.service
        After=nginx-proxy.service

        [Service]
        Type=simple
        Restart=always
        ExecStartPre=-/usr/bin/docker stop letsencrypt
        ExecStart=/usr/bin/docker run \
                    --rm \
                    --name="letsencrypt" \
                    -v /home/core/certs:/etc/nginx/certs:rw \
                    --volumes-from nginx-proxy \
                    -v /var/run/docker.sock:/var/run/docker.sock:ro \
                    jrcs/letsencrypt-nginx-proxy-companion
        ExecStop=/usr/bin/docker stop letsencrypt

        [Install]
        WantedBy=multi-user.target

    - name: newrelic.service
      command: start
      content: |
        [Unit]
        Description=newrelic
        Requires=docker.service
        After=docker.service

        [Service]
        Restart=always
        RestartSec=300
        TimeoutStartSec=10m
        ExecStartPre=-/usr/bin/docker stop newrelic
        ExecStartPre=-/usr/bin/docker rm -f newrelic
        ExecStartPre=-/usr/bin/docker pull uzyexe/newrelic:latest
        ExecStart=/usr/bin/docker run \
                    --rm \
                    --name="newrelic" \
                    --memory="64m" \
                    --memory-swap="-1" \
                    --net="host" \
                    --pid="host" \
                    --env="NEW_RELIC_LICENSE_KEY=ライセンスキー" \
                    --volume="/var/run/docker.sock:/var/run/docker.sock:ro" \
                    --volume="/sys/fs/cgroup/:/sys/fs/cgroup:ro" \
                    --volume="/dev:/dev" \
                    uzyexe/newrelic
        ExecStop=/usr/bin/docker stop newrelic

        [Install]
        WantedBy=multi-user.target

    - name: docker-registry.service
      command: start
      content: |
        [Unit]
        Description=docker registry
        Requires=docker.service
        After=docker.service

        [Service]
        Restart=always
        RestartSec=300
        TimeoutStartSec=10m
        ExecStart=/usr/bin/docker run \
                    --rm \
                    --name="docker-registry-service" \
                    -p 5000:5000 \
                    -e REGISTRY_STORAGE_S3_ACCESSKEY=アクセスキー \
                    -e REGISTRY_STORAGE_S3_SECRETKEY=シークレット \
                    -e REGISTRY_STORAGE_S3_BUCKET=バケット \
                    -e REGISTRY_STORAGE_S3_REGION=ap-northeast-1 \
                    -e REGISTRY_STORAGE_S3_ROOTDIRECTORY=/v2 \
                    -e REGISTRY_STORAGE=s3 \
                    registry:2.0
        ExecStop=/usr/bin/docker stop docker-registry-service

        [Install]
        WantedBy=multi-user.target


インストールが終わったら下記コマンドで各サービスが running になっていることを確認します。
systemctl list-units --type=service 

2. Dockerのベースイメージ作成

新しいアプリをインストールするたびに nginx や ruby をインストールするのは大変なのでベースイメージを作っておきます。
Dockerはローカルマシンにインストールしておいてください。
今回はRails+nginxの構成を想定しているので下記のような Dockerfile を準備。このDockerfileはnginxをaptで入れるなどの楽をしているので、古いバージョンのnginxが入ってしまう点は改善の余地ありです。
FROM ruby:2.3.0

RUN apt-get update
RUN apt-get install -y nodejs nginx supervisor
RUN apt-get install -y libssl-dev

# forward request and error logs to docker log collector
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
        && ln -sf /dev/stderr /var/log/nginx/error.log

RUN gem update --system

CMD ["bash", "-l", "-c"]
ビルドします
$ docker build --no-cache -t localhost:5000/base-image .
Docker Registryを立ち上げてPushします。
$ docker run -d \
      --name="docker-registry" \
      -p 5000:5000 \
      -e REGISTRY_STORAGE_S3_ACCESSKEY= \
      -e REGISTRY_STORAGE_S3_SECRETKEY= \
      -e REGISTRY_STORAGE_S3_BUCKET= \
      -e REGISTRY_STORAGE_S3_REGION=ap-northeast-1 \
      -e REGISTRY_STORAGE_S3_ROOTDIRECTORY=/v2 \
      -e REGISTRY_STORAGE=s3 \
      registry:2.0
$ docker push localhost:5000/base-image
これでベースイメージの作成が完了です。

3. RailsアプリのDockerイメージ作成

複数サービスを立ち上げるのであればProcess式のものよりThread式のが良いかなと思ってなんとなくPumaにしています。
アプリ自体は普通に作るだけなのですが、Dockerを使うにあたっていくつかポイントがあります。

ログは標準出力に

Dockerでは基本標準出力にログを出すことで簡単にログを見ることができます。詳しくないのでもっと良い方法があったら教えて下さい。
config/environments/production.rb

...
config.logger = Logger.new(STDOUT)
...

DBなどの情報は環境変数を見るように

こんな感じにしてます。Productionだけでも良いわけですがそこは環境に応じて。
database.yml
default: &default
  adapter: mysql2
  encoding: utf8
  reconnect: false
  pool: 5
  host:     <%= ENV['RAILS_DATABASE_HOST'] %>
  username: <%= ENV['RAILS_DATABASE_USER'] %>
  password: <%= ENV['RAILS_DATABASE_PASSWORD'] %>
  database: <%= ENV['RAILS_DATABASE'] %>

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default

Dockerfileなどの設定

上記を踏まえてDockerfileとnginx.confとsupervisord.confが下記のような感じに。
Pumaもnginxの設定と合わせてセットアップが必要なのでうまいことやります。
FROM localhost:5000/base-image

ENV APP_HOME /webapp
WORKDIR $APP_HOME

ADD Gemfile* $APP_HOME/
RUN bundle install --without test development
RUN cat Gemfile.lock

ADD . $APP_HOME
RUN bundle exec rake assets:precompile

COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

EXPOSE 80
CMD ["/usr/bin/supervisord"]
docker/nginx.conf
http {
  upstream puma {
    server unix:/webapp/tmp/sockets/puma.socket;
  }

  server {
    listen  80;

    location /assets {
      root /webapp/public;
    }

    location / {
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $host;
      proxy_redirect off;
      proxy_pass http://puma;
    }
  }

  error_log stderr;
  access_log /dev/stdout;
}

pid /var/run/nginx.pid;
worker_processes 2;
events {
    worker_connections  1024;
    # multi_accept on;
}
docker/supervisord.conf
[supervisord]
nodaemon=true

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:puma]
command=bundle exec puma -C config/puma.rb
directory=/webapp
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
config/puma.rb
# Start Puma with next command:
# RAILS_ENV=production bundle exec puma -C ./config/puma.rb

# uncomment and customize to run in non-root path
# note that config/puma.yml web path should also be changed
application_path = "#{File.expand_path("../..", __FILE__)}"

# The directory to operate out of.
#
# The default is the current directory.
#
directory application_path

# Set the environment in which the rack's app will run.
#
# The default is “development”.
#
environment 'production'

# Daemonize the server into the background. Highly suggest that
# this be combined with “pidfile” and “stdout_redirect”.
#
# The default is “false”.
#
daemonize false

# Store the pid of the server in the file at “path”.
#
pidfile "#{application_path}/tmp/pids/puma.pid"

# Use “path” as the file to store the server info state. This is
# used by “pumactl” to query and control the server.
#
state_path "#{application_path}/tmp/pids/puma.state"

# Redirect STDOUT and STDERR to files specified. The 3rd parameter
# (“append”) specifies whether the output is appended, the default is
# “false”.
#
# stdout_redirect "#{application_path}/log/puma.stdout.log", "#{application_path}/log/puma.stderr.log"
# stdout_redirect '/u/apps/lolcat/log/stdout', '/u/apps/lolcat/log/stderr', true

# Disable request logging.
#
# The default is “false”.
#
# quiet

# Configure “min” to be the minimum number of threads to use to answer
# requests and “max” the maximum.
#
# The default is “0, 16”.
#
# threads 0, 16

# Bind the server to “url”. “tcp://”, “unix://” and “ssl://” are the only
# accepted protocols.
#
# The default is “tcp://0.0.0.0:9292”.
#
# bind 'tcp://0.0.0.0:9292'
bind "unix://#{application_path}/tmp/sockets/puma.socket"

# Instead of “bind 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert'” you
# can also use the “ssl_bind” option.
#
# ssl_bind '127.0.0.1', '9292', { key: path_to_key, cert: path_to_cert }

# Code to run before doing a restart. This code should
# close log files, database connections, etc.
#
# This can be called multiple times to add code each time.
#
# on_restart do
#   puts 'On restart...'
# end

# Command to use to restart puma. This should be just how to
# load puma itself (ie. 'ruby -Ilib bin/puma'), not the arguments
# to puma, as those are the same as the original process.
#
# restart_command '/u/app/lolcat/bin/restart_puma'

# === Puma control rack application ===

# Start the puma control rack application on “url”. This application can
# be communicated with to control the main server. Additionally, you can
# provide an authentication token, so all requests to the control server
# will need to include that token as a query parameter. This allows for
# simple authentication.
#
# Check out https://github.com/puma/puma/blob/master/lib/puma/app/status.rb
# to see what the app has available.
#
# activate_control_app 'unix:///var/run/pumactl.sock'
# activate_control_app 'unix:///var/run/pumactl.sock', { auth_token: '12345' }
# activate_control_app 'unix:///var/run/pumactl.sock', { no_token: true }



ビルドしてPushします。
$ docker build -t localhost:5000/hoge-project .
$ docker push localhost:5000/hoge-project //docker registryは立ち上げてある前提

4. サブドメインの設定

nginx-proxyはVIRTUAL HOSTでリバースプロキシを設定するのでお使いのネームサーバで今回のサービスに対するサブドメインを設定します。

5. CoreOS側でコンテナの立ち上げ

5.1 設定を cloud-config.yml に追記

書き方は systemd のヘルプを参照してください。
VIRTUAL_HOST は nginx-proxy のための設定、 LETSUENCRYPT_* はSSL証明書の発行のため、RAILS_*はrails用の環境変数なので環境に応じて設定してください。
cloud-config.yml
...
    - name: hoge.service
      content: |
        [Unit]
        Description=hoge
        Requires=docker-registry.service
        After=docker-registry.serivce

        [Service]
        Type=simple
        Restart=always
        ExecStartPre=-/usr/bin/docker stop hoge
        ExecStart=/usr/bin/docker run \
                    --rm \
                    --name="hoge" \
                    -e "VIRTUAL_HOST=hoge.mydomain.com" \
                    -e "LETSENCRYPT_HOST=hoge.mydomain.com" \
                    -e "LETSENCRYPT_EMAIL=hoge@mydomain.com" \
                    -e "TZ=Asia/Tokyo" \
                    -e "RAILS_ENV=production" \
                    -e "RAILS_DATABASE_USER=" \
                    -e "RAILS_DATABASE_PASSWORD=" \
                    -e "RAILS_DATABASE_HOST=" \
                    -e "RAILS_DATABASE=" \
                    -e "SECRET_KEY_BASE=" \
                    localhost:5000/hoge-project
        ExecStop=/usr/bin/docker stop hoge

        [Install]
        WantedBy=multi-user.target
...


5.2 cloud-config.ymlを読み込み

sudo coreos-cloudinit -from-file=cloud-config.yml
sudo cp cloud-config.yml /var/lib/coreos-install/user_data

5.3 サービスの起動

$ sudo systemctl start hoge.service
$ sudo systemctl status hoge.service
以上で、サービスインすることができます。
この状態で放置しておくと1時間に1回、SSLの証明書の更新が行われるのでhttpsでアクセスできるようになります。

更にサービスを追加する

上記3〜5を繰り返します

一定時間ごとに実行するサービスを作る

cron的なものも systemd で実現できるので cloud-config.yml に書きます。
oneshotのサービスを定義してtimerを作成します。
    - name: sample-job.service
      content: |
        [Unit]
        Description=sample-job
        Requires=docker-registry.service
        After=docker-registry.serivce
        [Service]
        Type=oneshot
        ExecStart=/usr/bin/docker run \
                    --rm \
                    -e "TZ=Asia/Tokyo" \
                    localhost:5000/sample-job
    - name: sample-job.timer
      command: start
      content: |
        [Unit]
        Description=Run sample-job
        [Timer]
        OnCalendar=*:*
確認はsystemctlを使う。
systemctl list-timers

スケール戦略

私の環境はまだスケールするような状況にはなってないですが、もしユーザ数が増えた時にそれに対応できるというのは重要ですよね。
今回Dockerコンテナはほぼ独立しているので、アクセスが増えてきたらサーバを増やして該当のサービスだけ切り出した cloud-config.yml を利用して簡単にセットアップできます。また、LBを追加して複数のサーバに負荷分散することも容易です。
それでもパフォーマンスが気になってきたら、AWSなどの他のクラウドに移すことを考えるとしてもDockerのイメージを持っていくことは大した作業にはならないと思います。基本的な汎用技術しか使っていないのでその時々に応じた最適な環境に持っていけるはずです。(DBのデータの移行は必要ですけどね!)

まとめ

以上、Dockerを使っての複数サービスを作る際のマイベストプラクティスでした。
一見複雑な気もしますが、サーバの状態は cloud-config.yml だけで定義されています。こういう使い方をすると cloud-config.yml が非常に縦長のファイルになってしまいますが、個人的には複数にわかれてるより管理しやすくて好きです。
各サービスの設定もほぼ定形の Dockerfile, nginx.conf, supervisord を置いておいて docker build をするだけなのでわかりやすいと思います。
実際の運用は
  • ローカルマシンでのビルド、プッシュ
  • CoreOS上でのcloud-config.ymlのアップデート
辺りは簡単なスクリプトにまとめています。
今回説明したものをなんとなくGitHubにまとめておきました。スクリプトの一部しか書かれてなくてよくわからない場合は参考にしてください。

おわりに

これを読んで、CoreOSじゃなくてもDockerとsystemdが入ってればいいじゃんとか、素直にAWS使えばいいじゃんとか色々意見はあると思いますし、私もこの環境での運用は長くはないので問題は色々出てくると思っています。ぜひみなさんが考えるベストなシステム構成を教えて下さい。
あなたもコメントしてみませんか :)
ユーザー登録(無料)
すでにアカウントを持っている方はログイン


0 コメント:

コメントを投稿