はじめに
エンジニアやっていると色んなサービスを作りたくなると思うのですが、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の設定ファイルのみで簡単にできるようになっていて、必要な管理はそれだけなのが特徴です。
順に構成要素を説明していきます。
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をインストールします。
基本となる 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を使う。
スケール戦略
私の環境はまだスケールするような状況にはなってないですが、もしユーザ数が増えた時にそれに対応できるというのは重要ですよね。
今回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使えばいいじゃんとか色々意見はあると思いますし、私もこの環境での運用は長くはないので問題は色々出てくると思っています。ぜひみなさんが考えるベストなシステム構成を教えて下さい。