kubernetesとhelmで作る最強のオレオレheroku,yadockeri

この記事は LAPRAS Advent Calendar 2019 の18日目です.

会社でWebサービスを開発していると,検証したりレビューしたりするときに専用の環境が欲しくなる.それは開発しているブランチごとに独立した環境であって欲しいし,なんなら本番っぽいデータが入っているとなお良い. そしてエンジニアだけでなくデザイナーやPOもアクセスできて欲しい.俺のローカル環境を立ち上げればいい?最初は確かにそうなんだけれど,開発メンバーが増えてきたときに,全員がそれをやらなきゃいけないというのはコストだ.レビューのたびにそれをやるとなると,かなりのコストだ.

そいうわけで社内に検証環境を立ち上げるyadockeriというプロダクトを前職で作っていたんだけど,今の職場でも作りたくなってしまったのであった(3年ぶり2回目).

kubernetesで,helmで

本番環境は全部kubernetesに乗せてある.であるなら,検証環境もkubernetesで作ろう.前回はdocker swarmで作っていたんだけど,流石にもうdocker swarmのメンテナンスはやりたくない.

kubernetes上に,任意のブランチごとにアプリケーションを立ち上げるとして,さて,kustomizeを使うかhelmを使うかという話になる. 社内で使うものだし,どっちでもいいのだがhelmを使うことにした.特に深い理由はない,強いて言うなら俺の趣味だ.

前提として,インフラはすべてAWS上に構築してある.そのため,これから一部AWSに結構依存したコンポーネントを利用することがある.

やりたいこと

  • GitHubにPushした任意のブランチのコードが全部入ったDocker Imageを生成しECRにPushする
  • ↑のDocker Imageを使ってkubernetes上にアプリケーションを起動する
  • DBもkubernetes上に起動し,データはS3上からダンプを取得してきて突っ込む
  • このようにして起動したアプリケーションに,会社IPからのみアクセスできるようにURLを発行してほしい

作戦

実現するにあたり,いくつか作戦を立てた.

まず,Docker Imageの作成は,CIに全部任せる.helm chartを作って,それをhelm installすることを考えると,Docker Imageは予め用意されていなければならない.helm installする前にユーザが手動でビルドするとかはめんどくさいのでNG. というわけで,CIでは,master以外のすべてのブランチでDocker Imageを作成,ECRへのpushを行っている.

次に,helmとkubernetesで実現することは以下の通り.

  • アプリケーションとDBの定義を含んだhelm chartを作成し,これをhelm installすることでアプリケーションを起動する
  • DBのダンプデータ取得は,ConfigMapにシェルスクリプトを埋め込み,DBのinitContainerで実行させる
  • アプリケーションの起動時にinitContainerでmigrationを流す
  • 外部からのアクセスについてはALB Ingress Controllerを使い,ALBをブランチごとに立てる
  • external-dnsを使い,↑のALB向けにRoute53のレコードを作成させる

というところまでを行う.

最後に,このhelm chartをインストールするにあたり,ブランチの名前と,それを適切なURLに変換する必要がある.これについてはyadockeriアプリケーション内部で行ってしまうため,あとで説明する.

yadockeriはWebアプリケーションであってほしい

上記のようなことは,helmのCLIでも十分実現可能だ.chartさえ作れば.

ただ,レビューで使うことを考えると,エンジニア以外でも操作できることが望ましいし,helmコマンドの環境構築をしていない人でも使えて欲しい.

さらに,GitHubからのブランチ名の取得や,ブランチ名をURLに変換するロジック等はすべて統一したい.これをユーザに手入力させていると,存在しないブランチを指定する可能性があるし,ブランチ変換ルールをミスる可能性もある. そういわけで,この辺を内包したWebアプリケーションになっていると嬉しい.

すなわち,

  • GitHub認証でログインできて
  • 指定のリポジトリのブランチ一覧が見えて,そこから特定のブランチを選択でき
  • 選択したブランチを特定のルールによりURLに変換し
  • それらの情報に基づいて,裏側でhelm installする

ようなWebアプリケーション,これがyadockeriである

helm chartを作る

やらなきゃいけないことはだいたいわかった.が,とりあえずhelm chartを作らないといけない.というか正直helm chartが作れれば,あとはそれをどうやって適用していくかという話でしかないので,ここが本体といっても過言ではない.

helm chartの作り方は,helm create chart_name してもらえば雛形ができるので,あとは公式を参照してもらうとして.

DBについて

DBは少し手の混んだことをする必要がある.まず,検証環境レベルでreplicaを作る必要はないので,1台だけ立ち上げるとして.Deploymentでも,StatefulSetでも良いのだがreplicas: 1 にして,MySQLを立ち上げる.

そして,こんなConfigMapのtemplateを作る.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-restore-db
  namespace: {{ .Values.namespace }}
data:
  restore.sh: |-
    #!/bin/sh
    aws s3 cp s3://database-for-yadockeri/{{ .Values.db.backupFilename }}.tar.gz /var/opt/backup/
    cd /var/opt/backup && tar -xvf {{ .Values.db.backupFilename }}.tar.gz
    if [ ! -e /var/opt/backup/{{ .Values.db.backupFilename }}/mysql/ib_logfile0 ]; then
        echo "There are INVALID backup dir. Is [{{ .Values.db.backupFilename }}] correct directory?"
        exit 1
    fi
    rm -rf /var/opt/data/*
    mv /var/opt/backup/{{ .Values.db.backupFilename }}/mysql/* /var/opt/data
    ls -lha /var/opt/data

DBのダンプは,sqlではなく/var/lib/mysql をコピーしたものになっている.こっちの方が,SQLを流し込むより速い.

そして,MySQLのinitContainersでは,これを実行している.

  template:
    spec:
      volumes:
        - name: restore
          configMap:
            name: {{ .Release.Name }}-restore-db
            defaultMode: 0777
        - name: mysql-data
          persistentVolumeClaim:
            claimName: {{ .Release.Name }}-db-volume
      initContainers:
        - name: restore
          image: h3poteto/awscli:latest
          imagePullPolicy: Always
          args: ["/var/opt/scripts/restore.sh"]
          volumeMounts:
            - name: restore
              mountPath: /var/opt/scripts/
            - name: mysql-data
              mountPath: /var/opt/backup/
              subPath: backup
            - name: mysql-data
              mountPath: /var/opt/data/
              subPath: data
      containers:
        - name: mysql
          image: mysql:8.0
          args: ["--default-authentication-plugin=mysql_native_password"]
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql
              subPath: data

migrationについて

上記のようにしてDBを用意するとなると,DBのPodの起動にはそれなりに時間がかかるということになる(ファイルのダウンロード+解凍時間).その間,アプリケーションのPodは,起動できないし,migrationは流れてほしくない. というわけで,ここを待たせるスクリプトを用意している.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-wait
  namespace: {{ .Values.namespace }}
data:
  wait.sh: |-
    for i in $(seq $HEALTH_CHECK_RETRY_LIMIT)
    do
        echo 'waiting for mysql...'
        mysql -u $MYSQL_USER -h $MYSQL_HOSTNAME -P $MYSQL_PORT -p$MYSQL_PASSWORD -e 'show databases' || (sleep $HEALTH_CHECK_RETRY_WAIT; false) && break
    done

こいつを,アプリケーションのinitContainersで,migrationの手前で実行する.

  template:
    spec:
      initContainers:
        - name: wait
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          args: ["/var/opt/app/wait.sh"]
          volumeMounts:
            - name: wait-script
              mountPath: /var/opt/app
          env:
            - name: HEALTH_CHECK_RETRY_LIMIT
              value: "30"
            - name: HEALTH_CHECK_RETRY_WAIT
              value: "30"
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          args: ["poetry", "run", "./manage.py", "migrate"]

外部アクセスを受け付ける

外部アクセスを受け付けるためには,ALBIngressControllerとexternal-dnsを使う.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: {{ .Release.Name }}-ingress
  namespace: {{ .Values.namespace }}
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: {{ .Values.ingress.certificate }}
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
    alb.ingress.kubernetes.io/security-groups: {{ .Values.ingress.security_group }}
    alb.ingress.kubernetes.io/subnets: {{ .Values.ingress.subnets }}
    alb.ingress.kubernetes.io/healthcheck-path: /service/health_check
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: '30'
    alb.ingress.kubernetes.io/healthcheck-timeout-seconds: '10'
    alb.ingress.kubernetes.io/healthy-threshold-count: '2'
    alb.ingress.kubernetes.io/unhealthy-threshold-count: '3'
  labels:
    app: {{ .Release.Name }}
spec:
  rules:
    - host: {{ template "application.hostname" . }}
      http:
        paths:
          - path: /*
            backend:
              serviceName: ssl-redirect
              servicePort: use-annotation
          - path: /*
            backend:
              serviceName: {{ .Release.Name }}-web-service
              servicePort: {{ .Values.service.port }}

application.hostnameは,以下のように定義した.

{{- define "application.hostname" -}}
{{- .Release.Name -}}.{{- .Values.base_host }}
{{- end -}}

Release.Name は,helm install時に与えるname属性だ.つまり,nameに与えた値が,サブドメインとなってデプロイされる.

また,このときにALBにSecurityGroupの設定を行うが,検証環境自体は,会社IPからのみアクセスできるようにしたいので,そういうルールを書いたSecurityGroupをあてている.

yadockeriでできること

helm chartができあがれば,これをhelm installすることで検証環境自体を手動構築することはできる.

yadockeriは,これを自動で構築できるようにするものだ.

できあがったもの

github.com

f:id:h3poteto:20191218115158j:plain

使い方

GitHubログインすると,こんな画面でリポジトリを作成できる.

f:id:h3poteto:20191218124121p:plain

New Repositoryで,デプロイしたいアプリケーションのリポジトリを選択する.

このとき,

を選択する必要がある.chartはアプリと同じリポジトリでも問題ない.サブディレクトリも指定できるようになっているので,こいつでchartのパスを指定する.

f:id:h3poteto:20191218115417p:plain

BaseURLは,これをもとにALB Ingress Controller内で指定するホスト名を決定している.そしてそこで指定した値に応じてexternal-dnsがレコードを生成する.そのため,ここには実際にRoute53 Hosted Zoneに登録されているドメインを入れる必要がある.また,そのドメインのHosted Zoneにexternal-dnsがアクセスできる必要がある.external-dns側の設定については後述.

また,このときにchartのValuesをoverrideすることができる.ここで指定した値は,Valuesをoverrideしてhelm installされる.

image.tag として {{.CommitSHA1}} を指定しているのは,CIでDocker Image作成時にタグとしてコミットハッシュを埋めているところに対応している.

github.com

yadockeri側では,{{.CommitSHA1}} を,指定したブランチの最新コミットのsha1として復号するように作ってある.

f:id:h3poteto:20191218115853p:plain

ここまでくれば,あとはブランチを選択し,

f:id:h3poteto:20191218120047p:plain

デプロイするだけだ.

f:id:h3poteto:20191218120120p:plain

これで裏側ではhelm installが走っている.

DBのリストア等でchartによっては多少の時間がかかるが,デプロイが完了すればALBが起動しRoute53にAレコードが作成されるので,会社からアクセスできるようになる.

サンプル

ここまで書いてきたが,やはり全体が見えないとわかりにくいと思い,サンプルを用意した.

まず,デプロイ対象のアプリケーションは,

github.com

である.適当に作ったRailsアプリケーションだ.

そして,これを動かすためのhelm chartが,

github.com

これだ.

これを,yadockeriの画面上から選択してやれば良い.

一部,SecurityGroupや,Subnetの指定は空になっているので,chart側で埋めるか,yadockeri側でのリポジトリ登録時にoverrideとして記述する必要がある.

中身の話

中身はgoのwebアプリケーションになっている.client-goによってkubernetesの認証をパスした上で,helmのメソッドを叩いてhelm install 等を実現している.

ただし,現状まだhelm v3には上げていないので,helm v2のクラスタでしか利用することができない.

ブランチの変換ルール

例えば,GitHub上に 1234/feature/add_method みたいなブランチをPushしたとする.このブランチの検証環境を立ち上げるときに,http://1234-feature-add-method.yadockeri.com みたいなURLになってほしいわけだ. この,サブドメインの部分はブランチ名によって変化する. gitのブランチ名は割と自由に命名できるので, /とか .とか_ を使えてしまうのだが,これらの記号はURL上特別な意味を持ってしまう.

そのため,問答無用でこの辺の記号達を全部 - に変換してやることにした.

これはyadockeriでブランチを選択した時点で,自動計算される.

ALB Ingress Controllerとexternal-dnsの用意

先程作成したchartは,ALB Ingress Controllerとexternal-dnsを利用することを前提にしている.そのため,これらのコンポーネントは先にクラスタにインストールされている必要がある.

どちらもhelmfileでインストール可能なので,

  - name: alb-ingress-controller
    namespace: kube-system
    chart: incubator/aws-alb-ingress-controller
    version: 0.1.11
    values:
      - rbac:
          create: true
      - clusterName: # your cluster name
      - awsRegion: ap-northeast-1
      - awsVpcID: # your vpc id

ALB Ingress Controllerは特にカスタマイズせずデフォルトの設定でインストールする.

  - name: external-dns
    namespace: kube-system
    chart: stable/external-dns
    version: 2.10.2
    values:
      - rbac:
          create: true
      - provider: aws
      - sources:
          - ingress
      - policy: sync
      - zoneIdFilters:
          - # your route53 hosted zone id
      - registry: txt
      - txtOwnerId: lapras-inc

external-dnsでは, zoneIdFilters で対象となるHosted Zoneを絞っている.画面でRepositoryを作成する際に入力したBaseURLは,ここのHosted Zoneに対応している必要があり,ここに記述されているHosted Zone以外は,BaseURLで指定したところでexternal-dnsによるRoute53操作が行われないので注意.

この2つがインストールされていれば,先のchartは動く.

yadockeriの起動方法

yadockeri自体はdocker imageを用意しているので,それをpullしてもらえれば立ち上げることができる.

$ docker pull h3poteto/yadockeri

ただし,リポジトリ情報やブランチの情報等を保存するためにPostgreSQLを利用する.というわけでPostgreSQLのDBを用意しておく.

> create database yadockeri;

あとは,dockerを起動すれば良い.

$ docker run --rm --service-ports \
  -v $HOME/.kube:/root/.kube \
  -e KUBECONFIG=/root/.kube/config \
  -e CLIENT_ID=github_application_id \
  -e CLIENT_SECRET=github_application_secret \
  -e ORGANIZATION=github_organization \
  -e POSTGRES_HOST=database_hostname \
  -e POSTGRES_USER=database_username \
  -e POSTGRES_PASSWORD=database_password

Kubernetesの認証を通すためにKUBECONFIGが必要になる.手元に認証情報がある場合は,/root/.kube あたりにマウントして,KUBECONFIGを適切に設定するとその設定を利用してくれる.

あとは,GitHub認証を行うためにGitHub ApplicationのClientIDとClientSecretが必要になる.

また,ORGANIZATIONは,自分で建てたyadockeriにログインできるユーザをORG内に制限するために設定する.現状orgの設定は必須になっている.まぁこの用途のプロダクトを,誰でもログインできる状態で使いたい人はあまりいないとは思うが…….

POSTGRES関連の設定については,上記で作成したDBにつながるようにしてやれば良い.

今後の予定

ひとまず,社内で環境を立ち上げる分には使えるレベルまで来た. しかし,まだまだやることがたくさん残っている.

  • helm v3への対応
  • kubernetes 1.16系へのアップグレード
  • 起動したPodのログを画面から見られるようにしたい
  • GitHubのWebHookを受け付けられるようにしたい
    • デプロイ済みのブランチにアップデートがあった際に自動で更新したい
    • 設定済みのリポジトリにブランチが増えた際に自動でデプロイしたい
  • GitHubのWebHookを設定できるようにしたい
  • chartのValuesのoverrideを,リポジトリ単位ではなくブランチ単位でも設定できるようにしたい

WebHookによる自動デプロイは,もう少し作戦を考えないといけないとは思っている.現状,GitHubへのpush後,CIでDocker Imageを作成している.このDocker Image作成を待たずにWebHookを受け付けてデプロイしてしまうと,ImagePullErrorになってしまう.ここをなんとかリトライさせるか,待たせる仕組みは必要だと思っている.

Valuesのoverrideに関しては,ブランチごとに設定を変えたかったりする場面が出ており,それに対応したい.

また,yadockeriは画面を見てもらえばわかるが,複数のリポジトリ(プロジェクト)を管理できるように作ってある.そのため,chartさえ作れば,複数のアプリケーションでこのような検証環境を立ち上げることができる.また,接続先をchartのValuesで変更できるようにしてさえおけば,マイクロサービスのように分離されたアプリケーションを,それぞれブランチごとに起動し,連携させることも可能となる.

自分で作ってて一番面倒なのは,なんと言ってもchartを作るところだろう.本番みたいにDBのことをRDSに丸投げできなかったり,AWS関連のサービスを叩く場合に,staging用のリソースを用意したらいいのか,localstackだけあればいいのか……みたいなことを一つずつ考えなきゃいけない.

yadockeri自身で言うと,kubernetesとhelmの依存解決を完璧にできてコンパイルできる状態に持っていくまでにかなり時間を要した.そもそも作り始めたときは,kubernetes側がmodに対応しておらず,glideで依存解決していた.glideで解決するのも結構大変だったし,modに移行するのも面倒だった…….