newmo 技術ブログ

技術で地域をカラフルに

JSConf JPでModular Monolith Monorepoについて発表しました

こんにちは、newmoでソフトウェアエンジニアをしている @yui_tangです。

2024年11月23日に開催されたJSConf JP 2024にて、「Modular Monolith Monorepo -シンプルさを保ちながらmonorepoのメリットを最大化する-」というテーマで発表させていただきました。

スライド:

speakerdeck.com

動画:

https://www.youtube.com/live/2BXwigWGjWQ?feature=shared&t=21596

JSConf JP 2024について

JSConf JPは日本最大級のJavaScriptカンファレンスです。newmoは初めてスポンサーとして本イベントに協賛させていただきました。今年も国内外から多くの開発者が集まり、JavaScriptエコシステムに関する知見が共有される貴重な場となりました。

発表内容

今回の発表では、newmoでプロダクト開発初期から取り組んでいるMonorepoでの開発について、特にModular Monolithの考え方を取り入れた設計アプローチと、それらを実現する為のアプローチの一つとしてのpnpmを活用したOne Version Ruleの実践、そして意思決定プロセスの具体例として、社内UIコンポーネントライブラリのDesign Docsを使った具体的なアプローチを中心にお話させていただきました。

セッション中、オフラインで発表を聞いていただいた方に質問を呼びかけたところ、Monorepoを実践している方が参加者の1/3程度、Modular Monolithを実践している方はいらっしゃいませんでした。そんな中多くの方が当セッションに来ていただきありがとうございました。

Modular MonolithやMonorepoについては、今後も当Tech Blogから情報を発信・共有していければと思います。

Q&Aの公開

以下、発表後に話しかけていただいた方々のQ&Aの一部を整理し記載しておきます。

Design Docsを書くのにどれぐらい時間をかけていますか?

扱うものによると思います。短いものだと1営業日以内で、長いもので5営業日程度です。 Tierが高くなるほどちゃんと調べて書くようになるので、時間をかける気がします。

One Version Ruleだとパッケージのアップデートが難しくなりませんか?

はい。One Version Ruleでは1つのバージョンしか使わないため、パッケージのアップデートをすると全てのアプリケーションに影響があります。基本的にはdefault catalog(catalog:)を使いますが、段階的にパッケージのアップデートをしていく際にはNamed Catalogsが利用できます。

Named Catalogsはあるパッケージのバージョンに名前をつけることができるため、アップデートしたパッケージを参照したいアプリケーションだけNamed Catalogsを参照することで段階的なアップデートが可能です。monorepo内で一時的に複数のバージョンがある状態を許容できるような仕組みになっています。

全てのアプリケーションがアップデートし終わったらdefault catalogsをアップデートして、再びOne Version Ruleを再開できます。

パッケージのアップデートは手動ですか?

当記事執筆時点だと手動です。

手動なのは、現時点だとDependabotRenovateがpmpm catalogに対応してないためです。

ただし、利用するパッケージの数が線形的に増えないようにしているため、手動でも管理できる数しかありません。 そのため、パッケージの新しいバージョンがリリースされたタイミングで更新しています。

また、カタログの定義で意味あるグループに分けているのでグループ単位でアップデートすることが多いです。 意味のあるグループごとのパッケージでアップデートすることで、アップデート時に注意する点も分かりやすくなり、アップデートがやりやすくなっています。 基本的には自動テストが通ればマージしています。

将来的には自動アップデートにしたいです。この時にカタログのグループごとのPRを出せるようにすることで、人間も追いやすいアップデートが可能になるのではと考えています(グループごとのアップデートはrenovatebotのpackageRulesで実現できます)。

引き続きソフトウェアエンジニアを募集しています

newmoではMonorepoでModular Monolithでアプリを開発していきたいエンジニアを積極的に採用中です!

ブラウザで動作する地理空間データ処理ライブラリとして DuckDB-wasm を使い、 SQL を TypeScript で管理する仕組みを作る

newmo では、地図データや地理情報を扱う場面が多くあります。

たとえば、タクシーやライドシェアでは、営業区域のような営業していいエリアといった地理的な定義があります。 また、乗り入れ禁止区域のようなタクシーが乗り入れてはいけないエリアといった定義も必要になります。

これらの地理に関する定義は GeoJSON のような地理情報を扱うデータ形式で管理されることが多いです。 しかし、GeoJSONなどの定義をテキストとして手書きするのは困難です。 そのため、地図上に区域を作図するエディタやその定義した区域が正しいかをチェックするような管理ツールが必要です。 管理ツールは、ウェブアプリケーションとして作った方が利用できる環境が広がります。

このような地理情報は一度に扱うデータが多かったり、空間的な計算処理が必要になるため、専用の仕組みを使うことが多いです。 このような技術を、地理情報システム(GIS:Geographic Information System)と呼びます。

ブラウザやウェブ標準には、GIS処理を行う仕組みは特にないため、すでに安定したGISの実装があるサーバ側で処理することも多いです。 しかし、データを変更するたびに、クライアントからサーバにリクエストしてサーバ側で GIS の処理を行うと、変更の反映がリアルタイムにできないため体験が良くありません。 そのため、地図情報の編集ツールといったプレビューの表示はクライアント側でも地理空間データ処理をして、操作内容を即時に反映できると体験を向上させることができます。

これらを行うには、ブラウザで動作する地理空間データ処理を行うライブラリが必要です。

ブラウザで動作する地理空間データ処理ライブラリとして DuckDB-wasm を使う

ブラウザで地理空間データ処理を行うライブラリとして何を使うかを検討して、最終的にDuckDB-wasmを使うことにしました。

DuckDB-wasmは、名前の通りDuckDBの WebAssembly ビルドです。 この、DuckDB-wasm はブラウザや Node.js など Wasm を実行できる環境で、DuckDB を動かすことができます。

DuckDB にはSpatial Extensionというものがあり、地理空間データ処理を扱えます。

データベースでは、Postgres 拡張のPostGISや SQLite 拡張のSpatiaLiteなどがあります。 DuckDB のSpatial Extensionもこれと同様の地理空間データ処理を扱う拡張で、面白い点として WebAssembly で動く点があげられます。

PostGIS と DuckDB の Spatial Extension をおおまかに比較してみると次のようになります。 また、JavaScriptで実装された地理空間データ処理ライブラリである Turf.js との比較も併せてみます。

基本的なCategory DuckDB WASM PostGIS Turf
基本データ型 GEOMETRY, POINT_2D, LINESTRING_2D, POLYGON_2D geometry, geography Point, LineString, Polygonなど
空間演算 ST_Area, ST_Distance, ST_Union ST_Area, ST_Distance, ST_Union, ST_Buffer area, distance, union, buffer
空間関係 ST_Contains, ST_Within, ST_Touches ST_Contains, ST_Within, ST_Touches, ST_Crosses booleanContains, booleanWithin, booleanOverlap
座標変換 ST_Transform ST_Transform, ST_SetSRID transform
ジオメトリ生成 ST_MakePoint, ST_MakeLine ST_MakePoint, ST_MakeLine, ST_MakePolygon point, lineString, polygon
集計関数 ST_Extent, ST_Union ST_Extent, ST_Union, ST_Collect collect, combine
クラスタリング なし ST_ClusterDBSCAN, ST_ClusterKMeans clustersKmeans
測地線計算 ST_Distance_Spheroid ST_Distance_Spheroid, ST_Length_Spheroid geodesicDistance
GeoJSON 周りの比較 DuckDB WASM PostGIS Turf
GeoJSON 変換 ST_AsGeoJSON ST_AsGeoJSON feature, featureCollection
ジオメトリ → GeoJSON SELECT ST_AsGeoJSON(geom) SELECT ST_AsGeoJSON(geom) turf.feature(geometry)
プロパティ付き Feature json extension ST_AsGeoJSON(t.*)  turf.feature(geometry, properties)
FeatureCollection 生成 json extension json_build_object('type','FeatureCollection','features',json_agg(ST_AsGeoJSON(t.*)::json)) turf.featureCollection(features) 
CRS 指定 ST_Transform + ST_AsGeoJSON ST_Transform + ST_AsGeoJSON1 なし(WGS84 固定)
オプション 基本的な GeoJSON 出力のみ maxdecimaldigits, bbox, CRS 指定など bbox, id 指定可能
基本作成 ST_MakePolygon, ST_Polygon ST_MakePolygon, ST_Polygon, ST_PolygonFromText polygon, multiPolygon
Polygon 操作 DuckDB WASM PostGIS Turf
ポリゴン演算 ST_Union, ST_Intersection, ST_Difference ST_Union, ST_Intersection, ST_Difference, ST_3DUnion union, intersect, difference
空間分析 ST_Area, ST_Perimeter ST_Area, ST_Perimeter, ST_3DArea, ST_3DPerimeter area, perimeter
空間関係 ST_Contains, ST_Within, ST_Overlaps7 ST_Contains, ST_Within, ST_Overlaps, ST_3DIntersects booleanContains, booleanWithin, booleanOverlap
検証 ST_IsValid, ST_IsSimple ST_IsValid, ST_IsSimple, ST_IsValidReason isPolygon, isMultiPolygon
変換 ST_Transform ST_Transform, ST_Force3D transformScale, transformRotate
単純化関数 ST_Simplify ST_Simplify simplify,polygonSmooth
サポートしているデータ形式 DuckDB WASM PostGIS Turf
入力形式 GeoJSON, Shapefile, GeoPackage, KML, GML GeoJSON, Shapefile, GeoPackage, KML, GML, WKT, WKB GeoJSON のみ
出力形式 GeoJSON, WKT, WKB GeoJSON, KML, SVG, WKT, WKB GeoJSON のみ
GeoJSON 操作 ST_AsGeoJSON, ST_GeomFromGeoJSON ST_AsGeoJSON, ST_GeomFromGeoJSON feature, featureCollection10
KML 操作 ST_AsKML ST_AsKML, ST_GeomFromKML なし
WKT 操作 ST_AsText, ST_GeomFromText ST_AsText, ST_GeomFromText なし
データ読み込み ST_Read ST_Read, ST_AsBinary JSON.parse
データ書き出し ST_AsGeoJSON, ST_AsText ST_AsGeoJSON, ST_AsKML, ST_AsSVG JSON.stringify

おおまかに比較しても、DuckDB の Spatial Extension は PostGIS と同等の機能性を持っていることがわかります。 逆にTurf.jsは演算のみなので、データ形式の変換などについてはスコープ外となっていることもわかります。

ブラウザ上で動作する地理空間データ処理ライブラリを決めるために、このような機能比較、実装イメージ、ユースケース、サイズ、メリット/デメリットなどを書いた Design Doc を書いて議論しました。 その結果、DuckDB-wasm を使うことにしました。

📢 newmoでの Design Doc について

newmo のフロントエンドでは、大きなライブラリを導入する際には Design Doc を書いて議論してから決めることが多いです。 これについては、以前書いた One Version Rule を実践するためと、なぜそのライブラリを使っているのかという経緯が Design Doc(Architectural Decision Records としての役割)として残るためです。

この Design Doc とライブラリの管理などについては、yui_tang が 2024年11月23日(土曜) のJSConf JPで発表する予定です。

DuckDB-wasm + TypeScript で SQL を管理する

地理空間データ処理ライブラリとしてDuckDB-wasmを使うことにしました。 これは、クライアントサイドで SQL を書いて、クライアントサイドのWasm上で SQL を実行する必要があるということを意味しています。

今回は DuckDB のデータを永続化はせずに In-Memory DB として利用しているので、マイグレーションのような複雑な問題はありませんが、 それでも SQL を管理する方法は考える必要があります。

DuckDB-wasmはまだ新しいライブラリであるため、どのように扱うかのベストプラクティスが確立されていません。 そのため、今回 DuckDB-wasm を扱うにあたって、DuckDB で実行する SQL を TypeScript で管理する仕組みを作りました。

仕組みと言っても単純で、DuckDB で実行する SQL に型をつけて定義する Utility 関数を用意しただけです。

次のような SQL を実行できる関数を定義できる Utility 関数を提供しています。

  • defineQueryOne: 一つの結果を返すクエリを実行する関数を定義する
  • defineQueryMany: 複数の結果を返すクエリを実行する関数を定義する
  • defineQueryExec: 結果を返さないクエリを実行する関数を定義する
  • transformQuery: クエリの実行結果を変換して、変換した結果を返すようにするクエリのラッパー

また、どの関数も第一引数にDuckDBContextを受け取り、DuckDBContextは DuckDB と接続するための情報を持っています。

defineQuery.ts: SQLを管理するUtility関数のコード(クリックで開く)

import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";

/**
 * 構文エラーなのでクエリの書き方の問題がある
 */
export type DuckDBParserError = {
  type: "DuckDBParserError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * 変換エラーなのでデータの問題がある
 */
export type DuckDBConversionError = {
  type: "DuckDBConversionError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * データがない場合のエラー
 */
export type DuckDBNoRowError = {
  type: "DuckDBNoRowError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * その他のエラー
 */
export type DuckDBUnknownError = {
  type: "DuckDBUnknownError";
  message: string;
  query: string;
  cause: Error;
};
export type DuckDBSQLError =
  | DuckDBParserError
  | DuckDBConversionError
  | DuckDBNoRowError
  | DuckDBUnknownError;
/**
 * DuckDBのクエリに渡すContext
 */
export type DuckDBContext = {
  db: AsyncDuckDB;
  conn: AsyncDuckDBConnection;
};
/**
 * DuckDBのエラーをエラーオブジェクトにする
 */
export const translateDuckDbError = ({
  message,
  query,
  error,
}: {
  message: string;
  query: string;
  error: unknown;
}): DuckDBSQLError => {
  if (error instanceof Error) {
    if (error.message.includes("Parser Error")) {
      return {
        type: "DuckDBParserError",
        message,
        query,
        cause: error,
      };
    }
    if (error.message.includes("Conversion Error")) {
      return {
        type: "DuckDBConversionError",
        message,
        query,
        cause: error,
      };
    }
  }
  return {
    type: "DuckDBUnknownError",
    message,
    query,
    cause: error as Error,
  };
};

// Inputが {} の場合は、 Inputをoptionalにする
// keyof {} は never になるのを利用して判定している
// https://stackoverflow.com/questions/62403425/conditional-type-for-empty-objects
export type QueryFunction<Input, Output> = keyof Input extends never
  ? QueryFunctionWithOptionalArgs<Input, Output>
  : QueryFunctionWithArgsRequiredArgs<Input, Output>;
export type QueryFunctionWithArgsRequiredArgs<Input, Output> = (
  context: DuckDBContext,
  args: Input
) => Promise<
  | {
      ok: true;
      data: Output;
    }
  | {
      ok: false;
      errors: DuckDBSQLError[];
    }
>;
export type QueryFunctionWithOptionalArgs<Input, Output> = (
  context: DuckDBContext,
  args?: Input
) => Promise<
  | {
      ok: true;
      data: Output;
    }
  | {
      ok: false;
      errors: DuckDBSQLError[];
    }
>;
/**
 * 一つの結果を返すクエリを定義する
 * データが存在しないときは、DuckDBNoRowErrorのエラーを返す
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const selectId = defineFunction<{id: string}, { id: string }>({
 *   name: "selectId",
 *   query: () => `SELECT * FROM table WHERE id = ${id`,
 * });
 * const result = await selectId(context, { id: "1" });
 * console.log(result.data.id); // => "1"
 * const notFound = await selectId(context, { id: "2" });
 * console.log(notFound.ok); // => false
 * ```
 *
 */
export const defineQueryOne = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input,
  /**
   * クエリの実行結果で取得できるデータ型
   */
  Output
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, Output> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :one
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      const resultTable = await q.query(args);
      const firstData = resultTable.toArray()[0];
      if (!firstData) {
        return {
          ok: false,
          errors: [
            {
              type: "DuckDBNoRowError",
              message: `No row found: ${name}`,
              query,
            },
          ],
        };
      }
      return {
        ok: true,
        // それぞれのアイテムはPlainなオブジェクトではないので、spread syntaxでnon-enumerableなプロパティを落とす
        // 型には定義されてない、生のプロパティをできるだけ触れないようにする
        // TODO: JSON.parse(JSON.stringify(firstData)) なら全て落とせるが、パフォーマンスが悪い
        data: { ...firstData },
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, Output>;
};

/**
 * 複数の結果を返すクエリを定義する
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const selectAll = defineFunction<{id: string}, { id: string }>({
 *   name: "selectAll",
 *   query: "SELECT * FROM table",
 * });
 * const result = await selectAll(context, {});
 * console.log(result.data); // => [{ id: "1" }, { id: "2" }]
 * ```
 */
export const defineQueryMany = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input,
  /**
   * クエリの実行結果で取得できるデータ型(要素の型なので、[]は不要)
   */
  Output
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, Output[]> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :many
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      const resultTable = await q.query(args);
      return {
        ok: true,
        data: resultTable.toArray(),
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, Output[]>;
};

/**
 * 結果を返さないクエリを定義する
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const update = defineFunction<{id: string}, undefined>({
 *   name: "update",
 *   query: "UPDATE table SET id = $id",
 * });
 * const result = await update(context, { id: "1" });
 * console.log(result.ok); // => true
 * console.log(result.data); // => undefined
 * ```
 */
export const defineQueryExec = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, undefined> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :exec
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      await q.query(args);
      return {
        ok: true,
        data: undefined,
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, undefined>;
};

/**
 * クエリの実行結果を変換して、変換した結果を返すようにするクエリのラッパー
 * @example
 * ```ts
 * const selectId = defineQueryOne<{id: string}, { id: string }>({
 *    name: "selectId",
 *    sql: ({ id }) => `SELECT * FROM table WHERE id = ${id}`,
 * });
 * const selectIdWithTransformed = transformQuery(selectId, (data) => {
 *   return {
 *     id: Number(data.id),
 *   }
 * });
 * const result = await selectIdWithTransformed(context, { id: "1" });
 * console.log(result.data.id); // => 1
 */
export const transformQuery = <TransformOutput, Input, Output>(
  query: QueryFunction<Input, Output>,
  transformFn: (data: Output) => TransformOutput
): QueryFunction<Input, TransformOutput> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const result = await query(context, args ?? ({} as Input));
    if (!result.ok) {
      return result;
    }
    return {
      ok: true,
      data: transformFn(result.data),
    };
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", {
    value: `${query.name}WithTransformed`,
    configurable: true,
  });
  return fn as QueryFunction<Input, TransformOutput>;
};

これらの Utility 関数を使って、次のように SQL を実行する関数を定義できます。

import { defineQueryOne, defineQueryMany, defineQueryExec } from "./defineQuery.ts";

/**
 * DuckDBにSpatial拡張をインストールするクエリ
 */
export const installSpatialExtension = defineQueryExec({
  name: "installSpatialExtension",
  sql: () => `
    INSTALL spatial;
    LOAD spatial;
  `,
});
/**
 * テーブルを作成するクエリ
 */
export const createTable = defineQueryExec({
  name: "createTable",
  sql: () => `
    CREATE TABLE table (
      id STRING
      name STRING
    );
  `,
});
/**
 * idを指定してデータを取得するクエリ
 */
export const selectId = defineQueryOne<
  { id: string },
  { id: string; name: string }
>({
  name: "selectId",
  sql: ({ id }) => `SELECT * FROM table WHERE id = ${id}`,
});
/**
 * 全てのデータを取得するクエリ
 */
export const selectAll = defineQueryMany<{}, { id: string; name: string }>({
  name: "selectAll",
  sql: () => `SELECT * FROM table`,
});
/**
 * データを挿入するクエリ
 */
export const insert = defineQueryExec<{ id: string; name: string }>({
  name: "insert",
  sql: ({ id, name }) => `INSERT INTO table VALUES (${id}, ${name})`,
});

これらのクエリは次のように実行できます。 DuckDB-wasm の使い方については、公式ドキュメントも参照してください。

import * as duckdb from "@duckdb/duckdb-wasm";
import duckdb_wasm from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm";
import duckdb_wasm_next from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm";
import { installSpatialExtension } from "./sql.ts";

const setupDuckDBForBrowser = async () => {
  const MANUAL_BUNDLES: duckdb.DuckDBBundles = {
    mvp: {
      mainModule: duckdb_wasm,
      mainWorker: new URL(
        "@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js",
        import.meta.url
      ).toString(),
    },
    eh: {
      mainModule: duckdb_wasm_next,
      mainWorker: new URL(
        "@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js",
        import.meta.url
      ).toString(),
    },
  };
  const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
  const worker = new Worker(bundle.mainWorker!);
  const logger = new duckdb.ConsoleLogger();
  const db = new duckdb.AsyncDuckDB(logger, worker);
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

  const conn = await db.connect();
  const duckDBContext = {
    conn,
    db,
  };
  const installedResult = await installSpatialExtension(duckDBContext);
  if (!installedResult.ok) {
    throw new Error("Failed to install spatial extension", {
      cause: installedResult.errors,
    });
  }
  return duckDBContext;
};

const duckDBContext = await setupDuckDBForBrowser();
// insert
const insertResult = await insert(duckDBContext, { id: "1", name: "name" });
if (!insertResult.ok) {
  console.error("Failed to insert", insertResult.errors);
  return;
}
// select
const selectResult = await selectId(duckDBContext, {
  id: insertResult.data.id,
});
if (!selectResult.ok) {
  console.error("Failed to select", selectResult.errors);
  return;
}
console.log("select", selectResult.data); // => { id: "1", name: "name" }
// select all
const selectAllResult = await selectAll(duckDBContext);
if (!selectAllResult.ok) {
  console.error("Failed to select all", selectAllResult.errors);
  return;
}
console.log("select all", selectAllResult.data); // => [{ id: "1", name: "name" }]

このdefineQuery*関数などのUtilityは200-300行程度の小さなUtilityですが、クエリを定義する側はシンプルにSQLを書くだけで良くなります。 SQLのInputとOutputはTypeScriptで型定義することで、クエリを実行する側は型安全にクエリを実行できます。

また、クエリの実行結果は{ ok: true, data: Output }または{ ok: false, errors: DuckDBSQLError[] }というResult型のような値を返すようになっています。 これは、エラーも値として返したほうが型安全にエラーハンドリングを書きやすいためです。

DuckDB-wasm のテストをNode.jsで動かす

DuckDB-wasm はブラウザで動作するライブラリですが、WebAssemblyなのでNode.jsでも動かすことができます。 DuckDBで行う処理は特にブラウザに依存はしていないので、Node.jsで動くとテストが簡単に動かせるようになります。

Node.js向けの公式のドキュメントがまだ整備されていないので、参考程度になりますが、次のようにNode.jsでもDuckDB-wasmを使うことができます。 次のテストコードでは、"@duckdb/duckdb-wasm/blocking"を使って、Node.jsでBlocking APIのDuckDBインスタンスを動かしています。

web-workerなどのNode.js向けのWeb Worker APIを使うと、ブラウザと同じ非同期APIのDuckDBを使うこともできます。 ただ、余計なライブラリが必要だったり、テスト目的ならBlocking APIでもあまり困らなかったので、"@duckdb/duckdb-wasm/blocking"を使っています。

import { createDuckDB, NODE_RUNTIME } from "@duckdb/duckdb-wasm/blocking";
import { createRequire } from "module";
import { dirname, resolve } from "path";
import * as duckdb from "@duckdb/duckdb-wasm";
import {
  defineQueryExec,
  defineQueryMany,
  defineQueryOne,
  type DuckDBSQLError,
  type DuckDBContext,
  transformQuery,
} from "./defineQuery.ts";
import { describe, it, expect } from "vitest";

const require = createRequire(import.meta.url);
const DUCKDB_DIST = dirname(require.resolve("@duckdb/duckdb-wasm"));

/**
 * create initialized duckDB for Node.js
 * @returns {Promise<void>}
 */
export async function setupDuckDBForNodejs(): Promise<DuckDBContext> {
  const DUCKDB_BUNDLES = {
    mvp: {
      mainModule: resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"),
      mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-mvp.worker.cjs"),
    },
    eh: {
      mainModule: resolve(DUCKDB_DIST, "./duckdb-eh.wasm"),
      mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-eh.worker.cjs"),
    },
  };
  // SyncDBとして作成してしまう
  // .thenや.catchなどを使わなければ、特に違いは意識しなくていい
  // TODO: Syncは公式にサポートされているがAsyncはWebWorkerに依存しているため工夫が必要
  // https://github.com/duckdb/duckdb-wasm/blob/6fcc50318b3a0e6b4e30c78bfdda19b9f86f4012/packages/duckdb-wasm/test/index_node.ts#L56
  const logger = new duckdb.ConsoleLogger();
  const db = await createDuckDB(DUCKDB_BUNDLES, logger, NODE_RUNTIME);
  await db.instantiate();
  const conn = db.connect();
  // @ts-expect-error -- syncのものをasyncとして渡しているため
  const duckDBContext = {
    db,
    conn,
  } as DuckDBContext;
  // spatial extensionをインストール
  const installResult = await installSpatialExtension(duckDBContext);
  if (!installResult.ok) {
    throw new Error("Failed to install spatial extension", {
      cause: installResult.errors,
    });
  }
  return duckDBContext;
}

/**
 * クエリの実行結果が成功しているかAssertionする
 * 失敗してる時のログを出力する
 * @param result
 */
export function assertQueryResultOk(result: {
  ok: boolean;
  errors?: any[];
}): asserts result is { ok: true } {
  if (!result.ok) {
    const error = new Error(
      "Assertion failed: query result is not ok. expected result.ok is true",
    );
    console.error(error, {
      errors: result.errors,
    });
    throw error;
  }
}

const createTestTable = defineQueryExec<{}>({
  name: "createTestTable",
  sql: () => `
    CREATE TABLE test_table (
      id UUID PRIMARY KEY DEFAULT uuid(),
      name TEXT
    )
  `,
});
const insertTestItem = defineQueryOne<
  { name: string },
  {
    id: string;
  }
>({
  name: "insertTestItem",
  sql: ({ name }) => `
    INSERT INTO test_table (name) VALUES ('${name}')
    RETURNING id
  `,
});
const getTestItem = defineQueryOne<
  { id: string },
  { id: string; name: string }
>({
  name: "getTestItem",
  sql: ({ id }) => `
    SELECT id, name FROM test_table WHERE id = '${id}'
  `,
});

describe("DuckDB Utils", () => {
  describe("defineQueryOne", () => {
    it("should return one result", async () => {
      const duckDBContext = await setupDuckDBForNodejs();
      assertQueryResultOk(await createTestTable(duckDBContext));
      const insertResult = await insertTestItem(duckDBContext, {
        name: "test",
      });
      assertQueryResultOk(insertResult);
      const insertedId = insertResult.data.id;
      const result = await getTestItem(duckDBContext, {
        id: insertedId,
      });
      assertQueryResultOk(result);
      expect(result.data).toEqual({ id: insertedId, name: "test" });
    });
  });
});

これで定義したクエリのテストをNode.jsでも動かせるので、Unit Testなども簡単に書けるようになっています。

今後の展望

defineQuery*関数で発行されるSQLは、-- name: ${name} :oneのような形式コメントを入れていることに気づいた人もいるかもしれません。 これはsqlcを意識して作った仕組みであるため、defineQuery*関数もそれぞれsqlcのQuery annotationsに対応した形で作成しています。

sqlcは、SQLを書いてGoのコードやTypeScriptのコードを生成できるツールです。 現状のsqlcはDuckDBには対応していません。将来的には、SQLを書いてそのクエリを実行できるコードを生成するような仕組みに置き換えることも検討しています。

現状のUtilityはInputとOutputの型定義が完全に手動ですが、これらのツールが対応されるとDBのスキーマからTypeScriptで型定義を生成できたりしてより効率的に開発できるようになるかもしれません。

今回紹介した実装では、SQLのエスケープやprepared statementは特に対応を書いていません。 これは、実行するSQLの対象がブラウザ上の一時的な計算のためのデータで、漏れたり変更しても問題ないデータであるためです(あくまでデータはそのブラウザ内の値で、ページ内に閉じています)。 まだDuckDB Wasmのprepared statementの挙動がまだおかしい部分もあるため、できるだけシンプルな仕組みにしたかったのもあります。

おそらく、今後prepared statementの対応や@vercel/postgresのようなTagged Functionを使ったエスケープが必要になるかもしれません。 そのため、この記事のコードを利用する場合は、この点に留意してください。

また、defineQuery*関数とは別にクエリの実行結果を変換できるtransformQuery関数を提供しています。 クエリを実行できる関数の定義と変換処理を分けたのは、将来的にはクエリを実行できる関数は自動生成する可能性があると思ったためです。 クエリの定義と変換処理を分けておくことで、クエリの定義だけを自動生成するような仕組みを作りやすくなります。

そのため、defineQuery*関数の中には、クエリの実行結果をあまり変換する処理は入れないようにしていて、シンプルな実行結果を返すだけの関数にしています。

まとめ

DuckDBのWebAssembly版であるDuckDB-wasmを使って、ブラウザ上で地理空間データ処理をするSQLの管理をする仕組みを作りました。

小さな仕組みですが、SQLはSQLとしてある程度独立したものとして定義できるようになり、型定義も明示的に書く必要があるのでTypeScriptからも扱いやすくなったと思います。 秩序なくアプリケーションのコードのSQLをベタがきしてしまうと、後から変更もできなくなってしまいます。 将来的には、SQLからコード生成をして、もっと安全で楽にDuckDB-wasmを使うような仕組みを作ることも検討しています。

宣伝

2024年11月16日(土曜)に開催されるTSKaigi Kansai 2024で、ブラウザで完結!DuckDB Wasmでタクシー地図情報を可視化というタイトルでスポンサーLTをするので、ぜひ聴きに来てください!

また、スポンサーブースでは、DuckDB-wasmの選定に使ったDesign Docや、今回のSQL管理の仕組みを議論したDesign Docなども展示する予定です

newmoでは地理情報システム(GIS:Geographic Information System)に興味のあるエンジニアを積極的に採用中です!

MonorepoでのTerraform運用を楽にする!tfactionを使ったGitHub Actions Workflowの構築

はじめに

newmoではGoogle Cloud等のリソース管理にTerraformを使っています。また、newmoではMonorepoを使って開発しています。 Monorepoについてここでは詳しく説明しませんが、バックエンドのGoのコードもフロントエンドのTypeScriptのコードもTerraformのコードもすべて同じGitHubのレポジトリで管理し開発を行っています。

TerraformのコードをMonorepoで管理することで、以下の要素を統一的に制御できるようになりました

  • CICDパイプライン
  • TerraformとProviderのバージョン
  • セキュリティポリシー
  • Lintルール
  • クラウドリソースの構成
  • パフォーマンスとコストの最適化

リソースをTerraformのコードで管理する場合に用意するGitHubでのWorkflowは一般的には以下のようなものになると思います。

  1. Terraformのコードを書いてPull Requestを作成する
  2. 自動的にTerraformのPlanが実行される
  3. TerraformのコードとPlanの結果をレビューして承認する
  4. Mainブランチにマージすると自動的にTerraform Applyが実行される

私もこれまで何度かこのようなWorkflowを作成して利用してきたことがあります。単純にPlanとApplyを実行するだけなら難しいことはあまりないのですが、Monorepoで今後広く長く使われるWorkflowということでもう少し要件が出てきました。

  • 複数のTerraform stateに関連するPull Requestは並列に実行したい
  • Terraform のPlan, Apply結果をPull Requestのコメントで通知したい
  • Applyに失敗したときに、リトライしたい
    • 一時的なエラーによりApplyが失敗するケースが多いため、リトライ機能は重要です
  • Terraform stateが進んでいる場合にはApplyをさせない
    • 他のPull Requestが先にマージされた場合にはstateが変わっているので、Plan結果とは違う変更が行われてしまうのを防ぎたい
    • GitHub の Require branches to be up to date before mergingで防ぐことができるが、Monorepoで有効にするとマージ待ちが多く発生してしまうため、有効にしたくない
  • terraform fmt, terraform validateに加えてTFLintやTrivyでコードのチェックをしたい

以上のような要件をすべて自分で実装するのは結構大変なので利用可能なツールを探していたところ、 tfaction を知りました。

tfactionとは

tfaction - GitHub Actions で良い感じの Terraform Workflow を構築 に概要が書かれていますが、自分が上で挙げていた要件を満たすようなGitHub ActionsのWorkflowを簡単に構築できるActionのセットです。

tfactionの主な機能と特徴

ここでは実際に私たちが利用している機能だけ紹介します。

1:高度なPlan/Apply管理

2: PlanとApplyの並列実行

GitHub Actions build matrixを利用したWorkflow

3: セキュリティとコード品質

4: local moduleのサポート

これらの機能をGitHub ActionsのWorkflowを書くだけで利用することができます。 AtlantisやHCP Terraformなどとちゃんと比較したわけではないのですが、tfactionでやりたいことが実現できています。

newmoでの利用

Monorepoにおけるディレクトリ構成

newmoではMonorepoの lib/terraform 以下にTerraform関連の設定、たとえばTerraform Moduleやtfactionの設定、TFLintのルールなどを置いていて、Terraformの実際のコードはいくつかのディレクトリに分かれています。 Terraform stateのサイズを管理可能な範囲に保つため、サービス単位とProvider単位でディレクトリを分割しています。

.
├── lib
│   └── terraform
│       ├── tfaction-root.yaml
│       ├── tflint.hcl
│       ├── modules
│       │   └── # 共通で使用するTerraformモジュール
│       └── templates
│           └── # 共通で使用するTerraformテンプレート
└── server
    ├── a
    │   └── terraform
    │       ├── dev
    │       │   ├── googlecloud
    │       │   └── cloudflare
    │       └── prod
    │           ├── googlecloud
    │           └── cloudflare
    └── b
        └── terraform
            ├── dev
            │   └── googlecloud
            └── prod
                └── googlecloud

tfactionを使ったTerraform Workflowの設定

GitHub ActionsのWorkflowの設定の例を以下に載せておきます。 かなりコードを省略しているのでこのままでは動きませんが、設定の雰囲気は伝わるかと思います。 バージョンも省略していますがこの設定の時点ではtfaction v1.7.0を利用していました。

name: CI Terraform Plan
on:
  pull_request:
    paths:
      - lib/terraform/**
      - server/**/terraform/**

concurrency:
  group: ${{ github.workflow }}--${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  AQUA_CONFIG: "${{ github.workspace }}/lib/terraform/aqua.yaml"
  TFACTION_CONFIG: "${{ github.workspace }}/lib/terraform/tfaction-root.yaml"

jobs:
  setup:
    timeout-minutes: 10
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}

    steps:
      - uses: actions/checkout
      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/list-targets
        id: list-targets

  plan:
    name: "terraform plan (${{ matrix.target.target }})"
    timeout-minutes: 30
    needs: setup

    # skip if targets is empty
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''

    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}

    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFLINT_CONFIG_FILE: "${{ github.workspace }}/lib/terraform/tflint.hcl"
      TRIVY_SEVERITY: HIGH,CRITICAL

    steps:
      - uses: actions/checkout
        with:
          sparse-checkout: |
            .github/actions
            lib/terraform
            ${{ matrix.target.working_directory }}

      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/get-target-config
      - run: github-comment exec -- github-comment hide

      - uses: suzuki-shunsuke/tfaction/setup

      - uses: suzuki-shunsuke/tfaction/test

      - uses: suzuki-shunsuke/tfaction/plan

      - name: Playbook
        if: failure()
        uses: ./.github/actions/playbook
        with:
          message: |
            # ${{ github.job }} が失敗しました

            ## 影響
            - このままでは Terraform applyができません

            ## 調査方法
            - Jobの落ちてるステップのエラーログを確認してください

            ## 修正方法
            - Jobをretryすることで解決する場合もあります
            - 分からない場合は Platform Teamメンバー に聞いてください

最後のstepにあるPlaybookについては GitHub ActionsのJobが落ちたときに何をするべきかを記述するPlaybookの仕組みを作って運用している話 - newmo 技術ブログ の記事で詳しく紹介しています。

tfactionについてはほとんどデフォルトの設定のまま使っています。

tfaction-root.yamlの設定の一部

# 高速化のためparallelism を50に変える (defaultは10)
env:
  TF_CLI_ARGS_plan: "-parallelism=50"
  TF_CLI_ARGS_apply: "-parallelism=50"

# lintの有効化(デフォルトで有効)
tflint:
  enabled: true
trivy:
  enabled: true

TFLintの設定は、まだ特別なものはないです。

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "google" {
    enabled = true
    version = "0.28.0"
    source  = "github.com/terraform-linters/tflint-ruleset-google"
}

rule "terraform_naming_convention" {
  enabled = true
}

今後の改善

newmoのTerraform Workflowにおける今後の改善点として考えているものには以下のようなものがあります。

TerraformやTerraform providerのバージョンを統一する

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する - newmo 技術ブログ で紹介したように、newmoではできる限りMonorepo内のTerraformやTerraform Providerのversionを統一したいと考えています。Terraformとしてterraform.tfに記述するバージョンを外から指定する機能は自分が知る限りないので、Terragrunt などのツールを試してみるかもしれません。

セルフサービス化

現在はPlatform teamが一元的にコードレビューを行っていますが、開発チームの安全で自律的な運用に向けて以下の取り組みを検討しています

  • Terraform Moduleより高度な抽象化
  • さまざまなセキュリティポリシーの自動チェック
  • TerraformやProviderの自動アップデート

Workflowの高速化

サービスやPlatformの成長とともに、Workflowのstepが増えたりTerraform stateが大きくなったりしてTerraform Workflowにかかる時間も増加していくことが予想されます。 今はまだ問題になることはないですが、Terraform Workflowに要する時間を計測して継続的に改善していきたいと考えています。

OpenTofu?

移行することは考えていないですが、気になっています。

ちなみにtfactionはOpenTofuもTerraguntもサポートしているようなのでその点も安心です。

まとめ

以上のようにnewmoではMonorepoにおいてtfactionを利用してTerraform Workflowを構築しています。 特に以下の点で効果を実感しています:

  • 運用しやすいシンプルなWorkflowの設定
  • Workflowの自動化による運用効率の向上
  • セキュリティとコード品質の確保
  • インフラ変更の安全性向上

GitHub ActionsでのTerraform Workflow構築を検討されている組織にとって、tfactionは優れた選択肢となるでしょう。 以前はいくつかnewmoのレポジトリにフィットしない部分もあったのですが、作者の suzuki-shunsuke さんに相談してv1.6.0に入れてもらった改善によって以前より快適に使えるようになりました。

書いた人: tjun

OpenTelemetry Collectorを使ったCloud Run to Datadogの実装パターン

newmoでは現在アプリケーションサーバーをCloud Runで動かし、Datadogを利用してサービスの監視をすることを考えています。 複数のCloud Runサービスからメトリクス、トレース、そしてログをDatadogへ送信する方法としていくつかのパターンが考えられます。 Datadogへメトリクスやトレース、ログを送る方法としてDatadog Agentを使う方法が一般的ですが将来のための柔軟性や拡張性を考えてOpenTelemetry Collectorを利用することを検討しました。この記事では、検討した構成案を紹介します。

はじめに

Datadog公式ドキュメントのOpenTelemetryのページに、OpenTelemetryのテレメトリデータをDatadogへ送信する方法を紹介しています。

docs.datadoghq.com

Datadog Agentを使う方法と、OpenTelemetry Collectorを使う方法があります。

実装パターン

Cloud RunからDatadogへテレメトリデータを送信する方法として、以下のような実装パターンがあります。

1. アプリケーションから直接送信する

Datadog のライブラリを使って、直接送信することができます。 この方式はインフラ観点で管理するリソースが少ないというメリットがあります。しかし、アプリケーションの実装と密結合になってしまう、Envoyなど自分たちが実装していないサービスを動かす際に利用できない、などの課題があります。また、将来的には OpenTelemetry Protocol(OTLP)でも直接送信することができるようになるかもしれませんが、おそらく今はできません。

2. 同じコンテナでDatadog Agentを動かす

Datadogが公式にサポートしている方式です。

Datadogのserverless-initがアプリケーションのプロセスをwrapすることでメトリクスやトレース、ログを自動的に収集するものです。 すべてのCloud Runサービスのコンテナに設定が必要となります。

3. サイドカーとして動かす

Cloud Runでサイドカーが使えるようになったので、サイドカーで収集してDatadogへ送信することができます。サイドカーにもいくつかやり方があります。

  1. Datadog Agentをサイドカーとして動かす
  2. OpenTelemetry Collectorをサイドカーとして動かす
  3. Cloud RunのためのDatadog サイドカーを使う(2024年10月7日時点ではPrivate Beta)

アプリケーションコンテナからサイドカーに送り、サイドカーからDatadogに送信するという方式です。アプリケーションと分けてCollectorやAgenetを管理することができます。 各Cloud Runサービスごとにサイドカーの設定をする必要があります。 ログについては、サイドカーとの共有Volumeを作ってそこに書くか、OTLP(HTTP)でサイドカーへ送信することでOpenTelemetry Collectorが受け取ることができます。

最後のDatadog サイドカーは少し前に発表されていましたが、まだ公開されていないので今回の比較には入れていません。参考: Automatically instrument your Google Cloud Run services with the Datadog sidecar

4. Cloud RunサービスとしてOpenTelemetry Collectorを動かす

OpenTelemetry Collectorを独立したCloud Runサービスとして動かすこともできます。 Cloud Runサービスが一つ増えることにはなりますが、各Cloud Runサービスで追加のリソースはなくOpenTelemetry CollectorへOTLPで送信するだけになります。

newmoでは、Cloud Runサービスごとに設定を持たず1箇所に集約してPlatformチームで管理できる点と、OpenTelemetry Collectorの拡張性などの利点を考慮して、上記4のOpenTelemetry CollectorをCloud Runサービスとして動かす方式を採用してみることにしました。

OpenTelemetry Collectorの設定

各Cloud RunサービスからOpenTelemetry CollectorへOTLP(gRPC)でメトリクスやトレースを送信します。 ログについては、サービスがOTLPでログを送信する以前にエラーを出して停止してしまう場合もログを拾いたい、またCloud Runのリクエストログも合わせて取得したいということで、少し工夫して Cloud RunがCloud Loggingに出すログをLog RouterでPub/Subへ送り、OpenTelemetry Collectorの Pub/Sub receiverを使って取得する、という方式にしました。

Logの収集

設定は以下を参考にしました。

実際の設定例

receivers:
  otlp:
    protocols:
      grpc:
  googlecloudpubsub:
    subscription: projects/${env:GOOGLE_CLOUD_PROJECT}/subscriptions/${env:LOGGING_SUBSCRIPTION}
    encoding: cloud_logging

processors:
  batch:
    # Datadog APM Intake limit is 3.2MB. Let's make sure the batches do not
    # go over that.
    send_batch_max_size: 1000
    send_batch_size: 100
    timeout: 10s
  memory_limiter:
    # drop metrics if memory usage gets too high
    check_interval: 1s
    limit_percentage: 65
    spike_limit_percentage: 20

connectors:
  datadog/connector: # DatadogのAPM Trace metricsのための設定

exporters:
  datadog/exporter:
    api:
      key: ${env:DD_API_KEY}
      site: api.datadoghq.com

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [datadog/connector, datadog/exporter]
    metrics:
      receivers: [datadog/connector, otlp]
      processors: [batch, memory_limiter]
      exporters: [datadog/exporter]
    logs:
      receivers: [googlecloudpubsub]
      processors: [batch]
      exporters: [datadog/exporter]

こんな感じで設定して、メトリクス、トレース、ログを収集してDatadogへ送信することができていました。 長くなるので省略しますがtrace_idのフィールドがRequestログとApplicationログでズレていたりするのを揃えたり、など細かい調整も入れています。

OpenTelemetry Collectorのビルド

OpenTelemetry Collector は otel/opentelemetry-collector-contrib という全部入りのImageを使うこともできるのですが、必要なコンポーネントだけ入れたものを OpenTelemetry Collector Builder (ocb) を使ってビルドすることが推奨されています。 現在 googlecloudpubsub receiverで encoding: cloud_logging でログを読むとときどきクラッシュする問題があり、修正のPRがマージされないままCloseされてしまっていたので、とりあえず修正を手元で当ててビルドすることで回避しています。

ocbに渡す設定例

dist:
  name: otelcol
  description: Custom OpenTelemetry Collector
  output_path: /app

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.106.1
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/googlecloudpubsubreceiver v0.106.1
    path: ./googlecloudpubsubreceiver
processors:
  - gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.106.1
  - gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.106.1
exporters:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter v0.106.1
connectors:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/datadogconnector v0.106.1

これで必要なものを入れたImageを作成できます。

ハマったところ

ログ収集まわりのデバッグでログのフィールドの確認のためにOpenTelemetry Collectorのデバッグログを数行を出したところ、そのログをOpenTelemetry Collectorが拾って1行のログに対して数行ログが出るループができてログが爆発しました。ログ周りのデバッグ時は、OpenTelemetry Collector自身のログをExcludeする設定をしておいたほうが安全かもしれません。

まとめ

newmoでは、今後のOpenTelemetry Collectorの拡張性への期待から、OpenTelemetry CollectorをCloud Runサービスとして動かしてDatadogへテレメトリデータを送信しています。 まだ使い始めたばかりで送っているデータも多くないためパフォーマンスやコストは問題となっていませんが、今後もっと利用が増えていくと見直しが必要になる部分もあるかもしれません。 OpenTelemetry Collectorを使うのは初めてなので、改善できる部分や他の事例などがありましたら気軽に教えてもらえると助かります。

書いた人: tjun

newmoインターンがgqlparserにプルリクエストを投げた話

こんにちは。8月からnewmoでインターンをしている堀之内(@horinouchi09)と申します。

nemwoではバックエンドエンジニアとして、ビジネスドメインのAPIの開発やプラットフォームエンジニアリングのタスクなど多岐にわたってプロダクト開発に携わっています。

Go言語での開発は未経験からのスタートでしたが、バックエンドエンジニアのitoさんをはじめ多くの方々にサポートしていただき、楽しく開発ができています!

さて、今回は私がnewmoでのインターンを通して人生初のOSS Contributeをした話をします。

newmoの開発スタイル

newmoではクライアントからサーバーのAPIを呼び出すスタイルとしてGraphQLを採用しています(詳しくはこちらをご覧ください)。

GraphQLのディレクティブ

GraphQLに関する詳しい説明は省略しますが、GraphQLでは以下のようなスキーマを定義することでAPIを明示することができます。

# スキーマ定義
type Query {
  passenger: Passenger!
}

type Passenger {
  passengerId: String!
  name: String!
  nickname: String! @deprecated(reason: "old")
}

先ほどのスキーマ定義の例にあった@deprecated はディレクティブと呼ばれるものです。

GraphQLでは、ディレクティブをスキーマ定義のフィールドなどに付与することで情報を追加することができます。

例えば、先ほどの例の@deprecated は、Passengerのnicknameフィールドが非推奨であることを意味するGraphQLの標準仕様で定義されているディレクティブです。

さらに、@deprecatedのような標準で用意されているディレクティブに加えて、newmoでは独自のディレクティブを定義しています。

@validateStringというディレクティブがその一例です。

# 例
input DriverInformationInput {
  driverId: String! @validateString(format: UUID)
}

ここでの@validateString はクラアントから送られてくるdriverIdのバリデーションルールを明示できるディレクティブです。

ディレクティブとして定義することでdriverIdがUUIDの形式であることがクライアントからも明白になり仕様が明確になります。さらにnewmoではgqlgenというGo用のGraphQLコード生成ツールにプラグインを追加することでバリデーションのコードも自動で生成されるようにしています。

コード生成の実装としては、gqlgenが提供しているGenerateCodeというhookを利用してプラグインを書き、バリデーションの実装も含まれたresolverのコードを自動で生成されるようにしています。 

import "github.com/99designs/gqlgen/api"

// gqlgenによるコード生成部分
if err := api.Generate(
    cfg,
    api.AddPlugin(plugin.New()), // pluginの追加
); err != nil {
    return fmt.Errorf("failed to generate files: %w", err)
}
// pluginに実装しているGenerateCodeの例
func (m *Plugin) GenerateCode(data *codegen.Data) error {
    if !data.Config.Resolver.IsDefined() {
        return nil
    }
 
    // 具体的なロジック
    // 内部では"github.com/99designs/gqlgen/codegen/templates"のRenderでGoのファイルを生成しています。
    return m.generateSingleFile(data)
}

認可用のディレクティブの導入

APIにはそれぞれの認可用のトークンが必要です。しかし、現状のスキーマからはどのAPIがどのような認可情報が必要かわかりません。また、認可チェックを手書きで実装する必要があり漏れが発生しうる、などの問題がありました。

そこで認可用のディレクティブ@authorizationを定義することで表現力を高めつつ、そのようなディレクティブを元に認可チェックのコードも自動生成をしようと考えました。

@authorizationの仕様

type Query {
    Driver: Driver! @authorization(userType: hoge)
}

@authorizationは上記の例のようにQueryやMutationのフィールドに付与することで認可情報を表現するようにしたいと考えました。

しかし、このままでは全てのQueryやMutationのフィールドに対して@authorizationを付与していかなければなりません。また、newmoでは各スキーマの大半のAPIが同一の認可情報を必要としていたので、スキーマのデフォルトの認可情報を定義できた方が合理的です。

そこで以下のようにschemaに対して@authorizationを付与することにしました。

extend schema @authorization(userType: hoge)

type Query {
    Driver: Driver!
}

このように定義して、@authorizationのないDriver もuserTypeがhogeであることが必要になるものとして実装を進めます。

gqlgenによるコード生成

ディレクティブの仕様が決まれば、次は@authorizationに沿ったコードを生成するgqlgenのプラグインの実装です。

新たなファイルを生成するには、以下のCodeGeneratorというインターフェイスを満たすプラグインを作り、GenrateCode内でロジックを書くことになります。

type CodeGenerator interface {
    GenerateCode(cfg *codegen.Data) error
}

ここでのcodegen.Dataは以下のような構造体です。

この中でschemaに付与したディレクティブが入ってそうなSchema *ast.Schema は以下のような構造体でした。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

しかし、このSchemaという構造体の中にはschemaに付与されたディレクティブが入っていませんでした(この中のDirectives はスキーマ全体で定義されているディレクティブを指していて、schemaに付与されたディレクティブを格納している訳ではありません)。

gqlgenの利用するDataにschema上のディレクティブが入っていないのでは、プラグインとして先ほどの仕様を満たすコード生成を実装することができません。

gqlparserの修正

gqlgenはgqlparserというGraphQLスキーマをパースする別リポジトリのOSSに依存しています。

特に前述したSchema *ast.Schemaには、gqlparserのLoadSchemaという関数を用いて定義されたスキーマが落としこまれています。gqlparserのLoadSchemaの中で使われているvalidatorのLoadSchemaは以下のような関数です。

func LoadSchema(inputs ...*Source) (*Schema, error) {
    sd, err := parser.ParseSchemas(inputs...)
    if err != nil {
        return nil, gqlerror.WrapIfUnwrapped(err)
    }
    return ValidateSchemaDocument(sd)
}

よくよくgqlparserを読み込んでいくと、ParserSchemaではschemaに付与したディレクティブをきちんとParseしていましたが、ValidateSchemaDocument 内で捨てられていることがわかりました。

そこで、以下のようにSchemaDirectivesというフィールドを追加しValidateSchemaDocument 内で付け足すように変更するプルリクエストを投げることにしました。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition
    SchemaDirectives DirectiveList // このフィールドを追加!

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

実際のプルリクエストがこちらです。

https://github.com/vektah/gqlparser/pull/318/files

こちらのプルリクエストがマージされ、実際にnewmoのgqlgenはSchemaに付与したディレクティブを元にコード生成ができるようになりました。

終わりに

このような背景で人生初のOSS Contributeに成功することができました!

newmoでは周りのエンジニアの方々が当たり前のようにOSSにプルリクエストを投げているので、自分自身の意識も変わってきている感覚があります。

newmoはビジネスドメインの開発にスピード感がある一方で、生産性を高めるためのツールの整備や将来の負債にならないような設計などにも力を入れており、インターンとして勉強できることが大変多いです。

今後もnewmoのプロダクト開発に少しでも力になり、「移動で地域をカラフルに」を実現できるように邁進します!


newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓ careers.newmo.me

YAPC::Hakodate 2024 に参加&学生支援ランチでLTしました! #yapcjapan

YAPC::Hakodate 2024に社員4名が参加&学生向けにLTを実施しました

こんにちは。newmoのソフトウェアエンジニアの @tenntenn です。

2024年10月5日に開催されたYAPC::Hakodate 2024にて、今年はnewmoからエンジニア4名が参加。さらに、学生支援スポンサーとして学生向けにLTをさせていただきました。

このブログでは、LTの登壇資料や補足情報、参加した社員の感想などをシェアします。

 

学生支援スポンサーランチLT

 

参加された学生のみなさんに向けて、tenntennが入社した理由についてお話しました。自分の身の回りの課題を自分で解決したいという思いを語りました。

参加したnewmoエンジニアたち

当日はnewmoからこの4名が参加しました!左から、 @kamipo, genkey6@shugenshugen, @tenntenn, @yaotti

公式バッグに封入したチラシの一面

東京からクリアファイルとステッカーを持ち込み、お話した方に直接お渡ししたりも。

参加した人の感想

@kamipo

今回のYAPC期間中にフォロワーが70人増えました!たくさんお話してくれたひとたちありがとうございます!また次のYAPCでたくさんお話しましょう!

 

genkey6(@shugenshugen)

YAPC初参加勢でしたが、前夜祭から2日間参加させていただき、皆さんの熱量に圧倒されました!当日は主にセッション巡りをしていたのですが、どの発表も濃密でとても勉強になりました。

来年は登壇含めて盛り上げていけるようにチャレンジしたいです!

 

@yaotti

交流させてくださった皆さん、運営スタッフや登壇された方々、ありがとうございました!YAPC::* は10年ぶりの参加でしたが、面白く楽しい時間を過ごすことができました。

newmoとしてもスポンサーに加えて登壇などでコミュニティ貢献していけるように頑張りますー!

 

おわりに

私も今回が初めてのYAPC参加でした。普段参加しているGoコミュニティとはまた違った雰囲気で楽しかったです。

(苫小牧から函館は遠いですが)住んでいる北海道での開催だったので嬉しかったです!はこだて未来大学のキャンパスも素晴らしく、こういう雰囲気で学べる学生の皆さんは羨ましいなと感じました。

次回は地元(宮崎)のある九州(福岡)で計画中ということなので、そちらも楽しみです!

 

newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓

careers.newmo.me

 

newmoでは新卒採用も行っています!興味ある方は長期インターン(ソフトウェアエンジニア)にぜひご応募ください。詳しくは↓のページをご覧ください。

careers.newmo.me

 

2024年10月28日(月)に「地域交通の未来を創る 新卒採用説明会」というイベントを開催します。興味のある方はぜひお越しください。

 

地域交通の未来を創る 新卒採用説明会

イベントの詳細確認や参加登録は以下のフォームから行えます。

https://forms.gle/g1jK6BVxi4Ev8pW19

Google Cloud PAMを使った権限昇格の仕組みと、Terraformでloopをネストする方法

PAM(Privileged Access Manager)とは

Google CloudのPrivileged Access Manager(PAM)という機能をご存知でしょうか。

詳しくは 新しい Privileged Access Manager を使用して常時オンの特権からオンデマンド アクセスに移行 | Google Cloud 公式ブログ に書かれています。

簡単な方法で、必要なときにのみ、必要な期間だけ、必要なアクセス権を正確に取得できるようにすることで、最小権限の原則を実現するのに役立ちます。PAM は、常時オンの常設特権から、ジャストインタイム(JIT)、時間制限付き、承認ベースのアクセス昇格を備えたオンデマンドの特権アクセスに移行できるようにすることで、リスクを軽減します。

常に強い権限を持つ運用にしていると、アカウントが乗っ取られた場合のリスクや開発環境だと思って作業したら本番環境だった、みたいなオペレーションミスのリスクがあります。普段は必要最低限の権限にしておいてPAMを使って権限昇格する運用にすると、権限を取得するためには他の人からの承認が必要となり、また時間経過とともに自動的に権限が元に戻るので、リスクを減らすことができます。 PAMを使わずTerraformなどで権限昇格の設定を書いて承認をもらって権限を取得する、という運用もできますが、時間制限で自動的に権限が消えること、また申請して権限昇格するまでのリードタイムを考えるとPAMのような仕組みがあると便利です。

つまり、普段は必要最低限の権限だけ持っておいて必要なときだけPAMを使って権限昇格して一時的に必要な権限を取得するようにすることで、セキュリティと利便性のバランスを取ることができます。

PAM利用の流れ

PAMでは、Entitlementという事前に作成した権限に対して申請を行い、指定した承認者が承認・却下を行います。 newmoではSlackにPAMの申請の通知を流し、Platform teamが承認を行う運用にしています。

PAMの利用

PAMを使えるように設定をする

PAMを利用するためには、事前にEntitlementを作っておく必要があります。 Entitlementでは以下のような項目を指定します。

  • 申請できるアカウント(user, groupなど)
  • 承認できるアカウント(user, groupなど)
  • 申請する権限
  • emailの通知先
  • 権限付与できる最大の時間(3時間など)

申請可能な権限の数だけEntitlementを各Google Cloudのprojectで作成しておく必要があります。そこでPAMのためのTerraform moduleを作って各Projectで設定することにしました。しかし一つ課題がありました。

Terraformのloopをネストする

PAMのためのEntitlementを必要となる権限に対して用意するにあたって、複数の権限をセットにして扱いたいことがありました。 たとえば、 roles/secretmanager.secretVersionAdder の権限だけを付与してもGoogle Cloud コンソールから作業するためには roles/secretmanager.viewer も必要となる、みたいなことがあるため、それぞれの権限を別で申請しなくても良いように権限セットを1つにまとめたEntitlementを作成しておきたいです。 そのためにはTerraformにおいて 権限セットごとにEntitlementを作成する というloopと 権限セットに複数の権限を入れる というloopが必要になるのですが、Terraformでは for_eachはネストできないという課題があります。

そこで今回はmoduleの中でmoduleを利用することで対応することにしました。以下のように書くことができます。

pam-module では、role setの数だけpam-internalを作ります

module "pam-internal" {
  source     = "./pam-internal"
  project_id = var.project_id

  for_each    = merge(local.default_role_sets, var.role_sets)
  name        = each.key
  roles       = each.value
  slack_email = data.google_secret_manager_secret_version_access.pam_slack_email.secret_data
}

pam-internal moduleでは、一つのentitlementにrole set内のroleの数だけblockを作ります

resource "google_privileged_access_manager_entitlement" "entitlements" {
  entitlement_id       = var.name
  max_request_duration = "18000s" # 5 hours
  parent               = "projects/${var.project_id}"

  privileged_access { 
    gcp_iam_access {
      dynamic "role_bindings" {
        for_each = toset(var.roles)  # roleの数だけ作成
        content {
          role = role_bindings.value
        }
      }
      resource      = "//cloudresourcemanager.googleapis.com/projects/${var.project_id}"
      resource_type = "cloudresourcemanager.googleapis.com/Project"
    }
  }

  eligible_users {
    principals = [
      "group:abc@example.com" # 申請できるgroup
    ]
  }

  approval_workflow {
    manual_approvals {
      steps {
        approvers {
          principals = [
            "group:platform@example.com".  # platform teamが承認
          ]
        }
      }
    }
  }
}

実際に利用するところでは以下のような感じで指定できます。

module "pam" {
  source = "..../modules/pam"

  project_id  = "newmo-example"

  role_sets = {
    "bigquery-data-editor" = [
      "roles/bigquery.user",
      "roles/bigquery.dataEditor",
    ]
    "bigquery-data-viewer" = [
      "roles/bigquery.user",
      "roles/bigquery.dataViewer",
    ]
    "secret-manager-secret-version-adder" = [
      "roles/secretmanager.viewer",
      "roles/secretmanager.secretVersionAdder",
    ]
    "storage-object-viewer" = [
      "roles/storage.objectViewer",
    ]
  }  
}

(ここで挙げたコードは多くの部分を省略しているので、そのままでは動きません)

これを各projectで設定することで、Entitlementの準備ができました。 あとはPAMを利用するように必要な権限を付与しておけば、ドキュメントのとおりにGoogle Cloudコンソールから申請や承認が可能です。

使ってみた感想

以前に似たような仕組みを作ったことがあったのですが、Google Cloudの標準の機能として提供されたのはうれしいです。 課題としては、EmailをSlackに飛ばしている場合かなり見にくく、申請なのか承認された通知なのかExpireされた通知なのか、よく見ないと分からない点があります。公式にSlack通知をサポートしてもらえるとうれしいです。

参考

書いた人: tjun