チームが成長するたびに変わっていった朝会の話

こんにちは、カンムのフロントエンドチームでエンジニアリングマネージャーをしている佐藤です。

これは カンム Advent Calendar 2024 の7日目の記事です。昨日は always_allokay によるお世話になっているライブラリをちょっと見てみるシリーズ、shopspring/decimalの記事でした!

この記事ではチームで行っている朝会の様子や改善してきたことを紹介します。

背景

バンドルカードチームでは、今年の頭から各職能チーム(Unit)のメンバーが異なる目的を持ったチーム(いわゆるフィーチャーチーム)に属し、それぞれが独自のプロジェクトやタスクに取り組む形でプロダクト開発を進めています。フロントエンドチームメンバーもそれぞれがそれらのチームに属す形になっています。チーム数などは実際とは異なりますが、構成としては以下の図のような一般的なイメージのものです。進捗確認や細かい機能のアサインなどのやり取りはフィーチャーチーム内でスムーズに行われています。

目的

フィーチャーチームの誕生前から存在していたフロントエンドチームでの朝会では、各メンバーの「直近の仕事の予定や、今の困りごとを相談する」という目的で実施され、チームの知識の平均化や、気軽なコミュニケーションの場などとして一定の機能を果たしてきました。

他にも、朝会での何気ない一言が他のメンバーに引っかかり、潜在的な問題を早めに発見できる役割もあります。基本的にはフィーチャーチームでの各プロジェクトの仕様や進め方は各メンバーが決めていくという体制ですが、フロントエンド観点で最低限抑えておくべきものなどを広い視点で拾うことができる良い場だと感じています。

なお、ここまでわかりやすさのため「朝会」と書いていましたが、実施は午後の15時にしています。これは、午後の集中力が落ちてくるタイミングで気分転換を図るという狙いである時点からそうしています。様々なライフステージのメンバーがいるなかでも参加率も高く好評でした。

課題

概ね良い部分もありつつ、フロントエンドチームへの人数の増加や、それぞれのフィーチャーチームにおける中期的なプロジェクトが本格化したことで、議論の幅や深さが増してきました。
プロジェクトの状況や課題、それらの実装など共有するトピックが格段に増えたことで、単純に時間を大幅にオーバーすることが増えたり、あとの方に話すメンバーの持ち時間が足りなくなるという部分に課題感がありました。

そういったこともあり、何度か手法の見直しを行ってきたので少し紹介します。

試行錯誤の過程

とりあえず全部吐き出す

まずは各メンバーが実装から仕様まで様々なそこそこ大きなトピックを話す機会が増えたということで、もともと30分だったMTG時間を1時間に拡大し、思う存分話すようにしてみました。
仕様の議論から実装の詳細まで幅広いトピックを扱う場として機能し、他のチームの状況の共有やそこそこ深い議論ができるのは非常に良い点でした。

一方で、内容が詳細になりすぎたり、情報量が多すぎて議論への集中が難しくなる場面も多々ありました。また、そういった内容を毎日1時間のMTGで話すのは次第にヘビーに感じられるようになっていました。

ゆるい時間制限とファシリテーションの導入

課題を感じながらも上記の運用を続けた後に、大きな問題であった時間不足に対処するため、MTG時間を30分に戻し、1人あたり5分程度を目安とする時間制限を設けてみました。

もともとは話し始める人を入社順にしていましたが、後の方になると時間が足りなくなるという課題があったので、日ごとに話し始める人兼ファシリテーターがおおむね輪番になるようにしました。

また、ファシリテーターが固定だと聞き役も偏ってしまう状態でもあったため、持ち回りで担当することで、自然な会話が生まれるようにする意味もあります。ファシリテーターの選出には、朝会に使用しているNotionの適当なデータベースに組んだNotion数式をとりあえずと思って使いましたが、意外と使い勝手もよくそのまま使用しています。

このあたりを進めることである程度共有の効率が上がりました。とはいえ時間制限が曖昧だったため、結局時間オーバーになることが多く、どの程度の詳細さで話すべきかという悩みは解消されませんでした。なにより、盛り上がっているトピックの話をファシリテーターが中断するのはかなり勇気が必要でした。

厳密な時間管理と議論コーナーの設置

そこで、厳密な3分制限を導入し、時間管理を徹底してみることにしました。また、それだけだとあまり変化がないので話し足りない内容や少し議論したいトピックのために、一通り全員が話し終わったあとに議論や雑談のコーナーを設けました。これまでも少し大きなトピックの場合は別でMTGを設定するようにはしていましたが、朝会で盛り上がった内容を改めてMTGとして設定する塩梅が結構難しく、この変更でそのあたりのバランスがとれたと思っています。

盛り上がった会話だったり、あらかじめ話したい話題を書いておき、後で取り上げている

この形式により、共有の効率が飛躍的に向上し、綺麗に時間内に全員が発言できるようになりました。詳細な議論も後半で行えるため、話す粒度の調整も容易になりました。

時間制限に関して特に大きくやり方を変えたわけではなく、曖昧になっていた認識を揃えたこと、後続で話せるコーナーを設けたことで自然にあとで議論する流れが作れています。 これは今のところよく機能していて、多少のメンバー増加が伴っても問題はなさそうです。

色々と試したが、いまはこんな感じでタイマーをNotionページに埋め込んでいる

終わりに

この一年間でチームメンバーが急増し、それに伴って場が機能しているかを意識する機会が増えました。特に朝会はその中の一つであり、メンバー全員が価値を感じられる場を模索してきました。

こうして改めて振り返ると、ルールの設計はもう少し最初からいい感じにできたな、と感じる部分もあります。とはいえ、それ以上に重要だったのは自分を含めたメンバーが場に対してどのような点に価値を見出しているのかを理解し、それを上手く残していくことだったとも感じます。こうした過程を経ていったんの最適解を見つけるのも案外良かったのではと思っています。

現状としては情報共有の場以上に良い形になっていると感じますが、朝会に限らずこういった場は組織体制の変化に応じて柔軟に形式を見直していく必要があると思います。 似たような組織体制や課題を抱えるチームの参考になれば幸いです。

カンムでは引き続き、フロントエンドエンジニアを募集しています。興味のある方は、ぜひご連絡ください。

team.kanmu.co.jp

使っているライブラリを見てみよう(shopspring/decimal編)

こんにちは. Poolでエンジニアをしている @always_allokay です。

こちらはカンム Advent Calendar 2024 の6日目の記事です。 昨日はprinさんによるボボステの記事でした。

タイトルの通り、お世話になっているライブラリをちょっと見てみるシリーズです。(今はじめました)
今回は、shopspring/decimal について見ていきます。

shopspring/decimalとはなにか

まずはライブラリのREADMEをみます。

Arbitrary-precision fixed-point decimal numbers in go.

Note: Decimal library can "only" represent numbers with a maximum of 231 digits after the decimal point.

記載の通り、「Goでの任意精度固定小数点10進数」を実現するライブラリです。

浮動小数点はよく聞くワードですが、固定小数点とはなにか?という疑問が湧きます。 詳細は以下の資料にゆずりますが、数値を格納するときに、整数部と小数部の桁数をあらかじめ決めてある、というのが固定小数点です。 とてもわかりやすくて良いスライドなのでぜひ一読をお勧めします。

speakerdeck.com

なにを解決するライブラリか?

さきほどのスライドでも言及がありますが、コンピュータでは最終的に2進数でデータを扱う都合上、小数点以下を厳密に表現することができない、というよく知られた問題があります。
日常的にプログラミングする分には問題ない精度であることがほとんどなのですが、金融や決済などの領域では、致命的な誤差になりえます。 これを解決(≒ほとんどの場合に問題ないように)するためのライブラリです。
カンムでも、決済や利率計算の際に誤差がでないように利用しています。

ライブラリの構造

それではさっそくみていきます。まずはrepository全体を概観。

$ tree .
.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── const.go
├── const_test.go
├── decimal-go.go
├── decimal.go
├── decimal_bench_test.go
├── decimal_test.go
├── go.mod
└── rounding.go

1 directory, 11 files
$ wc -l *
      76 CHANGELOG.md
      45 LICENSE
     139 README.md
      63 const.go
      34 const_test.go
     415 decimal-go.go
    2339 decimal.go
     314 decimal_bench_test.go
    3649 decimal_test.go
       3 go.mod
     160 rounding.go
    7237 total

全体でもたった11ファイル、テストコードを除けば、3000行弱のソースコードで、実現されていることがわかります。

github.com

// Decimal represents a fixed-point decimal. It is immutable.
// number = value * 10 ^ exp
type Decimal struct {
    value *big.Int

    // NOTE(vadim): this must be an int32, because we cast it to float64 during
    // calculations. If exp is 64 bit, we might lose precision.
    // If we cared about being able to represent every possible decimal, we
    // could make exp a *big.Int but it would hurt performance and numbers
    // like that are unrealistic.
    exp int32
}

ライブラリの基礎となるdecimal.Decimal structに注目すると、Decimalは、*big.Int型のvalueとint32型のexpの二つからなることがわかりました。 そして、このライブラリが提供する最大精度もexpがint32であることに起因することがわかります。 であれば、これをより大きな数値を扱える型にするとよいのではと安直に思ってしまいますが、それに関する2つのトレードオフについてコメントされています。

  • ひとつは、64bitにする場合について。計算途中で、float64にキャストする部分があり、そこで桁溢れを起こさないためとありますね。 オーバーフローを起こすと、精度云々ではなく数値がぶっ壊れてしまうのでこれは納得。
    • 実際にfloat64にcastしている箇所は以下でした。 github.com
  • もうひとつは、big.Intなどとする場合について。こちらは性能が損なわれてしまうことと、そこまで大きな数値を扱うケースが現実的ではないことから使わないと判断されているようです。

一つ賢くなりました。

さらに、Add, Sub, Mul, Divなどの四則演算をみていくと、実はMulメソッドは、不正確な数値を返しそうになる場合panicを発生させていることを発見しました。 普段使ってるメソッドにも知らないことがあるものですね。とはいえ、現実に扱う数値では遭遇することはなさそうです。

github.com

そのほかの発見としては、丸め処理にも、通常の四捨五入以外にも、BankerRouding、CashRouding, RoundDown, RoundUpという丸め処理があることがわかりました。
それぞれ、近い方の偶数に丸める、最小通貨単位に丸める、0に近づくように丸める、0から遠ざかるように丸めるという処理です。
現在は利用していないのですが、CashRoundingは実は任意の円単位(千円、万円、億円 etc)に数字を丸めるときに使えるかもしれません。

得られた知見

  • shopspring/decimalは、231桁の精度まで担保している。これは言い換えると小数点以下2,147,483,647桁≒小数点以下21億桁の精度なので、現実的には十分な精度である。
  • 丸め処理にはBankerRouding, CashRoudingをはじめ多くのバリエーションがあること。

一言

もうちょい深掘りしたかったのですが、アルゴリズムとか最適化らへんは歯が立たなかったです。大学生ぶりにアークタンジェントとかみました。 つぎは標準ライブラリや準標準(x/exp/...)のライブラリを読みたいですね。

カンムはソフトウェアエンジニアを募集しています

team.kanmu.co.jp

最近のRedash

SREの菅原です。 この記事はカンム Advent Calendar 2024の4日目の記事です。

最近のRedashの開発状況について、知っている範囲ですこし書いてみたいと思います。


redash.io

Redashといえば様々なデータソースをSQLを使って可視化できるBIツールで、カンムでも業務のデータ分析に使われています。 ただ、一昔前にRedashがはやっていた頃に比べると、最近ではトレンドからは外れたような印象があります。

実際、SaaS Redashが終了した2021年から2023年の4月あたりのGitHubのアクティビティを見ると、活動が停滞しています。

Contributors to getredash/redash · GitHub

この頃、CVE-2023-0286の対応のため、私はRedashのDockerイメージのベースをDebian busterからbullseyeに更新しようとしたのですが、そもそもDockerイメージをビルドするCIが壊れていたので、それを直すPull Requestを作成してたりしました。

github.com


2023年4月にRedashがコミュニティ主導のプロジェクトになるアナウンスがされます。

github.com

これを契機に開発が再び活発になり、古いIssueやPull Requestが整理され、新しいメンテナの方々によって依存ライブラリのバージョンアップなどのPull Requestがどんどんマージされるようになりました。

あわせてGitHub Actionsへの移行、CIの改修、docker composeまわりの改善も進み、開発しやすい体制が整っていきます。

私もいくつかPull Requestを作成してマージしてもらいました(懸念だったベースイメージの更新もできました)。


2024年は前年ほどではないものの粛々と開発は続いています。

ずっとWarningを出していた開発停止ライブラリが削除されたり

github.com

ARM64のDockerイメージの作成されたり。

github.com

また開発ドキュメントの整備も進んでいます。

github.com


懸念は安定版がなかなかリリースされないことです。 (最新の安定版のリリースは3年前)
以下のディスカッションで議論はされていますが、外からはメンテナンスチームの動きがあまり見えないので、気になるところではあります。

github.com

プレビュー版のリリースは活発なので、もし来年の前半ぐらいまでに安定版がリリースされないのであれば、プレビュー版に移行していくしかないかな…となんとなく考えています。

まとめ

ほとんど更新が止まったかに思えたOSSが再びよみがえっていく様子を見られたのはなかなかうれしいことでした。 まだ懸念点はあるものの、コミッターには日本人の方もおり、改善のPull Requestも受け入れられやすい状況にあるのではないかと思います。

カンムでは引き続きRedashを使っていく予定なので、今後もRedashコミュニティに多少なりとも貢献できればと考えています。

モダンな状態管理の探求:Redux からZustand へ ― 実装から原理まで

はじめに

こんにちは、エンジニアの王です。 フロントエンドの開発者として、私はいくつかのプロジェクトでRedux Toolkitを使用してきました。Redux Toolkitは確かに強力な状態管理ツールですが、プロジェクトが進むにつれて、その構造的な複雑さを次第に強く感じるようになりました。 そこで、より軽量で使いやすい状態管理ライブラリを探し始めました。新しいソリューションの条件として、Redux Toolkitの堅牢性は維持しつつ、より簡潔なAPIと学習コストの低さを重視しました。その過程で出会ったのが、Zustandです。

Reduxの実装の複雑さ

シンプルなカウンターを管理するためにRedux Toolkitで必要なコード量を見てみましょう:

// store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer
  }
})

// store の型定義
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

// 型付きフックの定義
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

// counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from './store'

// 状態の型定義
interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
}

// スライスの作成と型定義
export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
    },
  },
})

// コンポーネントでの使用
// Counter.tsx
import { useAppSelector, useAppDispatch } from './store'
import { increment, decrement } from './counterSlice'

export function Counter() {
  // 型付きフックの使用
  const count = useAppSelector((state) => state.counter.value)
  const dispatch = useAppDispatch()

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  )
}

このRedux Toolkitの実装でも、以下のような課題が残ります:

  1. ファイル構成が複雑:slice、store、コンポーネントと、複数のファイルでの実装が必要
  2. 概念的な理解の必要性:createSlice、reducer、dispatch等の概念を理解する必要がある...初心者には理解が難しい
  3. 型サポートの設定:TypeScriptプロジェクトでは、追加の型定義の設定が必要
  4. ボイラープレートコードの存在:Redux Toolkitによって簡略化されているものの、なお一定量のセットアップコードが必要

これらの課題は、特に小規模なプロジェクトや、新しいチームメンバーの参加時に影響を与える可能性があります。

Zustandとの出会い:エレガントな代替案

代替案を探す過程で、Zustandを発見しました。Zustandで同じカウンター機能を実装してみましょう。

Poimandres(旧PMND)チームによって開発・メンテナンスされているZustandは、React向けの小さくて高速、そしてスケーラブルな状態管理ソリューションです。2019年にリリースされて以来、GitHubで48k以上のスターを獲得し、多くの開発者から支持されています。 GitHub: https://github.com/pmndrs/zustand

import { create } from 'zustand'

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}))

// コンポーネントでの使用
const Counter = () => {
  const { count, increment, decrement } = useStore()
  
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

違いがわかりますか?Zustandを使用すると、以下のことが可能です:

  1. すべての状態ロジックを1つのファイルで完結
  2. 設定不要で、Providerのラップも不要
  3. 直感的なAPI:create関数でストアを作成し、set関数で状態を更新
  4. シンプルな理解で済む:actions、reducersの概念がなく、直接状態を変更可能

Zustandの魅力:具体的な改善点

Zustandの実装例を見てきましたが、このシンプルさの裏には、従来の課題を解決するための様々な工夫が詰まっています。Zustandを活用することで、以下のような具体的なメリットが得られます:

  1. 状態管理の簡素化

    • Providerやconnectの設定が不要でプロジェクト構造がクリーンに
    • コンポーネント内で直接状態にアクセス可能な設計
  2. TypeScriptとの親和性向上

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}));

この例からわかるように、型定義がシンプルかつ直感的で、開発者の負担を大きく軽減できます。

  1. 状態更新の柔軟性向上 Zustandは複雑な状態管理や非同期処理においても、シンプルで理解しやすい実装を可能にします。
    • 状態の直接的な修正が可能になり、コードの見通しが改善
    • 非同期操作もmiddlewareなしでスムーズに実装可能
// 非同期操作の実装例:TODOリストの取得
import { useEffect } from "react";
import { create } from "zustand";
const useStore = create((set) => ({
  todos: null,
  error: null,
  fetchData: async () => {
    try {
      const res = await fetch(`https://jsonplaceholder.typicode.com/todos`);
      const todos = await res.json();
      set({ todos });
    } catch (error) {
      set({ error });
    }
  },
}));

export default function App() {
  const { todos, fetchData, error } = useStore();
    useEffect(() => {
      fetchData();
    }, []);
    if (!todos) return <div>Loading...</div>;
    if (error) return <div>{error.message}</div>;
    return (
      <div>
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
           ))}
        </ul>
      </div>
   );
}
  1. パフォーマンスの最適化 Zustandは状態が変更された時に必要なコンポーネントのみを再レンダリングすることができます。Reduxと比較すると、より簡潔な実装方法でありながら、同等のパフォーマンス最適化を実現しています。主な特徴は以下の通りです:
    • 選択的な状態購読
    • shallow比較やカスタム比較関数を使用して不要な再レンダリングの防止
    // 基本的な購読
    const count = useStore(state => state.count);
    
    // 複数の状態を選択的に購読
    const { user, settings } = useStore(state => ({
      user: state.user,
      settings: state.settings
    }));

    // shallow比較を使用して再レンダリングを最適化
    const { todos, done } = useStore(
      state => ({ 
        todos: state.todos,
        done: state.done 
      }),
      shallow
    )

    // カスタム比較関数の使用
    const todoCount = useStore(
      state => state.todos.length,
      (a, b) => Math.abs(a - b) < 5
    )

シンプルさを追求したZustandは、チームの生産性と新規参入のしやすさを両立させ、まさに現代のReactアプリケーションが求める状態管理ソリューションと言えるでしょう。

実践的なTodoリストの実装: 実践からパフォーマンス最適化まで

前の内容では、Reduxの複雑さとZustandへの移行理由とについて説明しました。今回は、完全なTodoアプリケーションの例を通じて、Zustandの使用方法とパフォーマンス最適化の実践について詳しく見ていきます。

完全的なSource Code:https://github.com/WWK563388548/simple-zustand-example

TodoアプリケーションのURL:https://regal-empanada-b5967a.netlify.app/

基本実装:Todoアプリケーションの構築

Storeの設計

まず、機能を完備したTodo storeを作成します:

import { create } from "zustand";

export type Todo = {
  id: string;
  title: string;
  completed: boolean;
}

type StatusType = 'all' | 'completed' | 'incompleted';

type Store = {
  todos: Array<Todo>;
  status: StatusType;
  setStatus: (status: StatusType) => void;
  setTodos: (fn: (todos: Array<Todo>) => Array<Todo>) => void;
}

export const useTodoStore = create<Store>((set) => ({
 // 初期状態
  status: 'all',
  todos: [],
 // 状態更新メソッド
  setStatus(status: StatusType) {
    set({ status })
  },
  setTodos(fn: (todos: Array<Todo>) => Array<Todo>) {
    set((prev) => ({ todos: fn(prev.todos) }))
  },
}));

この設計はZustandの主要な利点を示しています:

  • TypeScriptフレンドリーな型定義
  • 状態と操作メソッドの一元管理
  • シンプルで直感的な更新API

コンポーネントの実装

Todoアプリケーションの各コンポーネントを実装しましょう。 まず、App.tsxコンポーネント

// src/App.tsx
import { FormEvent } from "react"
import { v4 as uuid } from "uuid";
import { useTodoStore } from "../stores/todoStore"
import StatusGroup from "./StatusGroup"
import FilteredItemList from "./FilteredItemList";

const App = () => {
  const { setTodos } = useTodoStore()
  const add = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const title = e.currentTarget.inputTitle.value
    e.currentTarget.inputTitle.value = ''
    setTodos((prevTodos) => [
      ...prevTodos,
      { title, completed: false, id: uuid() },
    ])
  }

  return (
    <form onSubmit={add}>
      <StatusGroup />
      <input name="inputTitle" placeholder="Type ..." />
      <FilteredItemList />
    </form>
  )
}

export default App

後は、FilteredItemList.tsxStatusGroup.tsxコンポーネント

// src/FilteredItemList.tsx
import { a, useTransition } from "@react-spring/web"
import { useTodoStore } from "../stores/todoStore"
import TodoItem from "./TodoItem"

const FilteredItemList = () => {
  const { todos, status } = useTodoStore()
  const filterTodo = todos.filter((todo) => {
    if (status === 'all') return true
    if (status === 'completed') return todo.completed
    return !todo.completed
  })
  const transitions = useTransition(filterTodo, {
    keys: (todo) => todo.id,
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  })
  return transitions((style, item) => (
    <a.div className="item" style={style}>
      <TodoItem item={item} />
    </a.div>
  ))
}

export default FilteredItemList;

// src/StatusGroup.tsx
import { useTodoStore } from "../stores/todoStore"
import { Label } from "../components/ui/label"
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group"

const StatusGroup = () => {
  const { status, setStatus } = useTodoStore()
  return (
    <RadioGroup onValueChange={setStatus} value={status}>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="all" id="r1" />
        <Label htmlFor="r1">All</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="completed" id="r2" />
        <Label htmlFor="r2">Completed</Label>
      </div>
      <div className="flex items-center space-x-2">
        <RadioGroupItem value="incompleted" id="r3" />
        <Label htmlFor="r3">Incompleted</Label>
      </div>
    </RadioGroup>
  )
}

export default StatusGroup;

最後は、TodoItem.tsxコンポーネント:

// src/TodoItem.tsx
import { Todo } from "../lib/types"
import { useTodoStore } from "../stores/todoStore"

const TodoItem = ({ item }: { item: Todo }) => {
  const { setTodos } = useTodoStore()
  const { title, completed, id } = item

  const toggleCompleted = () =>
    setTodos((prevTodos) =>
      prevTodos.map((prevItem) =>
        prevItem.id === id ? { ...prevItem, completed: !completed } : prevItem,
      ),
    )

  const remove = () => {
    setTodos((prevTodos) => {
      return prevTodos.filter((prevItem) => {
        return prevItem.id !== id
      })
    })
  }

  return (
    <>
      <input type="checkbox" checked={completed} onChange={toggleCompleted} />
      <span style={{ textDecoration: completed ? 'line-through' : '' }}>
        {title}
      </span>
      <span onClick={remove}>X</span>
    </>
  )
}

export default TodoItem;

以上の実装により、ユーザーは以下の操作が可能になります:

  1. Todoの表示状態を「全て」「完了」「未完了」で切り替える
  2. 各Todoアイテムの完了状態を変更する
  3. 不要なTodoを削除する

これでTodoリストアプリケーションの基本機能が完成しました。シンプルながら、タスク管理に必要な主要機能を備えています。

パフォーマンスの問題と最適化

上記の実装では、いくつかのパフォーマンス問題が発生する可能性があります。具体例を通じて、これらの問題を理解し、解決方法を学びましょう。

レンダリングの問題を理解する

例えば、FilteredItemListコンポーネントで以下のような実装があったとします:

let renderCount = 0;
const FilteredItemList = () => {
  renderCount++;
  // store全体を購読してしまっている
  // ❌ 問題のある実装例
  const { todos, status } = useTodoStore();
  
  console.log('レンダリング回数:', renderCount);
  const filteredTodos = todos.filter(todo => {
    if (status === 'all') return true;
    if (status === 'completed') return todo.completed;
    return !todo.completed;
  });
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};

この実装には以下の問題があります:

  1. FilteredItemListコンポーネントがstore全体を購読しているため、todosstatus以外の状態が変更されても再レンダリングが発生
  2. StatusGroupコンポーネントで状態を変更すると、不必要な再レンダリングが発生する可能性がある

最適化手法

1. セレクター関数の使用

パフォーマンス最適化の最も基本的な方法は、useStore にセレクター関数を渡すことです。 セレクター関数を使用すると:

  • 必要な状態(この場合はtodos)のみを購読
  • 関係のない状態の変更による再レンダリングを防止
const FilteredItemList = () => {
  // 必要な状態だけを選択的に購読
  // ✅ 改善された実装:必要な状態のみを購読
  const todos = useTodoStore(state => state.todos);
  const status = useTodoStore(state => state.status);
  
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => {
      if (status === 'all') return true;
      if (status === 'completed') return todo.completed;
      return !todo.completed;
    });
  }, [todos, status]);
  return (
    <div>
      {filteredTodos.map(todo => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </div>
  );
};
2. createSelectorsの活用

大規模なプロジェクトでは、全てのuseStore呼び出しにセレクター関数を手動で追加するのは:

  • 面倒
  • ミスが発生しやすい
  • コードが冗長になる

この課題を解決するため、ZustandはcreateSelectorsというユーティリティを提供しています。これを使用することで、セレクター関数を自動的に生成できます。

// storeの作成
const useStoreBase = create((set) => ({
  todos: [],
  status: 'all' as 'all' | 'completed' | 'incompleted',
  setStatus: (status: 'all' | 'completed' | 'incompleted') => set({ status }),
  setTodos: (updateFn: (todos: Todo[]) => Todo[]) => 
    set((state) => ({ todos: updateFn(state.todos) }))
}));
// セレクターを自動生成
const useTodoStore = createSelectors(useStoreBase);
// 最適化されたコンポーネント
const FilteredItemList = () => {
  const todos = useTodoStore.use.todos();
  const status = useTodoStore.use.status();
  // ...
};
3. 浅い比較の使用

デフォルトでは、状態が変更された時、以下のプロセスで再レンダリングの要否が判断されます:

  1. セレクター関数を使用して最新の状態を計算
  2. Object.isを使用して、前回の状態と新しい状態を比較
  3. 比較結果に基づいて再レンダリングを判断

例えば:

const { todos, setStatus } = useStore((state) => ({
  todos: state.todos,
  setStatus: state.setStatus
}))

このコードの実行フロー:

  1. 初回レンダリング

    • セレクター関数が実行され、todossetStatusを含む新しいオブジェクトを生成
    • このオブジェクトが初期状態として保存
  2. statusフィールドが更新された場合:

    • セレクター関数が再度実行される
    • 新しいオブジェクトが生成される
    • Object.isで比較が行われる
    • todosとsetStatusの値自体は変更されていないが、新しいオブジェクトが生成されているため、Object.isはfalseを返す
    • 結果として不要な再レンダリングが発生

この問題を解決するため、ZustandはuseShallowで浅い比較を提供しています:

  • オブジェクトの最上位のプロパティ(この場合todosとsetStatus)それぞれについて個別に比較を行う
  • プロパティの値が実際に変更された場合のみ再レンダリングが発生
  • 無駄な再レンダリングを防ぎ、パフォーマンスが向上

このように、浅い比較を使用することで、オブジェクトの参照変更だけでは再レンダリングが発生せず、実際の値の変更があった場合のみ再レンダリングが行われるようになります。

const TodoSummary = () => {
  // ✅ `useShallow`で浅い比較をする
  const { todos, setStatus } = useTodoStore(
    useShallow(state => ({
      todos: state.todos,
      setStatus: state.setStatus
    }))
  );
  
  // ...
};

Zustand の核心的なAPI の解説と自作による原理理解

重要な前提

Zustand v5からはReact 18に依存するようになり、useSyncExternalStoreというReactの組み込みAPIを直接利用しています。これは重要な変更点であり、以下の特徴があります:

  • React 18ではuseSyncExternalStoreが組み込みAPIとして提供され、外部ストアとの同期を効率的に行えます
  • React 18未満のバージョンを使用する場合は、use-sync-external-storeのshimをインポートする必要があります

補足:useSyncExternalStoreはReact Reduxなども採用している外部ストア同期のための公式APIです。 これにより、並行レンダリング環境での信頼性の高い状態管理が実現できます。 しかし、Zustandはより軽量で設定が簡単ですよ

本文では、use-sync-external-storeパッケージのuseSyncExternalStoreWithSelectorを採用しております。Zustand5の公式実装方法については、Zustandのソースコードをご参照ください

Zustandの基本構造

Zustandの内部実装は非常にエレガントで理解しやすい構造になっています。主に以下の3つのコア機能で構成されています:

  • createStore: ストアの作成と管理
  • useStore: React Hooksとの連携
  • create: 上記2つを組み合わせた最終的なAPI

事前に型の準備

基本構造と各基礎的な機能を実装する前に、まずStoreの型定義について説明していきましょう。 (ユーザーがどのような値を渡すかわからないため、ジェネリックTを使用して実装していきます)

まず、現在の状態を取得する関数getStateの型を定義します:

type GetState<T> = () => T

次はsetStateの型定義 setStatecreate関数のコールバックパラメータとして使用され、Storeの状態を更新するために使用されます。以下の3つの使用パターンがあります:

  • 完全な状態更新
    • 完全な状態オブジェクトを渡して、現在の状態全体を置き換えます
    • 状態全体を一度に更新する場合に使用します
  • 部分的な状態更新
    • 状態の一部のみを渡します
    • Zustandは自動的に既存の状態と新しい部分的な状態をマージします
    • 特定のフィールドのみを更新する場合に便利です
  • 関数による更新
    • 現在の状態に基づいて計算や処理を行う関数を渡します
    • 関数は現在の状態を受け取り、新しい状態または部分的な状態を返します
    • 返された結果は現在の状態にマージされます

これらの使用パターンに対応する型定義:

type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void

そして、subscribe関数はuseSyncExternalStoreWithSelectorの引数として使用されるため、その型定義を流用します:

// subscribeの型定義
type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]

最後に、これまでのすべてのAPI型を含むStore全体の型を定義します:

// Store APIの型定義
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

基本構造の実装

Zustandのcreate関数には以下の3つの特徴があります:

  1. 関数を引数として受け取る
  2. Storeを作成する
  3. 内部状態にアクセスするためのフックを返す

これらの特徴を踏まえて、基本的な実装を見ていきましょう。

import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

type Subscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0]
type GetState<T> = () => T
type SetState<T> = (
  partial: T | Partial<T> | ((state: T) => T | Partial<T>),
) => void
type StoreApi<T> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe
}

const createStore = <T>(createState): StoreApi<T> => {
    let state;  // storeの内部状態はstateに保存される
    const getState = () => state; 
    const setState: SetState<T> = () => {}; // setStateはcreateが受け取る関数の引数
    // subscribeするたびにsubscribeをlistenersに追加し、コンポーネントの再レンダリングをトリガーする
    const subscribe: Subscribe = () => {};
    const api = { getState, setState, subscribe };
    state = createState(setState); // stateの初期値はcreateStateの呼び出し結果
    return api;
}

const useStore = (api, selector, equalityFn) => {};

export const create = (createState) => {
  // storeを取得し、storeを操作するすべてのメソッドを含む
  const api = createStore(createState);
  const useBoundStore = (selector, equalityFn) =>
    useStore(api, selector, equalityFn);
  return useBoundStore;
};

createStoreはStoreを作成し、内部状態とStoreを操作するためのAPI関数を管理します。主に以下の機能を提供します:

  • getState(): Store内の現在の状態を取得するための関数
  • setState(): Storeの状態を更新するための関数
  • subscribe(): コンポーネントがStoreを購読するための機能。状態が変更された際に、購読しているコンポーネントの再レンダリングをトリガー

useBoundStoreは2つの重要なパラメータを受け取るカスタムフックです:

  • selector: Storeの完全な状態から必要な部分だけを選択する関数。状態の一部分だけを使用する場合のパフォーマンス最適化に役立つ
  • equalityFn: 選択された状態の変更を比較するための関数。状態が実際に変更されたかどうかを判断し、不要な再レンダリングを防ぐ

useStoreuseSyncExternalStoreWithSelector(useSyncExternalStoreWithSelectorはReactの組み込み機能です)を利用して以下の機能を提供します:

  • Storeの購読管理
  • 状態の選択的取得
  • レンダリングの最適化
  • 選択された状態の返却

createは上記のすべての機能を組み合わせて、最終的なAPIを提供します:

  • createStoreでStoreを作成
  • useStoreを通じて状態管理を実現
  • useBoundStoreで状態の選択と最適化を可能に

useStoreの実装

create関数から返されるフックであるuseBoundStoreselectorequalityFnを受け取り、これらのパラメータをapiと共にuseStore関数に渡すことで、状態管理の機能を実現しています。

useStoreの実装は比較的単純な構造となっており、主にuseSyncExternalStoreWithSelectorという関数を活用しています。この関数は状態の購読や更新を管理するために、5つの重要なパラメータを必要とします。具体的には、api.subscribeによる状態変更の購読機能、api.getStateによるクライアントおよびサーバーサイドでの状態取得機能、渡されたselectorによる状態の計算機能、そしてequalityFnによる状態変更の比較機能が含まれています。

selectorパラメータにおいては、その指定が任意となっており、当該パラメータの指定を省略した場合においてはストアに保持される全状態が返却されることとなりますが、一方で当該パラメータを指定した場合においては、指定されたselector関数の実行結果がslice値として返却されることとなります。

これらの要素を組み合わせた実装コードは以下のようになります:

const useStore = <State, StateSlice>(
  api: StoreApi<State>,
  selector: (state: State) => StateSlice = api.getState as any,
  equalityFn: (a: StateSlice, b: StateSlice) => boolean
) => {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getState,
    selector,
    equalityFn
  );
  return slice;
};

subscribeの実装

コンポーネントがStoreから状態を取得する際には、Storeに対する購読(subscribe)が必要となりますが、これはStore内部の状態が変更された際にコンポーネントの再レンダリング(re-render)を確実に実行するために不可欠な仕組みとなっています。

この購読の仕組みを実現するために、以下のような実装を行っています:

type StateCreator<T> = (setState: SetSet<T>) => T

const createStore = <T>(createState: StateCreator<T>): StoreApi<T> => {
  const listeners = new Set<() => void>();
  let state: T;
  const setState = () => {};
  const getState = () => state;
  const subscribe: Subscribe = (subscribe) => {
    listeners.add(subscribe);
    return () => listeners.delete(subscribe);
  };
  const api = { setState, getState, subscribe };
  state = createState(setState);
  return api;
};
//...

この実装において特に重要な点は、購読関数(subscribe)がコンポーネントの再レンダリングを実行するための関数をパラメータとして受け取り、それをSet構造体に保存する仕組みを採用していることです。さらに、コンポーネントがUnmountされる際に購読を解除するための関数を返却することで、メモリリークを防止するための配慮もなされています。

先に実装したcreateStore関数と比較すると、今回の実装ではlistenersというSet構造体を新たに導入しており、これによってStoreの状態が変更された際(つまりsetStateが呼び出された際)に、保存されているすべての関数を順次実行することで、該当するStoreを購読しているすべてのコンポーネントの再レンダリングを効率的に実現することが可能となっています。 このような実装アプローチを採用することにより、以下のような利点が得られています:

  • Set構造体を使用することで、同一のlistenerが重複して登録されることを防止しつつ、効率的なlistenerの管理が可能となっています。
  • コンポーネントのライフサイクルに応じた適切な購読管理が実現され、不要なメモリ消費を防ぐことができています。
  • Storeの状態変更時に、関連するすべてのコンポーネントを確実に更新することができ、アプリケーションの一貫性が保たれています。

setStateの実装

subscribeの実装に関する前述の説明から、コンポーネントの再レンダリングをトリガーするすべての関数がlistenersに保存され、状態が変更された際にはこれらの関数を順次実行する必要があ ることが理解できました。このような仕組みを踏まえ、setStateの実装においては以下の2つの処理を実現する必要があります:

  1. storeの状態を更新すること
  2. listenersに保存されているすべてのパラメータを順次実行すること

これらの処理を実現するにあたり、以下のような異なるケースについて個別に考察する必要があります。

関数をパラメータとして受け取る場合

以下のような実装例を考えてみましょう:

const useDataStore = create((set) => ({
  data: { count: 0, text: 'test' },
  inc: () => 
    set((state) => ({
      data: { ...state.data, count: state.data.count + 1 },
    })),
}))

この実装例においては、データを保存するためのdataパラメータと、countを1増加させるためのinc関数を定義しており、特筆すべき点として、set関数に対して状態更新用の関数を渡してい ることが挙げられます。この関数は前回の状態を表すstateを受け取り、それに基づいて新しいcount値を計算する仕組みとなっています。 したがって、実装においては受け取ったパラメータが関数であるかどうかを判定し、関数である場合にはそれを実行する処理が必要となります。

具体的な値を受け取る場合

次のような実装例も考えられます:

const useDataStore = create((set) => ({
  count: 1,
  text: 'test',
  setCount: () => set({ count: 10 }),
}))
最終的な実装

これらの要件を満たす実装は以下のようになります:

const setState: SetState<T> = (partial) => {
  const nextState =
    typeof partial === "function"
      ? (partial as (state: T) => T)(state)
      : partial;
  
  if (!Object.is(nextState, state)) {
    state =
      typeof nextState !== "object" || nextState === null
        ? (nextState as T)
        : Object.assign({}, state, nextState);
    listeners.forEach((listener) => listener());
  }
};

この実装の重要な特徴として、状態を更新する際にすべての状態を明示的に指定する必要がないという点が挙げられます。例えば、() => set((state) => ({ ...state, count: 10 }))のように 全状態を記述する代わりに、更新したい部分のデータのみを渡すことで、ZustandがObject.assign({}, state, nextState)を使用して自動的に状態の統合を行ってくれます。

さらに、状態の更新においては完全な置き換えではなくパッチ方式を採用しているため、まず渡されたパラメータの型を判定し、Objectであり、かつnullでない場合にのみ統合処理を実行する という方式を採用しています。また、パフォーマンスの最適化として、前回のstateと計算されたnextStateが異なる場合にのみ再レンダリングがトリガーされる仕組みも実装されています。

おわりに

Reduxの課題から始まり、Zustandの基本実装、実践的なTodoアプリケーションの構築、そしてライブラリの核心的な仕組みまで、幅広く探求してきました。Zustandは単なるReduxの代替ではなく、モダンなReactアプリケーション開発における新しい状態管理のパラダイムを示していると言えます。

特に重要だと思っているポイントは、Zustandが実現した「シンプルさと柔軟性の共存」です。ボイラープレートコードを最小限に抑えながら、強力な状態管理機能を提供する approach は、開発者の生産性を大きく向上させます。また、TypeScriptとの優れた親和性や、パフォーマンス最適化のための充実したAPIは、大規模アプリケーション開発における実用性の高さを示しています。

今後のフロントエンド開発において、状態管理はますます重要な課題となっていくでしょう。その中で、Zustandの示す「必要十分な機能」と「直感的なAPI設計」という方向性は、次世代の状態管理ライブラリの在り方を示唆しているのかもしれません。

社内ものづくりイベント「Builders Day」を初開催した話

こんにちは、nakaji_dayoと申します。カンムでエンジニアリングマネージャーをしています。

2024年9月に「Builders Day」という社内ものづくりイベントを初開催しました。本稿ではイベント企画の背景や開催時の様子の紹介をします。

Builders Dayとは

はじめにイベントの概要ですが、「Builders Day」は普段の業務や職種に関係なく色々なメンバーがチームを組み、アイディアを出し合い、ものづくりを楽しむイベントです。

作るものは業務に関係なく完全に自由で、制作には1営業日を使います。また、発表会を社員総会に合わせる形でオフライン開催することにしました。

note.com (↑社員総会についてはこちら)

開催の裏側

ここで少し脇にそれますが、開催に至るまでの背景など裏側を紹介します。

このイベントは完全に新規という訳ではなく、カンムでは以前より「金融のテックカンパニー」を目指してTechDayというイベントを毎月開催してきました。今回のBuilders Dayはその改善から誕生しました。

akirachiku.com (↑これまでのTechDayでは業務効率化などの成果が生まれ続けています)

一方、当初に対し「社員数が増えた、リモートワークが主流」などの環境変化で、「参加のハードルや成果の出しづらさ」などの課題も出ていたことから、よりよい在り方はないかという所で有志のPJを作り検討を開始しました。

検討を進める中で「カンムはものづくりの会社である」という理念から「誰もが」「楽しく」「ものづくりを行える」イベントを目指したいと考え、以下の改善・変更を加えて「Builders Day」としての開催を行いました。

  • 年2回と開催頻度を減らすことで各回の確保時間は増やし、制作の成果を出しやすくする・運営にもリソースをかけられるようにする
  • オフラインの発表会により、イベントとして盛り上がるものにする
  • チーム参加を取り入れ、職種をまたぎアイディアを出しやすくする・参加ハードルを下げる => 今回は運営側でチームマッチングを行った
  • 推しテーマを設定し、そのワークショップや利用のサポートを行い、制作に取り組みやすくする => 今回は推しテーマを「Zapier」とし、その利用のワークショップを開催した

結果としては、任意参加ながら「人事/広報/業務/法務/事業開発/開発」など様々な職種の方、また入社から比較的日が浅い方も含めて、20名+の方々に参加いただけました。そして多くのチームがものづくりに熱中し、オフライン開催での発表会も盛り上がるものになったのではと思っています。 以降で制作Day・発表会の様子を紹介いたします。

制作日の様子

制作日はオンラインメインの実施で、チーム毎にSlack channelを作り、Google meetなどを使いつつ各チームが制作に取り組みました。 午前中には開会式と任意参加の「Zapierワークショップ」実施しました。

一部のチームはオフィスで開発

発表会の様子

発表会では、計12のチームまたは個人がデモなど交え制作物を紹介しました。 前述のとおり懇親会内で開催したので、観客は酒などを片手に発表を聞き、アイディアや作りきったことへの感心の声や、ユニークな目の付け所に笑いなども起こり、オフラインならではの盛り上がりとなりました。

Zapierも活用した「採用面談・面接の通知bot」の発表

発表会の様子

表彰式の様子 / 受賞作品の紹介

今回のイベントでは社員投票による「チーム・個人賞」とCTO審査による「審査員特別賞」、及びささやかな賞品を用意しました。 以下では受賞作品を紹介します。

チーム賞: 図に載せる君 / pocketnori3(ポケのり)

  • 制作物(2点)
    • 図に載せる君: ponzuとyokoiを図に乗せてくれる
    • pocketnori3(ポケのり): あるキーワードを与えると:nori3: が発言したslackメッセージとesaからいい感じのものを3つづつ選んでリンクを返してくれる
  • チーム名: チーム1_思考回路エクスプレス

こちらのチームは、@nori3(= 社内のあらゆる疑問に答えるすごい人)がソースの情報を探すslack botを制作し、これがシンプルながら大勢に刺さっていました。

チーム賞受賞の様子

個人賞: そこに法人データがあるから

データの整備・提供という地道ながら確実に実用的なエンジニア魂あふれる取り組みした @achiku が個人賞に選ばれました。

@achikuの発表の様子

審査員特別賞: esa watch/ リリース審査員

  • 制作物(2点)

こちらのチームは、ドキュメント共有サービスesaの記事をディレクトリ単位でなど柔軟に変更監視できるツールと、プレスリリースの作成を承認フロー実行やAIレビューによりサポートするツールの2本の業務上便利なツールを開発しました。

審査員特別賞受賞の様子

開催してみて

終わりにイベントを開催してみての私の感想を残します。

作品の全体感

  • Zapierを使った作品も多く、アイディアを手軽に試して見れている様子だった
  • ChatGPTを組み合わせた作品も多く時代を感じた。中にはInstruction Tuningの利用検証などの取り組みもあり面白かった。

良かった点

  • 普段関わりの少ない人達が共同でものづくりに取り組め、「広報・人事・法務など」を含むチームならではの視点での作品が生まれた
  • 作ったものが役立っていて、開催後も使われている
  • 開催後「Zapierですべての業務を自動化したい」などの声も上がっており、普段の業務の改善が期待できる
  • 発表会が盛り上がった

改善したい点

  • 制作日に「チームで物を作る楽しさ」を参加者全員に感じてもらうために、十分な環境や仕組みを用意できなかった
  • Zapierは多くの人の取っ掛かりになった一方で、エンジニアからは込入ったことをすると大変だったという声があり、チーム制作やテーマ設定の難しさを感じた

最後に

最後の最後にですが、本Builders Dayの企画運営PJは、@michiomochi , @sho_hata_, @nakaji_dayoのメンバー +@knee (CTO)のサポートで取り組んでいます。大感謝。


\カンム、積極採用中/

herp.careers

Builders Dayやカンムに少しでも興味を持っていただけたらお話だけでも是非!

SMS配信の冗長化と配信プロバイダの自動切り替え

エンジニアの佐野です。カンムでは SMS を多用しています。例えば以下のようなポイントで使っています。

  • ユーザ新規登録時の認証コード送信
  • ログイン時の認証コード送信
  • 3Dセキュア 認証時
  • 後払い機能利用時
  • ユーザにメッセージを送る時
  • ...etc

ここでもし SMS 送信ができないような事案が発生するとユーザの体験が悪くなり KPI にかなりのダメージが出ます。今回は SMS 送信が滞らないように戦った歴史と設計について書きます。

  1. SMS 配信サービスの利用と初期の頃の設計
  2. SMS 配信時に問題が発生するポイント
  3. PaaSoo 導入と手動切り替え時代
  4. Nexmo *1 接続時のエラーハンドリングと切り替え自動化
  5. Nexmo からユーザに SMS が届くまでの正常性確認と切り替えの自動化
  6. まとめ

1. SMS 配信サービスの利用と初期の頃の設計

カンムでは SMS 配信サービスとして NexmoPaaSoo を併用しています。Nexmo がプライマリ、PaaSoo がセカンダリという扱いです。 Nexmo の調子が悪いときは PaaSoo に切り替えます。

しかし元々は Nexmo だけを使っていて例えば認証コード送信およびその検証は次のような実装となっていました。

1.1 DB 設計

送信した認証コードの管理については次のように行っています。

  • phone_number_verification_request: 新規登録/ログイン...などを行うときに INSERT するテーブル。実際には有効期限であったり他のカラムがあるがここでは省略する。
  • phone_number_verification_code: こちらも新規登録/ログイン...などを行うときに INSERT するテーブル。phone_number_verification_request と 1:1 で紐付いているが紆余曲折あって別テーブルとなっていて認証コードはこちらに保存してある。ユーザが SMS を受信して受け取った認証コードを入力した際にここで保存されているものと一致しているかどうかの検証を行う。
  • phone_number_verification: 検証が完了したら INSERT するテーブル。

1.2 SMS による認証コード送信

ユーザ新規登録時やログイン時などに次の様なシーケンスでユーザに SMS を送信して認証コードを届けます。

1.3 認証コードの確認

ユーザの端末に SMS が届くとユーザは認証コードを入力、サーバ側では phone_number_verification_code を検索し、マッチしていれば phone_number_verification を作って認証完了とします。他にも端末のチェックであったり不正な認証が発生しないような処理は入っているのですが割愛します。

2. SMS 配信時に問題が発生するポイント

図示すると以下の箇所になります。

1つめは Nexmo に接続するときです。ここで Nexmo に接続できない、もしくは HTTP 5xx が返る可能性があります。このケースのときは DB はロールバックされ、ユーザにはエラー応答が返ります。 2つめは Nexmo に SMS 送信要求を出したあとになります。このケースのときは Vandle API から見ると SMS 送信は正常系で終了します。しかし Nexmo から先のキャリアネットワークであったりユーザの端末で何かが起きているとユーザは送信された SMS を受け取ることができません。ユーザはひたすら SMS の到達を待つことになります。

3. PaaSoo 導入と手動切り替え時代

データを見てみるとどうやら2019年のケツから2020年頭あたりに PaaSoo を導入したようです。たしかこのときにユーザが SMS を受け取ることができないという事案が長時間発生し、何人かでオフィスにこもって状況を見守っていました (見守ることくらいしかできませんでした)。

PaaSoo 導入時、単純にセカンダリを用意した、というだけではなく、phone_number_verification_provider_ratio というテーブルを用意してそこで Nexmo と PaaSoo の利用比率を管理、その比率に応じて送信先を振り分けるという戦略になりました。関連を作らない独立した単純なテーブルです。

Nexmo の不調を検知した時(接続時エラー、ユーザからのクレーム、etc)は担当者が管理画面から phone_number_verification_provider_ratio の設定をいじるという手運用でした。過去のデータを見てみると 2022年10月20は早朝から頑張っている形跡が見られました。

Nexmo が調子悪いならしばらく PaaSoo に切り替えたままにしときゃいいじゃねぇか、と思うかもしれませんが、 PaaSoo はプリペイド方式で契約をしており、利用可能残高が枯渇したらそれはそれで渋い話になるということもあり、しばらく使ったら Nexmo に切り戻す、というオペをしていました。今でもカンム社内では PaaSoo は一時利用という位置づけです。 この phone_number_verification_provider_ratio を利用して SMS 配信時のロジックにパッチを当てました。

4. Nexmo 接続時のエラーハンドリングと切り替え自動化

手オペ時代の次は自動化です。「SMS 配信時に問題が発生するポイント」に書いた1つめのポイント、まずは Nexmo に接続自体ができない、エラーが返ってきたケースに対応を行いました。Git を見るとこれは 2023年5月に行われました。Nexmo に接続して問題が発生したら即座に PaaSoo に切り替えるようになりました。PaaSoo 側でも問題が起きたらそれは諦めます。

5. Nexmo からユーザに SMS が届くまでの正常性確認と切り替えの自動化

そして最近行ったのがこちらです。「SMS 配信時に問題が発生するポイント」に書いた2つめのポイント、Vandle から Nexmo への SMS 送信要求は成功したが、ユーザに SMS が届いていないであろうときに PaaSoo に自動切り替えする対応です。

ここで「ユーザにとどかないとは?」ですが、DB 設計の箇所で、SMS 送信時に phone_number_verification_request が作られ、ユーザが認証コードを入力することで phone_number_verification が作られることを述べました。phone_number_verification_request が作られてから長い時間 phone_number_verification が作られていない場合は、「実は SMS がユーザに届いていないのではないか?」とします。

これを SMS 到達率として扱い、次のような SQL で3分間程度の到達率を計算します。なおしばらくリクエスト自体がない場合は1.0としています。

SELECT
  CASE WHEN COUNT(pnvr.id) > 0 THEN
    COUNT(pnv.request_id) / COUNT(pnvr.id)::numeric
  ELSE 1.0
  END AS success_rate
FROM phone_number_verification_request pnvr
LEFT JOIN phone_number_verification pnv ON pnvr.id = pnv.request_id
WHERE pnvr.requested_at > now() - interval '3 minutes'

これを用いて次のようなロジックに変更しました。phone_number_verification_provider_ratio で Nexmo:PaaSoo = 100:0 になっているときに SMS 到達率が低下していたら、phone_number_verification_provider_ratio を Nexmo:PaaSoo = 0:100 に変更します。そしてそれと同時にジョブキューを投入し、しばらくしたら Nexmo に戻るようにしました。

まだ改善点はあると思いますが、長い時間をかけて冗長化および切り替えの自動化が完了しました。

6. まとめ

  • 以下のようなタイムラインで SMS 配信プロバイダの利用と冗長化を行った
    • 2016年〜2019年: Nexmo 1つで運用
    • 2020年1月: PaaSoo 導入と SMS 配信プロバイダの手動切り替えが可能になった
    • 2023年5月: Nexmo 接続エラー時の自動切り替え対応
    • 2024年11月: SMS 未到達時の自動切り替え対応 (完全体?)

セカンダリとして PaaSoo を導入した契機となるような大規模障害が発生しないとこのようなものは後回しにされがちです。Nexmo ダウン時であったり SMS 未到達時にどんくらいの損失出ているのかを計算して改善の優先度を上げてもっと早めに着手すればよかったな、と今更ながらに思ったりしました(計算の結果たいしたことない、という結論になる可能性もあるが...)。

とりあえずこれにてオンコールの手間は減ったはずです。

おわり

*1:現在は Vonage という名称になっているようです

Atlantisのマルチクラウドへの対応について

SREの菅原です。

カンムではAWSGCP、Datadogなど様々をIaaS・SaaSをterraformで管理しているのですが、以前は「GitHub Actionsでplan」「管理者や開発者が手元でapply」というフローになっており、terraform applyの実行が管理者や一部の権限を持った開発者に集中してしまい、インフラの変更作業の速度が落ちてしまっている状態でした。

しかし、Atlantisという「Pull Request上でterraform plan・applyを実行する」ツールを導入したことで、うまくapply権限を各開発者に委譲することができるようになったので、Atlantisの運用について、特にマルチクラウドへの対応について書きます。

Atlantis

www.runatlantis.io

AtlantisはWebhookでGitHubのPull Requestのイベントを受け取って、Pull Request上でplanとapplyを実行するアプリケーションです。

Atlantisを使ったterraformのワークフローは以下のようになります。

  1. tfファイルを編集してPull Requestを出す
  2. Pull Requestが作成されるとterraform planの結果がコメントされる
  3. Pull RequestがApproveされたら atlantis appy とコメントする
  4. terraform applyが実行されてPull Requestがマージされる

公式ドキュメントの動画を見てもらうと雰囲気が伝わるかもしれません。

Atlantisのよい点は

  • terraform applyを開発者に委譲しやすい
  • applyできるための条件があり無用に強大な権限を委譲することがない
  • plan・applyの結果がPull Requestに記録される
  • applyに失敗した変更をmainブランチにマージせずに修正できる
  • 「atlantis applyをコメント」→「terraform applyを実行」→「結果がコメントされる」という流れがCLIで実行している感覚に近く「確実にapplyに成功した」という手応えを得られる
    • ※terraform実行時の出力はWebターミナルから確認できます

などです。

AWSマルチアカウントへの対応

カンムのAtlantisはAWS Fargateで動かしています。 Fargateで動かす場合、基本的にECSタスクのIAMロールの権限がAtlantisに付与されることになるのですが、複数のAWSアカウントのterraform plan・apply権限を一つのIAMロールに付与するのは最小権限の原則から望ましくはありません。 複数のECSサービスを動かせば異なるIAMロールを渡せますが運用のコストが増えるので避けたいところです。

そこでterraformの各プロジェクトごと、またそれぞれのapply・planで別々のIAMロールにAssumeRoleして不要な権限を持たないようにすることを考えました。

しかし、Atlantis自体にはAssumeRoleをするための機能はありません。 公式ドキュメントにも

It's up to you how you provide credentials for your specific provider to Atlantis: https://www.runatlantis.io/docs/provider-credentials

とあり、Atlantisを使う側で頑張ってほしいような雰囲気を感じます。

terraformではtfファイル内に直接IAMロールを記述しなくても .aws/config に設定があれば、AWS_PROFILE環境変数でAssumeRoleすることができます。

[default]
region = ap-northeast-1
credential_source = EcsContainer

[profile my-project-plan]
role_arn = arn:aws:iam::123456789012:role/atlantis-plan
$ export AWS_PROFILE=my-project-plan
$ terraform plan # atlantis-planロールで実行される

またAtlantisはワークフローをカスタマイズすることができ、plan・apply実行時に任意の処理を入れることができます。

www.runatlantis.io

そこでterraformプロジェクト、plan・applyごとに異なるAWS_PROFILEを設定してterraformを実行するカスタムワークフローを定義した独自のAtlantisのDockerイメージ作成しました。

Atlantisのカスタマイズ

AtlantisのDockerイメージを作成するためのリポジトリの構成はこんな感じです。

docker-atlantis/
├── Dockerfile
└── files/
    ├── etc/
    │   └── atlantis/
    │       ├── config.yaml
    │       └── repos.yaml
    ├── home/
    │   └── atlantis/
    │       ├── .aws/
    │       │   └── config
    │       ├── .markdown_templates/
    │       │   └── plan_success_wrapped.tmpl
    │       └── .sev.toml
    └── usr/
        └── local/
            └── sbin/
                ├── gcp-assume-role.sh
                ├── repo-config-generator.sh
                ├── terraform-apply.sh
                ├── terraform-init.sh
                └── terraform-plan.sh
# Dockerfile

FROM ghcr.io/runatlantis/atlantis:v0.30.0-debian

USER root

RUN apt-get update && \
  apt-get install -y \
  jq \
  apt-transport-https \
  ca-certificates \
  gnupg \
  curl

RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \
  echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && \
  apt-get update && \
  apt-get install -y google-cloud-cli

ARG SEV_VERSION=0.8.0
ARG SEV_SHA256SUM=a191501dc0f2c17b7d56c2a720a5db6b2abc3c0f9e8e185456387aafd15b0913
RUN curl -sSfLo sev.deb https://github.com/winebarrel/sev/releases/download/v${SEV_VERSION}/sev_${SEV_VERSION}_amd64.deb && \
  echo "$SEV_SHA256SUM sev.deb" > sha256sum.txt && \
  sha256sum -c sha256sum.txt && \
  dpkg -i sev.deb && \
  rm sev.deb sha256sum.txt

COPY files/ /
RUN chown -R atlantis:atlantis /home/atlantis

USER atlantis
CMD [ \
  "server", \
  "--config=/etc/atlantis/config.yaml", \
  "--repo-config=/etc/atlantis/repos.yaml" \
  ]

AssumeRoleするためのカスタムワークフローは /etc/atlantis/repos.yaml に定義されています。

workflows:
  with-aws-profile:
    plan:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-plan -- terraform-init.sh
            output: hide
        - run:
            command: sev ${PROJECT_NAME}-plan -- terraform-plan.sh
            output: strip_refreshing
    apply:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-apply -- terraform-apply.sh
# terraform-plan.sh

#!/bin/bash
terraform${ATLANTIS_TERRAFORM_VERSION} plan -input=false -refresh -out $PLANFILE
# terraform-apply.sh

#!/bin/bash
terraform${ATLANTIS_TERRAFORM_VERSION} apply $PLANFILE

ワークフロー実行時、プロジェクト名が環境変数PROJECT_NAMEで渡されるので、それを使ってterraform実行時のAWS_PROFILEを切り替えています。*1

「command:」の箇所に出てくる sev は引数のプロファイル名に紐付く環境変数渡してコマンドを実行するラッパーツールです。 github.com

plan時に PROJECT_NAME=foo-proj が渡されると .sev.toml から [foo-proj-plan] を検索して環境変数AWS_PROFILEを設定し、terraform-plan.shを実行します。

# /home/atlantis/.sev.toml

[foo-proj-plan]
AWS_PROFILE = "foo-proj-plan"

.aws/config に foo-proj-planプロファイルが定義されており、role_arnで指定されたIAMロールにAssumeRoleしてterraformが実行されます。

# /home/atlantis/.aws/config

[profile foo-proj-plan]
role_arn = arn:aws:iam::123456789012:role/atlantis-plan

このような仕組みでAWSアカウントごと、plan・applyごとに異なるIAMロールにAssumeRoleできるようにしました。

GCPへの対応

GCPのterraformはサービスアカウントで実行されます。 サービスアカウントはWorkload Identity連携を使うことでAWSのIAMロールから認証情報を発行することができます。

cloud.google.com

たとえば、terraform planを行うためのサービスアカウントの定義は以下のようになります。

resource "google_service_account" "terraform_plan" {
  account_id   = "terraform-plan"
  display_name = "terraform-plan"
}

resource "google_project_iam_binding" "viewer" {
  project = "..."
  role    = "roles/viewer"

  members = [
    "serviceAccount:${google_service_account.terraform_plan.email}",
  ]
}

resource "google_service_account_iam_binding" "terraform_workload_identity_user" {
  service_account_id = google_service_account.terraform_plan.name
  role               = "roles/iam.workloadIdentityUser"
  members = [
    "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.atlantis_plan.name}/attribute.aws_role/arn:aws:sts::223456789012:assumed-role/ecs-atlantis",
  ]
}

resource "google_iam_workload_identity_pool" "atlantis_plan" {
  provider = google-beta

  display_name              = "atlantis-plan"
  workload_identity_pool_id = "atlantis-plan"
}

resource "google_iam_workload_identity_pool_provider" "atlantis_plan" {
  provider = google-beta

  workload_identity_pool_id          = google_iam_workload_identity_pool.atlantis_plan.workload_identity_pool_id
  workload_identity_pool_provider_id = "atlantis-plan"
  display_name                       = "atlantis-plan"
  attribute_condition                = "assertion.arn.startsWith('arn:aws:sts::223456789012:assumed-role/ecs-atlantis/')"
  attribute_mapping = {
    "google.subject"     = "assertion.arn",
    "attribute.aws_role" = "assertion.arn.contains('assumed-role') ? assertion.arn.extract('{account_arn}assumed-role/') + 'assumed-role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn",
  }

  aws {
    account_id = "223456789012"
  }
}

このサービスアカウントを使うために、別のカスタムワークフローとラッパーコマンド gcp-assume-role.sh をAtlantisに追加します。

  with-gcp-creds:
    plan:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-plan -- gcp-assume-role.sh /usr/local/sbin/terraform-init.sh
            output: hide
        - run:
            command: sev ${PROJECT_NAME}-plan -- gcp-assume-role.sh /usr/local/sbin/terraform-plan.sh
            output: strip_refreshing
    apply:
      steps:
        - run:
            command: sev ${PROJECT_NAME}-apply -- gcp-assume-role.sh /usr/local/sbin/terraform-apply.sh
# gcp-assume-role.sh

#!/bin/bash
set -e
CREDS_JSON=.gcp-creds.json

gcloud iam workload-identity-pools create-cred-config \
  $GCP_WORKLOAD_IDENTITY_POOL_PROVIDER \
  --service-account="$GCP_SERVICE_ACCOUNT" \
  --aws \
  --output-file="$CREDS_JSON"

trap "rm -f $CREDS_JSON" EXIT
export GOOGLE_APPLICATION_CREDENTIALS="$CREDS_JSON"

AWS_CREDS=$(curl -sSf http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
export AWS_ACCESS_KEY_ID=$(echo "$AWS_CREDS" | jq -r .AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(echo "$AWS_CREDS" | jq -r .SecretAccessKey)
export AWS_SESSION_TOKEN=$(echo "$AWS_CREDS" | jq -r .Token)

source "$1"

また、sev では 環境変数 GCP_WORKLOAD_IDENTITY_POOL_PROVIDER と GCP_SERVICE_ACCOUNT を設定します。

[gcp-proj-plan]
GCP_WORKLOAD_IDENTITY_POOL_PROVIDER = "projects/098765432109/locations/global/workloadIdentityPools/atlantis-plan/providers/atlantis-plan"
GCP_SERVICE_ACCOUNT = "terraform-plan@my-proj.iam.gserviceaccount.com"

gcp-assume-role.sh でラップすることECSタスクのIAMロールから認証情報を取得し、サービスアカウントの権限でterraformを実行できるようになります。

Datadogへの対応

Datadogは単純なAPIキーでterraformを実行するので、APIキーを格納するAWS Secrets Managerのシークレットを作成し、そのシークレットの取得権限をもつIAMロールにAssumeRoleすることで権限を分けています。

resource "aws_secretsmanager_secret" "atlantis_datadog_DD_API_KEY" {
  name = "atlantis/datadog/DD_API_KEY"
}

resource "aws_secretsmanager_secret" "atlantis_datadog_DD_APP_KEY" {
  name = "atlantis/datadog/DD_APP_KEY"
}

resource "aws_iam_role" "atlantis_datadog" {
  name = "atlantis-datadog"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRole"
        Principal = {
          AWS = [
            "arn:aws:iam::223456789012:role/ecs-atlantis",
          ]
        }
      },
    ]
  })
}

resource "aws_iam_role_policy" "atlantis_datadog_terraform" {
  role = aws_iam_role.atlantis_datadog.name
  name = "terraform"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow",
        Action = [
          "s3:GetObject",
          "s3:PutObject",
        ]
        Resource = "${aws_s3_bucket.terraform.arn}/datadog.tfstate"
      },
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem"
        ],
        Resource = aws_dynamodb_table.terraform_lock.arn,
      },
    ]
  })
}

resource "aws_iam_role_policy" "atlantis_datadog_secret_access" {
  role = aws_iam_role.atlantis_datadog.name
  name = "secret-access"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "secretsmanager:GetSecretValue",
        Resource = [
          aws_secretsmanager_secret.atlantis_datadog_DD_API_KEY.arn,
          aws_secretsmanager_secret.atlantis_datadog_DD_APP_KEY.arn,
        ]
      }
    ]
  })
}

sev にはSecrets Managerから値を取得する機能があり、以下のように「secretsmanager://〜」とプレフィックスをつけると、指定されたsecret idの値で環境変数 DD_API_KEY と DD_APP_KEY を設定してterraformを実行します。

# .sev.toml

[datadog-plan]
AWS_PROFILE = "datadog"
DD_API_KEY = "secretsmanager://atlantis/datadog/DD_API_KEY"
DD_APP_KEY = "secretsmanager://atlantis/datadog/DD_APP_KEY"
# .aws/config

[profile kanmu-datadog]
role_arn = arn:aws:iam::000456789000:role/atlantis-datadog

このようにしてAPIキーを使うSaaSでも権限を分離するようにしています。

まとめ

Atlantisの導入に際して汎用的なマルチクラウド対応の仕組みを作ることで、様々なIaaS・SaaSを素早くAtlantisに対応させることができるようになりました。

Atlantisのマルチクラウド対応についてはあまり知見が見つからなかったのですが、比較的単純な仕組みで対応できたのはAtlantis自体の柔軟さによるところが大きいと思います。

terraformの自動化のためのAtlantisの導入、とてもオススメです。