はじめに
SubnetMap は Leptos 0.8(Rust 製の Web フレームワーク)と Axum 0.8 で作った IP アドレス管理ツール(IPAM)だ。フロントエンドが動いた。画面にサブネット一覧が表示された。ボタンも押せる。フィルタも効く。ここまでは順調だった。
次の問題はすぐに来た。データをどう保存するか、だ。
サブネット作成フォームが動いた日、IP アドレスの INSERT を実装した。status カラムに PostgreSQL の ENUM 型を使っていたので、$4::ip_status とキャストして sqlx::query_as! マクロに渡した。コンパイルが通らない。エラーメッセージは mismatched types — 「expected String, found String」。型は同じに見える。同じに見えるのに通らない。エラーメッセージを読んだ。ドキュメントを漁った。GitHub の Issue を検索した。半日が溶けた。答えは「マクロ版ではなくランタイム版の query_as() を使え」だった。たった1文字、! を消すだけだった。
半日かけて1文字を消した、という事実をどう受け止めればいいのか、正直まだ分からない。ただ、その半日がなければ、この記事は書けなかった。「PostgreSQL の CIDR 型を Rust にどう持ち込むか」「ENUM キャストで sqlx マクロが壊れたらどうするか」「N+1 クエリをどう避けるか」。ネットワーク管理ドメインならではの DB 設計の判断を、格闘の記録として残しておく。
DB 設計 — CIDR/INET 型とネットワークアドレスの表現
IPAM ツールの設計は DB スキーマから始まる。最初は VARCHAR(可変長文字列型)で "10.0.0.0/24" を文字列として保存しようとした。動く。動くが、「このIPアドレスはどのサブネットに属するか」を検索するたびに文字列パースが走ることになる。PostgreSQL にはネットワークアドレスを構造化データとして扱う専用の型がある。CIDR 型(サブネット表現用)と INET 型(個別アドレス表現用)だ。
CREATE TABLE subnets ( id UUID PRIMARY KEY, cidr CIDR NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, description TEXT, parent_id UUID REFERENCES subnets(id) ON DELETE SET NULL, used_count INTEGER NOT NULL DEFAULT 0, total_addresses INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_subnets_cidr ON subnets USING gist (cidr inet_ops);
CREATE TYPE ip_status AS ENUM ('available', 'assigned', 'reserved', 'deprecated'); CREATE TABLE ip_addresses ( id UUID PRIMARY KEY, address INET NOT NULL UNIQUE, subnet_id UUID NOT NULL REFERENCES subnets(id) ON DELETE CASCADE, status ip_status NOT NULL DEFAULT 'available', hostname VARCHAR(255), assigned_to VARCHAR(255), description TEXT, mac_address VARCHAR(17), created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_ip_addresses_address ON ip_addresses USING gist (address inet_ops);
CIDR 型を使う理由
CIDR 型は単なる文字列ではない。PostgreSQL が内部でネットワークアドレスとプレフィックス長を構造化データとして保持するため、「このアドレスはどのサブネットに含まれるか」といったネットワーク演算がインデックス付きで実行できる。
cidr >>= '10.0.1.5'::inet— このアドレスがサブネットに含まれるかcidr << '10.0.0.0/8'::cidr— このサブネットが別のサブネットの子かhost(address)— INET からホスト部分を抽出
GiST インデックス(汎用検索木 — 空間データやネットワークアドレスなど、通常の B-tree では効率的に扱えないデータ構造向けのインデックス)を inet_ops オペレータクラスで使うことで、これらの包含関係クエリが高速に実行される。文字列の LIKE 検索では実現できないセマンティックな検索だ。
Rust 側での CIDR パース
SubnetMap のサーバー関数 create_subnet では、ipnetwork クレートで CIDR 文字列をパースし、利用可能アドレス数を計算する。ドメイン知識として注意すべき点が2つある。
/31(RFC 3021 ポイントツーポイントリンク)と/32(ホストルート)ではネットワーク・ブロードキャストアドレスの除外がない2_i32.pow(32 - prefix)で計算しているため、/0のような極端な入力はi32のオーバーフローを起こす。実用上はprefixの範囲バリデーション(/8〜/32など)をサーバー関数に入れるべきだろう
TEXT キャストの必要性
SQLx で CIDR/INET 型を扱う際、最初は何も考えずに SELECT cidr FROM subnets と書いた。コンパイルエラーだ。SQLx は PostgreSQL の CIDR 型を Rust の String に自動変換してくれない。::TEXT キャストで明示的に文字列に変換する必要がある。
sqlx::query_as!( Subnet, r#"SELECT id, cidr::TEXT as "cidr!", name, description, parent_id, used_count, total_addresses, created_at, updated_at FROM subnets ORDER BY cidr"# )
cidr::TEXT as "cidr!" の ! は SQLx のカラム型オーバーライド構文だ。SQLx のコンパイル時チェックは CIDR → String の変換を自動推論できないため、「このカラムは非 NULL の String だ」と明示的に宣言する。
PostgreSQL ENUM と SQLx マクロの衝突
ここが冒頭で触れた「半日が溶けた」箇所だ。SubnetMap では3つの PostgreSQL ENUM 型(取りうる値を事前に列挙する型)を使う。
CREATE TYPE ip_status AS ENUM ('available', 'assigned', 'reserved', 'deprecated'); CREATE TYPE dns_record_type AS ENUM ('A', 'AAAA', 'PTR'); CREATE TYPE alert_type AS ENUM ('high_utilization', 'duplicate_ip', 'subnet_full');
SELECT 時は status::TEXT as "status!" で文字列として取得できる。問題は INSERT/UPDATE 時の ENUM キャスト だ。SELECT はうまくいくのに INSERT で壊れる、というのが厄介なところで、成功体験の直後に落とし穴がある。
sqlx::query_as! マクロの限界
当然のように INSERT にも query_as! マクロを使った。
// これはコンパイルエラーになる sqlx::query_as!( IpAddress, r#"INSERT INTO ip_addresses (..., status, ...) VALUES (..., $4::ip_status, ...) RETURNING ..."#, /* ... */ &status, // String を ENUM にキャスト )
sqlx::query_as! はコンパイル時に DB に接続してクエリを検証する。これ自体は素晴らしい機能だ — SQL の typo がコンパイルエラーになる。しかし $4::ip_status のような ENUM キャストを含むクエリでは、パラメータの型推論が壊れて mismatched types エラーが発生する。エラーメッセージは「expected String, found String」のような不可解なものだった。型は同じに見えるのに通らない。SQLx の GitHub Issues を掘り返してようやく理解した。マクロが ENUM キャストのパラメータ型を正しく推論できないのだ。
解決: ランタイム query_as() への切り替え
ENUM キャストを含む INSERT/UPDATE は、コンパイル時チェックを諦めてランタイム版の sqlx::query_as() を使う。!(マクロ呼び出し)が消えて ()(通常の関数呼び出し)になる、たった1文字の違いだ。
let ip: IpAddress = sqlx::query_as( r#"INSERT INTO ip_addresses (id, address, subnet_id, status, hostname, assigned_to, description, mac_address) VALUES ($1, $2::INET, $3, $4::ip_status, $5, $6, $7, $8) RETURNING id, address::TEXT as address, subnet_id, status::TEXT as status, hostname, assigned_to, description, mac_address, created_at, updated_at"#, ) .bind(id) .bind(&address) .bind(subnet_id) .bind(&status) .bind(&hostname) .bind(&assigned_to) .bind(&description) .bind(&mac_address) .fetch_one(&pool) .await .map_err(|e| ServerFnError::new(format!("DB error: {e}")))?;
ランタイム版を使うには、モデルに sqlx::FromRow の derive が必要になる。
#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct IpAddress { pub id: Uuid, pub address: String, pub subnet_id: Uuid, pub status: String, pub hostname: Option<String>, // ... }
使い分けのルール
SubnetMap で確立したルールを以下にまとめる。
| 操作 | ENUM キャスト | 使う API |
|---|---|---|
| SELECT | status::TEXT as "status!" |
sqlx::query_as! (コンパイル時チェック) |
| INSERT/UPDATE | $4::ip_status |
sqlx::query_as() (ランタイム) |
| DELETE / スカラー | なし | sqlx::query! / sqlx::query_scalar! |
コンパイル時チェックの恩恵を最大限に受けつつ、ENUM キャストが必要な箇所だけランタイム版にフォールバックする。「全部マクロ版で統一」も「全部ランタイム版で統一」もできるが、このハイブリッド方式が実用的な落としどころだった。
Rust 側の ENUM マッピング
PostgreSQL ENUM を Rust で表現する。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum IpStatus { Available, Assigned, Reserved, Deprecated, } impl IpStatus { pub fn as_str(&self) -> &'static str { match self { Self::Available => "available", Self::Assigned => "assigned", Self::Reserved => "reserved", Self::Deprecated => "deprecated", } } pub fn parse(s: &str) -> Self { match s { "assigned" => Self::Assigned, "reserved" => Self::Reserved, "deprecated" => Self::Deprecated, _ => Self::Available, // 注: 未知の文字列を Available にフォールバックする簡略実装 } } pub fn color_class(&self) -> &'static str { match self { Self::Available => "bg-green-500/20 text-green-400", Self::Assigned => "bg-blue-500/20 text-blue-400", Self::Reserved => "bg-yellow-500/20 text-yellow-400", Self::Deprecated => "bg-red-500/20 text-red-400", } } }
DB の値は String として取得し、UI 表示時に IpStatus::parse() で enum に変換する。color_class() メソッドは Tailwind のクラス名を返し、ステータスバッジの色を match の網羅性チェックで管理する — 新しいステータスを追加すれば色の定義忘れがコンパイルエラーになる。
parse() のワイルドカードフォールバック(_ => Self::Available)はサンプルの簡略化だ。本番コードでは Result<Self, String> を返すか、最低限 tracing::warn! で未知の値をログに記録すべきだろう。DB に新しいステータスが追加された場合、パースエラーではなく誤ったステータスで表示されるサイレントなバグの原因になる。
sqlx::Type derive を使えば DB 値と Rust enum の自動変換もできるが、明示的な変換の方が #[cfg(feature = "ssr")] ゲートが不要で、WASM ビルドでも同じ enum 型を使い回せる。
トリガーによるサブネット利用率の自動計算
ダッシュボードとサブネットカードで利用率を表示する。最初の素朴な実装では、サブネット一覧を表示するたびに各サブネットの IP アドレスを COUNT(*) するクエリが走っていた。サブネットが10個なら10回の COUNT クエリ。いわゆる N+1 問題だ。開発中は気にならなかったが、テストデータを50サブネット分入れた途端、ダッシュボードの表示が目に見えて遅くなった。
SubnetMap ではこの問題を カウンターキャッシュ で解決した。Rails の counter_cache と同じ発想で、subnets テーブルに used_count カラムを持たせ、PostgreSQL のトリガー(特定の操作を検知して自動実行される DB のプログラム)で自動更新する。
CREATE OR REPLACE FUNCTION update_subnet_used_count() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN IF NEW.status != 'available' THEN UPDATE subnets SET used_count = used_count + 1 WHERE id = NEW.subnet_id; END IF; RETURN NEW; ELSIF TG_OP = 'DELETE' THEN IF OLD.status != 'available' THEN UPDATE subnets SET used_count = used_count - 1 WHERE id = OLD.subnet_id; END IF; RETURN OLD; ELSIF TG_OP = 'UPDATE' THEN IF OLD.status = 'available' AND NEW.status != 'available' THEN UPDATE subnets SET used_count = used_count + 1 WHERE id = NEW.subnet_id; ELSIF OLD.status != 'available' AND NEW.status = 'available' THEN UPDATE subnets SET used_count = used_count - 1 WHERE id = NEW.subnet_id; END IF; RETURN NEW; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_subnet_used_count AFTER INSERT OR UPDATE OR DELETE ON ip_addresses FOR EACH ROW EXECUTE FUNCTION update_subnet_used_count();
ステータスを考慮したカウントになっている。available(利用可能)な IP アドレスは「使用中」に含めない。INSERT 時に status = 'available' ならカウントを増やさず、UPDATE で available → assigned に変わったときだけ +1 する。最初はこのステータス考慮を忘れていて、「全IPアドレスが使用中」という表示になった。ステータスの遷移パターンすべてを網羅する必要がある — INSERT/UPDATE/DELETE × ステータス変化の組み合わせだ。
トリガーを選んだ理由
- 原子性: INSERT/UPDATE と used_count の更新が同一トランザクション内で実行される
- DRY:
create_ip_addressの Rust コードは IP アドレスの INSERT だけに集中でき、カウント更新のことを知らなくてよい - 一貫性: psql から直接データを操作しても、カウントが正しく更新される
トレードオフとして、トリガーは暗黙的なロジックだ。Rust のコードだけ読んでいても used_count がいつ更新されるのか分からない。アプリケーションの振る舞いを理解するにはマイグレーションファイルも読む必要がある。この「見えないロジック」は Rust の「明示性を重視する」哲学とは相反するが、N+1 の解消というメリットの方が勝ると判断した。
もう一つのリスクは、トリガーが壊れたりバルクロードしたりした際に used_count と実際の COUNT が乖離する可能性だ。定期的に UPDATE subnets SET used_count = (SELECT COUNT(*) FROM ip_addresses WHERE subnet_id = subnets.id AND status != 'available') で補正するメンテナンスクエリを用意しておくのが現実的な対策になる。
条件付きコンパイルの実践パターン
Leptos アプリは SSR(サーバー用)と Hydrate(ブラウザ用 WASM)の2つのターゲットでコンパイルされる。DB アクセスや認証ロジックがブラウザに送られたら大問題だ。サーバー専用のコードがクライアントにバンドルされないよう、#[cfg(feature = "ssr")](条件付きコンパイル属性)を多用する。最初の実装ではこれを忘れて hydrate ビルドが壊れる、ということを何度か繰り返した。
パターン 1: 条件付き derive
#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct IpAddress { pub id: Uuid, pub address: String, // ... }
sqlx::FromRow は SSR ビルドでのみ必要。WASM ビルドでは sqlx クレートの依存を完全に排除できる。
パターン 2: SSR 専用の import
#[cfg(feature = "ssr")] use crate::models::tag::Tag;
Tag 型がサーバー関数内の sqlx::query_as! でのみ使われる場合、SSR ゲートなしだと hydrate ビルドで unused import 警告が出る。
パターン 3: サーバー関数内の限定的 use
#[server] pub async fn create_subnet(cidr: String, name: String, ...) -> Result<Subnet, ServerFnError> { use super::db::pool; use ipnetwork::IpNetwork; // サーバー専用の依存 let pool = pool()?; let network: IpNetwork = cidr.parse()?; // ... }
#[server] マクロは関数本体をサーバー専用にコンパイルし、クライアント側には RPC スタブだけを生成する。関数内の use 文はサーバー依存を安全に参照できる。
pg_trgm による複数エンティティ横断検索
管理画面にはグローバル検索が欲しい。「このホスト名どこにあったっけ」「10.0.1 で始まるサブネットは」。サブネット、IP アドレス、VLAN、DNS レコードの4つのエンティティを横断して検索する必要がある。最初は LIKE '%query%' で実装したが、タイポに弱い。「webserber」で検索しても「webserver」がヒットしない。PostgreSQL の pg_trgm 拡張を使い、トライグラム(3文字の部分文字列)ベースの類似度検索に切り替えた。これなら多少のタイポも吸収してくれる。
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_subnets_name_trgm ON subnets USING gin (name gin_trgm_ops); CREATE INDEX idx_ip_addresses_hostname_trgm ON ip_addresses USING gin (hostname gin_trgm_ops); CREATE INDEX idx_ip_addresses_assigned_to_trgm ON ip_addresses USING gin (assigned_to gin_trgm_ops); CREATE INDEX idx_vlans_name_trgm ON vlans USING gin (name gin_trgm_ops); CREATE INDEX idx_dns_records_hostname_trgm ON dns_records USING gin (hostname gin_trgm_ops);
サーバー関数側では、各エンティティを個別にクエリし、類似度スコア(0.0〜1.0)で統合ソートする。4つのテーブルを UNION ALL で結合する方法も検討したが、各テーブルの検索対象カラムが異なるため、個別クエリの方がシンプルだった。
#[server] pub async fn global_search(query: String) -> Result<Vec<SearchResult>, ServerFnError> { let mut results = Vec::new(); // サブネット検索: 名前の類似度 + CIDR の部分一致 let subnet_results = sqlx::query!( r#"SELECT id, name, cidr::TEXT as "cidr!", similarity(name, $1) as "sim!" FROM subnets WHERE name % $1 OR cidr::TEXT LIKE '%' || $1 || '%' ORDER BY similarity(name, $1) DESC LIMIT 10"#, query ) .fetch_all(&pool) .await .unwrap_or_default(); // エラー時は空結果を返す — 検索の部分的失敗でUI全体を壊さない判断 // IP アドレス検索: hostname + assigned_to の類似度、またはアドレスの部分一致 let ip_results = sqlx::query!( r#"SELECT id, address::TEXT as "address!", hostname, assigned_to, GREATEST( COALESCE(similarity(hostname, $1), 0), COALESCE(similarity(assigned_to, $1), 0) ) as "sim!" FROM ip_addresses WHERE hostname % $1 OR assigned_to % $1 OR address::TEXT LIKE '%' || $1 || '%' ORDER BY "sim!" DESC LIMIT 10"#, query ) .fetch_all(&pool) .await .unwrap_or_default(); // ... VLAN、DNS レコードも同様 ... // 全結果を類似度スコアで統合ソート results.sort_by(|a, b| { b.similarity.partial_cmp(&a.similarity) .unwrap_or(std::cmp::Ordering::Equal) }); results.truncate(20); Ok(results) }
% 演算子は pg_trgm の類似度検索オペレータで、GIN インデックスが効く。similarity() 関数で 0.0〜1.0 のスコアを取得し、GREATEST で複数カラムの最大類似度を採用する。
サブネット検索では name % $1 OR cidr::TEXT LIKE ... と、名前のファジー検索と CIDR の部分一致を OR で組み合わせている。「10.0」と入力すれば 10.0.0.0/24 も 10.0.1.0/24 もヒットする。
JSONB による変更履歴の記録
IP アドレス管理では「誰が何をいつ変更したか」の追跡が不可欠だ。「昨日まで使えていた IP アドレスが突然使えなくなった」というとき、変更履歴がなければ原因調査は暗闇の手探りになる。SubnetMap では全変更を audit_logs テーブルに記録する。
CREATE TABLE audit_logs ( id UUID PRIMARY KEY, entity_type VARCHAR(50) NOT NULL, entity_id UUID NOT NULL, action VARCHAR(50) NOT NULL, old_value JSONB, new_value JSONB, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id); CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
old_value / new_value に JSONB を使うことで、エンティティの型に依存しない汎用的な変更記録が可能になる。サブネットの変更も IP アドレスの変更も同じテーブルに記録できる。
サーバー関数での記録パターンを見てみよう。
// サブネット作成時 sqlx::query!( "INSERT INTO audit_logs (id, entity_type, entity_id, action, new_value) VALUES ($1, 'subnet', $2, 'create', $3)", Uuid::now_v7(), id, serde_json::to_value(&subnet).ok() ) .execute(&pool) .await .ok();
serde_json::to_value(&subnet) で Rust の構造体をそのまま JSONB に変換する。Serialize derive(自動シリアライズ実装)が付いていれば何でも記録できる。.ok() で監査ログの書き込み失敗を無視しているのは意図的な判断だ。「サブネットの作成は成功したのに監査ログの書き込みで失敗してロールバック」は本末転倒になる。
ただし、完全に無視するのは乱暴すぎるかもしれない。本番環境では最低限 tracing::warn! でログに記録する、あるいはメッセージキューに入れて非同期で再試行する、といった選択肢がある。コンプライアンス要件のある環境では監査ログの欠損は許容できないため、別トランザクションでの書き込みや専用のイベントストアへの委譲を検討すべきだろう。SubnetMap では社内ツールという前提でベストエフォートを選んだ。
削除時は事前に記録する。
// 削除前に記録 sqlx::query!( "INSERT INTO audit_logs (id, entity_type, entity_id, action) VALUES ($1, 'subnet', $2, 'delete')", Uuid::now_v7(), id, ) .execute(&pool) .await .ok(); // その後に削除 sqlx::query!("DELETE FROM subnets WHERE id = $1", id) .execute(&pool) .await?;
利用率監視と重複検知
SubnetMap のアラートは3種類の ENUM で定義される。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AlertType { HighUtilization, DuplicateIp, SubnetFull, } impl AlertType { pub fn color_class(&self) -> &'static str { match self { Self::HighUtilization => "bg-yellow-500/20 text-yellow-400", Self::DuplicateIp => "bg-red-500/20 text-red-400", Self::SubnetFull => "bg-red-500/20 text-red-400", } } }
color_class() パターンは IpStatus と共通だ。ENUM の各バリアントが UI 表現の知識を持つことで、テンプレート側の条件分岐が不要になる。
アラートモデルにも条件付き FromRow を付ける。
#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Alert { pub id: Uuid, pub alert_type: String, pub subnet_id: Option<Uuid>, pub message: String, pub is_acknowledged: bool, pub created_at: chrono::NaiveDateTime, }
ダッシュボードでは未確認アラートだけをフィルタして表示する。
let alerts = Resource::new(|| (), |_| list_alerts(Some(true))); // unacknowledged_only = true
おわりに
CIDR/INET 型による構造化されたネットワークアドレス管理、ENUM 型と SQLx マクロの衝突とその回避策、トリガーによるカウンターキャッシュ、pg_trgm による複数エンティティ横断検索、JSONB による汎用的な監査ログ。VARCHAR で文字列として保存していたら、これらの恩恵はすべて失われていた。PostgreSQL の型を信じた結果が、ここにある。
最も時間を使ったのは ENUM キャストの問題だった。コンパイル時チェックが売りの SQLx で、ENUM を使った瞬間にコンパイル時チェックが効かなくなる。この制限が SQLx のアップデートで解消されるのか、それとも PostgreSQL ENUM を避けて VARCHAR + CHECK 制約に寄せるべきなのかは、まだ結論が出ていない。
冒頭で書いた「半日かけて1文字を消した」話に戻る。あの半日は無駄だったのか。たぶん、無駄ではなかった。CIDR 包含検索やトライグラム類似度検索が動いたとき、あの半日の格闘がなければここまで辿り着けなかったと思った。思ったが、それが本当に因果関係なのか、ただの事後的な意味づけなのかは、正直分からない。分からないが、PostgreSQL の型を信じたことは、たぶん正しかった。たぶん。