はじめに

こんにちは、enzaプラットフォーム事業本部でエンジニアをやっている安藤です。
運用しているプロダクトの本番環境をAmazon EC2からAmazon EKSに移行する日が来まして、良い機会なのでこれまでやってきた対応をまとめてみました。 と言っても、EKS周りのことは特に言及しません。
タイトルの通り、Railsアプリケーションをコンテナ化してKubernetes上で動かすための対応について書いています。
DockerやKubernetesの仕組みなどの説明は端折っているので、ご了承ください。 Kubernetesが普及して久しいですが、「運用中のサービスを移行したいけど、結構大変だな…」というプロダクトも多いのでは無いかと思います。
この記事が、そういったプロダクトの移行作業の助けになれば、嬉しい限りです。

移行前のシステム

EKS移行前のシステム
すごくざっくりした図なのはご容赦ください。
まぁ、よくある構成ですよね。

抱えていた課題点

わりと長く運用しているサービスなので色々抱えていた課題はあるのですが、今回の載せ替えのモチベーションになったのはこの辺りです。
  • スケールイン・アウト等のオペレーションコストがかかる
    • 手順や注意点が多く、属人化しやすい(触ってた人が辞めたらアウト)
    • できる人がなかなか増えない
    • 作業が面倒くさい
  • 言語やライブラリのアップデートが大変
    • マシンイメージ作り直してインスタンスを全部取り替えたり
    • プロビジョニングツール流さないといけなかったり
    • うむ、面倒くさい

課題に対するアクション

行ったことは大きく分けてこの2点。
  • Railsアプリをコンテナ化する
    • Rubyのバージョンアップやライブラリの追加をするときはDockerfileを更新するだけで済む
  • Kubernetes上で動かす
    • インフラがPod / ReplicaSet / Deployment / Service等のリソースに抽象化される
    • インフラ構成をマニフェストファイルで一元的に管理できる
    • コード化されるため誰でも読めるし書ける(Infrastructure as Code)
他にもAmazon EKS周りの作業など色々とアツい移行話もあるのですが、あまりにボリューミーなので、それはまた次の機会に。

移行後のシステム

EKS移行後のシステム
移行後はこうなりました。
レッツコンテナオーケストレーション。

Railsアプリをコンテナ化する

コンテナで動くようにしていなかったので、以下の対応を実施。
  1. Dockerfileを書く
  2. コンフィグを整理する

1. Dockerfileを書く

まずは構成をどうするかですが、移行前から変える理由もなかったので、
  • Rails(Unicorn)+ Nginxの構成でコンテナはそれぞれ分ける
という形にしました。

Nginx

ディレクトリ構成はこう。

- docker
  - nginx
    - templates
      - nginx.conf.tmpl
      - (設定系のファイルが諸々)
    - Dockerfile

次にDockerfileですが、環境ごとにnginx.confの設定値を変えたかったので、Entrykitを使ってテンプレートのレンダリングを行っています。
そういった事情がないのであれば、公式イメージを使えばいいかなと思います。

ARG NGINX_VERSION=1.15.12

FROM nginx:${NGINX_VERSION}-alpine

ARG ENTRYKIT_VERSION=0.4.0
RUN apk add --no-cache openssl && \
    wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz && \
    tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz && \
    rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz && \
    mv entrykit /bin/entrykit && \
    chmod +x /bin/entrykit && \
    entrykit --symlink && \
    rm -rf /etc/nginx/nginx.conf.default /etc/nginx/conf.d/*

WORKDIR /etc/nginx

COPY templates/ /etc/nginx/

EXPOSE 80

ENTRYPOINT [ \
  "render", "/etc/nginx/nginx.conf", "--", \
  "/usr/sbin/nginx", "-c", "/etc/nginx/nginx.conf" \
]

Rails

何も考えずにDockerfileを書いてビルドすると、イメージサイズが1GB超えてて吐き気を催したりします。
軽量化しないとビルドやらプッシュやらに時間がかかって仕方ないので工夫必須。 よく書かれている対応方法のまんまですが、手を打ったのはこの2点。
  1. Alpine LinuxベースのRubyコンテナを使う
  2. Multi-stage buildsを使う

1. Alpine LinuxベースのRubyコンテナを使う

要するに、ベースとなるコンテナを軽量なものにしました。
Linuxとかの操作に慣れていると「え、ログインシェルがash?なにそれ」という感じになりますが、使い方さえ分かれば気にならないし、Kubernetes上で動かすようになったらコンテナにログインする機会自体が少ないので、特に支障はなかったです。

2. Multi-stage buildsを使う

bundle installはビルドステージで行い、最終的なステージには必要な成果物だけを持っていくようにして、サイズを軽くしました。
Multi-stage buildsを使わなくても、ビルド時に生まれた不要なファイルを手動で削除していけば軽くできるのですが、使ったほうがDockerfileもスッキリするし良いかなと思います。

できたDockerfileはこちら


# syntax = docker/dockerfile:experimental
# BuildKitを有効にしている

# ステージ共通で使うargs
ARG RUBY_VERSION=2.5.8
ARG ALPINE_VERSION=3.12
ARG BUNDLER_VERSION=1.17.3
ARG BASE_PACKAGES="busybox-suid git openssh shadow tzdata logrotate"

FROM ruby:${RUBY_VERSION}-alpine${ALPINE_VERSION} as builder

# Multistage buildsの場合はステージ内で再定義が必要
ARG BUNDLER_VERSION
ARG BUNDLE_OPTIONS
ARG BASE_PACKAGES
ARG BUILD_PACKAGES="alpine-sdk build-base linux-headers mariadb-dev"
ARG BUNDLE_OPTIONS="--jobs 4 --local --quiet --deployment --binstubs bin --without development test deploy"

COPY Gemfile* /
COPY vendor/cache /vendor/cache

RUN --mount=type=secret,id=ssh,target=/root/.ssh/id_rsa \
    apk add --no-cache ${BASE_PACKAGES} ${BUILD_PACKAGES} && \
    chmod 700 /root/.ssh && \
    ssh-keyscan -H github.com >> /root/.ssh/known_hosts && \
    gem install bundler --version ${BUNDLER_VERSION} --no-document && \
    bundle config set path /vendor/bundle && \
    bundle install ${BUNDLE_OPTIONS} && \
    find /vendor/bundle/ruby -path "*/gems/*/ext/*/Makefile" -exec dirname {} \; | xargs -n1 -I{} make -C {} clean

FROM ruby:${RUBY_VERSION}-alpine${ALPINE_VERSION}

ARG BUNDLER_VERSION
ARG BASE_PACKAGES
ARG RUN_PACKAGES="execline mariadb-client-libs openrc"
ARG APP_ROOT=/app
ARG APP_PORT=3000
ARG TIME_ZONE=Asia/Tokyo
# Rubyの公式イメージの設定を上書きする
ENV BUNDLE_APP_CONFIG ${APP_ROOT}/.bundle

WORKDIR ${APP_ROOT}

# 成果物だけをビルドステージから持ってくる
COPY --from=builder /vendor/bundle ${APP_ROOT}/vendor/bundle
COPY --from=builder /usr/local/bundle ${BUNDLE_APP_CONFIG}
COPY . ${APP_ROOT}

RUN apk add --no-cache ${BASE_PACKAGES} ${RUN_PACKAGES} && \
    cp /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime && \
    gem install bundler --version ${BUNDLER_VERSION} --no-document && \
    bundle config set path ${APP_ROOT}/vendor/bundle

# UnicornのSocketファイルをtmp配下に置くためNginxのコンテナからアクセスできるようにする
VOLUME ${APP_ROOT}/tmp
VOLUME ${APP_ROOT}/public

EXPOSE ${APP_PORT}

# プロセスの起動はシェルスクリプトにまとめている
ENTRYPOINT ["./entrypoint.sh"]

思ったよりボリューミーなDockerfileになってしまいましたが、結果としては約350MBまでサイズダウンに成功。
しかし、軽量化したとはいえビルド時間が4分くらいかかるのが気になりました。

そこでBuildKit先生の登場

「ビルド時間かかりすぎなんだけど…」
そんなときは、Docker v18.09から正式な機能に昇格したBuildkit先生の力を借ります。 これを使うと、命令の並列実行であったり正確なキャッシュ判定であったりを、なんか上手いことやってくれます。(雑)
Dockerfileにちょっと記述を加えるだけなので、試して損はないと思います。 必要な作業は、
1. 環境変数の設定

export DOCKER_BUILDKIT=1

2. Dockerfileの先頭にShebang的な記述を追加

# syntax = docker/dockerfile:experimental

以上です。
あとはビルドするだけ! 結果としては約60秒ほどビルドが速くなり、そしてビルド中の出力が格好良くなりましたw
Buildkit先生やりますね。
それと、Bundle install時にプライベートリポジトリのGemにアクセスするので、–secretというオプションも使っています。
Multi-stage buildsを使っていれば、機密情報がレイヤーに残ってしまう問題は気にしなくて良いと思いますが、まぁせっかくなので。 これでDockerfileのほうは完成です。

テスト

Dockerfileが完成したところで、動作検証を行います。
諸々の対応が終わって「よしデプロイだ!」ってときに「・・・動かない」ってなるのは辛いですからね。。 Docker Composeを使って、パパッとローカルで動かせるようにします。

version: '3.1'

services:
  rails:
    image: rails:test # ローカルでビルドしたイメージを指定
    container_name: api
    build:
      context: ./
    volumes:
      - app-log-data:/app/log
      - public-data:/app/public
      - tmp-data:/app/tmp
    ports:
      - 3000:3000
    env_file:
      - env_files/rails.env # 必要な環境変数を定義しておく
  nginx:
    image: nginx:test # ローカルでビルドしたイメージを指定
    container_name: web
    build:
      context: docker/nginx
    volumes:
      - nginx-log-data:/var/log
      - public-data:/app/public
      - tmp-data:/app/tmp
    ports:
      - 80:80
    depends_on:
      - rails
    env_file:
      - env_files/nginx.env # 必要な環境変数を定義しておく

volumes:
  app-log-data:
  nginx-log-data:
  public-data:
  tmp-data:

YAMLが書けたらdocker-compose upしてコンテナを起動。
あとはCurlコマンドなりで疎通確認ができればOKです。

2. コンフィグを整理する

続いてコンフィグ周りの整理となります。
移行前のシステムでは、環境ごとのコンフィグの管理はGlobalというGemを使っていました。 config/deploy配下に環境ごとの設定を用意して、Capistranoを使ってデプロイを行なうため、同じく環境ごとの設定値を定義できるGlobalとの相性がとても良かったのです。 しかし、コンテナ化してKubernetes上で動かすとなるとシステム自体が変わるため、環境ごとの設定値を管理する仕組みを考え直す必要がありました。 コンフィグは大きく分けて、
  1. コンテナのビルド時に必要なもの
    • DBの接続情報やNginx・Unicornの設定など
  2. アプリケーションの起動以降に必要なもの
    • AWSリソースの接続情報やサービス特有の設定など
の2種類があります。 KubernetesにはConfigMap/Secretというリソースがあるので、コンテナのビルド時に必要な設定は、それを使うことにしました。
アプリケーションの起動以降に必要なものもConfigMap/Secretにしようかと思ったのですが、長く運用しているサービス故に相当な量のConfigがあり環境も多いため、さすがに管理が厳しいと判断。 アプリケーションの起動以降に必要な設定は、RailsのCustom-configurationを使用して管理することにしました。
Dry-configurableのようなGemを使うか迷いましたが、Railsの機能で十分かなと。 ふむ、これで方針は決まりました。

どう移行するか

移行時に困ったこととしては、既存のコードはありとあらゆるコンフィグがCapistrano+Globalありきの仕組みになっており、それを何とかする必要があるということでした。
環境ごとにセットした変数をもとにテンプレートファイルをレンダリングして、生成したYAMLをデプロイするという仕組みなので、各環境のサーバーにしか完成されたYAMLファイルが存在しないのが厄介です。 もちろん、運用中のサービスなので「あ、DBの接続先間違えちゃった。えへへ」は許されません。
やり方は色々あるのでしょうが、何が何でも安心安全に移行していかねば。。 悶々と考えた結果、
  1. 正しい情報は、機械が生成した各環境のサーバーにあるYAMLファイル
  2. 間違える可能性があるのは、人間が新しく生成するCustom-configuration用のYAMLファイル
そう、間違えるのはいつも人間!
ということで、「1.」の値を正として「2.」の値を検証するRakeタスクを作成することにしました。

Rakeタスクを作る

と、タスクを作っていく前に、Custom-configurationの設定を書いていく必要があります。 (ついでにリファクタしたい気持ちをグッと堪えて)GlobalとCustom-configurationでコンフィグのKey/Valueは全く同じにして、Custom-configurationの設定はどこからも参照されないようにしておきます。 Custom-configurationでもGlobalのように機能ごとに設定ファイルを定義することができるので、

# config/example.yml
shared:
  foo:
    bar:
      baz: 1

development:
  foo:
    bar:
      qux: 2

production:
  foo:
    bar:
      qux: 3

というようなコンフィグを作っていきます。
そしてapplication.rbで、

# config/application.rb
module MyApp
  class Application < Rails::Application
    config.x.example = config_for(:example)
  end
end

というように定義しておけば、

# development environment
Rails.application.x.example[:foo][:bar] #=> { baz: 1, qux: 2 }

こういう使い方ができるようになるという代物です。
ふむ、良さそう。 しかしここで、Globalを使っていたが故に、とある問題にぶつかってしまいました。
そう…Globalは、

Global.example.foo.bar #=> { baz: 1, qux: 2 }

深い階層までドットでアクセスできてしまうのです! でもまぁこれくらいは想定の範囲内です。
「僕らにはHashieがあるぜ。ふへへ」と余裕綽々で調整してみたところ、なぜかうまく動かず。 「そんな馬鹿な…なぜだ…?」と焦りを隠しきれないまま調べていたところ、コンフィグ内の一部のKeyがHashの予約語に被っており使えないことが判明しまして。(確かdefaultというKey) 今回は人間を信じないという方針で進行しており、手動での対応は避けたい。
ということで思いついた苦肉の策がこちら。

# config/application.rb
module MyApp
  class Application < Rails::Application
    # コンフィグ内のNestされたHashにもドットでアクセスできるようにする
    # HashではなくOpenStructを使っているのは、コンフィグのKeyがdefaultといったHashの予約語を使ってしまっているため
    def define_nested_custom_config(config_hash)
      config_hash.each_with_object(OpenStruct.new) do |(k, v), s|
        if v.is_a?(Hash)
          v = define_nested_custom_config(v)
        end
        s.send("#{k}=", v)
      end
    end

    Pathname.glob(Rails.root.join("config", "custom", "*.yml")) do |f|
      k = f.basename(".*").to_s
      h = config_for(f).deep_symbolize_keys
      custom = define_nested_custom_config(h)
      config.x.send("#{k}=", custom)
    end
  end
end

OpenStructを使ってドットでアクセス出来るようにしました。
うーん、移行時の安全確保のためとはいえ、なかなか辛い実装になってしまいましたね…。
今思えば、もう少し他に使えるGemがないか調査を行っても良かったかもしれません。 しかし、これでようやく準備は整いました!
あとは検証用のRakeタスクを作成すれば完成です。

task :check_custom_config => :environment do
  Global.configuration.to_hash.keys.each do |key|
    custom_config_hash = Rails.configuration.x.send(key)
    global_config_hash = Global.send(key).to_hash.deep_symbolize_keys

    next if custom_config_hash == global_config_hash

    puts "Not match config! key:#{key}"
  end
end

それぞれのコンフィグの値が完全に合致しない場合、メッセージが出力されるようにしました。
これを各環境にデプロイして、実行して周ります。 恥ずかしいほどに愚直な手法でしたが、YAMLファイルの修正を確実に行うことができました。
修正を施してすべての環境で合致することを確認したら、最後はえいやっ!

git grep -l 'Global\.' | xargs sed -i '' -e 's/Global\./Rails\.configuration\.x\./g’

(ここまで検証しておいて最後はまさかの手動)
このRakeタスクでの検証・修正作業のおかげで、コンフィグ移行ミスによる障害を起こすことなく、対応を完了することができました!

Kubernetes上で動かす

さて、コンテナ化ができたら次はそれをKubernetes上で動かすようにします。
やることは以下の3点。
  1. マニフェストを書く
  2. ツールを使ってマニフェストを管理する
  3. CI/CDからデプロイする

1. マニフェストを書く

今回使うKubernetesのリソース一覧はこちら。
  • Namespace
  • Deployment
  • Service
  • ConfigMap
  • Secret
  • HorizontalPodAutoscaler
  • Job
各リソースについての理解やマニフェストの書き方を知るには、やはりKubernetesの公式ドキュメントを見るのが一番良かったです。 では、それぞれのマニフェストと考慮した点を紹介していきます。
一部の設定値は記事用の適当なものになっているので、ご了承ください。

Namespace

これは至極単純。
クラスター内に名前空間を作るためのリソースです。

apiVersion: v1
kind: Namespace
metadata:
  name: develop

使うのは名前空間を作るとき、つまりは1回こっきりです。

Deployment

PodやReplicaSetを管理するためのリソースです。
RailsとNginxの2つのコンテナで1つのPodが構成されています。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: app-rails
        image: app-rails
        imagePullPolicy: IfNotPresent
        resources:
          requests:
            cpu: 50m
            memory: 400Mi
        lifecycle:
          preStop:
            exec:
              command: ["rc-service", "unicorn-app", "stop"] # Graceful shutdown
        envFrom:
        - configMapRef:
            name: app-config
        - secretRef:
            name: app-secret
        ports:
        - containerPort: 3000
        volumeMounts:
        - name: app-assets
          mountPath: /app/public
        - name: app-sock
          mountPath: /app/tmp/sockets
      - name: app-nginx
        image: app-nginx
        imagePullPolicy: IfNotPresent
        resources:
          requests:
            cpu: 50m
            memory: 100Mi
        readinessProbe:
          httpGet:
            path: /heartbeat
            port: 80
          initialDelaySeconds: 60
          timeoutSeconds: 5
        livenessProbe:
          httpGet:
            path: /heartbeat
            port: 80
          initialDelaySeconds: 120
          timeoutSeconds: 5
        lifecycle:
          preStop:
            exec:
              command: ["/usr/sbin/nginx","-s","quit"] # Graceful shutdown
        envFrom:
        - configMapRef:
            name: app-config
        ports:
        - containerPort: 80
        volumeMounts:
        - name: app-assets
          mountPath: /app/public
          readOnly: true
        - name: app-sock
          mountPath: /app/tmp/sockets
      restartPolicy: Always
      terminationGracePeriodSeconds: 35 # Unicorn/SidekiqのTimeoutが30なので少し長く設定
      volumes:
      - name: app-assets
        emptyDir: {}
      - name: app-sock
        emptyDir: {}

他のリソースに比べると書くことが多いですが、ポイントとなる点としては、
  • Resources
  • Lifecycle
  • ReadinessProbe/LivenessProbe
あたりですかね。

Resources

設定できる項目はこちら。
  • Requests
    • デプロイするのに最低限必要なリソース量を指定できる
    • Worker Nodeのリソース使用量を見ないでデプロイしようとする
    • Cluster AutoScalerのNode数やサイズと想定を合わせておかないと、デプロイしてもリソース足りなくてずっとPendingみたいなことが起きてしまう
  • Limits
    • アプリが使用可能なリソース量を指定できる
    • 上限を超えるとコンテナのプロセスがKillされて再起動
ここに関しては弊社のインフラエンジニア・外道父さんの記事がとても勉強になるのでお勧めです。

Lifecycle

設定できる項目はこちら。
  • PostStart
    • コンテナが作成された直後に実行される
    • ただし、フックがコンテナのENTRYPOINTの前に実行されるという保証はない
    • 失敗した場合はコンテナを終了させて、restartPolicyに従い再起動させる
  • PreStop
    • コンテナが終了する直前に呼び出される
    • コンテナがすでに終了状態または完了状態にある場合、呼び出しは失敗する
今回扱うのはPreStopのほう。
「ユーザーのリクエストを処理中にPodを終了しちゃう」といった無邪気な事故を起こさないためにも、ここの設定はしっかり入れておく必要があります。 Podの終了処理についてはKubernetes: 詳解 Pods の終了の記事が参考になりました。

ReadinessProbe/LivenessProbe

Probeには、
  • LivenessProbe
    • コンテナが動いているか
    • 失敗するとKubeletはコンテナをRestartさせる
  • ReadinessProbe
    • コンテナがServiceのリクエストを受けられるか
    • 失敗するとServiceからそのPodのIPアドレスが削除される
という2種類があります。
LivenessProbeのほうは、いわゆる死活監視なので特筆することはないです。
今回ポイントになるのはReadinessProbeのほう。
「PodがRunning状態 = トラフィックが受けられる状態」とは限らないため、設定しておかないと「Unicornが起動しきってないのにトラフィックを受け始めちゃう」みたいなことが起きるので要注意です。

Service

Podへの接続を解決してくれる抽象的なリソースです。
Serviceを介して通信するPodは、Selectorによって決定されます。 (Kubernetesを触り始めた初期、Serviceがピンとこず迷走した記憶があります…)

apiVersion: v1
kind: Service
metadata:
  name: app-np
spec:
  type: NodePort
  selector:
    app: app
  ports:
  - nodePort: 30000
    port: 80

EKSではアプリケーションを外部に公開する場合、ALB Ingress Controllerを使う方法が主流かと思いますが、「移行するときに既存のALBを利用したい」「Terraformで管理したい」という都合があったので、NodePortを使う形にしています。

ConfigMap

アプリケーションの設定情報を扱うリソースです。
分かりやすいので、これは特筆すること無しですね。

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DB_NAME: "hoge"
  ...

Secret

クレデンシャルなどの秘密情報を扱うリソースです。
base64形式で保存されます。

apiVersion: v1
kind: Secret
metadata:
  name: app-secret
type: Opaque
data:
  SECRET_KEY_BASE: "ZnVnYQ=="
  ...

ただ、そもそもこういう情報がGitHubにコミットされること自体が危険なので、AWS KMSなどを使って管理するのが望ましいと思います。

HorizontalPodAutoscaler

DeploymentのReplica数を制御するためのリソースです。
その名の通り、CPUなどのメトリクスに応じて自動的にPodを水平分散してくれます。

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: app
  minReplicas: 1
  maxReplicas: 2
  metrics:
  - type: Resource
    resource:
      name: cpu
      targetAverageUtilization: 40

使用するためにはメトリクスサーバーをデプロイするのをお忘れなく。

Job

Podが正常に完了したことを追跡するためのリソースで、1つ以上のPodを作成して、指定された数が正常に終了することを保証してくれます。
指定された数が正常に終了するとJobが完了状態となり、Jobを削除するとそのJobによって作成されたPodも削除されます。

apiVersion: batch/v1
kind: Job
metadata:
  name: app-migrator
spec:
  backoffLimit: 1 # リトライ回数
  parallelism: 1 # 並列数
  completions: 1 # 完了数
  template:
    metadata:
      labels:
        app: migrator
    spec:
      containers:
        - name: app-migrator
          image: app-rails
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secret
      restartPolicy: Never # 失敗時にコンテナを再起動しない

今回はJobを使ってDBのマイグレーションを行っています。
実行するスクリプトは、Kubernetes の Job でマイグレーションを実行するの記事で紹介されているものを参考にさせて頂きました! Kubernetesでのマイグレーションって、結局どの方法が一番いいんでしょうねぇ。

テスト

マニフェストファイルが書けたところで、実際にkubectl applyでマニフェストを適用して動作検証をします。
ここで検証しておけば、CIからデプロイしたら「ずっとPodがCrashLoopBackOffなんだけど?」という悲しい思いをしないで済みます。(多分) ローカルでマニフェストファイルの検証を行うときは、Minikubeが便利でした。
ローカルのDockerイメージを使うように設定出来て、Amazon ECRなどのリポジトリにプッシュしなくて済むので、作業がだいぶ楽になります。 やり方はminikubeでローカルのdocker imageを使うの記事がまとめてくれているので、そちらを参考にしてみてください。

2. ツールを使ってマニフェストを管理する

今回はKustomizeというツールを使って、環境ごとのマニフェストを管理・生成できるようにしました。
KustomizeはKubernetesのマニフェストをパッケージングできるツールで、Kubectl v1.14.0にて統合されています。 基盤となるベース構成に各環境ごとのカスタマイズを加えてパッケージングが可能で、出力される単一のYAMLファイルをどのようにKubernatesに適用するかは使う側の自由です。
Overlayという機能を使うことで、ベースとなるマニフェストに対してパッチを当てることができるので、
  • 各環境で共通となる設定はベースにする
  • 各環境で異なる設定のみパッチを当てる
ということが出来て、嵩張りがちなマニフェストのYAMLファイルをスッキリさせることができます。
また、ConfigMapやSecretにハッシュ値を付与してバージョン管理することができるので、ロールバックに対応させることも可能です。 Overlayというのがどういう機能かと言うと、

- k8s
  - base
    - deployment.yaml
    - kustomization.yaml
  - develop
    - deployment_conf.yaml
    - kustomization.yaml
  - production
    - deployment_conf.yaml
    - kustomization.yaml

こういうベースとなるマニフェストファイル群と環境ごとのマニフェストファイル群があるとして。

# k8s/base/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: base-namespace
commonLabels:
  app: base-label
configMapGenerator:
- name: app-config
secretGenerator:
- name: app-secret
resources:
- deployment.yaml


# k8s/base/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: app-rails
        image: app-rails
        imagePullPolicy: IfNotPresent
        resources:
          requests:
            cpu: 50m
            memory: 400Mi
  ...

このようにベースとなるkustomization.yamldeployment.yamlを用意します。
そして次に環境特有の設定を、

# k8s/production/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
bases:
- ../base
namespace: production
commonLabels:
  app: production
patchesStrategicMerge:
- deployment_conf.yaml


# k8s/production/deployment_conf.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  template:
    spec:
      containers:
      - name: app-rails
        resources: # 本番環境用のResourcesの設定値を当てる
          requests:
            cpu: 1000m
            memory: 3500Mi

という感じで作ります。
こうすることで、ベースとなるDeploymentのマニフェストに対して、Resourcesの値だけを環境特有の値に更新することができるようになります。 環境ごとに丸々deployment.yamlを書くよりは、だいぶスッキリしますね。 実際にデプロイするときには、

# マニフェストが想定通りになっているか確認
kubectl kustomize k8s/production
# 本番環境用のマニフェストをレンダリングして適用
kubectl apply -k k8s/production

というコマンドで実行することができます。

その他のマニフェスト管理ツール

Kustomizeの他にもHelmとかCDK for Kubernetesとかツールの選択肢は結構あります。 その中で今回Kustomizeを選定した理由としては、
  • カスタマイズの自由度が高い
  • 学習コストが低い
という点は大きかったです。
ただ、実際に対応を終えた後に改めて振り返ると「Kustomizeじゃ厳しいか…」という場面もありました。
このあたりはやってみて分かったことなので、これから見直していきたいなと思っています。

3. CI/CDからデプロイする

さて、あとはデプロイするのみですね。
今回のプロダクトではCircleCIを使っているので、その設定の書き方となっていますが、どのツールを使うにしてもやることは大体同じだと思います。 ということで、デプロイするためのconfig.ymlはこんな具合です。

version: 2.1
...
jobs:
  deploy:
    executor: deploy_executor # デプロイ用のexecutorを用意
    parameters:
      current_stage: # デプロイ先の環境を定義
        type: enum
        enum: ["develop", "production"]
      eks_cluster: # デプロイ先のクラスターを定義
        default: "develop"
        type: enum
        enum: ["develop", "production"]
    environment: # ECRのリポジトリを指定
      RAILS_IMAGE: xxx.dkr.ecr.ap-northeast-1.amazonaws.com/app-rails
      NGINX_IMAGE: yyy.dkr.ecr.ap-northeast-1.amazonaws.com/app-nginx
    steps:
      - run:
          name: set secret access key
          command: |
            echo 'export AWS_ACCESS_KEY_ID="$APP_AWS_ACCESS_KEY_ID"' >> $BASH_ENV
            echo 'export AWS_SECRET_ACCESS_KEY="$APP_AWS_SECRET_ACCESS_KEY"' >> $BASH_ENV
      - run:
          name: setup kubectl
          command: |
            aws configure set region ap-northeast-1
            aws eks update-kubeconfig --name << parameters.eks_cluster >> --region ap-northeast-1
      - checkout
      - setup_remote_docker:
          version: 18.09.3
      - run:
          name: build image
          command: |
            docker version
            $(aws ecr get-login --no-include-email --region ap-northeast-1)
            export DOCKER_BUILDKIT=1
            docker build --no-cache --secret id=ssh,src="/home/circleci/.ssh/id_rsa" -t app-rails .
            docker build --no-cache -f docker/nginx/Dockerfile -t app-nginx docker/nginx/
      - run:
          name: push image
          command: |
            RAILS_IMAGE_NAME=${RAILS_IMAGE}:$(git rev-parse HEAD)
            docker tag app-rails ${RAILS_IMAGE_NAME}
            docker push ${RAILS_IMAGE_NAME}
            NGINX_IMAGE_NAME=${NGINX_IMAGE}:$(git rev-parse HEAD)
            docker tag app-nginx ${NGINX_IMAGE_NAME}
            docker push ${NGINX_IMAGE_NAME}
      - run:
          name: create namespace
          command: |
            kubectl apply -f k8s/<< parameters.current_stage >>/namespace.yaml
      - run:
          name: migration
          command: |
            # ここを参考にしてスクリプトを作成
            # https://blog.manabusakai.com/2018/04/migration-job-on-kubernetes/
            ./.circleci/migration.sh << parameters.current_stage >>
      - run:
          name: deploy
          command: |
            cd k8s/base
            RAILS_IMAGE_NAME=${RAILS_IMAGE}:$(git rev-parse HEAD)
            NGINX_IMAGE_NAME=${NGINX_IMAGE}:$(git rev-parse HEAD)

            # ビルドしたイメージを使うように置き換え
            kustomize edit set image \
            "app-rails=${RAILS_IMAGE_NAME}" \
            "app-nginx=${NGINX_IMAGE_NAME}"

            # マニフェストを生成して適用
            cd ..
            kubectl kustomize << parameters.current_stage >>
            kubectl apply -k << parameters.current_stage >>

やっていることは、
  1. ビルドの準備
  2. イメージのビルド(ECR)
  3. イメージのプッシュ(ECR)
  4. Namespaceの作成(無ければ)
  5. マイグレーション
  6. デプロイ
となります。 基本的にコードに書いてある通りなのですが、「6. デプロイ」の「ビルドしたイメージを使うように置き換え」という部分がポイントになるかなと思います。 デプロイ後に問題が発生した場合にロールバックが行えるように、イメージのタグにGitのHEADのハッシュ値をつけてECRにプッシュしています。
ここでプッシュしたイメージを使用してPodをデプロイするためにはdeployment.yamlの、

spec:
  template:
    spec:
      containers:
      - name: app-rails
        image: app-rails # ← ココ

にタグを含めて指定する必要があり、それを可能にするのがkustomize edit set imageというコマンドです。
これを使うことで、イメージのタグの指定を更新した上でマニフェストを適用することが出来ます。 こいつは便利ですね。
あとは実際にデプロイして動作確認するだけ。 これでようやく、アプリケーションをコンテナ化してKubernetes上で動かすところまでこぎつけることができました!

まとめ

長々と「コンテナ化してKubernetes上で動かす」ための作業について書かせて頂きました。 Kubernetesは学習コストが高いという話をよく聞きます。
確かに難しいし、いまだによく分かっていない部分が多いというのが正直なところです。 しかし今後、というか既にKubernetesを触ることはスタンダードだと思うので、本番環境の移行にチャレンジするというのは、良いアクションだったのではないかと感じています。
Kubernetes上で動かせるようにもなったし、次はGitOpsやProgressive Deliveryに挑戦してみようと、プロダクトメンバーたちと目論んでいます! そんな目論みやKubernetes周りのことを、自分とプロダクトメンバーが執筆しているので、気が向いたらそちらの記事もぜひm(_ _)m

本当はまだまだ積もる話があるんだ!

まだまだ移行作業での積もる話は尽きず、
  • EKS周りの設計・リソース作り
  • ログ収集どうするんだ問題
  • どうにもDNS周りが安定しない
  • ダウンタイム無しでどう本番環境をEC2EKSに載せ替えればいいのか
などなど、話したいことが沢山あります。
ぜひ続編を書けたらと思いますので、機会があればまた読んで頂けたら嬉しいです。 それでは、また。