プレイドでの開発インターン: 3ヶ月のフルタイムエンジニアとしての挑戦を経て

はじめに

こんにちは!5月の中旬から8月の中旬の3ヶ月間、ソフトウェアエンジニアとして株式会社プレイドでインターンをしていた田中健心です。

私はカナダのバンクーバにあるUBC(the University of British Columbia)という大学でコンピュータサイエンスを専攻している学部2年生です。

大学の先輩や同級生が過去にプレイドでインターンをしており、その時の体験ブログを読んだり直接彼らから体験談を聞き、業務内容のレベルの高さに触発され、今回開発インターンとして参加することを決意しました。

ここでは、インターンを始めるまで、そしてインターンを始めてからどのようなタスクに取り組み、どのような学びをえたのかを時系列で説明します。

インターンを始めるまで

先輩を含む、プレイドでの開発インターンを経験した他の学生のブログをひたすら読み、外から見えるプレイドでの開発体制の魅力や、採用しているモダンな技術スタックに胸を膨らませていました。

自分は過去にレガシーでモノリスな開発しかしたことがありませんでした。そのため、プレイドの秒間最大13万4千リクエストや月間180ペタバイトを超える解析にも耐えうるアーキテクチャの実装や、Kubernetesを全社共通基盤として使用してマイクロサービスの開発しているモダンな開発環境に魅力を感じていました。

また、面接の際、後のチームメンバーとなる社員さんから実際にKARTEのプロダクトのデモを見せて頂き、プロダクトの完成度の高さに加えそれを支える技術にまで踏み込んで話をして頂き、テクノロジーに向き合う姿勢に加えプロダクト自体への熱意を入社前から感じることができました。

KARTE Messageチームにジョイン

Messageチームについて

プレイドのインターンでは「インターン用の仕事」が与えられるのではなく、他の社員と同様に実際に運用されているプロダクトであるKARTEの開発チームにアサインされます。その中でも私は偶然にも過去に先輩がインターンしていたKARTE Messageチームに配属されることが決まりました。

KARTE Messageは、クライアントが持つデータを取り込むだけで、ノーコードでセグメント配信や、顧客ごとにパーソナライズされたマルチチャネルの配信を行えるマーケティングオートメーション(MA)プロダクトです。

技術スタック

KARTE Messageの技術スタックは多岐にわたります。配信対象の追加や絞り込みをノーコードで行う管理画面、秒間数千通の送信に耐えうる配信基盤、ユーザーがどのような行動を取ったかや配信結果を蓄える配信ログ基盤などが組み合わさっています。
これらは主にTypescriptで開発されていますが、アーキテクチャの刷新に伴い、配信基盤の一部はGoへのリプレイスも進んでいます。
フロントエンドではVue.jsを主に使用して構築されていますが、新規画面や既存画面についてもReactへのリプレイスを進めています。配信基盤はKubernetesでオートマネージされています。

テスト/継続的デプロイ

CI/CDも整備されており、各パッケージごとの単体テストやE2EをGithubActionやCircleCI上で実施します。リソース管理はTerraformで行い、Kubernetesのマニフェストも各パッケージ毎にDocker imageを更新することができ、迅速に変更のデプロイ/ロールバックが行える状態が整っています。

監視/モニタリング

DatadogやSentryを使用しハンドリング漏れのエラーや監視しているデータが閾値を超えるとアラートでSlackに通知が飛んできたりと、不測の事態に素早く対応できる仕組みも整備されています。

メッセージチームにはSRE(System Reliability Engineer)がいて、監視用のダッシュボードの作成や、どの程度までスケールするかを計算したり実際に負荷試験を行っていたりと、開発面のみでなくサービスの提供を継続させるための仕組みが備わっています。

チーム内コミュニケーション

プレイドではインターン生1人に付き社員のメンターが1人つき、こまめに1on1を入れて相談や進捗を同期したり、密にコミュニケーションを取りながら開発を進めていきました。
メンター以外の社員さんについても、必要があればSlackでメンションを飛ばしたりカレンダーに予定を入れたりしてミーティングする時間を設けたりと、私が業務進行上に必要なことに対しては積極的に相手の時間を取って貰いました。このような経験からインターン生ながら1人の社員として向き合って貰えているという実感を感じることができました。
また、後述するタスクを行う際にMessage以外のチームとも連携して進めないといけないものがあったのですが、プレイドの上下のないフラットな組織体制のお陰でスムーズにやり取りすることができました。

取り組んだタスク

インターン生にはメンターが抱えているタスクを切り分けて渡されます。
その中で、最初はプロジェクトの外観を掴むために数時間-1日程度で終わる粒度の小さなタスクを数多くこなしていきました。
具体的には、
エラーのハンドリング漏れに対し独自のエラー型を定義して解消
メールに値を埋め込む時に使う変数名が、SQLの予約後と同じ時にエラーになる問題の解消
等様々です。

序盤のタスク 「配信基盤e2eの改良」の開発

その中でも、序盤に印象的だったタスクはE2Eでテストする機能の追加です。
メッセージの配信する際に、配信基盤がCloudSpannerやBigQueryに必要な情報を書き込んだりするのですが、使うSpannerやBQのテーブルをテスト内で決め打ちしている状態でした。このタスクでは、
E2E毎に新たなSpannerやBQのテーブルを作成→ テストを実行 → 作成したテーブルの削除
を行いたいという要件でした。この要件を盛り込むことで、Spannerのスキーマに変更が加わっても最新のスキーマを使用してテストを実行することが可能になります。

出てきた課題と解決

上の要件を実装していく中で、新たな課題が生じました。それはE2Eの実行にかかる時間です。Cloud Spannerのマイグレーションファイルがおよそ30ファイルあったため、E2Eの中でマイグレーションがボトルネックになってしまう問題が起きました。
解決策として、実際に稼働しているCloud Spannerのスキーマをインポートしそれをベーススキーマとしました。そして新たにマイグレーションファイルが追加されているかどうかをGithubAction上でdiffを取ることで確認し、存在する場合は差分のファイルだけを使用しマイグレーションを実行するようにしました。
これにより当初かかっていたE2Eの実行時間を半分以上短縮し、マイグレーションファイルが増えてもそこがボトルネックにならないような設計にすることができました。
この「課題発見 → 解決」の流れの中で、自分の方針についてレビューして貰い、その他の代替案と比較し、最終方針を決定するなどの重要なサイクルを経ることができました。また、改めてチームの一員として活躍できていることも再認識し自信に繋がりました。

粒度の大きいタスク「KARTEイベント連携」の開発

インターンが2ヶ月目に差し掛かるに当たり、残りの時間をフルに使うような大きめのタスクを任せて貰いました。

タスクの説明
タスクの説明図

KARTEには、ユーザのイベントをトラッキングし、大量の行動データを分析することでユーザの次のアクションを予想したりと数々の応用を行うためのビックデータ解析基盤があります。
KARTE Messageは、後発に出てきたサービスであり、開発のしやすさのためログ基盤をMessageチーム内で閉じていました。そのため、KARTE MessageではKARTEのユーザートラッキング解析を使うことができていませんでした。

私のタスクは、Messageのみで閉じていたイベントデータをKARTEの本体と連携することで、
①KARTE側の横断のイベントデータにデータを蓄積し、KARTEの既存の機能と連携できるようにすること
②KARTEでイベントを解析できるようにすることでKARTEの接客にMessageのイベントを活用できるようにすることでした。

これらを実装するためには、最終的な連携先であるKARTE側の仕様を考慮しそこから逆算してシステムのアーキテクチャを設計する必要がありました。

具体的には、

  • 解析基盤Blitzの、Pub/Subに決まった形式でイベントを書き込むとKARTEにイベントが取り込まれる
  • このイベント解析を使用するために、生成するイベントにはKARTEのキャンペーンIDとアクションIDが入ってる必要がある
  • メッセージに存在するキャンペーンとアクションを、KARTEのキャンペーンとアクションと連携し紐付ける

といったようなものです。

タスクの分解

粒度が大きいタスクは、何から手をつけていいのか分からず混乱しがちです。
そのためメンターと相談し、以下のように大きくタスクを分解し、着手しました。

  1. karteのキャンペーンとMessageのキャンペーンの紐づけ
  2. 紐づけたIDの配信基盤へのつなぎ込み
  3. MessageイベントをKARTEイベントに変換するパイプラインの構築

1. KARTEのキャンペーンとMessageのキャンペーンの紐づけ

KARTEのキャンペーンとMessageのキャンペーンの紐づけの構成図

最初に行ったことは、Message側で閉じているキャンペーンやアクションを、KARTE側で保持しているキャンペーンとアクションに同期する作業でした。

KARTEにはキャンペーンやアクションといった概念があるのですが、KARTE Messageにもその概念を踏襲した別のキャンペーンとアクションが存在します。

MongoStreamを使い、MessageのキャンペーンやアクションのMongoへのINSERTイベントを監視することで、KARTE側のデータベースと同期する仕組みを構築しました。

KARTE Messageは、KARTE本体とは切り離されたマイクロサービスとして作られているため、KARTE本体のDBの操作はinternal APIを新たに作成+更新して行う必要がありました。

また、過去のキャンペーンについてもバッチ処理を作ることで紐付けを行いました。

2.紐づけたIDの配信基盤へのつなぎ込み

1の作業で、Messageのキャンペーン/アクションとKARTEのキャンペーン/アクションを同期することができました。

KARTEのキャンペーン/アクションidをMessage側と同期した理由は、変換後のKARTEイベントにキャンペーンidやアクションidを入れるためなので、変換元となるMessageイベントにもこれらのidを入れる必要があったからです。
Messageイベントは様々なパッケージから発火するため、まずはどこの場所からイベントが発生するのかを正確に把握する必要がありました。そしてイベントの発火場所を全て列挙した後は、イベントの型の更新と、それに伴う値の受け渡しを行いました。関数ごとにKARTEのidを取得する経路が異なるため、データの流れを正確に把握することがありました。
これらの配信基盤を構成する複数のパッケージを横断して変更するといった経験により、KARTE Messageの仕組みやアーキテクチャに対する解像度が格段に上がりました。

これらの仕事によりMessageイベントとKARTEのキャンペーンidとアクションidの紐づけを行ったことで、ようやくメッセージイベントを解析するための下準備が整いました。

3.MessageイベントをKARTEイベントに変換するパイプライン(KarteEventPublisher)の構築

MessageイベントをKARTEイベントに変換するパイプライン(KarteEventPublisher)の構築
構成図

次に行ったことは、Messageシステムのみに内在していたMessageイベントを適切な形に変換して解析基盤に繋ぎこむ新たなシステムを作成することでした。

このシステムが完成することで、Messageに対してアクションを行ったユーザのその後の購買行動などをトラッキングすることができ、より強固で詳細な施策の分析を行えることが期待されます。

システムの実装は、Apache Beamというデータの並列処理パイプラインを定義できるOSSの統合モデルをフルマネージドに利用できるGCPのDataflowを使用し、大量のイベントデータを分散して処理できるように構築しました。

実装にはいくつか詰まるポイントがあったのですが、最も難しかった点は変換に必要な依存データの注入方法の選択でした。
Dataflowのシステムは負荷が上がると、1つの物理ワーカーで最大500のスレッドが同時に立ち上がります。それぞれのスレッド上のワーカーにで分散して変換処理を行うのですが、ここでいくつかの課題が発生しました。

1つ目の問題は、ワーカーがAPIを叩いて依存データを見に行くと、APIに大量の負荷がかかる問題でした。処理毎にAPIを叩きにいく手法以外にも、スレッドが立ち上がる際にスレッドワーカー内でインメモリにキャッシュする手法を試しても、スレッドが同時に立ち上がるためこの問題は解決しませんでした。

2つ目の問題は、APIの負荷を軽減するためにスレッド間で共通で使用するためのファイルキャッシュを使用しても解決できなかったことです。デプロイの直後はキャッシュが存在せず、大量のイベントが飛び交う本番環境では最初のスレッドがキャッシュを作成するよりも前に他のスレッドがAPIを叩きに行く状態が発生しました。ロックを取得してキャッシュする方法も検討したのですが、ファイルロックを取得するflock(2)というシステムコールはプロセス間でのロックの取得しか行えず、スレッド間では効果がなかっためこの方法も機能しませんでした。さらに、スレッドのライフサイクルを自分で管理していないため、mutexを使ってロックを行うこともできませんでした。
他にもキャッシュサーバやプロキシサーバを建てる案もあったのですが、最終的に選択したのはDataflowのデザインパターンであるSideInputPatternでした。

2.1 SideInputPattern

我々が採択したのは、正確には “Slowly Updating SideInput Pattern”という手法で、データの変換に必要な、更新が頻繁ではなくデータが小さい補助的なオブジェクト(副入力)を、一定のTimeInterval毎に更新するというものです。
ApacheBeamは入力をきっかけに「入力→変換→出力」まで行うデータ変換モデルなのですが、SideInputの更新にもきっかけとなる「入力」が必要です。

GoのSDKには periodic.Impulseという空の入力を定期的に生み出す関数が実装されているのですが、開始時刻と終了時刻を引数として渡さなければならず、継続的に運用したいシステムに導入するには不適でした。

また、実験的に終了時刻を”10年後”のようにしてみたのですが、バックログと呼ばれるDataflowが予測するジョブ終了時刻が10年後になってしまい、ワーカーが大量にオートスケールしてしまうという問題が発生しました。

そのため、擬似的に空の入力を生み出し続ける仕組みを作成する必要がありました。
結論として、以下の画像(2.2)のように主入力であるMessageEventLogsのPubsubからの入力を最初のトリガーとし、ApacheBeamの機能であるWindow,Trigger,Combineといった変換を適用し、擬似的に時間指定の必要のないperiodic.Impulse関数(2.3)を再現しました。

こうすることで、生じていた複数の問題を対処することができました。

2.2 実装したDataflowのジョブグラフの詳細

実装したDataflowのジョブグラフの詳細構成図

2.3 sideinputを定期的に更新する部分のサンプルコード

sdkのperiodic.Impulseを使い、sideinputを定期的に更新する部分のサンプルコード

periodicImp := periodic.Impulse(s, startTime, endTime, periodicSequenceInterval,false)
updatedImp := beam.ParDo(s, update, periodicImp)
sideInput := beam.WindowInto(
    s,
    window.NewFixedWindows(periodicSequenceInterval),
    updatedImp,
    beam.Trigger(trigger.Repeat(trigger.Always())),
    beam.PanesDiscard(),
)

擬似的にperiodic Impulseを再現した関数

func customPeriodicImpulse(s beam.Scope, originPCol beam.PCollection, timeInterval time.Duration) beam.PCollection {
    // originPColを入力とし、一定時間ごとに溜めていたデータを流す
    impulse := beam.WindowInto(
        S,
        window.NewGlobalWindows(),
        originPCol,
        beam.Trigger(trigger.Repeat(trigger.AfterProcessingTime().PlusDelay(timeInterval))),
        beam.PanesDiscard(),
    )


    // 一定時間ごとに impulseが流れてくるが、impulseには溜まっていた大量のPCollectionが入っている
    // そのため、Combineを使って単位時間あたりに1つのPCollectionを流すようにする
   combineFunc := func(context.Context, []byte, []byte) []byte { return []byte{} }
        return beam.Combine(s, combineFunc, impulse)
    }

3. デプロイ

このシステムの継続的インテグレーションの構築は来たるインターン生へのTODOとし、ひとまず手動デプロイで本番環境で動かすことにしました。
発生させたKARTEイベントの解析を行うBlitzチームと密に連携を取り、諸事情で1度デプロイを取り下げたものの、無事プロダクション環境で私が構築したKarteEventPublisherというシステムを動かすことに成功しました。

4. SLO定義/計測/モニタリング

プロダクトが正常に動いているかを常に確認するためにはモニタリングや、SLOの定義が不可欠です。
KarteEventPublisherにもいくつかの数値的な閾値を定義し、その閾値を割るとSlackにアラートを飛ばすようにしました。
プレイドでは既に全社で、DatadogとSlackの連携が行われているためスムーズに連携を実装することができました。
チーム内のSREポジションの社員にメンターになってもらい、アラートが鳴った際に取るリカバリー行動や周辺のシステムの前提知識をまとめたプレイブックを書くなどもしました。

迅速なソフトウェア開発には、攻めた開発スピードと同じくらい守りの仕組みも大事であることを改めて認識することができました。

インターンで得たもの/学んだこと

今夏に経験した3ヶ月のインターンは、私の過去の経験の中で最も充実したものとなりました。
一般的なインターンシップでは、細部までの仕様が既に決まっており、実装内容も定められていて、単にコードを書くだけといった「作業」が主な内容となることが多いと思います。しかし、プレイドのインターンでは異なりました。
最終的なゴールのみが設定されており、その過程は社員を交えてミーティングを通じて共同で決定していくといった、「仕事」の体験ができました。この経験を通じて、私はソフトウェアエンジニアリングがコードを書くことだけでなく、プロダクトを第一に考え、それを実現する手段としての技術であることを深く理解しました。

インターンの1ヶ月終了時、メンターとの1on1で私の開発姿勢についてのフィードバックを受けました。私は初めから「社員と同等以上の大きな仕事をしたい」と伝えていました。そして、3週間後には期待通りの大きめの仕事を任されるようになっていました。
 しかし、メンターからのフィードバックで「普通のインターンとしては十分な働きをしているが、Kenshinが希望するような大きなタスクを任せるためには、全体を把握し、そのタスクの主導権を持って欲しい」との意見を受けました。
 これまで、私は与えられた仕事に関わるコードの部分だけを読み、全体の概要をあまり把握していませんでした。この1on1の機会をきっかけに、与えられたタスクのプロダクトへのインパクトを再確認し、そのタスク全体に対するオーナーシップを持つことの大切さを理解しました。

その後は、メンターに仕事を与えられるだけではなく、自分から提案やミーティングを組み、積極的にメンバーを巻き込んで仕事を進めるようになりました。

責任ある大きな仕事を任せてもらうには、まずチーム内での信頼が必要です。
与えられた仕事をこなすだけではなく、プロダクト全体ののアーキテクチャやコードベースを把握して、タスクに対する主導権を取りチームを引っ張って開発を進めることが、大きな仕事を任せてもらうための信頼に繋がることを体感しました。

最後に

今後どこで働こうとも一生使えるマインドを育ててもらったプレイドには深く感謝しています。

コードが書けることは当然とし、それ以上に大切なビジネス的な観点からの考え方であったり、仕事の進め方、コミュニケーションの取り方を教わることができました。

メンターのtetsuo(@tetsuo)さんを始めとするチームメンバーの方々、お互いに気楽に相談しあえた開発インターンの方々、そしてSlack上でやり取りしていただいたその他のチームの方々にも感謝を表したいです。

—---------------------------------------------------—---------------------------------------------------

チームメンバーとの集合写真
チームメンバーとの集合写真

kenshinさん、全体像が見えるようになった時の投稿のSlackスクリーンショット
kenshinさん、全体像が見えるようになった時の投稿(6/30)


CX(顧客体験)プラットフォーム「KARTE」を運営するPLAIDでは、「 KARTE自体の開発に興味がある!」「最高のCXを生み出すプロダクトを開発したい!」というエンジニアを募集しています。詳しくは弊社採用ページまたはWantedlyをご覧ください。

開発インターンシップも募集中なので、ソフトウェアエンジニアとして面白い経験を詰みたい方はぜひ応募してみてください!成長できること間違いなしです!!