はじめに
EXNOA プラットフォームインフラ部の角です。
私達の組織では、PCやスマートフォンなど複数デバイスでオンラインゲームやダウンロードゲームを遊べる、登録ユーザー3,100万人超のプラットフォーム「DMM GAMES」で利用しているサーバの構築と運用を行っています。
このプラットフォームは10年以上運営されており、一部のサーバはクラウドへ移行されていますが、オンプレミスの仮想サーバや物理サーバで運用しているものもあります。
今回は、2年ほど前から運用しているオンプレミス環境のDocker Swarmの運用について、Go製のGraphQLサーバを安全にローリングアップデートしている方法を含めて紹介したいと思います。
1.前提知識
まず、「DMM GAMES」で利用しているサーバの環境は主に以下のとおりです。
- AWS上のEC2, ECS, EKS
- GCP上のGKE
- オンプレミスの仮想サーバ
- オンプレミスの物理サーバ
今回の記事は、「オンプレミスの仮想サーバ」の話で、もう少し詳しく環境の説明をします。
オンプレミスの仮想サーバは、vSphere製品で構築されているプライベートクラウド上に起動されるサーバになり、vSphereそのものの運用は別の組織が担っています。
私達のチームでは、vCenterやvSphereを運用する組織から提供されているツール群を利用して、オンプレミスで必要な仮想サーバのプロビジョニングを行います。
サーバは仮想サーバを利用するケースが多いですが、ユーザーからのトラフィックを受け付けるロードバランサーは、パフォーマンス上の理由から物理ロードバランサーを利用するケースがほとんどです。
これらの
- サーバは、vSphere上の仮想サーバ
- ロードバランサーは、物理ロードバランサー
という2点と、ECS Anywhere、 EKS Anywhereなどのソリューションがない、2年以上前に構築して現在も運用しているシステムということを前提として、以降の記事を読んで頂ければと思います。
2.なにが課題だったのか?
きっかけは「AWSのAPIサーバからオンプレミスのDBのデータを参照したいので、複数のDBの情報をまとめて参照するためのGo製のGraphQLサーバをオンプレで動かしたい」という相談がアプリケーションの開発者から来たことです。
その際、
- DBと同じ役割をするので、アプリケーションを更新する時に、GKEのローリングアップデートのように接続断をなくしたい
- DBと同じ役割をするので、可用性が高い構成にしたい
- バイナリを実行するのみなので、コンテナを使っても使わなくてもいい
という要件が挙げられていました。
まず課題になったことは、Go製のGraphQLのアプリケーションでは、PHPのアプリケーションのように、ビルド後のプログラムをSSH経由で各サーバへ配布してアプリケーションを更新する方法が利用できないということです。
オンプレミスのアプリケーションのほとんどは、PHPを利用しています。
そのため、nginx + php-fpmが起動しているサーバへ、JenkinsからSSH経由でプログラムを配布することによりデプロイする方法が多く採用されています。
この方法であれば、新旧のバージョンの更新はミドルウェア側に任せることができるので、更新によるダウンタイムは発生しません。
しかし、Go製のGraphQLのアプリケーションは、httpサーバが内蔵されているのでバイナリを起動するとhttpサーバも起動します。
そのため、ゼロダウンタイム且つGracefulにアプリケーションをアップデートさせるには、
- https://github.com/cloudflare/tableflip のようなライブラリを利用して、アプリケーション側に更新処理を実装する
- サーバやコンテナ単位でローリングアップデートさせる
の2つの方法のどちらかで行う必要があります。
1つ目の方法は、
- 新しいプロセスを起動させる
- トラフィックを新しいプロセスの方に切り替える
- 古いプロセスを終了させる
この手順をアプリケーション側で実装するというものです。詳細はCloudflareのブログが参考になると思います。
https://blog.cloudflare.com/graceful-upgrades-in-go/
2つ目のサーバやコンテナ単位でローリングアップデートさせる方法は、
- ロードバランサーから切り離す
- アプリケーション側でGraceful Shutdownを実行させ、アプリケーションを終了させる
- バイナリを更新して、アプリケーションを起動させる
- ロードバランサーに登録する
この手順を自動化して行うというものです。
私達の組織では、開発者の負担の軽減や運用面で後者の方がインフラエンジニアにとって分かりやすいということから、ローリングアップデートを採用することにしました。
AWSやGCPを利用するのであれば、KubernetesやECS、 CloudRunと各クラウドのロードバランサーを連携させて利用することで、ローリングアップデートを簡単に実現することができます。
しかし、オンプレミスになるとローリングアップデートの全行程を自動で実行させるのは難易度が高くなります。
前提の部分で紹介した物理ロードバランサーにサーバの登録と解除を行うAPIは存在していますが、CI/CDツールとして利用しているCircleCIからネットワークの経路上の問題でAPIを実行することは不可能でした。
そのため、サーバ単位のローリングアップデートの方法を諦めて、コンテナを利用してどうにかできないか調査することにしました。
調査の結果、候補としては以下のようなものがありました。
- Kubernetes
- Nomad
- Docker
- Docker Compose
- Docker Swarm
Kubernetesに関しては、Launcherなどの構築と運用を簡単にするツールが存在しますが、オンプレミスでKubernetesを運用できる人的リソースもノウハウもないので候補から外しました。
NomadはConsulをIngressとして利用することでダウンタイムなしにコンテナやバイナリをローリングアップデートできそうでしたが、情報量も少なく知見もないので候補から外しました。
残りは、Docker関連です。
素のDockerでは、1つのコンテナが1つのHostのPortを専有してしまうので、コンテナをリスタートする時に必ずダウンタイムが発生してしまいます。
コンテナをリスタートする前後に、ロードバランサーから解除、登録すればよいのですが、物理ロードバランサーの制約で手動でその作業を行う必要があるので、候補から外れました。
Docker Composeでは1種類の複数のコンテナでHostのポートを共有して利用できますが、ローリングアップデートの機能は提供されていません。
最後に、Docker Swarmです。
Docker Swarmは、Kubernetesよりも構築や運用は容易でローリングアップデートの機能が提供されていますが、ネットワーク周りのIssueが放置されており、メンテナンスや開発が活発ではありません。
そのため、Docker Swarmも運用面で懸念があり、Kubernetesがコンテナオーケストレーションのスタンダードになっている状況であえてDocker Swarmを使うモチベーションもないので、候補から外れるという方向になりそうでした。
しかし、1台構成のDocker Swarmを複数クラスター用意して、その上でGo製のGraphQLのコンテナを起動させれば、ローリングアップデートが上手くいきそうだと思い、検証に取り掛かりました。
1台構成のDocker Swarmとは、サーバにDockerをインストールして、docker swarm initを実行した状態を指します。worker nodeをjoinさせてない1台構成のmanager nodeとも言い換えることができます。
これから、この1台構成のDocker SwarmのことをSingle Node Swarmと呼ぶことにします。
3.Single Node Swarmを利用した解決策
検証の結果、要件を満たせつつ運用も可能だと判断し、上の図のような構成でGo製のGraphQLサーバを運用しています。
まず、図の一番右の赤いサーバ群から説明します。
これらのサーバが、AWSのアプリケーションから参照したいDBサーバです。GraphQLサーバを経由して参照するDBサーバは複数種類あります。
次にDBサーバの左の水色のサーバ群が、Single Node Swarmのサーバです。このサーバ上で、Go製のGraphQLのコンテナが実行されます。
これらのサーバにはdocker-ceのパッケージがインストールされており、swarm modeが有効の状態になっています。
ただし、docker swarm joinは実行しておらず、先程説明した通り各サーバが独立した状態になっています。そのため、複数サーバにまたがるoverlayネットワークは構成されていません。
コンテナの起動は、各サーバでdocker stack deploy -c docker-compose.yamlを実行して、以下のようなyamlに定義された設定で起動されます。
# Source: docker-compose.yaml
version: "3.4"
services:
app:
image: ****:****
command: app
deploy:
replicas: 3
restart_policy:
condition: any
resources:
reservations:
cpus: "1"
memory: 1000M
stop_grace_period: 30s
labels:
com.datadoghq.tags.version: ******
com.datadoghq.ad.logs: '[{"source":"go","service":"app"}]'
com.datadoghq.tags.env: env
com.datadoghq.tags.service: app
environment:
- DD_VERSION=******
- DD_AGENT_HOST=datadog
- DD_PROPAGATION_STYLE_INJECT=Datadog,B3
- DD_PROPAGATION_STYLE_EXTRACT=Datadog,B3
- DD_TRACE_ANALYTICS_ENABLED=true
- DD_ENV=env
- DD_SERVICE=app
ports:
- 8080:3308
env_file:
- $HOME/env/.env
healthcheck:
test:
- CMD
- curl
- -f
- http://localhost:3308/healthcheck
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
logging:
driver: json-file
options:
max-size: 100m
max-file: 10
datadog:
image: datadog/agent:7
deploy:
replicas: 1
restart_policy:
condition: any
stop_grace_period: 5s
environment:
- DD_VERSION=7
- DD_ENV=env
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc/:/host/proc/:ro
- /sys/fs/cgroup:/host/sys/fs/cgroup:ro
ports:
- 8125:8125
- 8126:8126
env_file:
- $HOME/env/.env.datadog
healthcheck:
test:
- CMD
- curl
- -f
- http://localhost:5555/
interval: 1m
timeout: 30s
retries: 5
start_period: 5s
logging:
driver: json-file
options:
max-size: 100m
max-file: 10
緑色のサーバ群は、TLSの終端とアクセスログの収集を行うためのnginxのリバースプロキシです。nginxの設定でupstreamへのパッシブヘルスチェックを有効にしており、single node swamのサーバやサーバ上で起動しているコンテナ、アプリケーションの不具合でリクエストを処理できない場合は、そのサーバへのリクエスト送信を一定期間停止させています。
そして、その前段に物理ロードバランサーという構成になっています。
この構成で2年以上運用していますが、特に大きな問題が発生したことはありません。
この構成のメリットは、
- ローリングアップデートが可能である
- overlayネットワークなど複雑な仕組みを管理・理解する必要がほとんどなく、学習、構築、運用コストがとても低い
- vSphereのクラスター、仮想サーバ、プロセスレベルで冗長化することができる
だと考えています。
【メリット1 】ローリングアップデートが可能である
メリットの1つ目である、ローリングアップデートについてもう少し詳しく説明します。
アプリケーションを更新したい場合は基本的に、docker-compose.yamlのimageのtagを書き換えてdocker stack deployを実行するのみです。
後ほど説明しますが、CodeDeployと連携させることで、オンプレミス環境でもgithubのmergeをトリガーとして自動でローリングアップデートを実行することが可能になります。
また、以下の実装・設定をすることでユーザーからのリクエストにエラーを返さずにアプリケーションをアップデートすることができます。
- replicasに2以上に設定して起動するコンテナの数を2以上にする
- graceful_time_priodに適切な値を設定する
- アプリケーション側でSIGTERMを受け取ったら処理中のリクエストを返してからアプリケーションを終了するように実装
加えて、ユーザーが独自でスクリプトなどを記述する必要がなく、
- サーバ内のVIPからコンテナコンテナの切り離し、追加
- SIGTERMを送信して、コンテナを終了させる
をDocker Swarmが自動で実行します。
そのため、少しの設定とアプリケーション側でSIGTERMを受け取って最後のリクエストを処理してからアプリケーションを終了させるように実装するだけで、ユーザからリクエストを処理している状態でアップデートを実行したときのエラーを発生をなくすことができます。
【メリット2】 学習、構築、運用コストが低い
メリットの2つ目は、学習、構築、運用コストがとても低いということです。
Docker Swarmは、task、 serviceなどの用語がありますが、1サーバにつき1種類のアプリケーションのみをデプロイするのであればまったく意識する必要はありません。
Docker Composeにローリングアップデートの機能がついたものがSingle Node Swarmという説明で事足りるかと思います。
【メリット3】 冗長化することができる
メリットの3つ目は、vSphereのクラスター、仮想サーバ、プロセスレベルで冗長化することができる点です。
Single Node Swarmの仮想サーバは、複数台をそれぞれ違うvSphereのクラスターにプロビジョニングされています。
これにより、vSphereのクラスタの障害、物理サーバの障害、仮想サーバの障害のいずれにも対応できるかと思います。
また、物理ロードバランサーからNginxへの通信には、物理ロードバランサーからTCPレベルのヘルスチェックを実行しており、ヘルスチェックに失敗したNginxのサーバは自動で切り離すようになっています。
コンテナに関しても、1サーバあたり3コンテナデプロイしているので、コンテナの意図しない停止にもダウンタイムを最小限にできます。
さらに、1つのサーバに複数コンテナをデプロイすることが可能なので、Node.jsのようなシングルスレッドで動作するプログラムもサーバのCPUをフルに利用することが可能になります。
4. CodeDeployを利用して、安全にコンテナをアップデートさせる
次に、Docker Swarm上のコンテナをアップデートするためのパイプラインについて紹介したいと思います。
デプロイパイプラインは、CircleCIとAWSのCodeDeployを利用した以下のような構成です。
オンプレミスのサーバにCodeDeploy agentをインストールしてOn-premises instancesとして登録することで、オンプレミスのサーバに対してデプロイを実行することが可能になります。
デプロイパイプラインの工程としては、
- Docker ImageのBuild後に、ECRへDocker ImageをPushする
- docker-compose.yamlのimages tag部分を2でpushしたTagに書き換えてaws deploy pushとcreate-deploymentを実行してデプロイを実行する
になります。
2のデプロイで、Single Node Swarmのサーバで実行される処理は、
- ECRにログイン
- docker stack deploy app -c docker-compose.yaml --with-registry-auth を実行してコンテナのアップデート
- docker-stack-waitスクリプトを実行して、新旧のコンテナが入れ替わるまで待機する
になります。
docker-stack-waitスクリプトとは、以下のGithubのリポジトリで公開されている、docker stack deploy中のコンテナの更新をポーリングして成功した場合にexit code 0を、失敗した場合に0以外を返すシェルスクリプトのことです。
https://github.com/sudo-bmitch/docker-stack-wait
このスクリプトを実行することで、デプロイが失敗したときにCode Deployの処理を中断させるようにしています。
また、CodeDeployのDeployment configurationをCodeDeployDefault.OneAtATimeにすることで、複数台のSingle Node Swarmのサーバを1台ずつCodeDeployがデプロイを実行するようにできます。
デプロイに時間を要しますが、1台のサーバでデプロイが失敗するとその時点で処理を停止するので、コンテナが正常に起動しないなどのデプロイの失敗によるサービスダウンを防ぐことができます。
このように、デプロイ部分でAWSのサービスを利用することでコンテナレジストリーやデプロイ関連でクラウドの恩恵も受けつつオンプレミス環境でも安全なデプロイを実現しています。
5.Docker Swarmの運用のコツ
Docker swarmの運用において気をつけるべきポイントがあるので紹介します。
それは仮想マシンのストレージ容量についてです。
アプリケーションを更新、運用するにつれて、仮想マシンに古いDocker Imageとコンテナのログが蓄積し、ストレージ容量を圧迫します。
そのため、
- Cronで利用していないDocker Imageの削除
- コンテナの起動オプションに、--log-opt max-size, --log-opt max-fileを付与してログをローテーションさせる
の2点を行う必要があります。
普段、Fargateなどを利用しているとログや利用しなくなったDocker Imageでサーバのストレージが枯渇することはないので、オンプレミスの環境でコンテナを運用するときは気をつける必要があります。
監視とログの転送には、Datadogを利用しています。dockerのlabelに適切な値を設定することで以下のようにコンテナのバージョンごとにメトリクスが閲覧可能になります。
他のAWSのシステムやAPMでDatadogを採用しており、それに合わせるためにDatadogを利用しましたが、Dockerに対応しているツールやソリューションであれば、なんでも利用可能だと思います。
6.これから
現在、Single Node Swarmを利用してGo製のGraphQLサーバを安定稼働させることができていますが、1つの大きなリスクとして「docker-ceのパッケージからSwarmの機能が削除される」ということがあるかもしれません。(公式でアナウンスがあったわけではないので、私の想像での話です。)
現状は、Swarmの機能提供するswarmkitが統合された状態でパッケージが提供されている状態ですが、そのサポートが終了してswarmkitがパッケージから削除されると、Dockerのバージョンアップの時に少し困ります。
swarmkitそのものは別のコードベースとして管理されているので、削除されてもswarmkitを追加でインストールし、いまの構成を運用し続けることは可能であると思います。
しかし、いつか最新のdockerのバージョンにswarmkitが対応できなくなるという日も来るかもしれません。そのため、代替手段を検討しておく必要があると思います。
幸いアプリケーションそのものはコンテナで動いているので、そのほかのソリューションに移行しやすい形になっています。
ECS Anywhereは検証した結果、移行の候補には適さないことが分かりましたが、今後も今のソリューションよりもよいものがあれば、検証して取り入れていきたいと考えています。
最後に
私の所属するEXNOA プラットフォームインフラ部では、プラットフォームの安定運用やグロースに携わるインフラエンジニアを募集しています。
ご興味のある方は下記募集要項をご覧のうえ、ぜひご応募ください。
https://dmmgames.co.jp/recruit/entry/job/id=1092
また、AWSや監視に関してもモダンな技術スタックを利用して、構築や運用を行っているので、以下の記事も合わせてご覧ください。
https://inside.dmm.com/entry/2022/08/26/eks_is_hard
https://inside.dmm.com/entry/2021/05/17/amazon-eks-well-architected
https://inside.dmm.com/entry/2021/07/26/cloudnative-monitoring