水底

ScalaとかC#とかk8sとか

git repository と worktree が散乱して辛かったのでいい加減なんとかするツール baretree を作ってしまった

何ができたのか

git repository と worktree をいい感じに整理した形で作成・削除・移動などができるツールです。x-motemen/ghqsatococoa/wtp を足したようなイメージです。

github.com

既存ツールで困っていたこと

最近は雨後の竹の子のように worktree 管理ツールが表れていますが、「結局 worktree どこおきゃええねん」、「working directory と worktree の非対称性の気持ち悪さ」と「worktree と branch がごちゃごちゃでわけわかんなくなる」をまとめて解消できるものを見つけられませんでした。

baretree のコンセプト

ツール名からもわかるように、git repository を bare repository で管理することを根幹においています。これでまずは「working directory と worktree の非対称性の気持ち悪さ」をクリア。また、各リポジトリディレクトリ構成を以下のように統一することで、リポジトリごとの worktree 置き場を明確にしました。

my-app/                          # One repository
├── .git/                        # Bare git repository
├── .shared/                     # Shared across all worktrees (.env, etc.)
├── main/                        # main branch
├── feature/
│   ├── api/                     # feature/api branch
│   └── auth/                    # feature/auth branch
└── fix/
    └── ui/                      # fix/ui branch

worktree や branch 作成をコマンド経由でブランチ名・ディレクトリ名・worktree 名を一致させることで「worktree と branch がごちゃごちゃでわけわかんなくなる」問題が解決できました。 そしてこれにより worktree を置くディレクトリが一意に決まり「結局 worktree どこおきゃええねん」問題も解決できましたとさ。

さて、git の汎用的な機能のみを使うとはいえ、既存のリポジトリ、worktree が山ほどある状態から手作業で移行することなんて考えたくもありません。なので移行や修復を自動で行うコマンドも用意しました。1 コマンドで working directory や index、submodule の状態などをいい感じに保って乗り換えられるようになりました。わーい。

ついでにリポジトリ置き場が気になり、ghq のような複数リポジトリを集中管理することもできるようにしました。これによりリポジトリ間の移動が楽にできたのが個人的に嬉しいポイント。bt go <repo name> で行き来できるようになりました。

~/baretree/                              # All repositories organized
├── github.com/
│   └── user/
│       ├── my-app/                      # One repository
│       │   ├── .git/                    # Bare repository
│       │   ├── .shared/                 # Shared across all worktrees (.env, etc.)
│       │   ├── main/                    # worktree ← main branch
│       │   └── feature/
│       │       ├── auth/                # worktree ← feature/auth branch
│       │       └── api/                 # worktree ← feature/api branch
│       └── another-project/
└── gitlab.com/
    └── ...

余談

今回は TDD 的なノリでほぼ Claude Code に書かせました。こういう独立性の高いツールは特に相性がいいですね。

以前から Claude Pro Plan がコスパいいなーと個人的に使っていたのですが、やっぱりある程度一気に使おうとすると足りないですね。baretree を作る過程で 5h ごとに枠を使い切って待つを繰り返したせいで思うように進まず、次に weekly も使い切って Extra Usage を少しだけ買って一瞬でなくなってたけーーとなっていました。比べると Claude Pro はコスパ良いなと再認したり。結局もろもろ考えて 1 ヶ月だけ Claude Max Plan にしました💸

今さら読んだ:『エンジニアチームの生産性の高め方』

はじめに

積まれまくっていた『スタッフエンジニア マネジメントを超えるリーダーシップ』を今さら読んだので、自分用の備忘録です。完全に私の知識・経験を前提にしています。

読んだきっかけ

いくつかありますが、この本の目次を見たときに主に以下のような課題への回答を見つけられるかもしれないと思ったためです。

  • PRD や DesignDoc といった各種ドキュメントの運用について、どのようなライフサイクルを定義することで持続可能にできるかのヒントを探していた
  • リアーキテクトする際のテスト戦略について、知識としてインプットしておきたかった
  • 生産性とはなんなのか、Four Keys などは知っているが未だに腑に落ちたことがなかったので良い定義・解釈があれば知りたかった

先にまとめをば

私が 期待しているような知見は得られませんでした。イントロにも明示されている通り この本は解説書ではなく著者の経験をケーススタディとしてまとめた読み物 だからです。

ただし初歩的な用語の定義や概要の説明はしっかりされているので、第一部はこれからチーム開発を始める人にはおすすめできると思いました。扱っている情報がユニークで、例えば PRD の書き方を具体例とともにまとめている書籍は他に多くないでしょう (要件定義のやり方といった観点の書籍は大量にありますが)。 第二部は組織の作り方やその参考事例についてなので、これから開発組織のマネジメントに関わる人のインプットや所属している組織に生産性向上施策が行われようとしているエンジニアがその背景をキャッチアップするためには良いかもしれません。

いずれも状況に適応させないといけない項目が多数ある点には注意が必要です。銀の弾丸が存在しない領域なので仕方ないですね。

個人的なメモ

PRD では何を開発するのかの詳細に立ち入りすぎない

これはめちゃくちゃわかる~~となった部分です。開発プロセスによって求められるものは異なりますが、個人的な経験としてはビジネス要件とシステム要件 (要求と要件などと呼ぶ場合もある) が混ざっていると開発プロセスとしてスケールしづらい感覚があります。PRD を使ったプロセスで要件定義と開発を分業したいなら、PRD で何を開発するのか (≈システム要件) には深入りせず、ビジネス要件に集中されていると嬉しいですね。

参考

  • 1-2 代表的な章立て - 「機能要求」の章

PRD にリリーススケジュールおよびマイルストーンを書く

このことについて、書籍内では次のような背景が紹介されていました。外的要因の考慮として、市場投入タイミングが重要なプロダクトであればスケジュールも非常に重要だというものです。PRD にプロジェクトマネジメント的なものが入ると見なすと違和感がありましたが、非機能要件の一種のようなものと解釈することで納得できました。

参考

企画立案者が PRD のドラフトを作成するのが望ましい・ドラフトは概要~ユースケースが中心となる

機能要求以降の内容は概要~ユースケースを詳細化したものであるためドラフトの時点では書けないだろうと紹介されているのがとてもリアルに感じました。個人的には書けないに留まらず書かないと言い切ってしまってもよいのではと思っています。一人で書き進めた内容の手戻りリスクを考慮すると、早めに広いフィードバックを入れて手戻りリスクを軽減してから前進したほうが良いと思うからです。

このあたりの進め方について、特に規模が大きくプロトタイピングを挟むような場合、個人的には Dual-Track Agile のような考えも持っておくと良いと思っています。これ自体は Agile を前提とした戦術ですが、骨子である Discovery と Delivery を分けるという考え方は応用しやすいはずです。PRD を Discovery と Delivery のインターフェースにすると組織構造的にもプロセス的にもスケールしやすいだろうというのが私の持論です。

参考

  • 1-3 PRD の書き進め方 - 企画立案者によるドラフト版の執筆

運用中のプロダクトの PRD

PRD を開発プロセスに組み入れるとして、既に運用中のプロダクトの扱いって悩ましいですよね。もちろん時間をかければ PRD に起こすことはできますが、えーあいってやつでなんとかしてほしい。

参考

  • 1-3 PRD を書く単位

プロダクトが変化するときは新規で PRD を作成する運用の方がうまくいきやすい

これまたリアルでとても悩ましい問題です。PRD は背景やユースケースなども含むため、プロダクトの変化に追従させ続けるのは難しいです。一方で変化のたびに PRD を書く方式だと重複項目や古くなった情報の扱いが難しくなります。このあたりを完璧に運用するのは非現実的だと思いつつも、構造化することでなんとかできないかなとも思うところです。

参考

  • 1-3 PRD を書く単位

Design Doc は書き捨てる

書き捨てること自体には賛成ですが、後から参照できるようにうまいことストック情報に落とし込めないかと常々思っています。NotebookLM のようなものに食わせるのが正解なのだろうか。

参考

  • 2-3 タスクごとに使い捨てる

ウォーターフォールアジャイルを組み合わせたプロセス

ある程度の規模になるとやっぱりみんなこうなるのかというのが感想です。LeSS みたいなフレームワークもあるけどやっぱり難しいよね。

参考

  • 4-3 出前館におけるソフトウェア開発

SPACE フレームワーク

開発生産性を定義したフレームワークだそうです。定量的なアウトプットに基づくメトリクスだけでなく、満足度のような (中長期に効いてきそうな気がする) 定性的なものも組み込まれていて面白いですね。例えば Four Keys のようなアウトプットの計測に特化した指標とは違った意義があるかもしれません。とはいえ SPACE をちゃんと運用できる組織はどれだけ存在するのでしょうか。。。

参考

  • 7-8 生産性を定義するメトリクスの考案

おわりに

ちゃんと知りたいことが書かれていそうな本を選ぼう!

今さら読んだ:『スタッフエンジニア マネジメントを超えるリーダーシップ』

これは何

積まれまくっていた『スタッフエンジニア マネジメントを超えるリーダーシップ』を今さら読んだので、自分用の備忘録です。色んな人が書評を書いているので、全体像が知りたい人はそちらを探してください。

読んだきっかけ

前々から積んでいたことに加えて、マネジメントといわゆるスタッフエンジニア (IC) で取り沙汰されるリーダーシップの境界がいまいちわからなくなっていました。マネジメントと IC 両方に興味があるため、その区別をはっきりさせておきたかったからです。

個人的に学びがあった・覚えておきたいこと

スタッフエンジニアの典型的な分類

スタッフエンジニアと一口に言っても大まかに 4 種類 (テックリード・アーキテクト・ソルバー・右腕) があり、役割・スコープだけでなく組織上の配置、必要となる事業タイミングなども大きく異なると紹介されていました。一方でこの 4 分類に固執することも危険とされており、柔軟に自分と組織に合ったポジションを見つけることが重要だと認識しました。

参考

  • 第 1 部 (第 1 章)

スタッフエンジニアとして能動的に意識すべきこと

具体的には以下のような項目が挙げられていました。ありきたりではあるものの、これを一覧として定期的に見返すのは重要だなと。

  • 重要なことに力を注ぐ
  • エンジニアリング戦略を立てる
  • 技術品質を管理する
  • 権威と歩調を合わせる
  • リードするには従うことも必要
  • 絶対に間違えない方法を学ぶ
  • 他人のスペースを設ける
  • ネットワークを築く

参考

  • 第 1 部 (第 2 章)

スタッフエンジニアに求められること

なぜか補章のしかも面接についてのセクションに追いやられていましたが、客観的にスタッフエンジニアに求められていることが書かれていて参考になりました。以下の問いに答えられるかで客観的に自分に足りていないことが見つけられると思いました。

  • 自己認識: ミスの責任を認められるだろうか?など
  • 判断力: 困難な問題に取り組む際にリスクを減らせるだろうか?など
  • コラボレーション能力: 地震よりも経験の浅い人々と付き合っていけるのか?など
  • コミュニケーション能力: 他人が指摘する内容をよく聞き、理解できるだろうか?など
  • 発展力: リーダーになれば「組織のベンチマーク」は成長するだろうか?など

参考

  • 補章 スタッフになるための情報源 - スタッフプラスの面接ループ

リーダーシップとは

リーダーシップは職種 *1 に拘らず実践可能な アプローチ であると紹介されていました。またリーダーに求められる特性を整理すると以下のようになりました。

  • 物事の理想な状態を詳細に理解していること
  • 理想と現実のギャップを見極められること
  • 理想と現実のギャップに関心を払い続けられること
  • 理想と現実のギャップを埋めるために必要な行動を導き出せること
  • 理想と現実のギャップを埋めるための行動ができること

参考

  • 第 1 部 (第 2 章) リードするには従うことも必要

効率的なコミュニケーション

本全体でいくつか紹介されていたものからのピックアップです。

  • 話を聞き、明らかにし、空気を読む: アメリカだとローコンテキスト・ドライが前提かと思っていましたが、ローコンテキスト (目的を明確にする) もありつつ、意外とハイコンテキスト・ウェットな面 (空気を読む) も重要視されるんですね。ここで紹介されているアクティブリスニングのような行為は、普段からしているつもりだが今まで以上に意識したいと思いました。
  • SCQA フォーマット: 個人的に Background, Objectives, ... のようなちょっと論文に影響されたようなフォーマットで文章を書くことが多いですが、このあたりはまだ改善の余地があるなと感じたところです。それと、改めて結局根回しは重要なんだなと。英語の wikipedia ページ があるのも面白い。
  • うまく事が運ばなかったミーティングの直後にフィードバックをもらう: 早めに改善ループを回そう。

参考

  • 第 1 部 (第 2 章) 絶対に間違えない方法を学ぶ
  • 第 1 部 (第 2 章) 経営幹部の前で
  • 第 2 部 (第 5 章) ドミトリー・ペトラシュコ

自己評価

自分の業績評価を三人称で書くというアプローチは面白いですね。そうすることで批判より称賛が多くなるからと書かれていますが、それ以上に自然と客観的になれるというメリットが想像できました。

参考

  • 第 2 部 (第 5 章) ラス・カサ・ウィリアムズ

マネージャーとスタッフエンジニアの行き来

紹介されているほとんどのスタッフエンジニアはマネジメント経験があるもしくはマネジメントに関心があるようでした。マネジメントとスタッフエンジニアは排他的なものではなく、相互に影響がありうまく使いこなしている人が多い印象でした。エンジニアリングを活かしたマネジメントもマネジメントを活かしたエンジニアリングもあるべきで、特に「マネージャーとして計画が不十分なプロジェクトでエンジニアが苦労することを知っていることが、IC としてプロジェクトが良くない方向に進み始めたときにどんな警鐘を鳴らせばよいか知っていることがそれぞれ有利に働く」というストーリーは想像しやすかったです。

参考

  • 第 2 部 (第 5 章) ダン・ナ

まとめ

個人的にこの手の本は眺めてもそりゃそうだよねという感想しか出ないことが多いのですが、この本は比較的多くの新しい視点を与えてくれました。

なんかてきとうに書き留めていったら歪な日本語文章になってしまった。悲しい。

*1:「職業」と訳されていたが「職種」と捉えたほうが良さそう

自動化されたパーティションテーブルを使っていい感じに Inbox/Outbox Pattern を実現するやつ

はじめに

この記事は CADDi Tech/Product Advent Calendar 2025 15 日目の記事です。

なんかいろいろやっている伊藤 (@amaya382) です。今回は非同期なデータ永続化処理で使われる Inbox/Outbox Pattern と呼ばれるデザインパターンの実装小ネタです。特に非同期メッセージの送受信を具体例として紹介していきます。

Inbox/Outbox Pattern はメッセージングシステムを利用する際の冪等性や Atomicity 担保に有用ですが、運用負荷やパフォーマンスリスクが発生しやすいものとなっています。この記事では Inbox/Outbox Pattern の有用性はそのままに、それらを軽減する方法を紹介します。Inbox/Outbox Pattern 導入の参考にどうぞ。

なお具体の方法は概ね AlloyDB for PostgreSQL を前提とします。PostgreSQL や類似の互換製品でも大抵再現できると思いますが試さないとわかりません。

Inbox/Outbox Pattern

本題に入る前に、簡単に Inbox/Outbox Pattern についておさらいしておきます。

Inbox Pattern

Inbox Pattern はメッセージの受信時に冪等性を担保するために用いられる手法です。多くのシーンでメッセージングシステムではパフォーマンスなどの観点から At Least Once、つまり同じメッセージが複数回送られることを許容する戦略が用いられます。この場合、同じメッセージが複数回来たときに受信側で冪等になるような処理が求められます。Inbox Pattern は受信したメッセージをデータベース (Inbox テーブル) に保持し、既に処理済みとなっていたら処理をスキップすることで冪等性を担保するアプローチです。この重複チェックのため、再送が想定される期間はメッセージを Inbox テーブルに保持し続ける必要があります。

sequenceDiagram
    autonumber
    participant Broker as Message Broker
    participant App as Consumer App
    participant DB as Database

    Broker->>App: メッセージ受信 (MsgID: 123)

    Note over App, DB: 冪等性担保のための重複排除
    App->>DB: Inboxテーブルに MsgID: 123 があるか確認

    alt 既に処理済み (Inboxに存在する)
        App-->>Broker: ACK (何もしない)
        Note right of App: 重複メッセージのためスキップ
    else 新規メッセージ
        rect rgb(240, 248, 255)
            Note right of App: DBトランザクション開始
            App->>DB: ビジネスロジック実行
            App->>DB: Inboxテーブルに MsgID: 123 を保存
            App-->>DB: COMMIT (確定)
        end

        App-->>Broker: ACK (処理完了通知)
    end

    Note over DB: 定期バッチ処理
    DB->>DB: 古いInboxレコードを物理削除

Outbox Pattern

Outbox Pattern はデータベースへの更新とメッセージ送信を Atomic に行うために用いられる手法です。データ更新と連動するメッセージ送信を単純に行うと、いずれか一方だけが失敗し不整合となる可能性があります。Outbox Pattern ではこの不整合を回避するため、1 トランザクションでデータベースの更新と送信予定のメッセージの保存 (Outbox テーブル) を行い、その後非同期に Outbox テーブルからメッセージの送信を行います。送信が成功したメッセージは Outbox テーブルから削除できますが、トラブルシューティングやリプレイ (メッセージ送信の再現) のためにしばらく保持する運用が多く取られます。

sequenceDiagram
    autonumber
    participant Client
    participant App as Application
    participant DB as Database
    participant Relay as Message Relay<br/>(Poller/Publisher)
    participant Broker as Message Broker

    Note over Client, DB: 1. データの整合性を担保するフェーズ
    Client->>App: リクエスト (例: 注文確定)

    rect rgb(240, 248, 255)
        Note right of App: DBトランザクション開始
        App->>DB: ビジネスデータの更新 (Ordersテーブル)
        App->>DB: メッセージの保存 (Outboxテーブル)
        App-->>DB: COMMIT (確定)
    end

    App-->>Client: レスポンス (成功)

    Note over DB, Broker: 2. メッセージを送信するフェーズ (非同期)

    loop 定期ポーリング
        Relay->>DB: 未送信メッセージを取得
        Relay->>Broker: メッセージをPublish (送信)
        Broker-->>Relay: ACK (受信確認)

        rect rgb(240, 248, 255)
            Note right of Relay: ここで削除またはフラグ更新
            Relay->>DB: Outboxレコードを削除 (or 処理済みに更新)
        end
    end

    Note over DB: 定期バッチ処理 (フラグを使う場合)
    DB->>DB: 古いOutboxレコードを物理削除

Inbox/Outbox Pattern の厄介なところ

Inbox Pattern、Outbox Pattern はメッセージングシステムを効果的に使うために便利ですが、少し厄介な運用がつきまといます。その仕組み上、データベース上に多数のメッセージをしばらく保持しなければなりません。そしてメッセージは増え続けるため、定期的に削除しないとストレージが肥大化しコストやパフォーマンス悪化を引き起こしてしまいます。またメッセージの件数が多い場合、単純に DELETE 文で削除するとデータベースの負荷が高くなりやす点にも注意しなければなりません。

パーティショニングを使った Inbox/Outbox Pattern の運用

今回は Inbox/Outbox Pattern で溜まるメッセージを自動化されたパーティションテーブルで管理することで、運用負荷とパフォーマンスリスクの軽減を同時に図ります。

概要

やることはとてもシンプルで、pg_partman で Inbox/Outbox テーブルを時系列パーティショニングし、pg_cron で時系列パーティションの自動作成・削除を設定するだけです。例えば Inbox/Outbox テーブルをインターバルを 1 日としてパーティショニングし、1 週間経った古いパーティションを削除するといった具合です。これらの処理はすべて自動化できるため運用負荷が低く、パーティション単位の削除となるためレコード単位の削除と比較して非常に軽量にできます。

使うツール

AlloyDB for PostgreSQL を前提としています。AlloyDB でも利用できる PostgreSQL Extension を利用します。

pg_partman

パーティションの作成・削除などのメンテナンスを簡単にしてくれる PostgreSQL Extension です。インターバルや保持期間などを設定しておくと簡単にパーティションを作成・削除することができるようになります。Inbox/Outbox テーブルのパーティション管理を簡単にするために利用します。

注意: pg_partman_bgw と呼ばれるパーティションメンテナンスをバックグラウンドで自動実行してくれるモジュールも提供されていますが、残念ながら AlloyDB for PostgreSQL では利用できません。

GitHub - pgpartman/pg_partman: Partition management extension for PostgreSQL

pg_cron

データベースのバックグラウンドで動作する Cron-based なジョブスケジューラーの PostgreSQL Extension です。任意のクエリを Cron 方式でスケジュール実行できます。AlloyDB for PostgreSQL で pg_partman_bgw が利用できないため、その代替として利用します。

GitHub - citusdata/pg_cron: Run periodic jobs in PostgreSQL

手順

ここでは大まかなセットアップ手順を紹介していきます。

Extension のセットアップ

pg_cron のインストール

database_flags に以下の設定を追加することで AlloyDB に pg_cron をインストールできます:

alloydb.enable_pg_cron='on'

pg_partman のインストール

AlloyDB の場合、pg_partman はデフォルトでインストールされているので何もしなくて良いです。

Extensions の有効化

あらかじめパーティションメンテナンスに利用するユーザーを準備してください。実行ユーザーに注意しつつ、以下で有効化できます。

-- 実行ユーザー: 特権ユーザー
-- pg_cron の有効化
CREATE EXTENSION pg_cron;
GRANT USAGE ON SCHEMA cron TO <メンテナンス実行ユーザー>;
-- 実行ユーザー: メンテナンス実行ユーザーを推奨 (以降これを前提としている)
-- pg_partman の有効化
CREATE SCHEMA partman; -- partman 用スキーマの作成
CREATE EXTENSION pg_partman SCHEMA partman;

パーティションが置かれるスキーマのセットアップ

パーティションテーブルを置くスキーマをセットアップしていきます。

-- 実行ユーザー: 特権ユーザーを推奨
-- 未作成なら作る
CREATE SCHEMA <パーティションテーブル用スキーマ>;

-- 実行ユーザー: 特権ユーザーを推奨
-- メンテナンス実行時に必要な権限を付与
GRANT USAGE, CREATE ON SCHEMA <パーティションテーブル用スキーマ> TO <メンテナンス実行ユーザー>;

-- 実行ユーザー: メンテナンス実行ユーザー
-- 今後作成されるテーブルにアクセスする権限をアプリケーション用ユーザーに自動付与
ALTER DEFAULT PRIVILEGES IN SCHEMA <パーティションテーブル用スキーマ>
  GRANT SELECT, INSERT, DELETE, UPDATE ON TABLES TO <アプリケーション用ユーザー>;

パーティションテーブルのセットアップ

実際にパーティションを利用する Inbox/Outbox テーブルをセットアップしていきます。

-- 実行ユーザー: メンテナンス実行ユーザー
-- テーブル定義は必要な形で定義すること。
-- パーティションキーを primary key に含める必要がある。
CREATE TABLE <パーティションテーブル用スキーマ>.<パーティションテーブル> (
  "message_id" UUID NOT NULL,
  <パーティションキー> TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT <primary_key> PRIMARY KEY ("message_id", <パーティションキー>)
) PARTITION BY RANGE (<パーティションキー>);

自動パーティションメンテナンスのセットアップ

cron.schedule は論理データベースごとに、partman.create_parentパーティションテーブルごとに設定していきます。

-- (自動マイグレーションで扱いやすいように冪等な実装にしている)

-- 実行ユーザー: メンテナンス実行ユーザー (これを実行したユーザーでメンテナンスが実行されるため)
-- Schedule cron job only when it doesn't already exist
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM cron.job
    WHERE jobname = 'partman-maintenance-job' -- 任意の名前
  ) THEN
    PERFORM cron.schedule(
      'partman-maintenance-job',
      '@hourly', -- 失敗しても何度か実行されるように、パーティション単位より短いことが推奨されている
      'CALL partman.run_maintenance_proc()' -- partman の実行コマンドがこれ
    );
  END IF;
END $$;


-- 実行ユーザー: メンテナンス実行ユーザー
-- Create partman parent only when it doesn't already exist
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM partman.part_config
    WHERE parent_table = <パーティションテーブル用スキーマ>.<パーティションテーブル>
  ) THEN
    PERFORM partman.create_parent(
      p_parent_table => <パーティションテーブル用スキーマ>.<パーティションテーブル>,
      p_control => <パーティションキー>,
      p_interval => <パーティション単位, e.g, '1 day'>
    );
  END IF;
END $$;

UPDATE
  partman.part_config
SET
  infinite_time_partitions = true,
  retention = <パーティションの保持期間, e.g., '7 days'>,
  retention_keep_table = false -- 保持期間を過ぎたパーティションを削除する場合は false
WHERE
  parent_table = <パーティションテーブル用スキーマ>.<パーティションテーブル>;

監視

運用するとなると監視が気になるところですよね。 pg_cron/pg_partman のログは AlloyDB のログとしてみることができるため、そこにアラートなどを仕込むこともできます。またあまり運用には適していませんが、pg_cron で利用される cron.job_run_details テーブルを見ることでジョブの成否やエラーの内容などを確認することもできます。

補足: さらなるログ確認のためには pg_jobmon などの Extension が必要となりますが、残念ながら AlloyDB ではサポートされていません。

補足: PostgreSQL を使う場合

AlloyDB for PostgreSQL ではなく PostgreSQL を利用する場合はいくつか手順が変わります。概要だけ紹介しておきます。

  • pg_cron
    • pg_partman_bgw が使えるため不要になる
  • pg_partman
    • apt などを利用してホストに pg_partman をインストール
    • postgresql.conf に pg_partman_bgw の設定を追加
      • shared_preload_libraries = 'pg_partman_bgw'
      • pg_partman_bgw.interval など

おわりに

AlloyDB for PostgreSQL だと少し制約もありましたが、運用負荷もパフォーマンス懸念も小さくてはっぴー!

今年もいろんなポジションで人を探しているようです。私のいるチームもあるみたいですね。気になる人は Twitter の DM でもなんでも気軽にどうぞ!(お久しぶりでもはじめましてでも)

careers.caddi.com

Docker で動かすプロセスをちゃんと考える会

docker をなんとなく使っていて「Ctrl-C で抜けようとしてもコンテナが止まらない」・「docker stop すると 10 sec くらいかかる」・「k8s で rolling upgrade になんか時間がかかる」といった経験はないでしょうか。もしくは以下のようなことをしていないでしょうか?

  • とりあえず動くからという理由で npm run start のような形で docker の実行プロセスを指定している
  • 何か良からぬことが起こるらしいので tini を使っている
  • もともと単一プロセスだけ実行される docker image だったが runtime に前処理を差し込みたくなり CMD ["sh", "-c", "sh setup.sh && node server.js"] のような書き方をしている

バッチリ設定しているから大丈夫だぜ!という方はブラウザバックで大丈夫です。逆に心当たりがある方は結論だけでも見ていくとハッピーになれるかもしれません。 以下ではサーバー的なプロセスを前提とします。特に nodejs 環境の話をしますが、それ以外のアプリケーションでも根本は共通です。 graceful shutdown の具体的な実装方法は利用しているフレームワークによるところが大きいので今回は触れません。

結論

常に意識するべきこと

  • CMDENTRYPOINT も常に exec 形式にする
  • 前処理が必要なときは、
    • 処理が簡単なとき: CMDsh で wrap しつつ、最後のメインの処理は exec で呼び出す
      • e.g., CMD ["sh", "-c", "sh setup.sh && exec node server.js"]
    • 処理が複雑なとき: ENTRYPOINTentrypoint.sh を配置し、最後に exec "$@" する。CMD にメインの処理を置く。 e.g.,
# entrypoint.sh
# some complex preprocessing
# ./setup-something-1.sh
# ./setup-something-2.sh
# ...
# finally
exec "$@"

# ------------------

# Dockerfile
ENTRYPOINT ["sh", "entrypoint.sh"]
CMD ["node", "server.js"]

nodejs 関連で意識すべきこと (nodejs 以外でも取るべき方針は同じ)

  • graceful shutdown するための SIGTERM (& SIGINT) handler を常に実装する
  • CMD では node を使う
    • npm, yarn などは挟まない
    • tinidocker run --init も基本的には不要
      • docker run --init に依存すると、local の docker では利用できるがいざ k8s で動かそうとすると手を加える必要が出てきてしまう
      • どうしてもゾンビプロセスを生む可能性が避けられないプログラムを動かしたいときに限り、tini を挟んだ方が良い
        • k8s で動かす想定でも同じ。shareProcessNamespace: true も解決策になりうるが、そもそも docker image に閉じた問題であるためおすすめしない。

その他のケースの話

  • CMD で変数展開など shell 固有機能が実行コマンドに必要なときは entrypoint.sh を作り、その中で最終的に exec <command> になるようにする
  • PID=1 だとおかしくなるようなプロセスやゾンビプロセスを生む可能性が避けられないプログラムを動かしたい場合に限り ENTRYPOINT として tini を挟む必要がある
    • PID=1 で動かしたときに SIGTERM などで死んでくれてそれで良いなら tini を挟んでも何も変わらない
    • node は signal handler を適切に実装すれば PID=1 でも期待通り動くようになるので不要

おまけ

結論に至るまで

この結論に至るためには結構いろんなことが必要だったので、ごく初歩的なところから雑に並べておきます。

linux process の振り返り (1)

子プロセスを作成すると親プロセスとは異なる PID を持つことになります。 例えば sh -c 'echo hello' を実行したら、sh が親プロセスとなり、echo が子プロセスとして実行され親から wait されます。

linux process の振り返り (2)

exec コマンドで実行された場合は子プロセスを作ることなくそのプロセスで実行されます。 例えば sh -c 'cmd1 && exec cmd2' を実行したら、cmd1sh の子プロセスとして実行されますが cmd2sh と同じプロセスで動くことになります。

linux process の振り返り (3)

親プロセスが先に死んでしまった場合、子プロセスは孤児プロセスになります。孤児プロセスは kernel によって reparenting され PID=1 の子になります。一般的な linux サーバーのように PID=1 で init が動いている場合、元の親プロセス代わって reparenting されてきたプロセスを wait します。

Docker で実行されるプロセス

単純に ENTRYPOINTCMD (k8s 環境であれば加えて command, args) を組み合わせて PID=1 のプロセスとして実行されます。

docker stop

docker stop を実行すると、対象のコンテナの PID=1 プロセスに SIGTERM が送られます。そのプロセスが SIGTERM により速やかに終了すれば docker stop もそのまま終了します。 ただし SIGTERM がタイムアウト (デフォルトだと 10 sec) 経ってもプロセスが終了しない場合、SIGKILL が送られます。docker stop でちょうど 10 sec 程度かかる場合は SIGTERM でプロセス終了ができていないことを疑うとよいでしょう。

docker entrypoint での tini や docker run --init

プロセス管理

これらを使うとコンテナ内の PID=1 で実行されるプロセスが特別なものになり、ユーザーが実行したい node のようなプロセスはその子プロセスとして実行されます。 PID=1 で動くプロセスは linuxinit と同じように子プロセスの wait をしてくれます。これは孤児プロセスが発生しても PID=1 への reparenting 後に wait されるため、ゾンビ化を防げることを意味します。例えば nodesh のような一般的なプロセスはこのような機能を持ち合わせていないため、それらが PID=1 で動作しているときに孤児プロセスが発生するとやがてゾンビプロセスになってしまいます。

signal handling

PID=1 に来た signal は子プロセスに伝搬されます。signal を伝搬するだけなので、当然 tini や docker run --init を利用しただけでは graceful に終了 (例えば Web サーバーであれば処理中のリクエストを待ってから exit) されません。依然として graceful shutdown のためには適切な signal handler の実装が必要です。

nodejs の挙動

nodejs は PID=1 で実行することが想定されていないため、PID=1 で実行したときとそれ以外で挙動が異なります。 docker-node/docs/BestPractices.md at main · nodejs/docker-node · GitHub

PID=1 以外で signal handler が実装されているとき

signal handler が実装されている場合はそれらを実行します。handler で process.exit() などをした場合に限りプロセスが終了します。

PID=1 以外で signal handler が実装されていないとき

デフォルトだと SIGTERM、SIGINT などの signal を受け取ると雑に exit します。

PID=1 で signal handler が実装されているとき

signal handler が実装されている場合はそれらを実行します。handler で process.exit() などをした場合に限りプロセスが終了します。

PID=1 で signal handler が実装されていないとき

デフォルトだと SIGTERM、SIGINT などの signal を無視します。つまり docker stop してもプロセスは平然と生き続けます。(nodejs v10.9.2 で確認) これが巷で「docker で node を動かすときは tini のようなものが必要だ」と言われる理由だと思います。ただし一つ前の項目で述べたように、signal handler を適切に実装すれば SIGTERM で graceful にプロセスを終了できるため、そちらを優先すべきだと思います。

npm の挙動

npm run は内部的に @npmcli/promise-spawn を利用して子プロセス shスクリプトを実行します。 npm は SIGTERM、SIGINT などの signal を受け取ると基本的にエラーを吐いて終了します。ただしこのとき子プロセスに signal が送られるかは環境 (@npmcli/promise-spawn の実装) に依存します。余計な混乱を避けるため、この辺りに依存したシステムは組むべきではないと思います。 確認していませんが、yarn なども似たようなことになっているようです。

runn と GitHub Actions でお手軽自動 API テストを導入してみた 😎

この記事は CADDi プロダクトチーム Advent Calendar 2024 8 日目の記事です。

はじめに

最近は CADDi で検索と戯れている伊藤 (@amaya382) です。 いろいろと豪勢な記事が並んでいるので今日はちょっとした小ネタを、少し前に導入して便利だったテストツールセットをゆる〜〜く紹介しようと思います。この記事を読むことで、最小限の労力で API テストがバチっとできる道筋が見えるかもしれません。

最近開発している検索用のバックエンドサービスではユニットテストとシナリオテストを中心に品質を担保することができていました。しかしながら、シナリオテストに頼る箇所が多く、実施頻度が限られるシナリオテストでは品質は保証できても手戻りリスクが大きい状態でした。特に実際のデータに依存するような検査項目やミドルウェア・データベースによる影響がユニットテストで捉えきることができず、悩みのタネでした。

この課題を解消するには「早く気づくための自動化が容易であること」と「テストにリアルなデータを利用すること」という点に着目し、API テストの模索を始めました。その他にも OpenAPI を Schema-first に運用していたためこのあたりの検証もまとめてできないかなーと検討した結果行き着いたのが今回紹介する runn です。

runn とは

既に解説記事がたくさん出始めていますが、2 年前に公開が始まったばかりの API テストツールです。YAML で HTTP、gRPC の呼び出しやさらには DB へのアクセスも簡単に取り回せるのが特徴です。ドキュメントやチュートリアルが充実しているのもチームで活用していくにあたって嬉しいポイントですね。ちなみに作者が日本の方のため、比較的日本語のドキュメントが整備されています。

正直機能紹介はこれらを見れば十分なため、この記事では実運用して便利だったポイントを中心に紹介していきます。

実際にローカル使ってみた

今回の環境

実例を紹介する前に、今回適用した環境を簡単に紹介します。主なコンポーネントREST API サーバーと Elasticsearch です。API サーバーのインターフェースは OpenAPI で管理されています。開発の依存を減らすために Schema-first な戦略を取っているため、API テストの中で「OpenAPI 定義に従っているか?」もチェックしたいところです。

API テストを書いてみる

さてここからは runn で実装したテストを紹介していきます。大まかな流れは以下のとおりです:

  • refreshTestData: Elasticsearch 上のデータのセットアップを行う。
  • searchArticlesBeforeCreation: API server で記事の検索を行う。今の段階では何もヒットしない。
  • createArticle: 記事を作成する。
  • verifyArticleOnElasticsearch: 作成された記事が Elasticsearch にあることを確認する。
  • searchArticlesAfterCreation: API server で記事の検索を行う。直前に作成した記事がヒットする。
desc: test my API server
runners:
  myApi:
    endpoint: ${MY_API_URL:-http://localhost:8080}
    openapi3: path/to/openapi.yaml
    skipValidateRequest: false
    skipValidateResponse: false
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}
vars:
  articleId: article-01
steps:
  refreshTestData:
    include:
      path: ../.common/refresh-test-data.yaml
  searchArticlesBeforeCreation:
    myApi:
      "/v1/articles:search?q=nyan":
        get: {}
    test: current.res.status == 404
  createArticle:
    myApi:
      "/v1/articles":
        post:
          body:
            application/json:
              title: new title
              body: nyan
    test: current.res.status == 201
  verifyArticleOnElasticsearch:
    elasticsearch:
      "/articles/_doc/{{ vars.articleId }}":
        get: {}
    test:
      current.res.status == 200 &&
      current.res.body._source.title == 'new title'
  searchArticlesAfterCreation:
    myApi:
      "/v1/articles:search?q=nyan":
        get: {}
    test:
      current.res.status == 200 &&
      current.res.body.hits == 1 &&
      current.res.body.results[0].title == 'new title'

上から順に runn のどんな機能を使ってどんな検証がなされているか見ていきましょう。

runners

いきなり runn の便利さが詰まっている部分です。ここに HTTP クライアントを宣言することで、後の実行ステップで簡単に利用できるようになります。今回は REST の API サーバーと REST をメインのインターフェースとして Elasticsearch それぞれにクライアントを定義しています。注目してほしいのが myApi の定義にある openapi3 の指定です。ここで OpenAPI 定義ファイルを指定することによって、以降このクライアントを利用したときに自動的にリクエストとレスポンスが定義に従っているかを検証してくれます。わずか 1、2 行のことですが個人的に最も感動したポイントです。endpoint環境変数で外部から切り替えられるようにしています。

今回は利用していませんが、HTTP 以外にも gRPC や RDB クライアントも利用できます。YAML 上の最低限の記述だけでこれらのリクエストも扱えるのは他のツールにはあまり見られない特徴ですね。

runners:
  myApi:
    endpoint: ${MY_API_URL:-http://localhost:8080}
    openapi3: path/to/openapi.yaml
    skipValidateRequest: false
    skipValidateResponse: false
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}

vars

テスト内で使う変数を定義できます。後ほど出てきますが、外部の JSON ファイルを読み込むこともできます。

vars:
  articleId: article-01

steps

ここにはテストの各ステップが記述されます。

steps:
  refreshTestData:
  ...

steps.refreshTestData

テストデータを Elasticsearch 上に準備するためのステップです。include 機能を使っており、これは外部の runn テストケースを呼び出すことになります。テスト環境のセットアップのような、複数のテストで共通化したい処理をまとめておくことができます。

refreshTestData:
  include:
    path: ../.common/refresh-test-data.yaml

通化されたテストケースも簡単に見ていきましょう。Elasticsearch は JSON 形式でインデックス定義 (RDB のテーブル定義のようなもの) を行います。ここでは indexSetting.json という外部の JSON にその定義をおいておき、テスト実行時に読み込んでリクエストボディとして利用しています。サービスからとテストケースからで共通の定義を利用できるのは良いですね。

insertTestData ではデータ投入のためにシェルスクリプトを実行しています。YAML から Elasticsearch へのアクセスを記述していってもよいのですが、ある程度の処理であれば外部のスクリプトにまとめると便利なことが多いです。test でこれらの処理の検証もしていますが次で紹介します。

.common/refresh-test-data.yaml

desc: refresh test data
runners:
  elasticsearch:
    endpoint: ${ELASTICSEARCH_URL:-http://localhost:9200}
vars:
  indexSetting: "json://indexSetting.json"
steps:
  createIndex:
    elasticsearch:
      "/article/":
        put:
          body:
            application/json: "{{ vars.request }}"
    test: current.res.status == 201
  insertTestData:
    exec:
      command: ./insert_data.sh ${ELASTICSEARCH_URL:-http://localhost:9200}
    test: current.exit_code == 0

steps.searchArticlesBeforeCreation

さて元のテストケースに戻りまして、今度は API サーバーに対して GET で検索リクエストを投げています。ただしこの時点でのデータではこの条件には何もヒットしないという想定です。runn では test を書いて検証できます。ここでは何も見つからないことを HTTP ステータスが 404 として検証しています。

searchArticlesBeforeCreation:
  myApi:
    "/v1/articles:search?q=nyan":
      get: {}
  test: current.res.status == 404

steps.createArticle

API サーバーに対して POST リクエストで新しい記事を作成しています。

createArticle:
  myApi:
    "/v1/articles":
      post:
        body:
          application/json:
            title: new title
            body: nyan
  test: current.res.status == 201

steps.verifyArticleOnElasticsearch

API サーバー経由のやり取りだけでなく、データベースへ直接アクセスして内部のデータを検証するステップも入れることができます。ここでは直前に作られた記事が API サーバーから Elasticsearch に期待通り永続化されているかを確かめるために、Elasticsearch に直接取得を試みます。Elasticsearch 用の HTTP クライアントを利用して GET した中身をチェックしています。

RDB を利用している場合は代わりに SQL を書いて検証することができます。

verifyArticleOnElasticsearch:
  elasticsearch:
    "/articles/_doc/{{ vars.articleId }}":
      get: {}
  test:
    current.res.status == 200 &&
    current.res.body._source.title == 'new title'

steps.searchArticlesAfterCreation

再度先ほどと同じ条件で API サーバーに対して GET の検索リクエストを投げています。直前に作成した記事がヒットするという想定で、HTTP ステータスに加えてヒットした記事の title を検証しています。

searchArticlesAfterCreation:
  myApi:
    "/v1/articles:search?q=nyan":
      get: {}
  test:
    current.res.status == 200 &&
    current.res.body.hits == 1 &&
    current.res.body.results[0].title == 'new title'

ここまでのまとめ

あとは runn をインストールして Elasticsearch と API サーバーを動かしておけばすぐにテストを実行できます。runn は golang 製なので バイナリ を拾ってくるだけでよいですし、各種パッケージマネージャー からも簡単にインストールできます。

さてこれで開発中に動かすのは余裕ですね。なんですが、チーム開発で確実に API テストを実施するにはやはりこれを CI に組み込んで自動化しなければなりません。というわけで次は GitHub Actions を CI として利用するまでを紹介します。

GitHub Actions 上で runn による API テストを実行する

全体としては、GitHub Actions 上で docker compose を使って Elasticsearch と API サーバーを立ち上げ、あとのステップで runn を実行するという形を取りました。

GitHub Actions

基本的には以下のように docker compose でサービスを立ち上げて次のステップで runn 用のアクションを指定しているだけです。開発環境で既に docker compose を利用していたことやパラメーターに制約があったことから GitHub Actions サービスコンテナではなくステップ内で docker compose を呼び出しています。

.github/workflows/test.yaml

name: Tests on Pull Request
on: pull_request

jobs:
  api-test:
    runs-on: ubuntu-latest
    steps:
      # (snip. build and push docker image)
      - name: Run services for API testing
        run: |
          IMAGE="my-api:${{ github.sha }}" docker compose up -d --wait
      - name: Run API tests
        uses: k2tzumi/runn-action@v0.120.0
        with:
          path_pattern: api-server/runn/**/*.yaml
          enable-run-exec-scope: true
          verbose: true

docker compose

何の変哲もない docker compose ファイルです。強いて言えば立ち上がりの遅い Elasticsearch を待つために healthcheck を入れていることくらいでしょうか。docker compose up -d するときに --wait をつけることでサービスが healthy になるのを待ってくれます。

compose.yaml

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.16.1
    # (snip.)
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "curl -s http://localhost:9200/_cluster/health?pretty | grep status | grep -q -E '(green|yellow)'",
        ]
      interval: 3s
      timeout: 3s
      retries: 20
  myApi:
    image: ${IMAGE}
    # (snip.)
    depends_on:
      elasticsearch:
        condition: service_healthy
    healthcheck:
      test:
        [
          "CMD-SHELL",
          'node -e ''require("http").get("http://localhost:80/health", (r) => process.exit(r.statusCode === 200 ? 0 : 1));''',
        ]
      interval: 3s
      timeout: 3s
      retries: 20

さいごに

API テストを runn で実現しつつ GitHub Actions 上で動かしてみました。手軽に API テストケースを追加できるようになり、PullRequest の段階でユニットテストと同じくらい簡単に問題に気づけるようにもなりました。ヤッタネ!

どうやらいろんなポジションで人を探しているようです。気になる人は Twitter の DM でもなんでも気軽にどうぞ!(お久しぶりでもはじめましてでも)

VoicemeeterでUSBデバイス接続時の挙動を改善する

ふと思い立って前回のUSBデバイス版です。共通部分は省略。調べるのが面倒だったので使うIDとか一部適当です。

  1. USB接続イベントログを有効化
    • Event Viewerを開き,Applications and Services Logs > Microsoft > Windows > DriverFrameworks > UserMode > Operationalコンテキストメニューから Enable Log を選択
      • デフォルトでは無効になっているはず
  2. 対象のUSBデバイスのアドレスを確認
    • USB接続イベントログを有効化した状態で実際に接続すると Event ID=2100 (ID違うかも)としてログが吐かれるので,Event Properties > DetailsUserData > InstanceIdLifetimeId かも。適当)をメモ
  3. Task Schedulerの設定
    • Task Schedulerを開き,てきとうなディレクトリで Create Task
      • お作法をよくわかっていないが,Task Scheduler Library/Users を作っておいた
    • 以下を設定
      • General > Name: てきとう
      • Trigger > New: Begin the task=On an event
        • Settings > Custom > New Event FilterXMLタブから Edit query manually を有効化して以下を入力
          • 対象のUSBデバイスのIDはイベントログからメモったもの
            • & が含まれる場合はエスケープ (&amp;) が必要
          • <QueryList>
                <Query Id="0" Path="Microsoft-Windows-DriverFrameworks-UserMode/Operational">
                    <Select Path="Microsoft-Windows-DriverFrameworks-UserMode/Operational">
                        *[System[Provider[@Name='Microsoft-Windows-DriverFrameworks-UserMode'] and EventID=2100]] and *[UserData[UMDFHostDeviceRequest[InstanceId='【対象のUSBデバイスのID】']]]
                    </Select>
                </Query>
            </QueryList>
            
      • Actions > New: Action=Start a program Program/script=【作成したVBスクリプトのパス】
      • その他の設定はデフォルトでok