メルカリ社内ドキュメントツールの Crowi を Kubernetes に載せ替えました

最近、SRE になった @b4b4r07 です。今回は、直近のタスクだった社内アプリを Kubernetes に載せ替えた話をします。

前置き

メルカリでは全社的 1 に Crowi という Markdown で書ける Wiki アプリケーションをナレッジベースとして採用しています。

site.crowi.wiki

以前は、プロダクトチームは Qiita:Team、コーポレート系は Google Sites と言った具合に、各部署ごとに異なるドキュメントツールを使っていました。これではよくないと、エンジニアに限らず誰でも書きやすく参照しやすい Wiki のようなサービスが必要とされ、Crowi の採用に至りました。

まずはみんなに使ってもらうために広めていこうと、試験的に導入が始まったため、今回の移行話までは 1 台の EC2 インスタンスにアプリケーションサーバと Nginx、MongoDB が動いていました。全社的に浸透し、Crowi 導入から 1 年が経った頃、社内ナレッジベースとしての重要性が日々増していく中、そのインフラ (構成とデータのバックアップなど) を見直そうという動きになりました。

そして今年の夏に SRE チームへの異動となった私がその初仕事として、社内ツールとして動いていた Crowi の構成と運用の見直しをすることになりました。

本記事では、Crowi を Kubernetes 上で動かす方法と、その載せ替えの際に得た経験についてまとめます。これについては、Crowi に限った話ではなく、一般化して広く「Web アプリケーション」を Kubernetes で動かすためにも活かせる知見だと思います。

本題

移行前の問題点と目標

  • EC2 インスタンス 1 台で運用している → スケールさせられるようにしておきたい
  • 記事 (MongoDB) のバックアップは cron によるダンプのみ → DB のレプリケーションを用意したい
  • 運用方法が定まっていなかった → デプロイや問題が発生したときのロールバックをハンドリングしたい

これらの課題を包括的に解決するために、アプリケーションを Docker 化して Kubernetes を使ったスケール & スケジューリング管理をするのがよいと判断しました。また、Kubernetes のプラットフォームとして Google Container Engine (GKE) を採用しました 2

構成

Crowi では以下のミドルウェアを使用しています。これらステートレスなアプリケーションは Docker 化して Crowi と同じように扱うことができます。

  • Elasticsearch: 記事の検索
  • Redis: セッション情報のキャッシュ
  • PlantUML: UML 記法のレンダリング

データストアである MongoDB は Kubernetes の外に出し、Google Compute Engine (GCE) のインスタンスにサーバを立てました3。また、今回は MongoDB の Replica Set を使ってレプリケーションを行うため、3 台のインスタンスを用意しました。

  • MongoDB: データベース

以上を踏まえ、最終的な構成図は以下のようになりました。Google Cloud Load Balancer (GCLB; Ingress) とアプリの間にいる Nginx については後述します。

f:id:b4b4r07:20170906192830p:plain

移行する

移行までのステップは以下の通りです。

  • GCP に専用プロジェクトを作り、GKE に Kubernetes クラスタを作る
  • Crowi の Dockerfile を書く (Redis などはオフィシャルのもの使うので不要)
  • 各種 Kubernetes の Object を書く
  • Object を Kuberntes クラスタにデプロイする
  • MongoDB 用のインスタンスを用意する
  • レプリケーションを作成する
  • Crowi と各種ミドルウェアをつなぐ
  • Crowi Pod を Rolling Update する (Database connection error が解決しつながるようになる)
  • 本番を一時止め、データのマイグレーションを行い、DNS の切り替えを行う

では、それぞれについて詳細にみてみます。

まずは、GKE 上に Kubernetes クラスタを作成します。これは gcloud コマンドから行ったほうがよいです。どのような設定が反映されたクラスタなのか、GUI を見るよりそのコマンドを見るほうが把握しやすいからです。

$ gcloud alpha container clusters create ${CLUSTER} 
--project="${PROJECT_ID}" 
--zone="${ZONE}" 
--machine-type=n1-standard-8 
--scopes=default,storage-rw,bigquery,sql-admin,datastore,logging-write,monitoring-write 
--enable-autoscaling 
--max-nodes=20 
--min-nodes=1 
--enable-cloud-logging 
--enable-cloud-monitoring

Redis、Elasticsearch、PlantUML は特にカスタマイズする必要がなかったので、Docker Hub にてオフィシャルでホスティングされているものを使用しました。Crowi については Dockerfile を書き、Google Cloud Registry (GCR) に Push しておきます。

次に Kubernetes に Object をデプロイするための準備として、YAML ファイルを書いていきます。

Kubernetes は Pod という単位でコンテナ (Docker コンテナなど) を括り、それらを Node (VM やインスタンスに相当する概念) にスケジューリングします。各 Pod は Kubernetes の機能である Auto-healing などで停止する可能性があり、再生成されるときにどの Node にスケジューリングされるかわかりません。ゆえに正しく Service Discovery するために Service というリソース (Load Balancer のようなもの) でまとめておくことが通例です。それらのデプロイを Deployment というリソースで管理することで、Pod の Rolling Update などを制御することができます。クラスタ (例えば Crowi という Service)を外部に公開するには、Ingress というリソースを用います。それらの設定を Kubernetes Object として YAML (JSON) 形式で記述します。

$ tree
.
├── crowi.yaml
├── elasticsearch.yaml
├── ingress.yaml
├── nginx.yaml
├── plantuml.yaml
└── redis.yaml
0 directories, 6 files

Crowi の場合は Crowi (app.js) と PlantUML の Ingress を用意します4ingress.yaml を除くそれぞれの YAML ファイルにそれぞれの Deployment、Service を書いています。詳しい書き方は公式ドキュメントを参照してください。

---
apiVersion: v1
kind: Service
metadata:
labels:
app: crowi
name: crowi-service
spec:
ports:
- protocol: TCP
port: 3000
targetPort: 3000
type: NodePort
selector:
app: crowi
tier: backend
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
labels:
app: crowi
name: crowi
...

次に MongoDB のレプリケーションの設定についてです。GCE にインスタンスを 3 つ用意します。

  • Primary
  • Secondary
  • Arbiter

Arbiter は昇格用インスタンスではないのでパワーは必要ありません。インスタンスのセットアップは Terraform で、プロビジョニングは Ansible で行いました。

詳しくは以下のマニュアルが参考になります。

GCE のインスタンスはその GCP 内で有効な内部 DNS が自動で振られています。

hostName.c.[PROJECT_ID].internal

https://cloud.google.com/compute/docs/vpc/internal-dns

これを使って Primary の MongoDB インスタンスのアドレスを Crowi に教えてあげるとデータベースへ接続することができます。

また、Redis や Elasticsearch といった Service もその Kubernetes 内で有効な内部 DNS が自動で振られています。これは他の Pod の中から nslookup して、その Service に振られている内部 IP アドレスを引くことで確認できます。

$ kubectl describe svc redis-service
Name:                   redis-service
Namespace:              default
...
Port:                   <unset> 6379/TCP
...
$ kubectl exec -it POD nslookup redis-service.default.svc.cluster.local
Name:      redis-service.default.svc.cluster.local
Address 1: 10.15.251.10 redis-service.default.svc.cluster.local

https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/

これらの必要な情報を環境変数に設定し、Crowi の Pod を Rolling Update しましょう。env に書きづらいパスワードのシード値といったものなどは Secret 経由で渡すと良いでしょう。

env:
- name: MONGO_URI
value: mongo-1.c.mercari-wiki.internal:27017/crowi
- name: REDIS_URL
value: http://redis-service.default.svc.cluster.local:6379
- name: ELASTICSEARCH_URI
value: http://elasticsearch-service.default.svc.cluster.local:9200

これにて、サービスが動くようになるので本番のデータをマイグレーションし、DNS の切り替えを持って移行の完了です。

移行後

移行前に挙げた課題と目標は以下のように達成されました。

  • EC2 インスタンス 1 台で運用している → スケールさせられるようにしておきたい → Kubernetes のオートスケール、MongoDB の Replica Set を使った自動フェイルオーバー
  • 記事 (MongoDB) のバックアップは cron によるダンプのみ → DB のレプリケーションを用意したい → レプリケーションによる冗長化、ホットスタンバイ
  • 運用方法が定まっていなかった → デプロイや問題が発生したときのロールバックをハンドリングしたい → Docker イメージにしてバージョニング、Kubernetes によるデプロイ

移行から 1 ヶ月以上経ちましたが、今のところ問題なく稼働しています。

ハマリポイント

今回の移行を経て実際にハマったポイントを、後学の「Kubernetes 上で Web アプリケーションを動かすときにハマりそうな点」としてまとめます。

Ingress の Health checks について

現在の仕様では、すべてのバックエンド Service は、GCE ロードバランサから送信された HTTP(S) ヘルスチェックのために、次のいずれかの要件を満たす必要があります。

  • / で 200 を返すかどうか
  • readiness probe として公開されたバックエンド Service の任意のエンドポイントが 200 を返すかどうか

Ingress のコントローラはまずはじめに readiness probe を見に行きます。設定があれば、そこのエンドポイントに対して GCE ロードバランサから HTTP(S) のヘルスチェックが行われます。readiness probe がない場合は、GCE ロードバランサは / に対して HTTP(S) ヘルスチェックを行います。

つまり、バックエンドサービスのヘルスチェック用のエンドポイントに対して readiness probe を設定しないと、/ に対して 200 を返すかどうかのチェックが走るわけです。すべてのサービスが必ずしも / で 200 を返すかどうかはわかりません。例えば、/ でリダイレクトが実行される場合 (セッション情報がないクライアントは / -> /login のリダイレクトにより 302 を返す) などはヘルスチェックに失敗します。

うっかりハマりがちなので、正しくヘルスチェック用の readiness probe を設定することが推奨されます。

readinessProbe:
httpGet:
path: "/_api/healthcheck"
port: 3000

これについて、詳しくは公式の README にあります。

https://github.com/kubernetes/ingress/tree/master/controllers/gce#health-checks

Ingress での HSTS 設定

Ingress は、インバウンドな接続を許容するために使用され、サービスを外部から到達可能な URL にしたり、トラフィックの負荷を分散したり、SSL ターミネーションをしたりすることができます。そして、Ingress コントローラは通常、ロードバランサを使用して Ingress を実行します。

これらの設定に metadata.annotations が使われるのですが、どうやら HTTP Strict Transport Security (HSTS) ないしは HTTPS への Force redirect についてはできないようです。

https://github.com/kubernetes/ingress/blob/master/docs/annotations.md

GCP サポートに問い合わせたところ、現在 Ingress の GCE コントローラでの HSTS の設定はできないようで、自分のアプリケーションや別途立てた Nginx でリダイレクトするべきといった返答をもらいました。

よって、今回は Ingress と Backend Service の間に Nginx 用の Service を挟むことで HTTPS へのリダイレクトを設定しました。

    location / {
if ($http_x_forwarded_proto != "https") {
return 301 https://$host$request_uri;
}
proxy_pass http://crowi-service.default.svc.cluster.local:3000;
}

https://github.com/kubernetes/ingress/tree/master/controllers/gce#redirecting-http-to-https

Ingress で Nginx Service を Backend Service として設定し、nginx.conf で Crowi Service へプロキシしてあげます。また、同じクラスタネットワーク内にいるため、Service への内部 DNS が有効です。

まとめ

本記事では、Crowi という Wiki サービスを Kubernetes 上で動かす方法と、そこで得た経験やポイントなどをまとめました。

最近のメルカリでは Microservices 化を推進しています。Microservices として切り出せそうなものについては切り出され、徐々にプロダクションに投入されています。Mercari Wiki (社内ツールとしての Crowi) もその流れを汲み、同じ技術スタックを用いました。これはまだメルカリ社内には Crowi の開発チームがなく SRE で一部運用する目的も含んでいるためです5

これを読んで、フルタイムで Crowi を開発してみたい方や、Kubernetes によるサービス基盤開発などに興味のある方はぜひ応募してみてください。

Software Engineer, Site Reliability

Software Engineer (Internship))


  1. プロダクト (エンジニア、デザイナ、プロデューサー)、カスタマーサポート、コーポレート、HR など

  2. メルカリでは新たな Microservices が生まれるとき GKE に置くことが多いです

  3. Kubernetes の StatefulSets も検討したが今回は GCE に置きました

  4. Crowi の実装上、PlantUML サーバは外部からアクセスできる必要があります

  5. 社内ツールの開発チームがあれば他の Microservices と同じく、開発や運用を委任することが望ましいです

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加