サーバーサイドで動的に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

Utility First な CSS in JS フレームワークの導入と3ライブラリの比較

JX 通信社のフロントエンドでは React TypeScript や Emotion のような CSS in JS を技術選定することが多いです。弊社 SaaS の FASTALERT、新型コロナ関連情報などでも同様の技術選定で、過去にもエンジニアブログで紹介してきました。

tech.jxpress.net

tech.jxpress.net

今日は、Emotion の活用の極地「Utility First な CSS in JS フレームワーク」についてご紹介します。

Emotion で開発する悩み

素の Emotion や類似の CSS in JS ライブラリでは、 1 つの TS/JS ファイル内に CSS を書くような感じでスタイル設定を行っていきます*1。CSS in JS ライブラリに概ね共通しているのが、 styled.タグ名 でスタイリングすることです。

const Title = styled.h1`
  font-weight: bold;
  font-size: 20px;
`

const Entry = styled.div`
  line-height: 1.5;
  margin-top: 20px;
`

type Props = {
  title: React.ReactNode
}

const Article: React.FC<Props> = ({ title, children }) => {
  return <>
    <Title>{title}</Title>
    <Entry>{children}</Entry>
  </>
}

コンポーネント志向で作っていく上で、上記のコードには責務的な問題があります。 Entry は「上部の要素と 20px 空ける」というマージン指定をしていますが、「隣接要素との距離」は当該コンポーネントの責務外です。あくまで Entry を呼び出す側(親)が、どれくらいの間を開けるのが適切か知っているべきです。

Utility First な CSS in JS フレームワークを導入すると、次のように書くことができます。以下の例での「mt」という prop は、margin-top を指定するための prop です。

import { Title, Entry } from '~/components'

const Article: React.FC<Props> = ({ title, children }) => {
  return <>
    <Title>{title}</Title>
    <Entry mt={4}>{children}</Entry>
  </>
}

マージン、サイズ、色などのあらゆるスタイルが prop で渡せるような CSS in JS フレームワークを、本稿では「Utility First な CSS in JS フレームワーク」と呼称します。このようなフレームワークは、素の Emotion や styled-components を拡張するような形(一緒にインストールする)で作られています。

Utility First な CSS in JS フレームワークのメリット

上記の例は inline style (style prop にわたす形)でも実現できる例ですが、Utility First なフレームワークには次のようなメリットがあります*2

  • 型安全性が高い
  • レスポンシブに対応している
  • デザインシステムとの親和性が高い
  • ホバー時などの挙動も指定できる

例えば、以下のコードは、上記のメリットを同時に満たしています。

import { x } from '@xstyled/emotion'

const Box = (props) => {
  return <x.div
    // primary のような独自定義の色名が型安全に
    color="primary"
    // レスポンシブ指定
    display={{ _: "block", md: "flex"}}
    // ○○px じゃなく、デザインシステムに則ったマージンパターンを指定
    mt={4}
    // ホバー時の色指定
    hoverColor="red"
    {...props}
  />
}

実際に書いてみるとわかるのですが、 TypeScript (JavaScript)の仕組みの上で動いているためコード補完が効きやすく、コード量も少なくスタイルが書けるので、生産性が高く感じられます。そのため、自分が関わるプロジェクトでは積極的に導入しています。

f:id:yamitzky:20210404212309g:plain

ここからは、このような特色を持った React 向けの CSS in JS フレームワークを3つ紹介していきます。いずれのフレームワークも、2019年ごろにリリースされ、アクティブに更新されています。特に最近は Chakra UI の人気が高いようです。2021 年 4 月現在の GitHub Stars をライブラリ名に併記しています。

www.npmtrends.com

Chakra UI (★16.6k)

chakra-ui.com

Chakra UI は Utility First な CSS in JS フレームワークとしての機能と、(Material UI のような)リッチな既成コンポーネントが一緒になったライブラリです。 <Box> が基本的なユーティリティコンポーネントになっているので、これを使っていきます。<Flex><Grid> といったレイアウト用の Box も用意されています。

import { Box } from "@chakra-ui/react"

const Entry = (props) => {
  return <Box 
    // as で div 以外のタグを指定できる
    as="section"
    // レスポンシブは { base: 2, md: 3 } のような形だけでなく、array で指定できる
    m={[2, 3]}
    color="gray.600"
    {...props}
  />

xstyled (★1.6k)

xstyled.dev

xstyled も Chakra UI と同じようなユーティリティ prop が定義されていますが、CSS in JS フレームワークとしての機能に特化しています。xstyled は <x.タグ名> というコンポーネントが用意されていて、通常のタグでのコーディングと書き味が似ています。

import { x } from "@xstyled/emotion"

const Entry = (props) => {
  return <x.section
    // レスポンシブ
    m={{ _: 2, md: 3 }}
    // 
    color="primary"
    {...props}
  />

4月1日にリリースした、信濃毎日新聞様との参院長野補選特設ページ でも使っています。

Theme UI (★3.6k)

theme-ui.com

Theme UI も Chakra UI 同様、 CSS in JS フレームワークとしての機能と、一部既成コンポーネントが一緒になったライブラリです。Chakra UI に比べて、既成コンポーネントは少なく、また、prop で網羅的に CSS 指定できることを志向していません*3。そのため <Box> だけでなく sx という prop (style prop に類似)を併用して使います。

import { Box } from "@xstyled/emotion"

const Entry = (props) => {
  return <Box
    m={[2, 3]}
    sx={{
      // textAlign という prop は Box に存在しない
      textAlign: 'center'
    }}
    {...props}
  />

番外編1:Tailwind CSS

最近もっともよく聞く Utility First な CSS フレームワークは Tailwind CSS でしょう。Tailwind はスタイルをクラス名で指定していきます。Tailwind はそもそも CSS-in-JS のライブラリではないので、番外編としました。Chakra UI や xstyled と似たような機能が生えていますが、型安全な prop で渡すわけではありません。

const Box = (props) => {
  return <div
    className="mt-4 rounded text-blue-600 md:text-green-600"
    {...props}
  />
}

番外編2:自前で整える

本稿で紹介したようなライブラリを入れたくないという人もいると思います。これらのライブラリは emotion や styled-components の上に作られているフレームワークに過ぎないため、自前で整えていくことも可能です。

// mixins.ts
type MarginProps = {
  m?: CSSProperties['margin']
}
const marginMixin = ({ m }: MarginProps) => ({
  margin: m,
})

// Entry.tsx
const Entry = styled.div<MarginProps>`
  ${marginMixin}
  line-height: 1.5;
`

// Page.tsx
const Page = () => {
  return (
    <>
      <Title />
      <Entry m={30} />
    </>
  )
}

自前であれば軽量に始めやすいのですが、最初に紹介したようなメリット(レスポンシブ等)を網羅的に用意していくのが相当面倒くさいです。styled-system のようなライブラリもありますが、メンテされていません。*4

IE 対応

Internet Explorer のサポートを捨てられない、という場合もあると思います。対応状況を簡単にまとめたので、ぜひ参考にしてください。

ライブラリ 公式サポートの宣言 動作 補足
Chakra UI
xstyled ごく一部に CSS Variables が使われているので、それを避ければ可能*5
Theme UI 公式サポートの記載はないが、CSS Variables を使わないための設定が明示されている
core-js/web/dom-collections のポリフィルが必要
Tailwind 未調査

残念ながら、明確に公式サポートを謳っているライブラリはないのですが、 xstyled、Theme UI あたりは動作可能です。先ほど紹介した参院長野補選の特設サイト では IE 11 もサポートしていますが、概ね問題なく動いています。

IE で全く動かないものは、CSS Variables を使っているものです。CSS Variables の方がパフォーマンスはよさそうなので、ブラウザ互換性とパフォーマンスとのトレードオフになりそうです。

まとめ

本稿では、Utility FIrst な CSS in JS フレームワークを紹介しました。デザインシステムや TypeScript との親和性が高く、生産性高くコーディングできるので、ぜひ利用を検討してみてください。

また、JX 通信社ではフロントエンドに限らずインターン生を通年で募集しています。フルリモートでも働けます。

*1:エディタのプラグインを入れると、スタイル部は CSS のようにハイライトされます

*2:xstyled の説明を元に加筆しています

*3:下位互換というよりは、思想の違いだと思います

*4:styled-system の作者が Theme UI を開発メンバーの一人です

*5:ソースコードを var で検索するとでてきますが、Flex 向け spacing や transform などです。色、サイズ、マージンなどの基本機能は問題なく動きます。

Google App Engineではじめる, らくらくTV砲対策 - AIワクチン接種予測の舞台裏

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

ちょっと前のお話になりますが, JX通信社のニュース速報アプリ「NewsDigest」で, 「AIワクチン接種予測」という新機能の提供を開始しました.

prtimes.jp

「自分がいつコロナワクチンを接種できるか?」を簡単に予測できるサービスです. 使っていただけると嬉しいです🙏

大変ありがたい事に,「AIワクチン接種予測」はリリース後多くの反響を頂いていまして,

  • リリースから約半月で利用回数が100万回を突破(プレスリリース).
  • 同じく, リリースから半月で20本以上ものTV番組で紹介

と, 多くのユーザーさんにお使いいただきました. ありがとうございます🙏

これだけ多くの方に使っていただくとなると,

  • リクエスト数・ユーザー数の増減に合わせたコンピューティングリソースの配分
  • 特に, 「TV経由で認知したユーザーさんが一気にやってくる」という急激なトラフィックの増加(いわゆるTV砲)に耐える構造と運用

が重要となってくるのですが,

「AIワクチン接種予測」はGoogle App Engine(GAE)の基本的な設定でTV砲のアクセス増から無事サービスを守りきりました.

私はこのプロジェクトにおいて, プロダクトマネージャー兼エンジニアをさせていただきましたが, ひとまずこの山を乗り越えてホッとしています.

このエントリーでは,

  • 「AIワクチン接種予測」のざっくりなアーキテクチャ
  • 「TV砲をさばく」ためにやったこと

を可能な限り紹介します.

TL;DR

瞬間風速でやってくるトラフィックはApp Engineの基礎を知っていればいい感じにさばけます

おしながき

このエントリーで扱うこと(扱わないこと)

このエントリーでは「AIワクチン接種予測」の以下の話について扱います

  • Google Cloud Platform(GCP)まわりのアーキテクチャ話
  • Google App Engine(GAE)に関するTips

上記の話題に関して網羅的に扱います.

また, 以下の件については後日公開予定(もしくは非公開)とさせてください.

  • フロントエンド関連の話題
  • 予測モデルの内容および運用に関する全般的な話題

ご了承ください🙇‍♂️

AIワクチン接種予測のアーキテクチャ

全体像

AIワクチン接種予測のプロダクトは,

  • Next.js + TypeScript(フロントエンド)
  • Flask, Fast API(バックエンド)
  • App Engine(一部Cloud Run)

で構成されています.

ざっくりな全体像はこちらです.

f:id:shinyorke:20210224211951j:plain

ホントはストレージやCDNなど, 「一般的なWebサービスでいるもの」も当然いますが図では割愛しています🙇‍♂️

ポイントとしては,

GAE(一部Cloud Run)の採用により, スケールしやすい構成を取ったことです.

この構成のおかげでTV砲対策(一時的なインスタンス増加)がすごくやりやすくなりました.

App Engineを全力で使う

AIワクチン接種予測のプロジェクトではGCP, 特にGAEを全面的に採用しました.

サービス構成を決めるにあたり, 社内で何人かのメンバーに相談した結果,

  • シンプルなアプリケーションになりそうなので, 全力でサーバレスを前提としたアーキテクチャに乗っかれそう
  • 一時的な負荷増に対する対策(例えばメディアに取り上げられるなど)とかも楽にできるといいよね

という視点で考えた結果, チーム内で提案(と使いたい要望)があったGAEに決まりました.

私も, 以前在籍したベンチャーでGAEを運用した経験があり, GAEの利点(と辛み)を理解していた(かつ私もメッチャGAEを使いたかった)のでアッサリとGAE採用を決めました*1.

構成図の通り, フロントエンドとバックエンドの主要サービスはGAEにしたのですが, バックエンドの処理の一部(画像生成など)でGAEでやるにはややこしい部分*2があったので一部の処理をCloud Runで構築するなどしました.

実践・TV砲対策

実際のTV砲対策を(話せる範囲で)紹介します.

「AIワクチン接種予測」はNewsDigestのイチ機能としての提供であるため,

  • AIワクチン接種予測本体(GAE + Cloud Run)の負荷対策
  • 接種予測に至るまでの導線を提供する, NewsDigest(のバックエンド)の負荷対策
  • 広報, セールス等を含めたTV出演情報の共有(メディア対策もあるが障害時のエスカレも含む)

これらを出演の度に行いました.

App EngineとCloud Runの負荷対策

GAEとCloud Runの対応は,

TV出演でトラフィックが増えそうな時間帯に限り, インスタンスの数で押し切る(&トラフィックが落ち着いたら元に戻す)

というシンプルな対策で乗り切りました*3.

より具体的には公式ドキュメントを参考に,

  • TV出演の前に, GAEのapp.yamlmin_instances および max_instances の数を増やす
  • TV出演が終わり, トラフィックが落ち着いた頃合いで上記パラメータを元に戻す

これらを愚直にやりました.

なお作業はシンプルで, 定義値を変更したapp.yaml を含んだアプリをデプロイする. たったこれだけでした.

cloud.google.com

基本的には公式ドキュメントを読んで,設定を決めて対応しました*4.

また, Cloud Runの負荷対策も似たような感じで,

  • Cloud Runのコンソールでインスタンス数(最小・最大)を適切な値に設定
  • 上記を再デプロイ(ボタン一発)

これで終わりました.

cloud.google.com

私が携わるプロジェクトでCloud Runを採用したのは初めてでしたが, GAE同様違和感なく対応できてよかったです.

NewsDigestの負荷対策

AIワクチン接種予測に訪れるユーザーさんは必ずNewsDigestの導線を通ることになるので, NewsDigestの負荷対策も重要なタスクの一つでした.

以前はNewsDigestの構成を知っているメンバーが負荷対策をしていたのですが, TVでの露出が多くなると知ってる人に頼るのもどうかなー?と, SREのたっち(@TatchNicolas)さんに相談した結果,

  • 負荷対策の手作業オペレーションを社内ツール化して半自動化. 具体的には手元でスクリプトを叩けばOKぐらいに簡略化
  • 上記の社内ツールを担当者(今回は私)にハンズオンして引き継ぎ

...といった事を爆速で行ってくれました(圧倒的感謝).

このおかげで今まではNewsDigestの負荷対策を中身を知ってるメンバーにお願いしてたのですが, 私自身がコマンド一発でできるようになりました.

これぞDevOpsの醍醐味ですね, 素晴らしい.

ちなみにこの話を相談したのがリリースした2/15から間もない頃で, 翌日には爆速で仕組みができあがっていたので, 流石に驚きました.

TV出演スケジュール管理

今回はTVに連続して出る, という状況が続いたのでスケジュール管理が重要でした.

スケジュール管理はCTOの柳さんを中心にトライアルで導入を進めている, 今流行りのNotionを活用しました.

具体的な利用・感想については, AIワクチン接種予測に色々協力いただいた藤井さんが背景も含めた素晴らしいnoteにまとめていただいたのでこちらをご覧いただけると幸いです.

note.com

Notionの該当ページを見たら「出演時間」「内容」「負荷対策やってますか?」的な内容・チェックリストが確認できる仕組みだったのでとても楽でした.

結び

今回は「AIワクチン接種予測」の, 主にインフラやTV露出対策をどうしたか?というお話を紹介しました.

AIワクチン接種予測のプロダクト単体で言えば, 「GAE採用してよかった」というオチになるのですが,

  • NewsDigest本体の負荷対策をカイゼンしてくれたり
  • Notionを中心とした情報共有・オペレーションの最適化をしたり

といった, チーム力が生きたと思いますし, これが何よりもの「TV砲対策をらくらくにした真の理由」だったのかなと思っています.

また, 今回のプロジェクトではフロントエンド・サーバーサイド両方でエンジニアインターンの皆さんが活躍してくれました(圧倒的感謝).

www.wantedly.com

www.wantedly.com

JX通信社ではインターンの皆さんもユーザーさんに直接価値を届けるような開発タスクができます, ご興味ある方はぜひカジュアル面談来てください.

最後までお読みいただきありがとうございました!

*1:ちなみに私がGAEを触るのは5年ぶりでした

*2:例えばフォントの指定など. GAEだとできなかったっぽいのでCloud Runにしました.

*3:もっというと, このようなシンプルな対策で収まる事を期待してGAEとCloud Runにしました&狙いは見事に的中しました.

*4:個人的な話でいうと, GAEを使ったのが5年ぶりでそこそこブランクがあったのですが, 解説がわかりやすく割とアッサリ勘を取り戻せました. ドキュメント大切ですね.