エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Kubernetes上でのGoによるシンプルなAPIの開発と、その効率化のためのcookiecutter templateを作った話

エンジニアリンググループ AI・機械学習チームの笹川です。

これはエムスリー Advent Calendar 2019の12月4日の記事です。

前日は、CTO 矢崎による 1つの terraform で複数 AWS Account をまとめて構築・管理する でした。

今回は、AIチームでのAPI開発で利用している技術の紹介と、開発の効率化のため、プロジェクトの雛形を自動的に生成するcookiecutterのプロジェクトテンプレートを作成した件について紹介します。

qiita.com

背景

エムスリーでは、早くからマイクロサービス化に取り組んでおり、m3.com などエムスリーが運営する多くのサービスで、各種マイクロサービスがAPI連携して大きな機能を実現する構成が取られています。

AIチームでも、チーム内で開発した機械学習プロダクトをサービス側に提供する手段として、 REST APIを開発する機会が多くあります。 AIチームで提供するAPIの多くが、事前計算しておいたデータをストレージ (RDB など) にストアしておき、リクエスト時に、ストレージのデータを参照し、整形して返すだけのシンプルなものとなっています (ちなみに、返すデータをリアルタイムに計算せず、事前計算しておくのは、ユーザ体験向上のためレスポンスタイムを重視していることが理由です) 。 日頃、このようなAPIをたくさん作るため、開発のたびにほぼ同じ実装になる、CIや、ストレージ、Kubernetesの設定はテンプレートを提供してやることで削減可能と考え、今回 cookiecutter を用いたプロジェクトテンプレートを作成することにしました。

プロジェクトテンプレートの実装

今回はプロジェクトテンプレート作成用のコマンドラインツールであるcookiecutterを利用しました。 AIチームではMLアルゴリズムのプロジェクトテンプレートにもcookiecutterを利用していた為、他の候補を検討しませんでしたが、選択肢としてはgiter8 などがありそうです。

以下では、前提となる、APIとリポジトリの技術構成と、作成したプロジェクトテンプレートについて説明します。

APIとリポジトリの構成

f:id:hsasakawa:20191130114633j:plain
APIとリポジトリの構成

今回作るAPIは図のような構成となっています。

APIの実装には Goa というGoのフレームワークを用いています。

Goa :: Design first.

GoaはDSLでAPIのrequestとresponseを定義し、それをコンパイルするとswagger documentと、GoのIF部分のコードが自動生成されます。 あとは、ロジック部分を書くだけでAPIが完成します。 上記のようなシンプルなAPIの構成である場合、Goaの自動生成と相性がよく、実装を短時間で終えることができます。

APIは、Google Kubernetes Engine (以下 GKE) 上のクラスタで動いており、Cloud SQLを Cloud SQL Proxy を介して参照する形になっています。 余談ですが、AIチームの比較的新しいプロダクトは、1つのKubernetesクラスタ上で動いており、コストメリットと、インフラ実装の簡単化を狙っています。 クラスタを共有化しているため、API新規開発時にはCloud SQLを準備するのみで (ほぼ) 済むので、開発するAPI毎に個別でコンピューティングリソース全てを用意していた時より大分楽になりました。

また、図の右側のリポジトリについては、弊社ではGitlabを利用しているので、Gitlab CI上で、静的解析や、テスト、dockerイメージのbuild、GCRへのpush、GKEへのデプロイまでを実行しています。

プロジェクトの内容

以下のようなプロジェクトが生成されることが目標です。

$ tree default -a
default
├── .gitignore
├── .gitlab-ci.yaml
├── Dockerfile
├── README.md
├── default.go
├── default_test.go
├── design
│   └── design.go
├── go.mod
├── go.sum
├── k8s
│   ├── api.yaml
│   ├── deploy-api.sh
│   ├── dev
│   │   └── conf
│   └── gen-secrets.sh
├── load_test
│   ├── load_test.py
│   └── pyproject.toml
├── scripts
│   └── goa-gen.sh
└── sql
    └── sample.sql

生成されているのは、主に以下のファイル/フォルダです。

  • Gitlab CIの設定用のYamlファイル (.gitlab-ci.yaml)
  • READMEテンプレート
  • Dockerfile
  • APIロジックとそのテストファイル (default.go, default_test.go, sql/以下など)
  • GoaのDSLファイル (design/以下、scripts以下はコンパイル用のシェル)
  • Kubernetesのマニフェストファイル各種 (k8s/以下)
  • 負荷試験用のシナリオファイル (load_test/以下)

cookiecutterでは、上記の構成をそのままフォルダに保存して、適宜ファイル名や、ファイルに記載する変数名を {{cookiecutter.hoge}} などで指定される変数で置き換えてやることで完成します。 その部分自体にはほぼ困難がないので、ここでは生成されるファイルの中身の一部について、工夫した点などを詳しく見ていきます。

GoaのDSLファイル

GoaのDSLファイルの中身は以下のようになっています。

package design

import . "goa.design/goa/v3/dsl"

// API describes the global properties of the API server.
var _ = API("default-api", func() {
    Title("default")
    Description("api document title")
    Server("default-server", func() {
        Host("https://default.example.com", func() {
            URI("http://0.0.0.0:8080")
        })
    })
})

// Service describes a service
var _ = Service("echo", func() {
    Description("echo service")
    // Method describes a service method (endpoint)
    Method("echo-post", func() {
        Payload(ArrayOf("EchoPostPayload", func() {
            Required("name")
            Required("datetime")
        }))
        Result(String)
        HTTP(func() {
            POST("/echo-post")
            Response(StatusOK)
            Response(StatusBadRequest)
        })
    })
    Method("echo-get", func() {
        Payload(func(){
            Attribute("name", String, func() {
                Example("Taro")
            })
            Attribute("age", Int, func() {
                Default(20)
                Example(20)
            })
            Required("name")
        })
        Result(String)
        HTTP(func() {
            GET("/echo-get/{name}")

            Param("age")

            Response(StatusOK)
            Response(StatusBadRequest)
        })
    })
})

var _ = Type("EchoPostPayload", func(){
    Attribute("name", String, func() {
        Description("Your name")
        Example("Taro")
    })
    Attribute("datetime", String, func() {
        Format(FormatDateTime)
    })

    Required("name")
    Required("datetime")
})

このようにGoa DSLでAPIのI/Oを定義し、 goa gen コマンドを実行するとIF部分のコードと、swagger documentが自動生成され、そのコードを利用してAPIのロジックを書くだけでAPIが完成します。 先述のシンプルな構成の場合は、ストレージとの接続と参照、データのフォーマット処理を記述するのみなので、非常に早く実装できます。

また、documentが自動生成されるので、実装とdocumentが乖離する可能性を減らせる点も良い点です。

Dockerfile

Dockerfileの中身は以下のようになっています。

FROM golang:1.13 as goa_base
WORKDIR /go/src/example.com/default-api
ENV GO111MODULE=on
COPY go.mod .
COPY go.sum .
RUN go mod download
RUN go get -u goa.design/goa/v3@v3.0.6
RUN go get -u goa.design/goa/v3/...@v3.0.6

FROM goa_base as builder
WORKDIR /go/src/example.com/default-api
ENV GO111MODULE=on
COPY . .
RUN rm -rf gen cmd
RUN goa gen default-api/design
RUN goa example default-api/design
RUN CGO_ENABLED=0 GOOS=linux go build -v -o main ./cmd/default-api

FROM alpine:3.10.2
COPY --from=builder /go/src/example.com/default-api/main  /main
COPY --from=builder /go/src/example.com/default-api/sql  /sql
EXPOSE 8080
CMD ["/main"]

ここではmulti-stage buildを利用し、Goaのコンパイルと、実行用の環境を分けています。 こうすることで、実行時のイメージには、コンパイルに必要なフレームワークのデータなどは格納せず、ビルドしたバイナリのみを格納することで、最終的なイメージサイズを削減しています。

Kubernetesの設定ファイル各種

Kubernetesの設定ファイルについては長くなるので、詳細は紹介せず、考え方を共有します。

k8s 以下のディレクトリ構成を再掲します。

├── k8s
│   ├── api.yaml
│   ├── deploy-api.sh
│   ├── dev
│   │   └── conf

api.yaml にメインの設定を書き、環境ごとに変えたいパラメータを dev/conf 以下に書いています (prodなど別環境も実装時に順次追加していく)。 デプロイ時には、 deploy-api.sh の中で、 envsubst コマンドにより、パラメータを埋め込んだyamlを生成し、applyしています。 api.yaml の例を以下に挙げます。

containers:
  - name: ${PROJECT_NAME}
    image: ${DOCKER_TAG}
    imagePullPolicy: Always
    ports:
      - containerPort: 8080
    livenessProbe:
      httpGet:
        path: /liveness
        port: 8080

${PROJECT_NAME} などの部分に dev/conf で設定したのパラメータを埋め込みます。 このような用途では、テンプレートエンジンを利用することも考えられるため、各種テンプレートエンジンも検討したのですが、複雑になりすぎることと、まだデファクトがどれになるのか決まりきっていないように思われたので、現時点では利用を回避しています。 以下が deploy-api.sh の中身です。

#!/bin/bash -e

if [[ "${1}" = "" ]]
then
  echo "Usage: ${0} <config_file_path>"
  exit
fi

CONF="${1}"

set -o allexport
source ${CONF}
set +o allexport

envsubst < api.yaml | tee ./api.generated.yaml

# Apply k8s.generated.yaml
kubectl apply -f ./api.generated.yaml

# Clean up temporary yaml files
rm ./api.generated.yaml

シンプル!

負荷試験用のシナリオファイル

APIの実装後は負荷試験を行います。 負荷試験のツールとして、ここでは locust を利用しています。

Locust - A modern load testing framework

locustはPythonで負荷試験のシナリオを書けるため、Pythonをよく利用するAIチームではレビューもしやすく便利です。 こちらについての詳細は 公式ドキュメント をご覧ください。

まとめ

今回は、シンプルな構成のAPIをKubernetes上で動かすためのプロジェクトの構成と、開発効率化のためのテンプレートプロジェクトを作成した件を紹介しました。 上記で紹介した構成は、11月時点でのAIチームの標準的な構成ですが、今後もどんどんアップデートしていき、効率の良い開発を目指していきます。 また、今回紹介したプロジェクトテンプレートは、エムスリー内部に依存する実装を取り除いた上で、公開することを考えています。お楽しみに。

We're hiring!

エムスリーでは、httpを喋る 高速にイケてるAPIをガンガン開発する仲間を募集しています!! 我こそは!という方はぜひ以下からご応募ください!

jobs.m3.com