PyCon JP 2020のTwitter実況システムをGKE上に作った話

SREのたっち(@TatchNicolas)です。

今年のPyCon JPはオンライン開催でした。JX通信社はSilverスポンサーとして協賛したほか、イベントをより盛り上げるために、参加者の反応をリアルタイムに配信に反映するシステムを開発・提供しました。

jxpress.net

アプリケーションはPythonで作られており、基盤としてGKEを採用しました。データ分析基盤や昨年の開発合宿等で社内向けのプロジェクトにKubernetesの採用した事例は過去にもあったのですが、今回はじめて社外向けのシステムに採用したので、その裏側について書いてみたいと思います。

できたもの

構成図

構成としては比較的シンプルだと思います。

基本的な処理はGKE上で行い、データの永続化はFirestoreを使っています。一部、ブラウザで動く運営向けフィード画面上の操作(いいね・リツイート)について、Firebaseを使った書き込みをトリガーとしてCloud Functionsを使って反映させています。

f:id:TatchNicolas:20200827091516p:plain
PyCon JP 2020 SNSリアルタイム配信システム 構成図

(一部リソースは省略しています)

設計のポイント

前提としては、

  • 継続的に提供するサービスではなく、PyCon JP 2020が開催されている二日間のみ稼働すればよい
  • Kubernetes採用の理由は「普通に作っても面白くない、今までと違うことにチャレンジしよう
  • 一方で、短期間 + 開発チームは普段の業務と並行してすすめるため、あまり欲張りはできない

以上から、 「今までよりちょっと便利」を感じてもらいつつも「今までと違いすぎない」 を目指して構成を考えました。

やってみて

Kubernetes投入のハードルは思った以上に低かった

もともとJX通信社ではECSを使い倒しているので、「これはECSでいうとXXみたいな何か」という説明で、Kubernetesに馴染みのないメンバーにもすんなり理解してもらえたと思います。毎週社内の有志でKubernetes勉強会を開催していたので予備知識としても全くのゼロからのスタートではなく、実践の場として良い機会を作れたなと思います。

(最近は本を読むより、それぞれが作りたいものを作ってもくもくする時間になりつつあります。)

永続化や認証はFirebaseを利用したので、複雑になりがちな部分をうまくマネージドに逃がせたことも導入の容易さに繋がったと思います。

port forward のおかげでローカル開発が捗った

今回のプロジェクトでは単にツイートを取得してくるだけでなく、不適切な書き込みをできるだけ除去するために幾つかの機械学習モデルをWeb API化して利用しています。

実際には構成図のcrawlerから利用されていて、収集したツイートをスコアリングしています。ローカルでのcrawler動作確認時にもツイートの内容を判定するためにモデルを叩きにいきたいのですが、そのためにモデルをダウンロードしたりdocker-composeなど用意して推論APIをローカルに立てるのも面倒です。さらに、手元のマシンのリソースも余分に消費してしまいます。

そこで kubectl port-forward <推論APIのService> <ローカルのポート>:<推論APIのポート> とlocalhostで使えるようにすることで、別途APIを立てることなくスムーズに開発ができるようになりました。

APIのURLは環境変数としてcrawlerに渡すようにしているので、切り替えも簡単に行えます。

これは普段のECSの開発では無かった体験なので、開発メンバーからも「こりゃ便利だ」と言ってもらえました。

Secretsのおかげで開発用のAPIキーを簡単に共有できた

Slack連携やTwitter APIの取得のために、いくつか秘匿情報としてPodに渡したいAPIキーがありました。コーディング時は前述の推論APIのURLと同様、実際にSlack/TwitterのAPIを叩きたいのですが、メンバーが自分でキーを用意するのはやはり面倒です。

こちらも検証および本番環境ではSecretsリソースを使って環境変数としてアプリケーションへ渡しているので、ローカルで使いたい場合は検証環境から各種キーを取得して環境変数へセットするワンライナーを用意することで、各メンバーがそれぞれキーを発行することなく利用でき、開発に集中することができました

export SLACK_CHANNEL=$(kubectl get configmap crawler --template="{{.data.SLACK_CHANNEL}}")
export SLACK_TOKEN=$(kubectl get secret crawler --template="{{.data.SLACK_TOKEN}}"|base64 -D)

上記をそのままdirenvの .envrc に書いておけば、下準備はほとんど必要なく、pullしてきてすぐに開発を始められます。

まとめ

PyCon JPに限らず、様々なテックカンファレンスがオンラインで開催されている中で、JX通信社としてイベントを少しでも盛り上げるために貢献できたことはとても嬉しいです。

今回のPyCon向け配信システムに限らず、チーム全体での開発のスピードを上げるために、Kubernetesを使って社内のマイクロサービス基盤を改善していく取り組みも行っています。次回はもうすこしKubernetesのエコシステムを生かした仕組みづくりについて書いてみたいと思います。

アプリを利用しながらアプリの更新ができるin-app updates

Androidエンジニアの@sakebookです。 今まではストアに飛ばしたり、自前で用意したロジックやAPIで更新があるかを確認していました。しかしそんな時代はもう終わりました。Play Core Libraryを使えばアプリ内でアップデートが可能になります。

in-app updates

文字通りアプリ内でアプリのアップデートを行える機能です。アプリのアップデートといえば、知らない間に自動更新されていたり、ストアへ行って更新ボタンを押すなどがありましたが、それらのトリガーをアプリ内から任意のタイミングで引き起こすことが可能になったイメージです。

in-app updatesでは大きく分けて2つの方法がサポートされています。

フレキシブル(Flexible)

知らない間に自動更新

に相当するものです。ユーザにアプリを利用させつつ更新版アプリをDLし、DL完了したタイミングで再起動するメソッドを呼び出すことでアプリを再起動させられます。

アプリにとって必須とは言えないアップデート等の場合に有効です。

f:id:sakebook:20200826004435p:plain

即時(Immediate)

ストアへ行って更新ボタンを押す

に相当するものです。更新版アプリをDLしている間はユーザにアプリのUIを触らせない形になります。DL完了後、画面に従いアプリは自動で再起動します。

アプリにとって必須なアップデート等の場合に有効です。

f:id:sakebook:20200826004507p:plain

実装

詳細はドキュメントに任せます。

ざっくりいうと、アプデが可能かどうか確認し、可能であればFlexible or Immediateとしてアプデリクエストを送る流れです。

Flexibleの場合は、自動ではアプデ完了しないので、DLの進捗をモニタリングし、完了したら再起動を促すUIを表示するのが推奨されています。再起動自体はアプリをDL後、AppUpdateManager#completeUpdate()を呼び出すことで可能です。アプリがBGの状態で呼び出すとアップデートがサイレントインストールされます。

Immediateの場合は、全画面表示になりユーザのアプリの利用を阻害しますが、ユーザはキャンセルすることもできます。そのため、DLがキャンセルされた場合に途中から再開するべきか判定する処理があることが望ましいです。

どちらの場合でも、ユーザはキャンセル可能で、アップデートを強制させるものではないことに注意してください。

テスト

ロジックのテスト用にFakeAppUpdateManagerというクラスが用意されています。AppUpdateManagerと同様のinterfaceを持っています。状態を操作できることと、実際にDLやUIは表示させないこと以外は同じです。

初めての場合は実際にアプリがどんな挙動を取るのか確認したくなると思います。

そんなとき、Google Playの内部アプリ共有機能を使えば実際にin-app updatesを確認することも出来ます。

内部アプリ共有(internal app-sharing)

通常、ストアにあげるアプリにはVersionCodeをincrementしたりする必要がありますが、internal app-sharingを使えばincrementの制約を無視したり、許可リストによる配布が可能になります。

アプリをアップロードしてリンクを発行することで、認定テスターやリンクを知っている人のみDL可能にします。認定テスターとはメーリング リストに追加したテスターのことです。

注意点として、認定テスターはGooglePlayアプリの設定からバージョンを7回タップしてデベロッパー設定をしておく必要があります。設定をしていないと、発行したリンクを踏んでもDLできません。また、既存のアプリとは同じPackage Nameなので端末内で同居はできません。

認定テスターは発行したリンクを踏むことでストア内のinternal app-sharingのアプリページに遷移し、DLできるようになります。

in-app updatesは、GooglePlayアプリ上で更新があるかどうかでアプデ可能かを判定しています。internal app-sharingを使うと、既にストアに上がっているアプリとは別のアプリとして扱われます。

別のアプリとして扱われるというのは、更新判定が別という意味です。

例えばストアのアプリのVersion Codeが2で、internal app-sharingのアプリのVersion Codeを1にして、internal app-sharingのVersion Code1のアプリをインストールしていても更新判定は行われません。

f:id:sakebook:20200826004923p:plain

internal app-sharingのアプリで、Version Codeが2以上ものを用意し、そのリンクを踏んで、GooglePlayアプリ上で更新があることを認識させてあげることで、アプデ可能判定フラグを建てることが出来ます。

  • internal app-sharingのVersion Code 1のリンクを踏んでアプリをインストール
  • internal app-sharingのVersion Code 2のリンクを踏んで「更新」ボタンが確認できたら更新をせずストアから離れる
  • アプデ可能判定フラグが立つ
  • internal app-sharingのVersion Code 1のアプリでin-app updatesの実装をしている画面を表示
  • アプデ可能判定になりin-app updatesのフローに入る

f:id:sakebook:20200826004951p:plain

つまり、既存のストアアプリとは別で、バージョン違いの内部アプリ共有のアプリを2つ用意することで既存のストアアプリに影響を与えることなく実際にin-app updatesの確認ができます。

f:id:sakebook:20200826005325p:plain

任意の識別しやすい名前をつけることが出来ます。ニュースダイジェストではまだAndroid App Bundle(AAB)に対応していませんが、もちろんAABでも可能です。

ニュースダイジェストでは、Immediateの方式で実装しました。

ニュースダイジェストでは、ユーザ一人ひとりに対して返事を行うことは現状していません。しかしユーザからのフィードバックには目を通しています。

フィードバックの中には、アプリを最新版にしてもらえれば直っているものとか対応している機能があったりするのにと思うケースもちらほらあります。

そこで今回は、フィードバックを送る画面でin-app updatesを組み込むことにしました。そうすることで、現状送ろうとしていたフィードバックはアプデによって解決されるかもしれないことを示せるようにしました。

なので、FlexibleではなくてImmediateを選択しました。

もちろん内容によっては解決されないものもありますし、そもそもin-app updatesの仕組みを導入したバージョン以降でしか機能しないです。

f:id:sakebook:20200826005132p:plain

今回のような例の実装であれば、1日あればできるので、気になった方は仕組みとしては入れておくと良いと思います。

きめ細やかな制御

今回は利用しませんでしたが、ストアに更新可能なアプリが配布されてから何日経過しているかという情報や、Google Play Developer APIと組み合わせることでアプリのバージョンごとにアップデートのpriorityの設定が可能です。

更新が頻繁であったり、FlexibleとImmediateを組み合わせたい場合などに有効だと思います。

まとめ

開発者としてはなるべく最新のアプリを使ってもらいたいです。そうすることがユーザにとってもメリットです。

適切に更新導線を組み込むことで、ユーザにストレスなく最新のアプリを提供できるようになります。

in-app updatesはその選択肢の一つになるでしょう。

参考

アプリ内アップデートをサポートする  |  Android デベロッパー  |  Android Developers

Support in-app updates  |  Android Developers

Internal app sharing | Google Play Console

Exploring in-app updates on Android | by Joe Birch | Google Developers Experts | Medium

Support In-App-Updates Implementation Example | by Rajan Maurya | Medium

AppUpdateInfo  |  Android デベロッパー  |  Android Developers

Firebase をフロントエンドから適切に隠蔽するための「Hooks Injection パターン」

取締役の小笠原(@yamitzky)です。

JX通信社では、React 製のフロントエンドでも Clean Architecture で設計するなど、なるべく特定のバックエンドに依存しない設計を心がけたりもするのですが、一方で Firebase をラップした react-firebase-hooks などの「便利な Hooks」を使って開発スピードを加速したい、という課題を持っていました。

そして先日、次の記事が話題になっていました。まさに「どう Firebase を隠蔽するか」と「どう Firebase を活用するか」を両立する悩みです。

blog.ojisan.io

結論を言うと「Hooks そのものを注入する」のが筋が良いのではないか と思っています。個人的に「Hooks Injection パターン」と名付けたこの方法をご紹介したいと思います。

今回の記事の完成形はソースコードを公開し、デモアプリFirestore に依存しない Storybookも公開しています。

github.com

「便利な Hooks」とは

react-firebase-hooks や react-apollo などの、特定のバックエンドを手軽に使える Hooks ライブラリを、僕は勝手に「便利な Hooks」と呼んでいます。例えば react-firebase-hooks をつかうと、次のようなシンプルなコードを書くだけで、Firestore を扱えます。Firestore なので、もちろん変更はリアルタイム反映されます。

import React from 'react'

type Todo = { id: string; title: string }

// 記事一覧ページ
export const TodoList: React.FC = () => {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

react-apollo も useQuery() を呼び出すだけで、任意の GraphQL のバックエンドを適切なライフサイクル*1で扱えます。この2つは似たような思想のライブラリと言えるでしょう。つまりここで言う「便利な Hooks」は「特定のバックエンドに密結合にする代わりに、めちゃくちゃ便利にバックエンドを使えるようになる Hooks」です。この手軽さは、Hooks のない時代には考えられないものでした。

コンポーネントと Hooks (バックエンド)を密結合にさせてしまうのは大変便利なのですが、密結合の弊害もあります。例えば「GraphQL や Firebase を捨てたい!」となったときに UI (コンポーネント)を書き換えないといけません。また、コンポーネントに副作用がある(通信が発生する等)と、テストしづらく、Storybook などにも載せづらい等、再利用性の低いコンポーネントになってしまっています

疎結合にする方法はいくつかあるのでご紹介します。ただしここでは「react-firebase-hooks のような便利な Hooks を諦めない」という縛りを入れています。

疎結合にする方法1:Presentational Component と Container Component の分離

上記例の TodoList を疎結合にするだけであれば、なるべく外界(Firebase)との接点を限定的にする、という方針があるでしょう。いわゆる Presentational Component と Container Component の分離です。これは、Hooks が発明される前から(Redux の文脈とかでも)言われていた方法です。

// TodoListContainer.tsx = Container Component
export const TodoListContainer: React.FC = () => {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return <TodoList todos={todos} />
}

// TodoList.tsx = Presentational Component
export const TodoList: React.FC<{ todos: Todo[] }> = ({ todos }) => {
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

これで TodoList は再利用性が高まり、テストしやすくなりました。今後 UI の責務は TodoList が担い、通信などの責務は Container Component の方に逃がすことができます。一方で Container Component の方は引き続き副作用を持ってテストしづらいまま です。

しかしこれは単純すぎる例です。実際のアプリケーションでは、Container Component が様々なデータ(認証や関連データetc...)を扱う必要があり、どんどんファットになっていきます。Presentational / Container Component の分離という方法は王道ではあるものの、Hooks のメリットがどんどん薄れてしまうような良くない手段である と考えています(あくまで個人的な意見です)。

疎結合にする方法2:クライアントを Injection する

Apollo のような「便利な Hooks」ライブラリでは、通信クライアントを注入することができます。次の例は、Apollo を使った場合のイメージです*2

// App.tsx = 一番上に存在する root component
export const App: React.FC = () => {
  const client = new ApolloClient(/* 略 */)
  return (
    <ApolloProvider client={client}>
      <Routes />
    </ApolloProvider>
  )
}

// TodoList.tsx
export const TodoList: React.FC = () => {
  const { loading, error, data: todos } = useQuery(QUERY)
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

TodoList の実装は何も変えていませんが、テストはしやすくなっています。テスト時や Storybook で TodoList を利用する際には、適当なモックのクライアントを注入できるので、副作用がコントロールできているのです。

react-firebase-hooks には直接的にはこの仕組みはありませんが、React の Context API と useContext を使えば実現できるでしょう。

// Context.ts
export const ClientContext = React.createContext()

// App.tsx
export const App: React.FC = () => {
  const client = firebase.firestore()
  return (
    <ClientContext.Provider value={client}>
      <Routes />
    </ClientContext.Provider>
  )
}

// hooks/todo.tsx
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const client = useContext(ClientContext)
  const [todos, loading, error] = useCollectionData<Todo>(client.collection('todos'))
  return { todos, loading, error }
}

// TodoList.tsx
export const TodoList: React.FC = () => {
  const { todos, loading, error } = useTodos()
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

これで Firebase そのものに依存することなく、react-firebase-hooks を使うことができました。client を差し替えればテストもできそうです。また、useTodos() のようなカスタム Hooks を定義してみたことで、TodoList の実装もスッキリしてきました。

しかし useTodos()Firebase のライブラリインターフェースそのものに依存 しています。UI 層が間接的にライブラリのインターフェースに引っ張られてしまっており、今回の場合はこの方法はナンセンスだと思います(※Context を通じて注入するパターン自体はいい方法です)*3

疎結合にする方法3:Hooks 自体を Injection する

最後に紹介するのが、本記事の趣旨である「Hooks Injection パターン」です。

「方法2」は、UI から使う Hooks が Firebase のようなライブラリのインターフェースに引っ張られてしまっていることが問題でした。そこで、Hooks が使う Client を Injection するのではなく Hooks 自体を Injection すると、冗長さを減らしながらも、さらに疎結合にできているのではないか と思います。

// hooks/todo.ts
export type TodoHooks = {
  useTodos(): { todos?: Todo[]; loading: boolean; error?: Error }
}
export const TodoHooksContext = React.createContext<TodoHooks>()
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  return useContext(TodoHooksContext).useTodos()
}

// hooks/todo/firestore.ts
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const [todos, loading, error] = useCollectionData<Todo>(firebase.firestore().collection('todos'))
  return { todos, loading, error }
}

// App.tsx
import { TodoHooksContext } from './hooks/todos'
import * as firestoreTodoHooks from './hooks/todos/firestore'

export const App: React.FC = () => {
  return (
    <TodoHooksContext.Provider value={firestoreTodoHooks}>
      <Routes />
    </TodoHooksContext.Provider>
  )
}

// TodoList.tsx
import { useTodos } from './hooks/todos'
export const TodoList: React.FC = () => {
  const { todos, loading, error } = useTodos()
  if (error) {
    return <div>error!</div>
  }
  if (loading) {
    return <div>loading...</div>
  }
  return (
    <ul>
      {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}

TodoList.tsx は次の制約を満たしています。

  • コンポーネントは Firestore に依存しておらず、Hooks の実装の詳細が何であるかを知らない
  • コンポーネントからのカスタム Hooks の使い方が自然
  • hooks/todo/firestore.ts のみが Firestore のライブラリのインターフェースに依存している
  • 新たな class の定義などは不要で、カスタム Hooks を自然に実装すれば良い
  • テストや Storybook での Hooks の実装が差し替え容易
  • ちゃんと型安全

冗長になった部分として、Hooks のインターフェースを別に定義したり、Provider を通じて Injection していますが*4

Firestore をやめる方法

疎結合にできたので、Firestore をやめて別のバックエンドを採用したい場合でも、差し替え容易です。

例えば、次のように実装を変えたファイルを作って、Context に渡すパッケージを変更するだけです。

// hooks/todo/rest-api.ts
export const useTodos: { todos?: Todo[]; loading: boolean; error?: Error } {
  const [todos, setTodos] = useState(null)
  useEffect(() => {
    axios.get('/todos').then(r => setTodos(r))
  }, [])
  return { todos, loading: /* 略 */, error: /* 略 */ }
}

認証

認証も同様に react-firebase-hooks を諦めずに疎結合にできます。同様に定義していけば、次のように認証することもできます。(あまり良いコンポーネント分割ではない例ですが)

// TodoList.tsx
import { useTodos } from './hooks/todos'
import { useAuth } from './hooks/auth'

export const TodoList: React.FC = () => {
  const { user } = useAuth()
  const { todos } = useTodos({ user }) // ユーザーの記事一覧を見る
  return (
    <div>
      <h1>{user.name} さんの記事一覧</h1>
      <ul>
        {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
      </ul>
    </div>
  )
}

おわりに

今回は、Hooks 自体を Injection することで、react-firebase-hooks のような便利な Hooks を諦めず、UI と Firebase を疎結合にする、、、といった方法を紹介しました。

実務でも使っているパターンではあるのですが、最近思いついただけなので、色々と穴があるかもしれません。何かありましたら、ご指摘をいただけると嬉しいです。

また、JX通信社では React/TypeScript/Firestore などを使いながらプロダクト開発をする学生インターンを募集していますので、ぜひよろしくお願いいたします。

www.wantedly.com

*1:変なムダリクエストが発生しない、適切にキャッシュされる、などを指しています

*2:実際には本ブログのために動かしてるわけではないので、間違ってたらすみません

*3:あえて触れてないのですが、Firebase そのものではなく、それをラップしたクライアントを注入する方法の方がベターだと思います。しかし方法3の方が Hooks っぽい責務の分割になると思っています

*4:関数を増やしても Provider は一つで良いです。けど、意味的な単位でわけた方が良いと思います(hooks/user.ts、hooks/todo.ts、hooks/commment.ts など)))、疎結合にしたいのであれば流石に許容範囲でしょう。ただ、 ./hooks/todo.ts の中で useContext を通じて Hooks の実体を呼び出すコードが書かれているのはちょっと微妙だなと思っています((デコレータなど使えば回避策はありそうですが

データ基盤を支える技術 - ETLフレームワークの実践的な選び方・組み合わせ方

JX通信社シニア・エンジニア兼データ基盤担当大臣の@shinyorke(しんよーく)です.

最近やった「ちょっとした贅沢」は「休日, 自宅で🍺片手に野球を見ながらUberEatsで注文したランチを楽しむ」です. ⚾と飲食を提供してくださる皆さまに心から感謝しております🙏

JX通信社では,

  • 機械学習を用いたプロダクト開発・施策
  • プロダクト・サービスの改善に関する分析
  • 日々のイベントをメトリクス化して可視化(いわゆるBI的なもの)

を円滑かつ効率よく行うため, 昨年からデータ基盤を整備・運用しており, 現在では社員のみならず(スーパー優秀な)インターンの皆さまと一緒に活用し, 成果を出し始めています.

ainow.ai

なぜデータ基盤が必要か?どういった事をしているのか?...は上記のインタビューに譲るとして, このエントリーでは「データ基盤を支える技術 - ETL編」と称しまして,

  • Python製ETLをどうやって選ぶか?
  • JX通信社におけるETLフレームワークの使い分け
  • 小さく作って運用するETLならprefect最高やで!

というお話をさせてもらえればと思います.*1

TL;DR

  • ETLフレームワークはどれも癖があって得意不得意も違うので, 一つに絞らず適材適所にやってこ.
  • JX通信社では, 大きめのWorkflowを運用するのにAirflow(Cloud Composer), 1〜複数個のコンテナで収まるバッチをprefectでやってます.
  • 大きめWorkflow/小さいETL両方行ける(と思われる)prefectに期待する未来はあって良さそう

おしながき

対象読者

そこそこハイコンテクストな話題なので一応書きます.

  • 規模の大小を問わず, 企業および何かしらの団体でデータ基盤構築・運用に携わっている方.
  • AirflowやLuigi, kubeflowといったETLフレームワークを使った構築・運用をしたことがある方. SRE的なレイヤーが得意ならなお望ましい.
  • ETLとかWorkflowとかDAGとかそのへんの単語を解説しなくても読める方.
  • 「PythonicなETL話」なので, DigDag等, 別言語で実装・定義されたETLとの比較はしておりません.

Python製ツールの話メインですが言うほどPython出てこないです&統計・機械学習のアルゴリズム等は出てきませんのでそっち方面の知識は不要です.

PythonicなETLフレームワークの選び方

私の話で恐縮ですが, 過去のお仕事および個人プロジェクトでAirflowおよびLuigiを活用してきました.

上記以外にもJX通信社の仕事をはじめ結構色々とETLを構築・運用しましたが, ETLフレームワークの選び方として以下の3つが大事なのでは?と気が付きました.

  • Workflow(複数のタスク処理)実行に必要なプロセス(コンテナ)の数)
  • CPU・メモリの利用量が変動するか
  • 開発のしやすさ

構築するETLの要件・規模(と開発者の好みなどなど)を上記の3つの視点で組み合わせるといい感じになります!というのがこの先のお話になります.

Workflow(複数のタスク処理)実行に必要なプロセス(コンテナ)の数

結論から言うと,

Dockerのコンテナ1個で処理が終わるか, それとも複数のコンテナ・プロセスで処理するかで採用するフレームワーク変わるやで!

ということです.

データ基盤のバックエンドのタスクは,

  1. 欲しいデータを取ってくる「Extract」タスク
  2. 前処理・クレンジング・集計etc...何かしらの変換を行う「Transform」タスク
  3. DWH, ストレージなど必要としている所にデータを読み込む「Load」タスク

この3つの組み合わせで構成され*4, 大抵の場合「Extract -> Transform -> Load」の順で「Workflow」バッチを組むことになります.

これらの設計を進めると,

  • 一つのDBテーブル, 一つのアプリケーションログを相手にする比較的小さいETL Workflow
  • 複数のデータセット, 複雑な集計処理を伴うかつ, 別のWorkflowと依存する大きめのETL Workflow

に別れます. そして大抵の場合,

  • 「小さいETL Workflow」は一つのアプリケーションで完結することが多い. 例えば「RDBにselectした結果をS3に保存」みたいな小さなタスクはEmbulkのプロセス一つ立てればそれで終わる.
  • 「大きめのETL Workflow」は「AのWorkflowが終わったらBのWorkflowを」的な複雑な順序および障害発生時の冪等性を担保するような大掛かりな仕組みが必要となり, 仕組み的にもWorkflowが担うSLA的にもゴツい仕組みが必要.

となります.

小さいものはDockerコンテナ1個で収まるとかが多いですが, 大きいものはk8sクラスタ一個まるごと使う的な事が多く, ETLフレームワークもそれらによって得意不得意が異なるのでここのポイントは結構大きいんじゃないかなと思っています.

CPU・メモリの利用量が変動するか否か

Workflowが使うComputing Resources, 特にCPUとメモリの利用量が変動するか否かも大切なポイントとなります(機械学習系だとここにGPUも加わることになります).

これも一言でいうと,

処理に必要なCPU・メモリが一定量ならDocker Containerもしくは関数系のサービスでやる, 変動するならフルマネージドなクラウドサービス使うとかしよう!

です.

「1日一回, マスターデータをS3にダンプする」「アプリケーションログを1時間に一度BigQueryに流す」みたいなタスクはCPU・メモリの利用量が安定することが多いので, コンテナで動かすアプリケーションで十分対応可能です.

が, 「複数のデータセットをかき集めてクラスタリングした後機械学習の予測とかやります」だと,

  • データセットをかき集める, クラスタリングするときに多くのメモリが必要
  • 機械学習の予測タスクってCPU頼り(もしくはここだけGPU使う)なのでは?
  • その他の小さいタスクは分散するなり遅延処理するなりしたらさほどComputing Resourcesいらなさそう

とかあったりします. Resourcesに変動がある場合は,

  • Airflowの「ECSOperator*5」「KubernetesPodOperator*6」など, 実行時にリソース指定ができる仕組みを使う
  • kubeflowなど, k8s前提の流動的・弾力的にリソースが使える仕組みを使う
  • Sparkとかの仕組みに乗れるものはAWS Glue/EMRやGCP Dataprocなどフルマネージドな分散処理サービスに乗っかる

などの工夫で切り抜ける必要があります(かつ, LuigiやEmbulkなどはこの手の仕組みがありません).

開発のしやすさ(含む環境構築)

開発のしやすさもかなり大事です.

まず, ETLフレームワークはどれも癖があります&人それぞれ好みも別れます.*7

迷ったらやりやすいやつを選ぶのも大事な視点です.

比較表にしてみると...

というわけで, 上記の視点で比較表を作ってみました.

なお, あくまでも個人の感想です.

Framework 実行プロセス数 CPU・メモリ変動 開発しやすさ(shinyorke個人の感想) 備考
Airflow 複数コンテナ必要・Cloud Composerなどを使ってk8sで運用が推奨 k8sクラスタ内で割り当てたり他のサービス使ったりと優秀 DAGはPython書ければ誰でも書ける, 他は怪しい. オンプレ・セルフホスティングは地獄ですのでオススメしません, Cloud ComposerもしくはどうしてもせるふでやるならhelmのAirflowテンプレを使うのがベスト.*8
Luigi Docker Container一つで収まる 基本的に起動したコンテナ・マシンのリソースのみ Python完全に理解した...レベルならかろうじて行けそう 軽量で良い仕組みですが少し癖がある&後述のprefectが良い後継なので今からやるならprefectがオススメ?
prefect Docker Container一つでもk8sなどでもどっちでも 環境によって柔軟にできる(らしい) メチャクチャやりやすい, Luigiの代替として優秀 Airflowの代替として使えるっぽいが試した事無いです
kubeflow k8s k8s内でいい感じにやれる Pythonで書きやすそう(まだ書いたこと無い) 機械学習Workflowとしてすごく使えそう. データを移送する程度のETLとしては少々割高かも.

個人の感想が思いっきり入ってるので参考程度にしてもらえるとありがたいですが,

  • 比較的SLAが要求されるWorkflowはAirflow
  • 小さめのWorkflowはprefectやLuigi
  • 機械学習目的でkubeflow

ぐらいに雑に考えると良さそうです.

JX通信社におけるETLの使い分け

JX通信社ではデータ基盤チーム・CTO室で試行錯誤や本番運用しながらのトライアルの結果,

  • 全体的な大きめWorkflowはCloud Composer(Airflow)で管理
  • 1コンテナで収まるタスクはprefectとLuigiを使う, Luigiは順次prefectに書き換え中
  • DBのimport等小さいタスクはEmbulk
  • ML Opsが必要なWorkflowはkubeflowの導入を前提に色々とお試し中

という感じで進めています.

全体像

現状の全体像はこちらです.

f:id:shinyorke:20200726103741p:plain
個別のタスクでLuigi, Prefect, Embulk, 全体管理でAirflow使ってます

アプリケーションログを扱うか, RDBMS/KVSを扱うかで仕組みがちょっと変わっていますが原則として,

  • すべてAirflowのDAGから実行
  • 個別のタスク(小さいWorkflow)はAWS ECSもしくはCloud ComposerのPodとしてクライアントアプリ(Python製のコードもしくはEmbulk)を実行
  • 最後はBigQueryに投入, 中間データはS3/GCSに残す

という運用を行っています.

Airflow(Cloud Composer)

前述のとおり, Cloud Composerにすべてを任せています.

f:id:shinyorke:20200726114817p:plain
DAGで順番をコントロールしています

アプリケーション(AWS)側のタスクもAirflowのトリガーで実行(すべてECS Fargateのタスクとして実行)できるのでものすごく楽にできます.

GCPのタスクもCloud Composer内のnode pool上でPodを立てて実行しています.

また, DAGについてはビジネスロジックを原則書かない運用としているため, コードベースも比較的スッキリしています.

ちなみに, すべてのAirflowタスクがCloud Composerという訳ではなく, 一部セルフホスティングしているものもありますがこれは近々Cloud Composerに引っ越しする予定で進めています.

Luigi

アプリケーションログなど, ある程度のフィルター・クレンジングが必要(≒方言がある)モノはLuigi/prefect製のアプリケーションをECS Fargateのタスクとして実行し, ログを出力する仕組みとしています.

f:id:shinyorke:20200726115413p:plain
アプリケーションの方言が入るログは独自のETLでやってます

これは「Dockerのコンテナ1個で処理が終わる」レベルなので, Luigiみたいな小さいFrameworkが良さそうということでLuigiで開発しました.

luigi.readthedocs.io

Luigiは, Dockerコンテナ一つで動かすようなWorkflowや, 小規模な機械学習pipelineに用いることが多く, (前述の事例通り)私もよく使っていたので昨年までは愛用していました(が後述の理由によりprefectに移行中です).

prefect(Luigiの後釜として)

Luigiは実績があって枯れているETL Frameworkではありますが,

  • Workflowの定義・記法がやや独特. Luigiのクラス・インターフェースの定義に従って書かないといけない.
  • 「前のタスクが途中か終わってるか」的な管理を時前で実装する必要がある. 具体的にはタスクの開始・終了をログファイルやDBの状態で判断するような書き方になるため, 運用(特に再実行とか)の際にハマりどころとなりやすい.

という欠点があります.

どうしたものか...と思案していたところ, 最近注目を浴びているprefectを使うとこの辺がシュッとなると気がついたので最近はprefectでの開発に切り替えています.

docs.prefect.io

Airflowの開発者が, Airflowの辛い所をいい感じにするぜ!というノリで開発したETL Frameworkで, クラウドサービスも存在します.

感覚的にはAirflow的な使い方・Luigiの替わり両方行けそうな感じで, JX通信社では今の所後者(Luigiでやれそうな軽いWorkflow)で使っています.

prefectは, Luigiと異なり,

  • ETL/Workflowに必要な機能がいい感じにまとまっている. 並列実行・GUIなど面倒を見てくれる
  • Workflowに必要なタスク・実装をdecoratorで定義できる
  • Pythonの一般的なclass/methodの実装ができたら比較的シュッとできる

利点があり活用しています.

このエントリーだけでは説明がしきれないところもあるので, prefectの詳細については近日中に別記事で紹介いたします!*9

その他(Embulk, kubeflowほか)

RDBMSやDynamoDBなどを相手にするものは処理が決まってるためEmbulkを使っています.

また, 最近はML Opsまわりをもっと進めるため試験的にkubeflowも使ったりしています.

結び - 結局どれも癖がありますので長いおつきあいを

というわけでこのエントリーでは「ETLフレームワークの選び方・組み合わせ方」について色々と書きました.

が, これが最適解かと言われるとまだまだな気がしますし, この手の事例って意外と無いんですよね...

ご指摘や改善点などありましたらコメントや意見をいただけると嬉しいです :bow:

というのと, これだけは自信を持って言えるのですが,

ETLフレームワーク, 結局どれも癖がありますので長いおつきあいを前提にやってこうぜ!

どれも癖はありますが長所を活かす使い方をするとしっかりバリュー出ます.

色々と試し, 自学自習して改善しながらいい感じにやってくのがベストかなと思います.

最後までお読みいただきありがとうございました&次はprefect編で会いましょう.

*1:なお, 余談ですがこのネタ本当はPyCon JP 2020のCfPとして提出したモノの一部となります. 悔しいことに不採択になったので供養エントリーという意味合いも含んでおります(真顔)

*2:PyCon JP 2017の発表で, 日々発生する野球の試合データを収集・可視化する基盤のバックエンドとしてAirflowを使いました.

*3:私が当時在籍していたRettyでの事例で, アライアンス関係のバックエンドをLuigiでゼロから作った話です.

*4:Transformあたりは事実上なかったりする場合も無きにしもあらず(例えばExtractがSQLでデータを取ってくるとか)ですが, 最低でもExtractとLoadの2つはある認識でいます.

*5:特定のDocker ImageをAWS ECSのタスクとして実行する仕組みでFargateも使えるためかなり便利です. 後ほど触れますがJX通信社のデータ基盤でも活用しています.

*6:指定したk8sのクラスタ・node pool上のResourcesを使って処理を行う仕組みでこれもDocker imageを元に処理することになります.

*7:PythonのWeb Frameworkを思い出してみてください. Django, Flask, sanic, FastAPIどれも良いところ・癖がそれぞれあって好みが分かれることを. 他の言語も同じでしょう.

*8:いずれもk8s前提の運用となります.

*9:ホントはこの記事のメインコンテンツのつもりでしたが思ったより文量が増えたため別けることに

ReactのコードをHooksにリファクタリングしていく話

フロントエンジニアの渡辺(@pentla)です。

AI 緊急情報サービスの「FASTALERT」は、Reactをフロントエンドのスタックとして採用しており、3年ほどコードベースは大きく変えずに運用しています。その過程で、Flow → TypeScriptへのスタックの変更など、継続的にリファクタリングを進めています。

tech.jxpress.net

今回は、Classベースのコンポーネントを、Hooksを使って関数ベースのコンポーネントに書き直す話です。

今回の対象読者は、

  • Reactを書いたことがある方
  • Reactのパフォーマンスについて気になっている方
  • Hooksは知っているけれど、プロダクション環境に入れるか迷っている方

を対象にしています。

Hooksとは

v16.8から追加された機能です。今までは、Stateやライフサイクルメソッド(表示時に一度だけ呼び出したいときなど)の機能はClassを使って書く必要があったのですが、このHooksを使えば関数ベースのコンポーネントでもStateを扱えたり、「表示後に一度だけ呼び出したい」などの要件を満たすことができるようになりました。他にもHooksを使うことで利点があり、このあたりに書かれているので、実際に使おうと思っている方は一読の価値ありです。

ja.reactjs.org

それぞれのHooksの特徴

hooksはライフサイクルの考え方が少し特殊で、最初はやや違和感があるかもしれませんが、少しずつ慣れていくと、思い通りにコンポーネントの状態を管理することができます。 簡単なhooksの紹介をします。

useState

stateの書き方を変えるだけなので、最もわかりやすいです。

// before
constructor() {
  this.state = {
      isSample = true
  }
} 

// after
[isSample, setSample] = useState(false)

公式ドキュメント「useState」

useEffect

componentDidUpdateなど、特定のタイミングでのみ起動させたいときは、useEffectを使います。 気を付けたいのは、第二引数です。useEffectの中で使用している変数、関数についてはすべてuseEffectの第二引数の配列に入れます。この引数に値を入れることで、「この中の値に変化があった場合のみ、useEffectの中の処理を走らせる」ことができます。

// before
componentDidMount() {
  this.callAPI(this.props.id);
}

// after
useEffect(() => {
  callAPI(props.id)
}, [props.id, callAPI])

公式ドキュメント「ヒント: 副作用のスキップによるパフォーマンス改善」に詳しい処理の内容があります。

リファクタリングのモチベーション

Reactのライフサイクルメソッドに、今後いくつかの修正が入ります。具体的には、ComponentWillReceiveProps、ComponentWillMount、ComponentWillUpdateの3つの関数がdeprecatedになります。

この機能は、Reactのv17.0以降削除されることが決まっていて、その際はUNSAFE_というプレフィックスをつけることが求められます。 新しいライフサイクルメソッドを使って書き直すことも可能ですが、せっかくの機会なので、少しずつ関数ベースのコンポーネントに書き直しています。

Hooksに書き直したときの利点

コード量が減った

明らかに減ります。このようにstateを少し使った簡単なコンポーネントであれば、行数もかなりの数減らせることができます。21行から9行と、半分程度まで減っていますね!

Classベースのコンポーネント: 21行

export default class MemberList extends PureComponent<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { filter: { name: '' } }
    this.handleClickRow = this.handleClickRow.bind(this)
  }

 handleClickRow(rawId: number) {
    history.push(`/xxxx/${rawId}`)
  }

  render() {
    const { members } = this.props
    const {
      filter: { name }
    } = this.state
    // フィルタ機能
    const filteredMember = (members && members.filter(m => m.name.includes(name))) || []
    return <div>{...レイアウト}</div>
  }
}

関数ベースのコンポーネント: 10行

const OrganizationList: React.FC<Props> = ({ organizations }) => {
  const [filter, setFilter] = useState('')
  const handleClickRow = (rawId: number) => history.push(`/xxxx/${rawId}`)
  // フィルタ機能
  const filteredMember = useMemo(() => members && members.filter(o => o.name.includes(filter))) || [], [members, filter])
  return <div>{...レイアウト}</div>
}

export default MemberList

パフォーマンスの考え方がわかりやすくなった

Memo化を使うと、描画の際のパフォーマンスの調整が簡単になります。今までは、PureComponentsなどで描画回数を制限するなどしていましたが、useCallback, useMemoなどメモ化を使用することで変数ごとに計算回数の調整がしやすいです。

今までは、shouldComponentUpdate関数を使って、「このコンポーネントをいつ描画するか」を管理する必要がありました。例えば、下は、webページにタイトルを表示するReact.Compoentの例です。

class Title extends React.Component {
  render() {
   // すっごく重い処理
   const title = heavyFunction(props.title)
   return <div>{props.title}</div>
  }
}

class Parent extends React.Component {
  render() {
    return (
      <div>
        <Title title={this.props.title}/>
        {...その他いろいろなコンポーネント}
      </div>
    )
  }

Titleコンポーネントには、すっごく重い処理が書かれています。Titleコンポーネントは、Parentコンポーネントの内部にあります。 仮に、Parentコンポーネントが再度描画された場合、このTitleコンポーネントは変更がないにもかかわらず、再度描画処理が走ってしまいます。この場合、titleは変更がないにもかかわらず大きい処理が走ってしまい、パフォーマンスに影響が出るケースがあります。

そのようなケースに対応するために、

  • shouldComponentUpdate関数で、props.titleに変更がなければ描画しないようにする
  • pureComponentを使う

などの対策を取る必要がありました。

Memo化を使うと、このようになります。

const Title = props => {
  // すっごく重い処理は、props.titleが変更されない限り走らない
  const title = useMemo(() => heavyFunction(props.title), [props.title])
  return <div>{props.title}</div>
}

useMemoを使うことで、props.titleに変更がない限り、再度重い処理が走ることはありません。useMemoの使い方については、下にも説明を記載しています。

書き直していく上でのtips

ここからは、FASTALERTを実際にリファクタリングした時の話です。

小さめのコンポーネントから取り組む

特に最初はuseEffect、useCallbackなどの特徴を掴んでいくために、小さめのコードをFunctional Componentに書き直していくことをお勧めします。小さいコンポーネントであればその分修正も少なく、挙動の変更も確認しやすいです。

無理に書き直さない

特にテストがないコンポーネントの場合や、ライフサイクルメソッドに複雑な処理を書いている場合、大きな変更を入れるとどうしても変更が読めない箇所というのも出てしまいます。目的は将来のアップデートに追従するためで、必ず全てのコンポーネントをhooksを使って書き直す必要はないので、代わりのメソッドで置き換えるか、一旦後回しにする選択肢をとっています。

まとめ

今回のリファクタリングの途中で、

  • 書き直している途中に明らかにパフォーマンスが悪い箇所がある
  • APIの呼び出し箇所のコードがわかりづらく、簡潔でない

箇所に気づけたりして、問題になる前にコードの見直しができたのも良かったです。コードの行数も目に見えて減るので、効果がわかりやすいです。

さいごに - フロントエンドをいい感じにするフレンズまってます!

JX通信社では、FASTALERTやNewsDigestといったプロダクトの改善, 特にフロントエンドまわりを頑張っています。

上記以外にも現在進行系で行っている取り組みがあります、仲間を募集しているので興味があるかた気軽にお話に来てください!

www.wantedly.com