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

More than 3 years have passed since last update.


はじめに

エンジニアやっていると色んなサービスを作りたくなると思うのですが、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のプライベートリポジトリはお金がかかるので小さいサービスを量産していく形だと非常にお金がかかってしまいます。AWSやGCPがDocker Registryを提供していますが、AWS CLIなどを入れて認証することが必要で複雑になるため利用を諦めました。そのため別途 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
command: start
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
command: start
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;

include mime.types;
default_type application/octet-stream;
gzip on;
gzip_http_version 1.0;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 1;
gzip_types text/plain
text/css
text/xml
application/json
application/javascript
application/x-javascript
application/xml
application/xml+rss
;
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}

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用の環境変数なので環境に応じて設定してください。

DBはConoHaの場合管理コンソールから作成しておきます。


cloud-config.yml


...
- name: hoge.service
enable: true
content: |
[Unit]
Description=hoge
Requires=docker-registry.service
After=docker-registry.serivce

[Service]
Type=simple
Restart=always
ExecStartPre=-/usr/bin/docker stop hoge
ExecStartPre=-/usr/bin/docker rm 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 マイグレーションの実行

(最初この手順が抜けてました)

$ /usr/bin/docker run --rm \

-e "RAILS_ENV=production" \
-e "RAILS_DATABASE_USER=hoge" \
-e "RAILS_DATABASE_PASSWORD=hoge" \
-e "RAILS_DATABASE_HOST=hoge.host" \
-e "RAILS_DATABASE=hoge" \
-e "SECRET_KEY_BASE=" \
localhost:5000/hoge bundle exec rake db:migrate

ちょっと長くて面倒ですが cloud-config.yml と同じ内容なのでコピペするだけなので許容範囲かなと思います。

この辺は好みに応じてスクリプトでラップしたり、そもそもdocker-buildの時点で環境変数入れてしまうなどで対応できます。


5.4 サービスの起動

$ sudo systemctl start hoge.service

$ sudo systemctl status hoge.service

うまく立ち上がっていなかったらログを確認して対応します

$ journalctl -fu hoge

以上で、サービスインすることができます。

この状態で放置しておくと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にまとめておきました。スクリプトの一部しか書かれてなくてよくわからない場合は参考にしてください。

https://github.com/miyasakura/my-docker-sample


おわりに

これを読んで、CoreOSじゃなくてもDockerとsystemdが入ってればいいじゃんとか、素直にAWS使えばいいじゃんとか色々意見はあると思いますし、私もこの環境での運用は長くはないので問題は色々出てくると思っています。ぜひみなさんが考えるベストなシステム構成を教えて下さい。