JX Press Tech Talk #python で「StreamlitとFlaskではじめる爆速プロトタイピングとTV砲対策」というトークをしました

JX通信社シニア・エンジニアかつ, 最近は自社のテックイベント「JX Press Tech Talk」の司会者をやってる@shinyorke(しんよーく)です.

6/23(水)に, 「JX Tech Talk #python Pythonista 達が語る速報サービス開発の舞台裏」というイベントを開催しました.

jxpress.connpass.com

参加いただいた皆さま, ありがとうございました!

私は前述の通り, このイベントの司会をさせていただいたと同時に, 登壇者として「StreamlitとFlaskではじめる爆速プロトタイピングとTV砲対策」というテーマでトークもさせていただきました.

このエントリーでは, 発表後のフィードバック・ご意見等を踏まえた上で,

  • 当日お話したこと
  • ちょっとした補足
  • JX Press Tech Talkについて

というテーマで軽く書きたいと思います.

TL;DR

  • エンジニアとデータサイエンティストが共存するようなプロジェクトの進め方は結構大事
  • Streamlitでプロトタイピングするときに合わせてテストを書こう
  • App EngineでできないことはCloud Runに任せるといい感じになる

おしながき

当日お話したこと

最初に軽く当日の話をふりかえります.

私のトークは,

  • Streamlitでプロトタイピング(プロトタイプ開発)してチームに共有する
  • Flask + GCP(Google App Engine, Cloud Run)でスケーラビリティある構成でシステムを開発・運用

という2本の軸でお話をしました.

Streamlitでプロトタイピング

議論のネタになるプロトタイプの用意がマスト(かつnotebookじゃないほうがいい)

という思いでStreamlitを使いました.

こちらのお話, 実は過去にこのブログにも書いたことでもありました.

tech.jxpress.net

JX Press Tech Talkでプレゼンしたデモのコードも上記エントリーで紹介したものとなります.

github.com

当日のトークでお話をしました,

  • notebookからstreamlitへの移行
  • ngrokを使ってチームに共有

というお話はこのエントリーのダイジェストであり, サンプルコードとして提供しているものでもありました.

当日お越し頂いた方も, このエントリーから知った方もぜひ手元で試してもらって, 「便利そうだな」って思ったら仕事や趣味に活かしてもらえると幸いです.

軽量FWとGCPを使ったプロダクト開発と運用

後半戦の話は,

  • プロトタイプから本プロダクト開発はFlask, FastAPIを使いました
  • 高負荷対策を楽にするためGoogle App Engine + Cloud Runにしました

という話でした.

Flaskの話メインでしたが, こちらについては「プロトタイプからの移植というストーリーから逆算して作るにはどうしたらいいか」というテーマで主にパッケージ構成とテストの話をしました.

f:id:shinyorke:20210629212143j:plain

Pythonは「データサイエンティストの人が作ったモデルをそのまま同じ言語でWebのプロダクトとして開発できる」明確な強みがある一方,

  • データサイエンティストが書くコードと, プロダクトのエンジニア*1が書くコードは(それぞれの領域・メンタルモデルが異なるため), 大切にする価値観・趣が異なる
  • なぜかといえば, データサイエンティストがやることはプロトタイプで, プロダクトのエンジニアは保守運用を目指して開発するから

という問題を抱えやすいため,

データサイエンティストとエンジニア両者の間を取るため, 「パッケージ構成とテストコード」を最後の砦とする

というルールで進めました.

この, 「データサイエンティストとエンジニアが指向するメンタルモデルの違い」は語ると長いので, 気になる方は「仕事ではじめる機械学習第2版」をご覧頂ければと思いますが, 割とありがちな課題だったりするのでAIプロジェクトをやる方はぜひ意識するといいと思います.*2

ちなみにこのプロジェクトでは「データサイエンティスト」「エンジニア」は私一人の役割(兼任)であったため, この問題は発生しませんでした.*3

また, 「TV砲に耐えるための高負荷対策」の件は, こちらのブログの内容そのままだったりします.

tech.jxpress.net

基本的にはこのエントリーのダイジェストという形でお話しました.

「GCPで作ったサービスをいい感じにTV砲対策する」ノウハウをまとめたつもりなので気になる方はぜひ読んで頂ければと思います.

なお, これがAWSや他のクラウドサービスであったとしても考え方は流用できるんじゃないかなと思ってます.

ちょっとした補足 - 当日話さなかったこと

当日および後日頂いたフィードバック・質問に対する補足です.

一部サービスをCloud Runで切り離した理由

最初はApp Engineのみでイケると思ったのですが,

  • SNSシェア用のOGP画像などで独自フォントが必要だった
  • その他, プロダクトの細かい仕様の制約

という理由で画像の生成のみCloud Runで切り離しました.

tech.jxpress.net

こちらも細かい話はブログに記載していますので気になる方はぜひチェックしてみてください.

当日話さなかった答えはすべてここにあります.*4

FastAPIからFlaskへの書き換え

プロトタイプの段階でFastAPIを使った簡易的なRESTful APIを用意していたのですが, このときはなぜかApp Engineで動かず, 調査する時間もさほどなかった為, Flaskに書き換えました.

ちなみに後日, 同僚から「App Engine, FastAPIでも動くやで」と聞いた&gunicorn使ってuvicorn動かせばよかったのねと気がついたのがJX Press Tech Talkの準備をしていた今月の話でもありました.

ちょっと見れば書き換えいらなかったかも...という後悔を覚えつつも, 極力Framework依存を減らした構成をとっていたので傷口はかなり浅く済んだのではと満足しています.

JX Press Tech Talkについて&結び

JX Press Tech Talk #python では, 私のトークの他,

  • @kimihiro_nさんによる, 「新しいメンバーにMake debutしてもらいやすくするための開発体制 with Python」
  • サーバーサイドエンジニアの鈴木さんによる「Python on Google Cloud Functionsで作るバッチ処理」

といった, JX通信社の開発チームで実際あった話・ノウハウの話がありました.

どちらも現場発の情報で参考になるんじゃないかなと思います.

また, イベント参加者のフィードバックにつきましても,

  • チャレンジできる環境があるのは素晴らしい
  • 多くの学びがあった

など, アンケート含めて好意的なご意見・今後の学びになるご意見を多数いただきました.

個人的には, 「司会が聞きやすかった」「進行が上手」というフィードバック嬉しかったです苦笑*5

次回は未定ですが, またお会いできる日を楽しみにしております!

なお, 最後に大事な話をしますが,

サーバーサイドエンジニアをはじめ, 絶賛募集中です!

jxpress.net

最後までご覧いただきありがとうございました.

*1:あえて「プロダクトのエンジニア」と書いたのは, 同じエンジニアでもデータサイエンティスト寄りでプロトタイピングががメインの方もいるので狭義の意味で縛る意味で「プロダクトの」という枕詞を付けました

*2:仕事ではじめる機械学習第2版の6章に詳しい話があります, 結構面白い話なのでオススメです.

*3:が, 今後はチームでやるとか普通にあり得るので一人の段階でも最初から考えてやりました.

*4:一時期, 当日の話でもやろうかなと考えていましたが, 尺が15分のトークで3つのテーマを話すのはキツイなという理由で画像生成の話はブログで先行して書いてリリースし, StreamlitとGCPの話をメインにするという決断をしました.

*5:毎回, 塩梅とか進行に苦心しているのでホント嬉しかったです, ありがとうございます&今後もがんばります

Slackアプリ開発の社内勉強会を開催しました

サーバサイド開発やインフラ周りをいじっているたっち(TatchNicolas)です。

JX通信社の日々の運用では、Slack workflowやbotが大活躍しています。

かなり作り込まれた高機能なBotもあり欠かせないものになっていますが、開発者メンバーのなかには普段そのリポジトリを触らない人・すでにあるものに機能追加・改修はするがゼロから立ち上げたことはない人などもいます。ハードルをグッとさげることで自分たちの斧を研ぎやすくできないか?と考えました。

そこで毎月開催している社内勉強会にて、今回はSlackアプリ開発をテーマにしましたのでその様子について紹介します。

内容

初めて触る人でも開発をすぐに始められるように、社内でよく使われる言語でテンプレになるリポジトリを用意しました。

また、Permissionの設定などは最初はとっつきにくいため、Tandem*1で複数人の画面共有をしながらお手伝いしつつ進めました。 その後基本的なポイントや概念の説明をして、みんなでワイワイしながら開発していく形式で会を進行しました。

最初に知っておくと良い概念

初めてSlackアプリケーションを開発する人にもわかりやすいように、前述の雛形や初期設定の他に知っておくと入りやすい概念について簡単に説明をしました。

Socket Mode

f:id:TatchNicolas:20210618214336p:plain

普通にSlackアプリケーションを開発すると、Slackからのイベントを受け取るのにpublicにhttpでリクエストを受けられるURLが必要です。ngrokなどのツールを使って用意しても良いですが、より手軽にSlackアプリケーションを開発できる方法として Socket Mode があります。

Socket ModeではpublicなURLを持つ場所へデプロイする必要がなく、ささっと手元ですぐにSlackアプリケーションを動かすことが可能です。

今回の勉強会には十数人が参加して、その分だけデプロイ先の環境を用意するのも大変ですし、勉強会のあとでお片付けも必要です。デバッグの容易さも含めて気軽さを優先するために上記のサンプルリポジトリではSocket Modeでテンプレートを作りました。

Event Subscription

f:id:TatchNicolas:20210618214405p:plain

Botがメンションを受け取ったり、誰かがチャンネルに入ったりなどSlack上の出来事のうち、どのイベント種別を受け取るかを設定するのがEvent Subscriptionです。

api.slack.com

たとえばPythonではデコレータの形で指定して、受け取ったイベントに対して処理を行う関数を書いていくことになります。

from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])

@app.event("app_mention")
def print_mention_event(event):
    """
    メンションが来たら発火する関数
    """
    print(event)

作品紹介

成果発表タイムで共有された作品を紹介します。限られた時間でしたが、なかなか面白いBot達が色々あって楽しい時間となりました。

画像認識(YOLO)Bot

f:id:TatchNicolas:20210618220132p:plain

形態素解析Bot

f:id:TatchNicolas:20210618220752p:plain

gou

f:id:TatchNicolas:20210618220816p:plain

f:id:TatchNicolas:20210618220824p:plain

社内で「いいこと」をした人をSlackで讃えたり ++ とインクリメントを送るとポイントとして記録するカルマボットをGoで実装したもの。*2

github.com

占いBot

f:id:TatchNicolas:20210619075529p:plain

なぜか極端にてんびん座に厳しくて笑いました。*3

まとめ

複数の言語で雛形となるリポジトリを用意し、初期設定の説明を画面共有しながら一緒にすることで、初めての方達にもすぐに手を動かして楽しんでもらうことができました。

クスッとくるようなBotから、普段の業務を生かした画像認識や形態素解析をBot化した作品もあり、JXらしさのある楽しい勉強会になりました。

Pythonトークイベントの告知

2021/06/23(Wed) 19:30から Pythonにまつわる色々な話をするイベントを予定しています。ぜひ参加してみてください。

jxpress.connpass.com

*1:最近JX通信社で使われているチームやプロジェクトごとの部屋に出入りして使う「バーチャルオフィス」なツールです https://tandem.chat/

*2:Goで業...

*3:漢字の星座名で条件を引っ掛けているので、ひらがなだとデフォルトで適当に返答するように作ったそうです(笑)。

サーバーサイドで動的にOGP画像をシュッと作る方法 - FastAPIとCairoSVGで作る画像生成API

JX通信社シニア・エンジニアの@shinyorke(しんよーく)です.

最近は色んなエンジニアリングをしつつ, イベントの司会業をしています(詳細は最後の方を見てね).

開発しているサービス・プロダクトの要件で,

  • TwitterやLINE, FacebookでシェアするOGP*1コンテンツ(タイトル・本文・画像)が欲しい
  • コンテンツはユーザーさんの操作で動的に変わる
  • テキストだけじゃなくて, 画像も変えたい←これ

なんて事は非常によくある話だと思います.

私はちょっと前に開発したAIワクチン接種予測でそれがありました.

f:id:shinyorke:20210521183040j:plain
こういうやつです

例えば上記画像のテキスト(地域・年齢・接種可能時期)は予測の結果を動的に画像テンプレートに入れて都度作っています.

上記のOGPを生成するために必要なことはこういう感じだろうなー, と以下の絵の通り整理し,

f:id:shinyorke:20210528180851j:plain
やったこと

結果的に, OGPを生成するためのサービスを200行にも満たない小さいAPIサーバーとして実現しました.

このエントリーでは,

  • CairoSVGを使った画像生成
  • FastAPI + CairoSVG + GCSを使ったAPI構築
  • Cloud Runでの運用

で上記のサービスを作った事例を紹介します.

なおこのエントリーは,

  • PythonでWebアプリを作ったことがある
  • FastAPIおよびJinja2を使える/使ったことがある
  • ラスタ画像とベクター画像の意味がわかる

程度の前提知識を読者の皆さまが持っている, と想定して執筆しています(Pythonアプリ開発初級みたいなノリです).

TL;DR

  • FastAPIとCairoSVGで画像の動的生成はいい感じにできちゃいます
  • テンプレートエンジンは迷ったらJinja2
  • フォント指定はハマるので気をつけよう

おしながき

ベクター素材から画像を作る🐍

前述の通り, やりたいことは

  • 地域・年齢・接種可能時期といったテキストをAIさんが準備し
  • 予め用意したテンプレートのベクター素材にテキストを埋め込み
  • pngなどのラスタ形式に変換(いわゆるラスタライズ)

です.

自分で調べたり, 社内で色々聞き回った結果,

  • SVGで素材を準備して(デザイナーさんにお願いして)それを使ってサーバーサイドでラスタライズする
  • SVGからのラスタライズはPythonのライブラリでできるっぽい

というのがわかったので早速やりました.

CairoSVGで画像を作る

SVGをラスタライズする手段は意外とあっさりで, CairoSVGというライブラリで解決しました.

cairosvg.org

github.com

公式サイトのサンプルを見る限り, やりたいことは出来そうとわかりました.

# pip install cairosvg で普通に入ります
import cairosvg

# SVGからPDF
cairosvg.svg2pdf(url='sample.svg', write_to='sample.pdf')


# SVGからpng(これが本命でやりたかったこと)
cairosvg.svg2png(url='sample.svg', write_to='sample.png')

コードもすごく短いですし, とても良さそうです👍

SVG素材をJinja2テンプレにする

やりたいことはCairoSVGでできそうとわかったのですが, 前述の通り今回やりたいのは

  • 地域・年齢・接種可能時期といったテキストをAIさんが準備(できている)
  • 予め用意したテンプレートのベクター素材にテキストを埋め込み
  • pngなどのラスタ形式に変換(CairoSVGでやれる)

で, 肝心の「テンプレートのベクター素材にテキストを埋め込み」が解決していません.

ちなみにやりたいことをコードに起こすとこんな感じです.

# 実際のコードとは異なります(あくまでサンプルです)

import cairosvg

def convert_ogp(area: str, age: int, period: str):
    # 何かしらの方法でSVGフォーマットなテキストを入手
   ogp_context = 'TODO: ここにSVGの中身が入る'
   # TemporaryFileとして書き出して後byteでもらう
    with NamedTemporaryFile('w') as f:
        f.write(ogp_context)
        f.flush()
        image_bytes = svg2png(url=f.name, write_to=None)
    return image_bytes

image = convert_ogp('東京都', 40, '10月下旬〜2月上旬')

一番単純な方法はヒアドキュメントとしてSVGテキストを用意して置換することなのでしょうが, デザイン素材を(文字列とはいえ)Pythonコードに書くのは若干気が引けた*2ので,

  • SVG素材をJinja2テンプレとして書き直す
  • 上記Jinja2テンプレを元に画像生成する

という方法でいい感じにやりました.

# 実際のコードとは異なります(あくまでサンプルです)

import cairosvg
from jinja2 import Environment, FileSystemLoader


def convert_ogp(area: str, age: int, period: str, template_file: str):
    # テンプレファイルと引数からテキストを生成
    env = Environment(loader=FileSystemLoader(os.path.dirname(template_file)))
    ogp_template = env.get_template(os.path.basename(template_file))
    ogp_context = ogp_template.render(area=area, age=age, period=period)

   # TemporaryFileとして書き出して後byteでもらう
    with NamedTemporaryFile('w') as f:
        f.write(ogp_context)
        f.flush()
        image_bytes = svg2png(url=f.name, write_to=None)
    return image_bytes

# area, age, periodという変数を持ったjinja2テンプレを事前に準備
image = convert_ogp('東京都', 40, '10月下旬〜2月上旬', 'templates/ogp.svg.j2')

テキスト(ベクター)として扱う時はまずJinja2ってくらいよく使ってるのでこれはあっさり思いついてすぐ実現できました.

何かしらのフォーマット(HTMLでもXMLでもYAMLでもJSONでも)で動的にコンテンツを生産したい時はJinja2便利です.

流石Pythonを代表するテンプレートエンジンなだけあります*3.

APIサーバーにする

画像生成の仕組みはこれで目処が付いたので, API化します.

FastAPIサーバーとして用意する

やることは,

  • 予測結果を受け取って画像をGoogle Cloud StorageにuploadするAPIを作る
  • 予測結果はPOSTパラメーターとして受け取る

です.

APIはFlask, bottle, FastAPIと比較的軽めなFrameworkなら何でも大丈夫*4なのですが今回は(社内でよく使ってる)FastAPIで実現しました.

# 実際のAPIはもっとちゃんと作ってます(イメージを掴むためのサンプルです)
from fastapi import FastAPI
from pydantic import BaseModel

# さっきのOGP生成関数
from sample_image import convert_ogp


class OgpContext(BaseModel):
    """
    OGPで使う項目
    """
    age: int
    area: str
    period: str

# docは公開する必要ない(しちゃ🙅)なので使いません
app = FastAPI(docs_url=None, redoc_url=None)


@app.post('/creage')
def create(form: OgpContext):
    image_byte = convert_ogp(form.area, form.age, form.period, 'templates/ogp.svg.j2')    
    # 取得したimageを何かしらの方法でアップロード
    upload(image_byte)
    return {'status': 'ok'}


if __name__ == '__main__':
    import os
    import uvicorn

    uvicorn.run(app, host='0.0.0.0', port=os.getenv('PORT', 8080))

画像作ってアップロードするだけなら(上記のコードでは端折ってる画像アップロード関数含めて)ほんの100〜200行で終わります.

Cloud Runでホスティング

実サービスのホスティングはCloud Runで行いました.

cloud.google.com

Dockerコンテナ一つで動く薄い画像生成APIとして作っていたので,

  • gcloudコマンド2回でデプロイまでいける
    • Cloud Buildでimage作ってレジストリ(GCR)にpush
    • Cloud Runにデプロイ
  • トラフィックに応じてインスタンスを増やす(減らす)がGUIコンソールをポチポチするだけでいける

という利便性を重視してCloud Runにしました.

ちなみに「コマンド2回でデプロイまで」はこんな感じです

# Docker buildとimage push
gcloud builds submit --tag asia.gcr.io/example-test-prj/ogp-generator

# 上記imageをdeploy
gcloud run deploy --image asia.gcr.io/example-test-prj/ogp-generator --platform managed

たったこれだけで終わるの最高です.

ハマったこと

ちなみに開発中ハマったこととして, 文字化けがありました.

  • 開発環境(自分のMac)では文字化けしない
  • Docker imageから作った環境だと文字化けする

原因は明確で, 「Macには存在するフォントだけどDocker image(Debian)には存在しないフォント」でやったのでこの差がでました.

FROM python:3.9

# install
COPY poetry.lock pyproject.toml ./
RUN pip install poetry
RUN poetry config virtualenvs.create false \
  && poetry install --no-dev

# app
COPY app.py ./
ADD templates templates

# templates/fonts配下のフォントを然るべき所にコピー
COPY templates/fonts /usr/share/fonts/truetype/dejavu

CMD  python app.py

SVGフォーマット上で使ってるフォントを洗い出し, 見つけて追加して無事解決しました.

運用の結果&結び

というわけで, このエントリーではサーバーサイドで動的に画像を作る方法について紹介しました.

AIワクチン接種予測がTVに出たときもこの仕組みが動いていたのですが, 大きなトラブルもなくトラフィックに耐えきったのでやり方として正解だった様に思えます.

なお, このエントリーは(このブログを書く前に)社内のWin Sessionでネタを披露したところ, ウケが良かったので今回ブログとして公開することにしました.

これで似たような課題を抱えている皆さまの手助けになると幸いです.

JX Press Tech Talk でPythonの話やります!

最後にちょこっと宣伝です.

jxpress.connpass.com

好評だった前回に続きまして, JX Press Tech Talk第二弾を6/23(水)にやることになりました!

私は前回に続いて司会をやると同時に「StreamlitとFlaskでPoCから本番運用までやりきったやで📺」的なお話をさせていただきます.

二刀流で頑張りたいと思います, お時間ある方ぜひいらしてください!

*1:Open Graph Protocolのことで, かんたんに言うとSNSシェアする時に出てくる文章や画像を定義するための仕様・お決まりのことです. ちなみにTwitterはTwitter Cardという独自のお決まりがあります(やろうとしてることはOGPと一緒)

*2:「明日にもリリースしなきゃ」という状況だったらこの方法は全然アリです. 今回は(タイトなスケジュールだったとはいえ)そこまでじゃなかったのでテンプレとコードはちゃんと分離しました.

*3:Flaskのデフォルトテンプレートエンジンだったりしますし, Djangoでも使ったりします. また, AnsibleのplaybookはJinja2がベースだったりします.

*4:もちろんDjango(厳密にはDjango REST framework)も候補になると思います. が今回はホントに薄いラッパーなので軽量FWを中心に考えました.

GitLabとKubernetesで作る、自動で起動・停止できるブランチ別環境

SREのたっち(@TatchNicolas)です 本記事は、先日の弊社主催のTechトークイベントで発表した内容について、もうすこし詳細に書いてみます。

jxpress.connpass.com

TL;DR;

  • GitLabのenvironmentとHelmのreleaseを対応させることで動的な環境の作成・削除を実現した
  • CIOpsとGitOpsを使い分けて、「ちょうどいい」使用感を目指した
  • 環境の順番待ちがなくなった!

背景

JX通信社のサーバサイド開発では、ECSによるコンテナベースでの開発・デプロイが主流です。*1

環境としては

  • ローカル環境: docker-composeやgo run yarn run などで起動する、文字通り手元のマシンのローカル上で動かす環境
  • 開発版環境: SQSやFirestoreなどのマネージドサービスや、ローカルで立てるのが大変な別のAPI*2との繋ぎ込み等に利用する環境
  • ステージング環境: 本番デプロイ前のチェックに使う環境
  • 本番環境: エンドユーザが実際に利用する環境

という構成になっています。

しかし、開発チームの規模が大きくなってくると開発版環境を使った繋ぎ込みの確認のために順番待ちが発生するようになり、Slack上でも「@here どなたか開発版環境つかってます?借りていいですか?」のようなやりとりが散見されるようになりました。結果、思うように開発スピードが上がらなくなってしまいました。

そこで、開発が進行している複数のトピックブランチごとに独立してコンテナのアプリケーションをデプロイできると、開発版環境の順番待ち問題を解決 できるのではないか?と考えました。

どうやって実現しているか

この問題を解決するには、以下の要件が満たされている必要があります。

  1. 環境が自動で作成され、独立したURLが動的に払い出される
  2. Merge requestが閉じられたら、その環境も閉じられる

そのために、最近社内でも導入の進んでいるKubernetesをベースにすれば、比較的簡単に実現可能と考え、以下の技術選定をしました

  1. URLの払い出し: Istio
  2. 自動的な環境作成・削除: GitLab CI & Helm

構成について

全体の構成を図に表すと以下のようになります。登場人物についてもう少し詳しく解説します。

f:id:TatchNicolas:20210521212304p:plain

Ingress

トラフィックの制御はIstioに任せるため、全てistio-ingressgatewayに流すようにしています。 そのため、図からは省略しました。

「クライアントからのリクエストのTLSを終端して、k8sクラスタの中へリクエストを連れて行く」ことを担当します。

GKEの場合は、GCPから発行される証明書がワイルドカードに対応していないため、cert-managerで発行したものをIngressで指定してLoadBalancerに持たせます。

EKSの場合は、AWS Load Balancer Controllerを利用しました。

Istio

「k8sクラスタの中に入ってきたリクエストを、Hostヘッダをみて環境ごとのk8s Serviceに振り分ける」を担当します。Ingressのルールでも同じことは可能ですが、Istioを使うことで以下のメリットを享受できます。

  • ホスト名やパスなどによるリクエスト振り分けをVirtualServiceで設定することで、Ingressの中央集権ではなく個々のアプリケーション単位で行えるようになる*3
  • 認証周りをアプリケーションから剥がすことができる

GitLab CI

「トピックブランチごとの環境の作成・削除」を担当します。

GitLabにもKubernetesクラスタと連携する機能はあるのですが、GitLabのお作法に従う必要が出てくるためあまり使っていません。

JX通信社では、プロダクト単位でGitLabのグループを分けており、その単位でクラスターを作成しているので、開発版環境のクラスタの情報を登録して、GitLab CIからkubectlhelm のコマンドを叩けるようにしました。*4

ArgoCD

「各クラスタに対するメインブランチの反映」を担当します。詳しくは後述

実際のワークフロー

(1) 開発者が、ある機能の実装のためにfeatureブランチを切ってpush

各アプリケーションに対応するmanifestsリポジトリを用意して、そこでブランチを切ります。

(2) GitLab CIがfeatureブランチに対応する環境を作成する

ここで、下記のCI Jobが発火します。

setup_mr_env:
  stage: mr_env
  image: docker-image-with-helm
  script:
    - helm upgrade -i -n fastalert-api ${CI_COMMIT_REF_SLUG} ./chart --set url=${CI_ENVIRONMENT_URL}
  environment:
    name: ${CI_COMMIT_REF_SLUG}
    url: https://${CI_COMMIT_REF_SLUG}.your-domain.com
    on_stop: teardown_mr_env
  only:
    - branches
  except:
    - main
    - tags

利用するHelmテンプレートは以下のようになっています。*5

chart
├── Chart.yaml
├── values.yaml
└── templates
    ├── NOTES.txt
    ├── _helpers.tpl
    └── main.yaml

chart/values.yamlhelm upgrade -i ... --set xxx=yyy で値を渡していきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: {{  .Release.Name  }}
  template:
    metadata:
      labels:
        app: {{  .Release.Name  }}
    spec:
      serviceAccountName: your-app-serviceaccount
      containers:
      - name: api
        image: {{ .Values.image }}
        ports:
        - containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}
spec:
  type: ClusterIP
  selector:
    app: {{ .Release.Name }}
  ports:
    - name: http
      protocol: TCP
      targetPort: 8000
      port: 80
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: {{ .Release.Name }}
spec:
  gateways:
  - istio-system/gateway
  hosts:
    - {{ (urlParse .Values.url).host }}
  http:
  - route:
    - destination:
        host: {{  .Release.Name  }}.your-app-ns.svc.cluster.local

(3) レビュー担当者は、コードを確認しつつ2.でできた環境を触って動作確認したりする

前述のマニフェストが反映されたら、 https://${CI_COMMIT_REF_SLUG}.your-domain.com というURLでアプリケーションにアクセスできるようになります。

また、正式なレビューに至る前のちょこっと書き換えて挙動を確認して、のようなことをするなら逐一CI越しのデプロイをせずにTelepresenceを使います。

(4) LGTMをもらってMergeすると、トピックブランチ用の環境は自動で削除される

GitLab上で、

  • Merge Requestに表示される停止ボタンをクリックする
  • Pipeline上で表示されるmanualなJobを実行する
  • Merge Requestが閉じられる

のいずれかの操作を行うと、下記のCI Jobが発火します。これにより、2. で作成されたhelm releaseがアンインストールされます。*6

teardown_mr_env:
  stage: mr_env
  variables:
    GIT_STRATEGY: none
  image: docker-image-with-helm
  script:
    - helm uninstall -n fastalert-api ${CI_COMMIT_REF_SLUG}
  when: manual
  environment:
    name: ${CI_COMMIT_REF_SLUG}
    action: stop
  except:
    - main
    - tags

(5) (6) ArgoCDがステージング・本番用ブランチの内容を同期する

ここだけ、GitOpsな方針になっています。詳しくは後述します。

もう少し細かい話

リポジトリ戦略について

JX通信社では、1つのアプリケーション(≒マイクロサービス)を1つのGitLabプロジェクト(=リポジトリ)に対応させ、その中でソースコードとデプロイの両方を管理しているケースがほとんどでした。

今回、Kubernetesを導入するにあたって、アプリケーションとデプロイの関係を疎にするために

  • アプリケーションリポジトリ:
    • CIではlint、テスト、コンテナイメージの作成までを担当する
  • マニフェストリポジトリ:
    • 開発版クラスタのトピックブランチ別環境へのデプロイのデプロイ
    • ArgoCDがこのリポジトリのmainブランチをクラスタに同期する

という構成にしました。マニフェストリポジトリのほうはモノレポではなく、デプロイしたい単位に分けています。するとアプリケーションとマニフェストを一対一に対応させたり、複数のAPIのセットをまとめて一つのデプロイの単位としても扱えたり、柔軟な運用が可能になりました。

CIOps v. GitOps

CIOpsとGitOpsの特徴などについては、すでに良い記事が世の中にたくさんあるので、ここでは説明を割愛しますが、「CIとCD」。

  • プロダクト開発チームとしては、どんどんコードを書いてデプロイしたい
  • でも本番環境はバージョン管理もセキュリティもしっかりしたい

そこで、JX通信社では

  • 開発版クラスタの各トピックブランチに対応する環境にGitLab CIからデプロイ(=CIOps)
  • 開発版クラスタおよび本番クラスタのmainブランチは、ArgoCDでデプロイ(=GitOps)

のように、環境に応じてデプロイ方式を変える方式を採用しました。すると、

  • 開発版トピックブランチへの作業はpushするたびに随時反映されていき、グイグイと開発を進められる
  • 本番環境クラスタに対する権限をGitLab CIに渡していないので、よりセキュアに保てるようになる

という環境ごとの目的にマッチした利用が可能になりました。

Namespace・ServiceAccountについて

EKS/GKE上で動くアプリケーションは、マネージドサービスを利用するためにWorkload Identity/IRSAを利用します。そのためには、AWS/GCPのIAMがKSAのあるNamespaceとServiceAccountを知っている必要があります。

そのため、動的に追加/削除されるHelmのtemplateからNamespaceとServiceAccountは除外して、クラスタ自体を管理してるリポジトリで、新しいアプリケーションの追加時にArgoCD Applicationの定義と一緒にNamespaceを定義・作成しています。つまり、各トピックブランチの環境はIAMを共有しています。ここは権限の変更も含めてトピックブランチ環境間で独立させられると理想的なのですが、複雑になってしまうため今回の仕組みからは外しました。

apiVersion: v1
kind: Namespace
metadata:
  name: your-app-ns
  labels:
    istio-injection: enabled
---
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::1234567890:role/your-app-role
  name: your-app-serviceaccount
  namespace: your-app-ns
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: your-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://your-gitlab.com/your-group/your-project.git
    path: path/to/chart/
    targetRevision: HEAD
    helm:
      releaseName: main
  destination:
    server: https://kubernetes.default.svc
    namespace: your-app-ns

まとめ

  • 環境の順番待ちがなくなって、複数の施策を並行して進めやすくなった
  • CIOpsとGitOpsのいいとこ取りをして、「ちょうどいい」ワークフローを実現できた
  • それぞれの環境はあくまで helm install / helm uninstall しかしてないので、中でどんなKubernetesリソースを利用しているのかは関係なくなり、CI/CDの仕組みが標準化できるようになった

以上、先日行ったTechトークの詳細をブログにしてみました。どなたかの参考になれば幸いです。

JX通信社では、2021/06/23(Wed) に二回目のTechトークイベントを予定しています。次回はPythonにまつわる色々な話をする予定ですので、ぜひ参加してみてください。

jxpress.connpass.com

*1:Serverless FrameworkやSAMを使ったLambdaへのデプロイもよく使われます Serverless Framework+mangum+FastAPIで、より快適なPython API開発環境を作る - JX通信社エンジニアブログ

*2:たとえば、VPCに閉じていて外部から触れない内部向けのAPIなどです

*3:AWS Load Balancer Controllerでは振り分けルールをバラして書くことができますが、GKEでもできるだけ近い方法でやりたかったのと、他にも重み付けやリトライ制御などServiceMeshの機能として使いたいことがあったのでL7ルーティングもIstioに任せました

*4:https://docs.gitlab.com/ee/user/group/clusters/

*5:実際には、IstioのAuthorizationPolicyの設定なども含めていますが省略しています

*6: GIT_STRATEGY: noneを指定しないと、GitLabでマージと同時に元ブランチを削除する設定にしていたときに失敗してしまいます。

APIとフロントのテスト環境を気軽に作れるようにして、動作検証の渋滞を解消したはなし

f:id:nsmr_jx:20210517131939p:plain

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。
今回はみんなが利用するテスト環境の渋滞を解消したはなしを。

テスト環境の渋滞

最近開発チームのメンバーが増えてきたりして、「テスト環境の渋滞」というのがよく発生するようになりました。 テスト環境というのは動作検証目的で AWS 上に作成している環境で、本番と同じようなシステム構成でデータだけダミーのものを用意してあるような環境です。

ローカルの Docker Compose で開発や動作確認が全部完結出来るというのが理想なのですが、「常時入ってくるデータを扱いたい」「本番に近いデータ量でパフォーマンスをみたい」「エミュレーションでは動くけれど実際に動くか確認したい」など、テスト環境を利用しないと分からないことが結構出てきます。 また「アプリと繋ぎこんで動作確認したい」「PO や非エンジニアに確認してもらいたい」みたいなケースでもテスト環境での確認が必要になってきます。 一方テスト環境はというと、1つの環境しか用意できておらず、使いたい人たちで譲り合って使うような形になっていました。

折角開発者が増えて機能開発速度も増えてきているのに、テスト環境の渋滞待ちがボトルネックとなってしまってはもったいないです。

API の テスト環境

そこで SRE チームに協力してもらい、テスト環境の API を簡単に複数立ち上げられるようにしてもらいました。

tech.jxpress.net

詳しくはスライド もしくは解説記事の方を参照していただきたいですが、 git にブランチを push するだけで個々のブランチがテスト環境として並列に動くようになりました。

これで「テスト環境の待ちがなくなる!」と期待していたのですが実際にはもう一つ課題がありました。

今のプロダクトはマイクロサービス的な指向でレポジトリを分けており、APIとそれを利用するフロントのレポジトリも分離されています。 なので、APIが複数用意されているだけでは不十分で、フロント側の接続先を変更して初めて複数のテスト環境を用意できたといえます。

フロントから API を呼び出す

フロントの方は静的なファイルを配信するだけなので、API のように Kubernetes でテスト環境を立ち上げなくても簡単にブランチごとの環境を作れるようになっていました。 ただ CIOps を採用してデプロイしているため、API の向き先を変えたい場合でも、都度コミットする必要がありました。

- export API_ENDPOINT=branch1.api.example.com
+ export API_ENDPOINT=branch2.api.example.com

コミットで接続先を管理してしまうと「プロダクトに関係ないコミットで汚れてしまう」「操作ミスで本番を壊しかねない」「非エンジニアが触りにくい」といった問題が出てきたため別のアプローチが必要となりました。

URLパラメータ & Cookie による動的つなぎ替え

紆余曲折あったのですが、最終的に以下の要件が満たせればよさそうということになりました。

  • コミットやデプロイに紐付かず設定出来る
  • 非エンジニアでも簡単に接続先が変更出来る
    • AWS 上の設定を変えるやブラウザの開発コンソールから操作も NG
  • ブラウザに古い API 接続先情報が残らない
    • 間違ったAPIを参照することによるトラブルを減らしたい
  • 同じフロントのテスト環境から API 1、API 2 へつなぎ替えられるようにしたい
    • API だけの開発を進める場合、同じフロントのコードから別の API をみたいというパターンも

これらを実現するために URLパラメータとセッション Cookie を利用することにしました。 URLパラメータというのはおなじみの ?param=hogefuga みたいなURLの末尾に付与出来るパラメータです。 セッション Cookie は Cookie の一種で、有効期限を設定しない Cookie を指します。有効期限を指定しない場合、ブラウザを終了する度 Cookie がリセットされるようになります。1

組み合わせることで、URL として簡単に設定でき、かつブラウザ終了などで簡単にリセットも出来るような設定変更が実現出来ます。

const TEST_API_NAME = 'custom_env'
const TEST_API_NAME_COOKIE = 'api_name'

// URL パラメータから取り出して Cookie にセット
if (IS_TEST_ENV) {
  const urlParams = new URLSearchParams(window.location.search)
  if (urlParams.has(TEST_API_NAME)) {
    const envName = urlParams.get(TEST_API_NAME)
    if (envName === '') {
      // 空文字がセットされていたら Cookie をリセット
      Cookie.remove(TEST_API_NAME_COOKIE)
    } else {
      Cookie.set(TEST_API_NAME_COOKIE, envName)
    }
  }
}

// API の向き先を変更
const API_ENDPOINT = (() => {
  if (!IS_TEST_ENV) return 'https://api.example.com'
  const envName = Cookie.get(TEST_API_NAME_COOKIE)
  if (envName === undefined) return 'https://api.example.com'
  return `https://${envName}.api.example.com/`
})()

コードとしてはこのような感じです。 本番環境で向き先が変わってしまうと困るので、テスト環境かどうかをチェックするコードも入れてあります。

https://front.example.com?custom_env=hoge のようにアクセスすると、api_name=hoge という Cookie がセットされ、https://hoge.api.example.com へ接続されるようになります。

どのように組み合わせてほしいかという情報も URL 1つで共有できるので、 エンジニア以外に確認してもらうときも簡単です。

また利便性向上のため、APIの向き先がデフォルトでない場合は「画面上にどのAPIを向いているかのラベル」と「デフォルトに戻すボタン」を表示するようにしています。 画面上に出すことでちゃんと向き先変更出来ているかが分かりますし、画面のスクショからでも問題の切り分けがしやすいです。

おわりに

上記の仕組みにより、API とフロントのテスト環境を気軽に作って使い分けが実現出来ました。 環境の順番待ちを強いられていた部分が解消したので、開発者の作業効率もグッと良くなりました。

API のテスト環境を複数作れるようにしてくれた SRE チームに感謝しながら終わりたいと思います。


  1. ブラウザのセッション復元機能によりブラウザを閉じても保持される場合もあります。https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies#define_the_lifetime_of_a_cookie