はてなでのKubernetes利用の取組み

こんにちは、入社 3 年目の SRE の id:kizkoh です。

今年から別のチームに異動になったのですが、以前は Mackerel チームで仕事をしていました。

このエントリでご紹介するのは私が以前担当していた Mackerel での Kubernetes(k8s) クラスタ利用の取組みになります。

はじめに

入社してから約 2 年間 Mackerel チームでサービス運用開発の仕事をしていました。直近のトピックでは 2 月に公開された Mackerel コンテナエージェントの開発、検証のお手伝いとして k8s クラスタでの検証や Mackerel のシステムのコンテナ運用として k8s クラスタ基盤の設計構築に取り組んでいました。

昨年 2018 年は国内で Japan Container Days (今年からは CloudNative Days です)が開催され k8s のトピックが多く取り上げられた一方で、世界的にも EU, USA 以外の国で初めて KubeCon + CloudNativeCon が開催され、 2019 年にはインドで Kubernetes Day が 1 day のイベントながら開催されるなど、今なお k8s を取り巻く技術とコミュニティは大きく広がりを見せています。

k8s はコンテナオーケストレーションのデファクトスタンダードとされており、コンテナ運用でのサービス監視、モニタリングサービスを提供する観点から技術トピックとして目を離せません。また Mackerel のシステムはコンテナで運用されていませんが、コンテナ運用を考慮したときのコンテナオーケストレーションツールとして k8s の選択肢は外せません。

Mackerel ははてなの事業のうちテクノロジーソリューションサービスの分野にあり、社内外で蓄積されたサービス運用のノウハウを事業としてアウトプットしている領域にありますが、k8s の運用事例は社内にありませんでした。そのため、 k8s の監視、モニタリングのキャッチアップを行い、コンテナのメリット(アプリケーション、インフラの変更高速化、Cloud Native への導入)を受け入れる取組みとして Mackerel を構成するコンポーネントの k8s への移行を進めています。

このエントリでは Cloud Native Landscape の trailmap に沿って、はてなでの Mackerel における k8s の取組みを紹介します。github.com


以下の節にわたって取組みの一部を紹介します。

  • コンテナ化
  • CI/CD パイプライン
  • クラスタオーケストレーション

コンテナ化

Cloud Native の取組みへの第一歩はコンテナ化です。コンテナ化はアプリケーションを動かす環境づくりです。

はてなではサーバをプロビジョニングする際に chef を利用してアプリケーションや必要なパッケージのデプロイを行っています。chef では社内の複数のリポジトリを参照して、サーバの運用に必要なスクリプトや各種ミドルウェアの設定をサーバに組み込んでいました。コンテナは単一の Web アプリケーションが可動するための環境であり、コンテナイメージの作成によって複雑なサーバプロビジョニングやサーバ管理のロジックを考える手間を削減することができます。

Mackerel は複数のアプリケーションから構成されるシステム構成になっています。今回は Go アプリケーションのコンテナ化を行ったため、例に取り上げます。

コンテナイメージの作成には Docker を使うため Dockerfile を書くのみです。コンテナ化対象のアプリケーションのテストも必要ですが、次節の CI/CD にて説明します。

Dockerfile は以下のように書きました(実際の Dockerfile とは異なります)。

# syntax=docker/dockerfile:experimental

# Stage: prepare go test and build
FROM golang:1.12 AS prepare

RUN go get github.com/golang/dep/cmd/dep && \
    go get golang.org/x/lint/golint
RUN mkdir -p /app
ADD Gopkg.toml Gopkg.lock /app
WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/dep \
    dep ensure --vendor-only
ADD . /app

# Stage: build go application
FROM prepare AS build
RUN go build -ldflags=$(BUILD_LDFLAGS) -o ./app

# Stage: build container image
FROM debian:stretch-slim
RUN apt-get update -yq && \
    apt-get install -yq ca-certificates && \
    rm -rfv /var/lib/apt
RUN mkdir -p /app
COPY --from=build /app/app /

マルチステージビルドを利用することで、Go のビルドを Dockerfile 内で完結しています。

ビルドに必要な Go モジュールを取得する prepare のステージと build のステージとでステージを分割しているのは、後の CI/CD の箇所でテストのステージを追加するためです。

また Go のビルドキャッシュと dep が取得する Go パッケージのキャッシュを Docker のビルドキャッシュとして利用するため、BuildKit の --mount=type=cache の用法を利用して Dockerfile を記述しています。

CI/CD パイプライン

k8s クラスタ上でサービスを運用するにあたり、最たる検討を重ねたのは CI/CD パイプラインの設計です。

システムのコンテナ運用にあたっての問題は稼働中のサービスのための CI/CD パイプラインを如何に影響を与えず、稼働中のサービスの既存の環境とコンテナ運用を並行して行うかにありました。

コンテナ化以前の EC2 インスタンス向けの CI/CD パイプライン全体像は以下のようになります。

f:id:kizkoh:20190516095254p:plain
Jenkins からの Capistrano を使ったデプロイ

Go アプリケーションのリポジトリの CI/CD パイプラインは Pull Request のマージがフックとなり、パイプラインが実行されます。CI フローでは Jenkins で go build, go test を実行し、CD フローではアプリケーションとアプリケーションに必要な環境変数の設定を記述したスクリプトを tarball に固めて、アプリケーションサーバから Jenkins に取りに行く形でデプロイしています。

まずは CI フローでのコンテナ化を行うため、docker build で EC2 インスタンスにデプロイするための tarball を作成するよう Dockerfile を追記しました。

...
# Stage: test
RUN --mount=type=cache,target=/root/.cache/go-build \
    go vet ./... && \
    golint -set_exit_status `go list ./... | grep -v /vendor` && \
    ! gofmt -s -d *.go | grep '^' # exit 1 if any output given && \
    go test -v ./... && \
    touch ./test.complete

# Stage: build and create tarball
FROM prepare AS package
RUN --mount=type=cache,target=/root/.cache/go-build \
    go build -ldflags=$(BUILD_LDFLAGS) -o ./app
RUN mkdir -p pkg && \
    mkdir -p pkg/bin && \
    cp ./app ./pkg/bin/ && \
    cp -R script/ ./pkg/ && \
    tar czvf pkg.tgz pkg && \
    rm -r pkg

# Stage: build container image
FROM debian:stretch-slim
RUN apt-get update -yq && \
    apt-get install -yq ca-certificates && \
    rm -rfv /var/lib/apt
RUN mkdir -p /app
WORKDIR /app
COPY --from=test /app/test.complete /app/
COPY --from=package /app/pkg/pkg.tgz /app/
RUN rm -f /app/test.complete
RUN tar xf /app/pkg.tgz


新たにテストのための test ステージと, tarball 作成のための package ステージを追加し、 tarball の作成を docker build で行い、作成した tarball をコンテナイメージの生成ステージで取り込んでいます。

docker build の成功後に docker cp によって tarball を取り出し、Jenkins の Artifact として出力しています。以下のコードは Jenkinsfile の該当箇所です。

    sh 'docker build --pull -f ./Dockerfile -t app:$(git rev-parse --short HEAD) .'
    sh 'docker create app:$(git rev-parse --short HEAD) > ./container-id'
    sh 'docker cp $(cat container-id):/app/pkg.tgz ./app.tgz'

これによって EC2 インスタンスへのデプロイに必要な tarball 作成に影響を与えることなく、 CI フローを通してコンテナイメージを作成することができます。

続いては CD ですが、k8s へのデプロイには様々なミドルウェアと思想があります。

最も有名なのは Netflix が開発している Spinnaker でしょう。

www.spinnaker.io

Spinnaker はオーケストレーションエンジンである Orca を中心に Egor や Deck など複数のコンポーネントから成る CD ツールです。Spinnaker の利用事例はいくつか日本の企業にもあるのですが、 Spinnaker 自体のストレージの維持やコンポーネントの運用が発生するため、 Spinnaker 自体の管理運用コストが発生しがちです。また Spinnaker 自体を k8s 上で運用することもできるのですが、必然的にサービスを提供するためのクラスタとは分離したいモチベーションが介在するため、 Spinnaker 用 k8s クラスタを作成することで管理する k8s クラスタも増えてしまいます。

その他いくつか CD ツールを調査したところ、 Google が開発する Skaffold の採用に至りました。

github.com

Skaffold は CD ツールに求めていた以下の要求をまさに満たすものでした。

  • k8s クラスタに依存せず、特定の k8s クラスタに密結合しないこと
  • 複数のコンポーネントから構成されず、メンテナンスが簡単なこと

実際に Skaffold を使った k8s クラスタへのデプロイについて解説します。

skaffold.yaml に以下を記述し Skaffold の設定を定義します。

apiVersion: skaffold/v1beta10
kind: Config
build:
  artifacts:
  - image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app
    context: .
    docker:
      dockerfile: ./Dockerfile
  tagPolicy:
    gitCommit: {}
  local:
    push: true
    useDockerCLI: true
deploy:
  kubectl:
    manifests:
    - manifest/k8s-*.yaml

manifest/k8s-deployment.yaml に以下のマニフェストを記述し、 Deployment のマニフェストを定義します。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: app-deployment
  namespace: app
  labels:
    app: app
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: app
    spec:
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsUser: 65534
      containers:
      - name: app
        image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app
        imagePullPolicy: Always
        command: ["/app"]
        ports:
        - containerPort: 8000
        livenessProbe:
          tcpSocket:
            port: 8000
          initialDelaySeconds: 30
          timeoutSeconds: 1
        resources:
          limits:
            cpu: 100m
            memory: 128Mi
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 5s"]

skaffold run -f skaffold.yaml を実行することで、コンテナイメージのビルド & プッシュ、 k8s クラスタへのマニフェストのデプロイまで行います。

コンテナイメージのビルド、プッシュまでは 以下のコマンドを実行することと変わりありません。

docker build --pull -f ./Dockerfile -t xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app:$(git rev-parse --short HEAD) .
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app:$(git rev-parse --short HEAD)

注目したいのはマニフェストのデプロイで、マニフェストの記述に image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app とあるように指定しているイメージのタグが記述されていないことです。マニフェストにイメージタグを指定していなくとも skaffold run ではビルドしたコンテナイメージのタグが付与された状態でマニフェストがデプロイされます(マニフェストのデプロイだけを行う skaffold deploy を実行する場合は skaffold deploy --images xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app のようにイメージ名とタグを指定する必要があります)。

なお環境ごとの k8s のマニフェストの書き換えには Skaffold から kustomize が利用できます。設定方法については skaffold.yaml | Skaffold を見ていただくとして割愛しますが、ステージングと本番のマニフェストの差異は kustomize(5/16 時点で skaffold は kubectl kustomize には対応していません、従来の kustomize コマンドをインストールする必要があります) の patchesStrategicMerge を利用して、起動する Pod 数や割り当てるリソースの変更を実現しています。kustomization.yaml は Skaffold のプロファイルを分けることで切り替えられますが、昨年当時は skaffold.yamlを環境ごとに分割し、デプロイする k8s クラスタに応じて context とマニフェストの切り替えるようにしました。

また、既存の EC2 インスタンスで稼働ホストしているアプリケーションとの並行運用に関しては、既存のエンドポイントの AWS ALB ターゲットグループに k8s Worker Node のインスタンスをアタッチし、 NodePort を利用して ALB からリクエストを Pod に forward して従来の EC2 インスタンスと k8s クラスタとの並行運用を行っています。以下のように manifest/k8s-service.yaml として作成しておくと、 Skaffold の実行時に Deployment のデプロイと同時に Service がデプロイされ、 k8s クラスタに反映されます。

apiVersion: v1
kind: Service
metadata:
  name: app-service
  labels:
    app: app-service
  namespace: app
spec:
  type: NodePort
  externalTrafficPolicy: Cluster
  ports:
  - port: 8000
    protocol: TCP
    name: http
    nodePort: 31000
  selector:
    app: app

本節のまとめとして Jenkinsfile の一部のステージの記述を転載します。

...
    stage('Process') {
        ansiColor('xterm') {
            checkout scm
            try {
                sh 'docker build --pull -f ./Dockerfile -t app:$(git rev-parse --short HEAD) .'
                sh 'docker create app:$(git rev-parse --short HEAD) > ./container-id'
                sh 'docker cp $(cat container-id):/app/pkg.tgz ./pkg.tgz'
            } catch(Exception e) {
                currentBuild.result = 'FAILURE'
                buildResult = 'FAILURE'
                echo "ERROR: ${e.toString()}"
            } finally {
                sh 'docker rm -v $(cat container-id)'
                sh 'rm -f ./container-id'
            }
            if (buildResult == 'SUCCESS') {
                archiveArtifacts pkg.tgz'
            }
        }
    }

    stage('Build') {
        sh '$(aws --region ap-northeast-1 ecr get-login --no-include-email)'
        if (env.BRANCH_NAME == 'develop') {
            String skaffoldPipelineFile = 'skaffold-staging.yaml'
            sh "skaffold fix -f ${skaffoldPipelineFile} --overwrite"
            sh "KUBECONFIG=\${HOME}/.kube/staging skaffold run -f ${skaffoldPipelineFile}"
        }
        if (...

改めて CI/CD フローを描いてみるとコンテナ化と Skaffold を使った CI/CD のフローは以前の Capistrano を使ったデプロイと比較して大きな変化がないことがわかります。

f:id:kizkoh:20190516102332p:plain
Jenkins からの Skaffold を使ったデプロイ

CI/CD パイプラインに Skaffold を利用することでアプリケーションエンジニアに CI/CD パイプラインの変化を意識させることなく、 k8s クラスタへの移行を進めました。

クラスタオーケストレーション

今までの節では k8s クラスタありきで、 コンテナ化、 CI/CD について述べてきました。

現在ではマネージド k8s として Google Kubernetes Engine(GKE), Amazon Elastic Container Service for Kubernetes(EKS) などが各種クラウドプロバイダーからマネージドサービスとして提供されていますが、 Mackerel のシステムのコンテナ化移行を検討した当初は EKS が日本のリージョンに提供されておらず、マネージド k8s の選択肢が限られていました。また、 AWS で稼働中のシステムを k8s クラスタに移行する際、既存のシステムが AWS 内のリソースを参照することやクラウドプロバイダー間の VPN によるオーバーヘッドを考慮した結果、 AWS 以外が提供するマネージド k8s を利用することが困難でした。一方で、 kops を用いた Workshop (GitHub - aws-samples/aws-workshop-for-kubernetes: AWS Workshop for Kubernetes) や kube-aws を用いたクラスタ構築事例(kube-aws: Highly Available, Scalable, and Secure Kubernetes on AWS | AWS Open Source Blog)が公開されており、マネージド k8s を利用せずとも k8s のオペレーションツールを利用することで、 k8s クラスタを比較的簡単に作成運用することができました。そのため、クラスタオペレーションツールを利用した k8s クラスタの構築運用を検討し、クラスタオペレーションツールの検証を行いました。

いくつかのクラスタオペレーションツールを検証した結果、 Ansible から構成される kubespray の採用を決めました。

github.com

kops や kube-aws を利用しなかった理由として、 Go による AWS API の実行や CloudFormation でのプロビジョニングといった複雑なロジックや特定の Cloud Provider との密結合を避けるモチベーションがあります。

それに対して kubespray は Ansible を利用するため、クラスタオペレーションのステップが非常に追跡しやすく、任意の Playbook のタスクから playbook の適用によるオペレーションの実行が可能となっています。kubespray にはインスタンスを作成する機能はなく、作成したインスタンスを対象に Playbook を実行する役割に限られているため、インスタンスの作成が必要になります。そのため、Terraform で VPC とインスタンスのプロビジョニングを行い、 作成したインスタンスを元に Terraform の template として Ansible のインベントリを作成することで、Terraform と kubespray を連携させることができます。これによって、クラスタのノード管理はファイルで管理できるため、 Git リポジトリとしてクラスタのスケールアウト、スケールインを含め構成管理を完結することができます。

Terraform の例が kubespray のリポジトリにあるので取り上げておきます。

kubespray/contrib/terraform/awskubespray/contrib/terraform/aws

このように kubespray で構築したクラスタに先の CI/CD フローを経てアプリケーションをデプロイし、本番サービスの一部コンポーネントとしてリリースしています。

まとめと今後

このエントリでは、はてなでの k8s における取組みを紹介しました。

コンテナイメージやクラスタオペレーションなど k8s の入門取っ掛かりとなるトピックが中心ではありましたが、ログやメトリックの収集、監視の設定は 1 エントリ中では書き切れないため、またの機会にご紹介したいと思います。

なお、このエントリで紹介した k8s クラスタは以下の理由により一時的に取り壊しています。

  • kubespray v2.8.0 からクラスタ管理方法に kubeadm が採用され、クラスタ管理のアップグレードが困難だった
  • アップグレードが遅れることによる k8s の脆弱性の問題(Critical: CVE-2018-1002105)の対応に懸念があった

今回の取組みの振り返りからクラスタ管理の工数を抑えるため、マネージド k8s への移行を進めています。

一方で最近では Mackerel チームでの k8s を実績として社内の一部のチームで k8s を利用する取組みも進行中です。

はてなでは CloudNative や k8s にモチベーションや関心をお持ちのエンジニアの応募をお待ちしています!