I ❤️ Kubernetes

f:id:tei1988:20171206163009j:plain こんにちは!プロジェクト推進室でエンジニアをしているTei1988です。

先日開催したSpeeeKaigi #3で「I ❤️ Kubernetes」を発表しました。

tech.speee.jp

Kubernetesは、コンテナオーケストレーションツールです。 コンテナ化されたアプリケーションの管理やデプロイ、スケールなどを一手に引き受けます。

Google Container EngineからGoogle Kubernetes Engineに名称変更されたり、EKS(Amazon Elastic Container Service for Kubernetes)も始まったりと、スタンダードなツールになりつつあるので、Kubernetesを学んでおいても損にはならないかと思います。

DockerもKubernetesもほぼ初めてだったのですが、触っていくうちにいつのまにか❤️ になっていました。

ここでは、今回やってみたことを紹介します。

0. そもそも

最近、自部署近辺でKubernetesというワードがちらほら出てきました。これまで、Dockerにもあまり触れられていなかったので、勉強のためにRailsでアプリケーションを作り、Kubernetes上で動かしてみることにしました。

1. Kubernetesを構築してみる

KubernetesはOSSなので、自分で構築することができます。どのような仕組みで動くのか興味があったので、IDCFさんの500円サーバを3台使って自分で構築することにしました。

構築するにあたって、下記の記事を参考にしました。
kubernetesによるDockerコンテナ管理入門 | さくらのナレッジ
丁寧に導入方法が書かれていて、とても助かりました。 また、文中のマスター-ノード間や、ノード-ノード間の動きの図がわかりやすく、動きの理解もしやすかったです。

ただ、オプションの命名規則が_から-に変更されていたようで、一通り設定を終えていざPodをデプロイしようという段階でデプロイに失敗してしまう現象に悩まされました。 マスター-ノードの認証をしている部分で、service-account-private-key-fileのオプションが効いておらず、認証のための秘密鍵が設定されていないために認証に失敗してしまうことが原因でした。 kubectl cluster-infoや、kubectl get nodesでクラスタやノードの状態を調べても正常な場合と差が見当たらず、原因がなかなか特定できませんでした。

とりあえず、kubectl create -f ...などはできて、nginxのwelcomeページは表示できるようになりました。 なんとなく仕組みを理解できたものの、 Railsアプリケーションに全然着手できていなかったので、自分で構築することを諦めて、GCPで提供されているKubernetes(GKE)を使うことにしました。

ちなみに、Kubernetes自体を試す場合には、GitHub - kubernetes/minikube: Run Kubernetes locallyというものがあり、こちらを使えばすぐに環境が作れます。

2. Railsアプリケーションをつくってみる

ウェブサイトとその管理画面、バッチ、非同期処理を一つのRailsアプリケーションとしました。

アプリケーションの詳細は特に変わったことをしているわけではないので省きますが、ウェブサイトとその管理画面を起動時の環境変数によって切り替えられるようにしました。

全体のシステム構成図は以下を想定しました。 f:id:tei1988:20171205203319p:plain

Railsアプリケーションの前にnginxを入れて、非同期処理にSidekiqを利用する、よくある構成だと思います。

3. Redisを導入してみる

Sidekiqで使うために、Redisを導入することにしました。

AWSにはElastiCacheがあるのですが、GCPには今のところありません。そのため、 Kubernetes上でRedisを動かすことにしました。 本格的にサービスに導入する場合は、冗長構成などを検討すると思いますが、今回はマスター1台としました。

下記のように、 readinessProbelivenessProbe で、Podが落ちていても再起動がかかるようにしました。

...
        readinessProbe:
          tcpSocket:
            port: 6379
          initialDelaySeconds: 5
          periodSeconds: 1
        livenessProbe:
          tcpSocket:
            port: 6379
          initialDelaySeconds: 10
          periodSeconds: 30
...

4. CloudSQLのプロキシを設定してみる

データベースは、GCPのCloudSQLを利用することにしました。Kubernetesからの接続にはいくつか方法があります。

今回は以下を参考にプロキシを挟むことにしました。

Connecting from Google Kubernetes Engine  |  Cloud SQL for MySQL  |  Google Cloud Platform

サイドカーコンテナを使うことが書かれているのですが、アプリ本体や、管理ツール、バッチ、Sidekiqでそれぞれサイドカーコンテナを設定するのはなんとなく避けたいと思ってしまったので、プロキシ単体でPodを作ってみました。

引数で、tcp:3306tcp:0.0.0.0:3306 に変えることでローカル接続以外も受け付けるようになります。

...
        - -instances=<project_name>:<zone>:<instance_name>=tcp:0.0.0.0:3306
...

あとは、 3306expose する設定をデプロイしてあげれば、Kubernetesの内部であればどのPodからでも接続が可能になります。

5. Railsアプリケーションをコンテナ化してみる

Railsアプリケーションをコンテナ化する際に、動作に必要の無いパッケージを極力含めたくなかったので、ruby:alpineからイメージを作ることにしました。

とはいえ、Gemの中にはNative Extensionを利用するものもあり、それらをインストールするためには開発用パッケージが必要になります。 最初の頃はDockerfileのRUNを一つにまとめて頑張っていたのですが、毎回パッケージインストールやらコンパイルやらが走ってしまい、イメージのビルドに時間がかかってしまっていました。 そんな時に、ECSとGoとDocker multistage build - Speee DEVELOPER BLOGでも紹介されているmultistage buildを知り、二段構えにすることにしました。

流れとしては以下のような形です。
f:id:tei1988:20171012203815p:plain

  1. Railsアプリケーションで使うGemのコンパイルをするためのapp:bundle-buildをビルドします。
  2. 実際に動かすためのapp:latestをビルドします。この時、app:bundle-buildからインストールしたGemのディレクトリをまるっとCOPYしてきます。

これにより、不要な開発ツールを実行用のイメージに含めなくてよくなると同時に、Gemの更新が無い場合はapp:bundle-buildはキャッシュされたものが使われるので、毎回nokogiriのコンパイルを待つ必要が無くなりました。

6. Railsアプリケーションを起動してみる

#5で作ったイメージには、ウェブサイトとその管理画面、バッチ(Rakeタスク)、非同期処理(Sidkeq)が含まれています。

そこで、Kubernetesのデプロイの設定にそれぞれの起動時のコマンド変えて、一つのイメージから起動させることにしました。

ウェブサイトは以下のようにしました。(部分抜粋)

...
      containers:
      - name: web
        image: gcr.io/<project_name>/app:latest
        imagePullPolicy: Always
        command:
        - bundle
        args:
        - exec
        - pumactl
        - -F
        - config/puma.rb
        - start
        ports:
        - containerPort: 3000
          protocol: TCP
        env:
        - name: RAILS_ENV
          value: production
        - name: RAILS_LOG_TO_STDOUT
          value: "1"
        - name: RAILS_SERVE_STATIC_FILES
          value: "1"
...

管理画面は以下のようにしました。(部分抜粋)

...
      - name: admin
        image: gcr.io/<project_name>/app:latest
        imagePullPolicy: Always
        command:
        - bundle
        args:
        - exec
        - pumactl
        - -F
        - config/puma.rb
        - start
        ports:
        - containerPort: 3000
          protocol: TCP
        env:
        - name: RAILS_ENV
          value: production
        - name: ADMIN
          value: "1"
        - name: RAILS_LOG_TO_STDOUT
          value: "1"
        - name: RAILS_SERVE_STATIC_FILES
          value: "1"
...

管理画面とウェブサイトの差分は、起動時に環境変数ADMINがセットされているかどうか、です。

Sidekiqは以下のようにしました。(部分抜粋)

      containers:
      - name: sidekiq
        image: gcr.io/<project_name>/app:latest
        imagePullPolicy: Always
        command:
        - bundle
        args:
        - exec
        - sidekiq
        - -C
        - config/sidekiq.yml
        env:
        - name: RAILS_ENV
          value: production
        - name: RAILS_LOG_TO_STDOUT
          value: "1"

ウェブサイトや管理画面との違いは、pumaではなく、sidekiqを立ち上げていることです。

kubernetesだけが解消できるという訳ではありませんが、環境変数を設定に書くことで、実はxxの環境変数が動作に影響していて、それはooの奥底で定義されていた!の様な、辛い経験とはおさらばです。

そのままでは他のPodから繋がらないのでapp3000番ポートをexposeするような設定を作って、デプロイします。

adminはそのままお外からも繋げるようにしました。 とはいえ、アクセス元は制限したいので、以下のような設定を追加しました。

...
spec:
  ports:
    - name: admin
      port: 80
      targetPort: 3000
  selector:
    app: admin
  type: LoadBalancer
  loadBalancerIP: <事前に取得した静的IP>
  loadBalancerSourceRanges:
  - <許可するアクセス元のIP/CIDR>
...

直接GCPのコンソールから設定を追加することもできるのですが、Kubernetes側で再度デプロイすると、当然ながら手動で入れた設定も上書きしてしまいます。 いつのまにか全世界に公開していた、ということになるかもしれないので、設定に含めるのが良いと思います。

7. nginxに設定を追加してみる

システム構成図の nginx の部分です。 Kubernetesでは、Serviceを作ると、そのServiceにつけた名前で名前解決ができるようになります。 *1

そこで、以下のような設定にしてみました。 この設定で、ちゃんとRailsアプリへアクセスが行きます。

...
upstream backend {
  server web:3000;
}
...
server {
...
  location / {
    proxy_pass http://backend;
    proxy_set_header  Host $host;
    proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header  X-Forwarded-Proto $scheme;
    proxy_set_header  X-Forwarded-Port $server_port;
    proxy_set_header  X-Forwarded-Host $host;
...
}

スケールしてRailsアプリを複数デプロイしてある場合でも、Serviceで複数のPodにマッチするように設定を書いている場合はこのままでアクセスが分散されます。すごい。

セッションなどを使う場合は、少し考えないとダメそうですね。

8. 定期的にジョブを実行する

Kubernetesには、cron的なものがあります。ズバリ、CronJobです。 1.8以降でAPIがbetaになったのですが、1.8以前はalpha版で、GCPでは使えませんでした。

自分が試した時はまだalpha版だったため、busyboxのcrondをフォアグラウンドで動かすことにしました。 バッチとbusyboxをイメージに入れて、crontabを書くだけなので、手軽だと思います。

kind: ConfigMap
apiVersion: v1
metadata:
  name: crontab
data:
  root: |-
    10,40 0-18 * * * echo "hoge"
---
kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  name: cron
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cron
  template:
    metadata:
      labels:
        app: cron
    spec:
      containers:
      - name: cron
        image: gcr.io/<project_name>/app:latest
        imagePullPolicy: Always
        command:
        - busybox
        args:
        - crond
        - -d
        - '6'
        - -f
        volumeMounts:
        - name: crontab
          mountPath: /var/spool/cron/crontabs
          readOnly: true
      volumes:
      - name: crontab
        configMap:
          name: crontab

9. Sidekiqのスケール

Kubernetesを使っていて、とても感動したことがこれです。

非同期処理をたくさん行う場合、Skidekiqのノードの数をたくさん増やしたくなると思います。

Kubernetesでは、
kubectl scale --replicas 3 deployment sidekiq
で、Podを3つに増やせます *2。 とても簡単でした。

10.さいご

SpeeeKaigiをきっかけに、KubernetesやDockerに触れられて、本当によかったと思いました。

自分でKubernetesを建ててみたことは無駄ではなく、下記「Be Docker Day」ですでに使い始めていた他のメンバーと同じぐらいKubernetesやDockerの話を深くすることもできました。
tech.speee.jp

ここ最近では、Java + AkkaなシステムをDocker化してKubernetesで動かしました。 今後も、DockerとKubernetesの良さをもっと広めていきたいなぁ、と思います。

*1:GCPで動かしている場合はデフォルトで動いているのですが、自前で構築した場合はkube-dnsを導入する必要があります。

*2:sidekiqという名前でSidekiqのDeploymentを作成していた場合