for Startups Tech Blog

フォースタ社員のエンジニアたちが思い思いのことを書き綴ります。

【初学者向け】MCPサーバー入門:まずは「ちょっと分かる」状態を目指す

テックブログのアイキャッチ画像

はじめに

こんにちは。フォースタートアップス株式会社エンジニアの田畑です。

MCPサーバーは2024年11月に発表されてから約1年半が経ち、技術トレンドの移り変わりが速い昨今では「今さら感」を感じる方もいるかもしれません。 ただその分、情報もある程度出揃っており、これから学ぶにはちょうどよい題材・タイミングだと思いました。

これからもAIと上手く付き合っていくための第一歩として、本記事ではMCPを題材に、チュートリアルレベルではありますが実際に手を動かしながら、「ちょっと分かる」状態を目指していきます。

MCPとは?

簡単にいうと、AIと外部のデータを連携するための仕組みを標準化したものです。 PCと外部データを接続するUSBのように、誰でも同じように扱える「規格」と捉えるとイメージしやすいです。

参考: What is the Model Context Protocol (MCP)?

MCPの全体構成

MCPは、主に「MCPホスト」「MCPクライアント」「MCPサーバー」の3つの要素から構成されています。 これらが連携することで、AIが外部のデータやツールをシームレスに利用できるようになります。

MCPの全体構成図
MCPの全体構成図

MCPホスト(Host)

ホストは、AIモデルを実行し、ユーザーと直接やり取りをするアプリ本体です。

例:Claude Desktop、Cursor、Visual Studio Codeなど

MCPクライアント(Client)

クライアントはホストの内部に組み込まれており、サーバーと通信を行うための「窓口」となる機能です。 ホストからの指示を受け取り、サーバーへリクエストを送ったり、サーバーからのレスポンスをホストへ返したりする役割を担います。

MCPサーバー(Server)

外部のデータソース(APIやデータベースなど)と直接つながり、MCPのルールに従ってAIにデータを提供する「橋渡し役」のプログラムです。

MCPという規格を利用することで、特定の機能やデータをAIから利用できるようになります。 たとえば、MCPサーバーを用意することで、AIが外部のAPIやデータベースにアクセスし、その結果をもとに回答できるようになります。

有名なものとしては、Slack MCP Server や GitHub MCP Server などがあります。

そしてこちらが、今回のメインテーマになります。

参考:

MCPの登場背景

そもそもMCPサーバーとは、何を目的として、何を解決するために登場したのでしょうか?以下は公式ドキュメントより抜粋した内容です。

最も高度なモデルでさえ、データとの連携が限られているという制約を抱えている。情報サイロやレガシーシステムに閉じ込められているため、新たなデータソースごとに独自のカスタム実装が必要となり、真に接続されたシステムの拡張は困難になっている。

MCPはこの課題に対処します。AIシステムとデータソースを接続するための普遍的でオープンな標準を提供し、断片化された統合を単一のプロトコルに置き換えます。その結果、AIシステムが必要なデータにアクセスするための、よりシンプルで信頼性の高い方法が実現します。

Introducing the Model Context Protocol

つまり、従来はAIが外部データと連携するたびに個別実装が必要で、拡張しづらいという課題がありました。 それを解決するために、AIとデータソースをつなぐ共通の仕組みとしてMCPが登場したようです。

MCPサーバーの構成

MCPサーバーは、主に「Tools」「Resources」「Prompts」の3つの機能から構成されます。

機能名 役割(ざっくり言うと?) 具体例
Tools(以下、ツール) AIが外部に対してアクションを実行するための機能(メイン機能) ・外部APIを呼び出して最新情報を取得する
・計算やデータ処理を行う
・データベースに情報を書き込む
Resources(以下、リソース) AIに読ませる「読み取り専用のデータ」(ユーザーが添付できる仮想ファイル) ・PC内のログファイル(error.log など)
・データベースのテーブル構造(スキーマ)
・システム全体を解説した仕様書テキスト
Prompts(以下、プロンプト) よく使う指示をまとめた呼び出し可能なテンプレート ・「議事録を要約して」テンプレート
・「バグ報告を整理して」テンプレート
・「この内容をブログ記事にして」テンプレート

MCPサーバーの内部構成と処理フロー
MCPサーバーの内部構成と処理フロー

図の処理の流れを順に追うと、以下の5つのステップになります。

  1. ユーザーの入力と準備

    ユーザーがAI(LLM)に対してチャットで質問や指示を行います。 このとき、必要に応じてMCPサーバーが提供する「指示テンプレート(プロンプト)」をユーザーが選択して指示を整形したり、「参考情報(リソース)」を付与して、AIに前提知識を与えることができます。

  2. AIの判断(情報が足りるか?)

    AIは、受け取った指示とリソースをもとに「この情報だけで回答できるか?」を判断します。情報が十分であれば、そのままツールを使わずに回答します。 一方で、ドキュメント検索など追加の情報が必要と判断した場合、次のステップに進みます。

  3. MCPサーバーへのツール実行リクエスト

    AIは、必要な情報を取得するために、MCPサーバーが提供する「ツール」を呼び出します(関数呼び出し)。

  4. 外部リソースへのアクセスとデータ取得

    呼び出されたツールは、必要に応じて外部リソース(ドキュメントやAPIなど)にアクセスし、情報を取得します。

  5. 回答の生成と出力

    取得したデータはMCPサーバーを通じてAIに返却されます。 AIは、元の質問・リソース・ツールの実行結果を統合し、最終的な回答を生成してユーザーに返します。

実際にMCPサーバーを作ってみる

一通り仕組みを理解したところで、実際にチュートリアルに沿ってMCPサーバーを作成します。

今回は、Railsにおけるクエリに関するドキュメントをデータソースとしたMCPサーバーを実装します。

実装の前提

なお、初期化や細かいセットアップについては本記事では扱いません。
詳細は公式チュートリアルをご参照ください。

動作確認環境

作成したMCPサーバーは、Claude Code Desktopをホストとして動作確認しています。

注意点
  • 対象サイトの利用規約やrobots.txtを事前に確認し、スクレイピングの可否を確認してください
  • 短時間に大量のリクエストを送らないようにし、適切な間隔でアクセスしてください

参考:公式チュートリアル

まずは全体像を把握するために、実装コード全体を掲載します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as cheerio from "cheerio";

const USER_AGENT = "rails-activerecord-mcp/1.0.0";
const QUERY_DOCS_URL = "https://guides.rubyonrails.org/active_record_querying.html";
const INTERNAL_RESOURCE_URI = "rails-activerecord://internal/guidelines";

const server = new McpServer({
  name: "rails-activerecord",
  version: "1.0.0",
});

const queryKeywordSchema = z.object({
  keyword: z.string().describe("クエリ設計に関する検索キーワード。例: N+1, includes, joins"),
});

const queryPromptArgsSchema = {
  userQuestion: z.string().describe("ユーザーから受け取った質問文"),
};

function buildInternalResourceText() {
  return `【社内Railsクエリ・ガイドライン】

  このドキュメントは、株式会社〇〇の社内ローカルルールです。
  公式ドキュメントよりも、この社内ルールを最優先して回答してください。

  【社内基本方針】
  - N+1問題の解消時、当社のプロジェクトでは原則として \`includes\` ではなく \`eager_load\` を第一候補とすること。(※JOINを強制してクエリ数を1つに抑えるため)
  - レコードの存在確認には \`present?\` ではなく、必ず \`exists?\` を使用すること。

  ※これら以外の一般的な仕様やメソッドの詳細について聞かれた場合は、適宜 \`get_query_docs\` ツールを使用して公式ドキュメントを検索してください。`;
}

server.registerResource(
  "internal_query_guidelines",
  INTERNAL_RESOURCE_URI,
  {
    title: "Internal Query Guidelines",
    description: "社内独自のRailsクエリ設計ガイドライン",
    mimeType: "text/plain",
  },
  async () => ({
    contents: [
      {
        uri: INTERNAL_RESOURCE_URI,
        mimeType: "text/plain",
        text: buildInternalResourceText(),
      },
    ],
  })
);

server.registerTool(
  "get_query_docs",
  {
    description: "ActiveRecordのクエリ設計に関する公式ドキュメントをキーワードで検索する",
    inputSchema: queryKeywordSchema,
  },
  async ({ keyword }) => {
    const headers = {
      "User-Agent": USER_AGENT,
      "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    };

    try {
      const response = await fetch(QUERY_DOCS_URL, { headers });
      if (!response.ok) throw new Error(`Failed to fetch ${QUERY_DOCS_URL}: ${response.statusText}`);

      const rawHtml = await response.text();
      const doc = cheerio.load(rawHtml);
      const normalizedKeyword = keyword.toLowerCase();
      const sections: string[] = [];

      doc("h3, h4").each((_: number, el: any) => {
        const headingElement = doc(el);
        const heading = headingElement.text();
        const headingId = headingElement.attr("id") ?? "";
        const headingAnchorHref = headingElement.find("a").attr("href") ?? "";
        const headingAnchorText = headingElement.find("a").text();

        let content = "";
        let next = headingElement.next();
        while (next.length && !next.is("h3, h4")) {
          content += next.text() + " ";
          next = next.next();
        }

        const searchableHeadingValues = [heading, headingId, headingAnchorHref, headingAnchorText].join(" ").toLowerCase();
        if (searchableHeadingValues.includes(normalizedKeyword) || content.toLowerCase().includes(normalizedKeyword)) {
          sections.push(`${heading}\n${content}`);
        }
      });

      const result = sections.length > 0 ? sections.join("\n\n") : "該当する項目が見つかりませんでした";
      return { content: [{ type: "text" as const, text: result }] };
    } catch (error) {
      console.error(error);
      return { content: [{ type: "text" as const, text: "Failed to fetch documentation" }] };
    }
  }
);

server.registerPrompt(
  "active_record_assistant",
  {
    title: "Active Record Assistant",
    description: "ActiveRecordに関する質問に答えるための基本プロンプト",
    argsSchema: queryPromptArgsSchema,
  },
  async ({ userQuestion }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: [
            "あなたは Rails / ActiveRecord のサポートアシスタントです。",
            "まず internal_query_guidelines Resource を読み、社内ルールの有無を確認してください。",
            "質問が Resource の内容だけで答えられる場合は、Tool を使わずに回答してください。",
            "質問が一般仕様や詳細なメソッド挙動を必要とする場合のみ get_query_docs を使ってください。",
            "回答は次の形式で出力してください。",
            "1. 結論",
            "2. 根拠",
            "3. 使用した情報源: Resourceのみ / ResourceとTool",
            "4. 社内ルールを参照した場合は、その該当箇所を1行で明記",
            "",
            `ユーザーの質問: ${userQuestion}`,
          ].join("\n"),
        },
      },
    ],
  })
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

上記の実装を、構成の観点からざっくり整理すると以下のようになります。

new McpServer()           // サーバー設定
server.registerResource() // リソース
server.registerTool()     // ツール
server.registerPrompt()   // プロンプト
server.connect(transport) // 起動処理

MCPサーバーの実装はシンプルで、サーバーを初期化し、リソース・ツール・プロンプトを登録したうえで起動する、という流れになっています。

1. サーバー設定
const USER_AGENT = "rails-activerecord-mcp/1.0.0";
const QUERY_DOCS_URL = "https://guides.rubyonrails.org/active_record_querying.html";
const INTERNAL_RESOURCE_URI = "rails-activerecord://internal/guidelines";

const server = new McpServer({
  name: "rails-activerecord",
  version: "1.0.0",
});

const queryKeywordSchema = z.object({
  keyword: z.string().describe("クエリ設計に関する検索キーワード。例: N+1, includes, joins"),
});

const queryPromptArgsSchema = {
  userQuestion: z.string().describe("ユーザーから受け取った質問文"),
};

const server = new McpServer({ ... }) でサーバーを初期化しています。nameversion で基本的な情報を設定しています。それ以外は、参照するURLやリクエスト設定、入力スキーマなどの定義です。

2. 起動処理
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

サーバーの初期設定が完了したら、次にAIクライアントと通信し、サーバーを起動するための処理を記述します。(※ 実際のソースコードでは、この後に追加するリソースやツールの登録が完了した後、ファイルの最後に記述してください)

リソース・ツール・プロンプトの3つの機能を組み込んだMCPサーバーを起動し、AIクライアントと通信できる状態にします。 ここでは、トランスポート方式(通信方法)として StdioServerTransport() を指定しています。

MCPの通信方式は、大きく以下の2つに分けられます。

  • Streamable HTTP通信:外部サーバーにデプロイし、ネットワーク越しにやり取りする方式
  • stdio通信(標準入出力):ローカル環境で、AIクライアントと直接データをやり取りする方式

今回は、ローカル環境で手軽に動作させるために、stdio通信を利用しています。これにより、AIクライアントとMCPサーバーが直接やり取りできるようになります。

参考: Transports

3. リソース
function buildInternalResourceText() {
  return `【社内Railsクエリ・ガイドライン】

  このドキュメントは、株式会社〇〇の社内ローカルルールです。
  公式ドキュメントよりも、この社内ルールを最優先して回答してください。

  【社内基本方針】
  - N+1問題の解消時、当社のプロジェクトでは原則として \`includes\` ではなく \`eager_load\` を第一候補とすること。(※JOINを強制してクエリ数を1つに抑えるため)
  - レコードの存在確認には \`present?\` ではなく、必ず \`exists?\` を使用すること。

  ※これら以外の一般的な仕様やメソッドの詳細について聞かれた場合は、適宜 \`get_query_docs\` ツールを使用して公式ドキュメントを検索してください。`;
}

server.registerResource(
  "internal_query_guidelines",
  INTERNAL_RESOURCE_URI,
  {
    title: "Internal Query Guidelines",
    description: "社内独自のRailsクエリ設計ガイドライン",
    mimeType: "text/plain",
  },
  async () => ({
    contents: [
      {
        uri: INTERNAL_RESOURCE_URI,
        mimeType: "text/plain",
        text: buildInternalResourceText(),
      },
    ],
  })
);

リソースでは、LLMが参照するための静的な情報を定義します。今回はテキストをそのまま情報源として定義しましたが、テキストファイルや画像なども定義することが可能です。

テキストファイル・画像ファイルをリソースとして登録する場合は、それぞれ以下のように実装します。

import fs from "fs";

// テキストファイル(ログ)
server.registerResource(
  "system_error_log",
  "file:///logs/error.log",
  {
    title: "System Error Log",
    description: "システムのエラーログ",
    mimeType: "text/plain",
  },
  async () => ({
    contents: [
      {
        uri: "file:///logs/error.log",
        mimeType: "text/plain",
        text: fs.readFileSync("./logs/error.log", "utf-8"),
      },
    ],
  })
);

// 画像ファイル(バイナリ)
server.registerResource(
  "architecture_diagram",
  "file:///docs/architecture.png",
  {
    title: "Architecture Diagram",
    description: "システム構成図",
    mimeType: "image/png",
  },
  async () => ({
    contents: [
      {
        uri: "file:///docs/architecture.png",
        mimeType: "image/png",
        blob: fs.readFileSync("./docs/architecture.png").toString("base64"),
      },
    ],
  })
);

Claude Code Desktopの場合、追加したリソースは、チャット入力欄の「+(添付)」アイコンから「コネクタ」を経由して手動で呼び出すことができます。

リソースの適用手順

呼び出したリソースは、1つのテキストファイルとしてチャット欄に添付されます。さらに、そのファイルを開くと、コード内で定義した内容がそのまま含まれていることが確認できます。

リソースの適用手順

このようにClaude Code Desktopでは、リソースは自動的に参照されるものではなく、ユーザーが必要に応じてAIに渡す「前提知識のファイル」であることが分かります。

リソースの適用手順

そこで、実際にリソース(社内ガイドライン)を添付した状態で「N+1問題の解消方法」を質問してみました。その結果、AIはツールを使用せず、リソースの内容だけをもとに回答を生成しました。

リソースの適用手順

このように、AIは提供された情報だけで十分と判断した場合、ツールを実行せずに回答します。

次に、リソースには含まれていない pluck メソッドについて聞いてみました。

リソースの適用手順

するとAIは「手持ちの情報だけでは不十分」と判断し、get_query_docs ツールを実行して外部ドキュメントを参照したうえで回答を生成しました。

このように、AIはリソースとツールを状況に応じて使い分けながら回答を組み立てることがわかります。

補足

Claude Code Desktopでは、リソースを使用する際にユーザーが明示的に指定する必要があります。 一方で、他のAIホストでは、リソースの利用可否をクライアント側の実装で判断したり、状況に応じてAIが自動的に選択・利用するケースもあります。

参考: Resources

4. ツール
server.registerTool(
  "get_query_docs",
  {
    description: "ActiveRecordのクエリ設計に関する公式ドキュメントをキーワードで検索する",
    inputSchema: queryKeywordSchema,
  },
  async ({ keyword }) => {
    const headers = {
      "User-Agent": USER_AGENT,
      "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    };

    try {
      const response = await fetch(QUERY_DOCS_URL, { headers });
      if (!response.ok) throw new Error(`Failed to fetch ${QUERY_DOCS_URL}: ${response.statusText}`);

      const rawHtml = await response.text();
      const doc = cheerio.load(rawHtml);
      const normalizedKeyword = keyword.toLowerCase();
      const sections: string[] = [];

      doc("h3, h4").each((_: number, el: any) => {
        const headingElement = doc(el);
        const heading = headingElement.text();
        const headingId = headingElement.attr("id") ?? "";
        const headingAnchorHref = headingElement.find("a").attr("href") ?? "";
        const headingAnchorText = headingElement.find("a").text();

        let content = "";
        let next = headingElement.next();
        while (next.length && !next.is("h3, h4")) {
          content += next.text() + " ";
          next = next.next();
        }

        const searchableHeadingValues = [heading, headingId, headingAnchorHref, headingAnchorText].join(" ").toLowerCase();
        if (searchableHeadingValues.includes(normalizedKeyword) || content.toLowerCase().includes(normalizedKeyword)) {
          sections.push(`${heading}\n${content}`);
        }
      });

      const result = sections.length > 0 ? sections.join("\n\n") : "該当する項目が見つかりませんでした";
      return { content: [{ type: "text" as const, text: result }] };
    } catch (error) {
      console.error(error);
      return { content: [{ type: "text" as const, text: "Failed to fetch documentation" }] };
    }
  }
);

まず全体の流れとしては、AIがユーザーの質問内容からキーワードを抽出し、そのキーワードをツールに渡します。ツールは受け取ったキーワードをもとに QUERY_DOCS_URL へリクエストを送り、該当する内容を取得して返す、という処理になっています。

このとき、コード上ではキーワード抽出の処理は実装していません。ツールの descriptioninputSchema をもとに、AIが質問文から適切なキーワードを判断し、自動的に引数として渡しています。

また、ページ全文をそのまま渡すと、不要な情報によってトークンを無駄に消費し、回答精度も下がってしまいます。そのため、h3h4 の見出し単位で内容を区切り、必要な部分のみを抽出してAIに渡しています。

ツールの実行手順

MCPサーバーあるあるかもしれませんが、LLMが賢すぎてツールを使わずにドヤ顔で自分の知識から答えてきました。特に一般的な質問では、MCPサーバーを介さずに完結してしまうケースもあります。

そのため、MCPサーバーを使って回答してほしい場合は、明示的に「ツールを使って」と指示したり、「公式ドキュメントから引用して」と逃げ道をなくしてあげるのがよさそうです。

MCPサーバーの利用を明示的に指示すると、ちゃんとツールを使って答えてくれました。

ツールの実行手順

5. プロンプト
server.registerPrompt(
  "active_record_assistant",
  {
    title: "Active Record Assistant",
    description: "ActiveRecordに関する質問に答えるための基本プロンプト",
    argsSchema: queryPromptArgsSchema,
  },
  async ({ userQuestion }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: [
            "あなたは Rails / ActiveRecord のサポートアシスタントです。",
            "まず internal_query_guidelines Resource を読み、社内ルールの有無を確認してください。",
            "質問が Resource の内容だけで答えられる場合は、Tool を使わずに回答してください。",
            "質問が一般仕様や詳細なメソッド挙動を必要とする場合のみ get_query_docs を使ってください。",
            "回答は次の形式で出力してください。",
            "1. 結論",
            "2. 根拠",
            "3. 使用した情報源: Resourceのみ / ResourceとTool",
            "4. 社内ルールを参照した場合は、その該当箇所を1行で明記",
            "",
            `ユーザーの質問: ${userQuestion}`,
          ].join("\n"),
        },
      },
    ],
  })
);

MCPサーバーにおけるプロンプトは、あらかじめ用意した指示書のようなものです。リソースと同様にチャット画面から選択して適用でき、AIの振る舞いや回答方針をあらかじめ定義することができます。

プロンプトの実行手順

プロンプトを選択すると、ユーザーの入力が求められます。

プロンプトの実行手順

質問を入力すると、入力した指示が含まれたプロンプト全体が、1つのテキストファイルとして自動で作成・添付されます。

プロンプトの実行手順

プロンプトの実行手順

冒頭にAIとのやり取りがありますが、それ以降はプロンプトで定義した通り、「結論 → 根拠 → 情報源 → 社内ルール参照の有無」の順で回答が生成されていることが確認できます。

一方で、N+1問題についてはリソース内でルールとして記載しているのですが、うまく読み取ってもらえなかったみたいです。このあたりは、表現やプロンプト設計を工夫することで改善の余地がありそうでした。

プロンプトの実行手順

プロンプトの実行手順

プロンプトの実行手順

終わりに

いかがでしたでしょうか。

本記事では、MCPサーバーの仕組みにフォーカスし、理解の解像度を上げることと、心理的なハードルを下げることを目的として、実際の実装例を交えながら紹介してきました。

本記事を通して、MCPサーバーについて「ちょっと分かる」状態になっていれば嬉しいです。

そのトレードオフ、消えるよ 🎾

目次

はじめに

こんにちは、フォースタートアップス株式会社エンジニアの野尻(@jsotakebmx)です。ヒューマンキャピタル支援システム(社内プロダクト)の開発を担当しています。
最初に断っておくと、この記事は完全に自戒です。 誰かの意思決定を指しているわけではなく、自分自身が「トレードオフ」という言葉をちゃんと使えているのか?を振り返った記録になります。

エンジニアをやっていると「トレードオフ」という言葉をよく使います。
技術選定、設計判断、スケジュールと品質のバランスをとるときなど…あらゆる場面で口にする機会があると思います。

実際私もそうです。意思決定の文脈でトレードオフという考え方をよく使いますし、ADR(Architecture Decision Record)を書くときも「トレードオフを踏まえた上で〜」という構成にすることが多いです。

ただ、最近「あれ、これって本当にトレードオフだったっけ?ただの妥協にトレードオフって名前をつけてないか?」と思い直すようになってきました。

この記事では、自分の経験を振り返りながら「トレードオフだと思っていたものが、本当にトレードオフだったのか?」を見つめ直してみたいと思います。

トレードオフが成立する条件

そもそも開発分野におけるトレードオフとは何かを改めて整理すると、私は「AとBの両方を最大化できないという構造的な制約の中で、意図的にどちらを優先するか選択すること」だと考えています。

個人的に考えるポイントは3つあります。

  1. 構造的な制約がある — AとBの両立が不可能である
  2. 意図的な選択がある — なんとなくではなく、判断している
  3. 犠牲の言語化ができる — 選ばなかった側の代償を説明できる

この3つが揃って初めてトレードオフと呼べるのではないかと思っている一方で、3つが揃っていたとしても永続するとは限らず、揃っているように見えて実は揃っていないこともあると思います。

改めて自分自身の経験を振り返ると、このトレードオフの成立根拠が消えてしまうパターンが3つありました。

賞味期限切れ

私のチームでは、「リリース速度を優先してテストの網羅性は犠牲にする」という意思決定がされてきました。フロントエンドのコアなビジネスロジック周りにはテストがあるものの、バックエンドにはほとんどありません。 この判断自体は、当時のコンテキストではトレードオフとして一定成立していたと思います。

限られたリソースの中で、テストを書く時間を実装に充てる。速度と品質保証の間で意図的に速度を選んでいる。構造的な制約、意図的な選択、犠牲の認識の3つの条件はある程度満たしていました。

ただ、ここで改めて「そのトレードオフ、今も成り立っているのか?」ということを問い直す必要があると感じています。
テストを書かないことで得られる「速度」は、本当に今も得られているのか。実感としては、むしろ逆のことが起きている気がしています。

テストがないことでリグレッションの検知が難しく、手動確認のコストも膨らみます。開発の後半になるほど保守性の悪化が効いてきて実装そのものが遅れたり、リリース後にバグが見つかれば、その対応で新規開発が止まります。
「テストを書かないことでスピードが上がる」はずだったのに、テストを書かないことでスピードが落ちている局面が出てきていると感じます。

さらに言えば、AI駆動の開発が当たり前になりつつある今、「テストを書く = 時間がかかる」という前提自体が一部揺らいでいるようにも感じています。テスト生成の補助が効く場面も増えてきた中で、「テストを書くコスト」と「テストを書かないコスト」のバランスは、判断した当時と同じなのか...

判断した瞬間は正しかった(あるいは少なくとも合理的だった)としても、前提が変われば結論も変わります。
一度下した判断を「確定したトレードオフ」として誰も見直さなければ、前提が変わっているのに結論だけが残る、「賞味期限切れ」の状態になってしまいます。

お腹を壊す前に、コンスタントに「賞味期限」を確認し新鮮な状態に戻してあげましょう。

情報の非対称

技術選定でADRを書くとき、複数の技術についてA vs Bの形式で整理することがあります。
実際私自身もADRを書くときは、トレードオフの体裁を取ることが多く、A/Bのメリット・デメリットを並べて、総合的に判断するように心がけています。

tech.forstartups.com

ただ、(当たり前かもしれませんが)形式だけ取っていてもあまり意味はありません。 たとえば、自分が知見のある技術Aについては5つのメリットと1つのデメリットが書いてあるのに、あまり触ったことのない技術Bについてはメリット1つ・デメリット2つ書かれているのでは、 情報量に明らかな差があります。

この状態で「比較した結果、Aを選びました」と言っても、それは結論が先にあって比較はそれを正当化するための装置になっている可能性があると思います。 トレードオフのフォーマットを借りた一種の確証バイアスとも言えるかもしれません。

誤解のないように書いておくと、知見のある技術を選ぶこと自体はかなり合理的だと思います。チーム/個人の習熟度は十分な判断軸ですし、未知の技術に賭けるリスクを避けることも妥当な選択です。 問題は、その構造を正直に書いているかどうかに尽きると考えています。
「技術的にAが優れているから選んだ」と書くのと、「Aの方がチームに知見があり、Bを十分に評価するコストを今は払えないからAを選んだ」と書くのでは、同じ結論でも誠実さが全然違いますよね。

後者は誠実な判断ですが、前者は(意図せずとも)妥協を隠してしまっていると言えるかもしれません。

制約の見落とし

そもそもトレードオフの比較対象にならないものを、比較対象として扱ってしまうケースです。

たとえば、レコメンド機能の開発を考えてみます。「ルールベースのレコメンドにするか、個人情報をAI解析してパーソナライズするか」という比較があったとします。レコメンド精度やユーザー体験を軸に比較すれば、AI解析の方が優れているように見えるかもしれません。

しかし、サービス規約で「個人情報を二次利用しません」と規定しているのであれば、AI解析という選択肢は最初から取れません。精度とコストの「トレードオフ」に見えていたものは、制約を見落としたまま存在しない選択肢を比較していただけです。

(もし、AI解析に興味があっても)技術的な興味はグッと堪えて、「その選択肢はビジネス上の制約としてそもそも選べるのか?」を最初に確認する必要があるなぁと感じています。

「消えるよ」🎾

3つのパターンを並べてみると、共通点が見えてきます。

パターン 実態 問題
賞味期限切れ 前提が変わったのに判断が残っている 再検討の欠如
情報の非対称 結論ありきの比較 フェアな評価の欠如
制約の見落とし 要件を満たさない案の採択 ビジネス要件との不整合

いずれも、ちゃんと見つめ直すとトレードオフとして成立していない(あるいは成立しなくなっている)ことに気づきます。前提が変わっていたり、比較がフェアでなかったり、そもそも選択肢じゃなかったり...

ただ、根拠が消えること自体は悪いことではなく、むしろ健全です。問題なのは、消えるべき根拠が消えずに残り続けて、意思決定の拠り所として引用され続けることだと思います。

自戒としてのチェックリスト

最後に、自分が意思決定をするときに確認したいことをまとめておきます。

「これはトレードオフか?」を判定する3つの問い

  • その判断の「前提」は、今も変わっていないか?
    • 前提が変わっているなら、結論だけ残っていても意味がない。トレードオフには賞味期限がある。
  • 選ばなかった側を、同じ熱量で説明できるか?
    • できないなら、情報が足りていないか、結論ありきになっている可能性がある。
  • 比較している軸は、達成すべきゴールに紐づいているか?
    • ビジネス要件と関係ない観点で優劣をつけていないか。技術的な面白さとビジネス上の正しさは別物。

まとめ

「トレードオフ」は便利な言葉です。使った瞬間に意思決定が合理的に見えるし、犠牲を伴っている感じが出るので説得力もあります。だからこそ、妥協や前提の変化を覆い隠す言葉としても機能してしまいがちです。

自分がこれまでトレードオフだと思っていたものの中に、実は賞味期限が切れていたり、比較がフェアでなかったり、そもそも成立していなかったものが混ざっていなかったか... 正直なところ、ゼロではないと思います。

「うーん、これはトレードオフ! 😀」と言ってしまいそうな時、その判断が本当にトレードオフとして成立しているのか、一拍置いて考える。そんな自戒でした。

デザイナーが入社して4ヶ月で、業務を理解し課題を見つけるために行ったこと

目次

はじめに

はじめまして。フォースタートアップス株式会社のUI/UXデザイナーいのうです。
ヒューマンキャピタル支援システム(社内プロダクト)を担当しています。
このシステムは、主にヒューマンキャピタリストが日々の業務で活用しており、支援業務に欠かせない基幹システムとなっています。

デザイナーとして新しい環境に飛び込んだとき、「事業会社のデザイナーは、入社後どのようにドメイン理解を深めているのだろう?」と疑問に思い、他社デザイナーのブログ記事を探した経験があります。

そこで今回は、私が入社後4ヶ月間で取り組んだ業務理解から課題発見、そして優先順位付けまでを、実体験を元に共有します。
私と同じように、新しい環境に飛び込み、何から手をつければいいか模索しているデザイナーの方々へ、この記事がヒントになれば幸いです。

プロダクトの現状把握と分析

1. 業務フローの可視化

社内資料の読み込みに加え、サービスブループリントを用いました。サービスブループリントでは、実務担当者・顧客・システムとの接点を時系列で可視化します。
実務上のより詳細なプロセスは残されているものの、まずは全体像を整理したことで、各ステークホルダー間の情報の流れや、プロダクトが介在するポイントを把握することができました。

サービスブループリントの画像
サービスブループリント

2. ウォークスルー & ヒューリスティック分析

ユーザー視点でメイン導線のウォークスルーを実施し、全画面のキャプチャを元にヒューリスティック原則に照らし、評価を行いました。
ヒューリスティック評価とは、経験則に基づいてユーザビリティを評価し、UI上の課題を発見する手法です。今回は、ニールセンの「10原則」を基準としました。
具体的にはキャプチャした各画面に対して、10原則のどの観点に該当するかを明記した上で、改善コメントを残していきました。
例えば、【Efficiency : 柔軟性と効率性】の観点から、課題や改善点を記載するといったように、原則とセットで気付きを言語化しました。

3. システム構造の理解

ナビゲーション構造や画面遷移図をFigJamで可視化し、システム構造の整理を進めました。
この過程で、既存設計の工夫されている点と、課題を抽出しました。

このようなFigJamのテンプレートをベースに実際の遷移図を作成しました。

4. ライティングの洗い出しと改善案の検討

プロダクト内のテキスト全体を調査し、表記揺れや改善の余地がある表現を洗い出しました。
これらをNotionに集約してプロジェクトチーム内で共有し、ユーザーにとってより分かりやすい表現や適切な表現についての議論を進めました。

ユーザー理解

1. ユーザーテストへの同席

実際にユーザーがプロダクトを操作しているところを間近で見ると、想定していなかった操作手順や、見落としていた視点など、多くの発見がありました。
その場で得られたフィードバックは、具体的な課題の抽出につながりました。実際に、現場の実態を直接自分の目で見たからこそ、得られた気づきであり、その重要性を改めて認識しました。

2. ユーザーインタビュー

実務で利用しているユーザーへのインタビューを実施しました。
設問設計から行い、実際の業務担当者ならではの視点や、作り手側では気づきにくいペインポイントを確認することができました。

課題の構造化と優先順位付け

1. 課題の抽出と分類

まず、自分自身の視点で気づいた課題やインタビューを行った中で抽出された課題をFigJamの付箋に集約しました。
次に、「ユーザーに与える影響度」と「施策にかかる時間」の2軸でマッピングを行いました。

縦軸にはUXピラミッドというフレームを元に、ユーザーに与える影響度を4段階に分けました。
横軸はギャレットの5段階モデルを元にスコープを分けています。

抽出した課題をギャレットの5段階モデルに分類したことで、自分が現時点でどのレイヤーを理解できているかが明確になりました。
表層(UI)については多く気づくことができましたが、上層の要件・戦略に関わる課題は業務についてのより深い理解が必要だと感じました。
この分類はrootさんの課題の分析と改善施策の発散プロセスを参考にしています。

課題の抽出と分類を行ったFigJamの画像
課題の抽出と分類

2. 課題の優先順位付けと実施方向性の検討

自分自身の気づき、要望、ユーザーフィードバックに基づいて抽出した課題をNotionのデータベースへ集約しました。
各課題に対して想定工数や優先度を定義し、プロジェクトチーム内で対応方針の議論を行っています。
これらの情報はNotionのプロパティで一元管理しており、プロジェクトの全体像の把握だけでなく、個別の課題における背景や経緯を記録するログとしても活用しています。

4ヶ月を通して得た気づきと今後の展望

この4ヶ月間を通じて、最適な情報設計は、業務やユーザーへの理解があってこそだと痛感しています。

また、分析結果をもとにマネージャーやPM、CTOへプレゼンを重ねたことで、意思決定層に向けた資料構成や伝え方のスキルを磨くことができました。
発見した課題をどのように改善提案へと繋げるか、一連のプロセスを経験できたことは、今後の糧となりました。

また、今後自分自身のドメイン知識が深まったとしても、「現場で業務にあたっているユーザーこそが最も詳しい」という事実を忘れず、常にユーザーの声に耳を傾ける姿勢を大切にしていきたいです。

現在、2026年度のロードマップに取り組んでいますが、さらなる情報の深掘りが必要な箇所が出てきました。
そのため、現在は設計の精度を高めるためのユーザーインタビューを計画中です。
今後もユーザーに寄り添い、ビジネスにおけるKPIの双方に寄与できる視点を持ち、プロダクト改善に取り組んでいきたいと考えています。

何から手をつければいいか悩むデザイナーの方へ

新しい環境で「何から手をつければ…」と悩んでいる方に、私自身の経験からお伝えしたいのは、まずは一人で完結できることからスタートすることです。

組織によってはUX文化が浸透していなかったり、ドキュメントが整備されていなかったりすることもありますが、まずは自分で動ける範囲から手をつけてみるのがおすすめです。

  • 社内情報の収集:社内ドキュメントやSlackログを読み込み、文脈を拾う
  • AIの活用:業界の全体像や専門用語をAIに整理してもらい、基礎知識を把握する
  • 外部リソースの調査: 業界レポートやカオスマップを探して読み、市場の全体像を掴む

まずは自分で把握できる範囲から整理し、カスタマーサクセスへのヒアリングや商談への同席など、段階的に周囲を巻き込む範囲を広げていくのが、理解を深めるための着実なステップになるはずです。

サーバーレス構成のIaCにAWS SAMは本当に適しているのか

テックブログのアイキャッチ画像

はじめに

こんにちは。フォースタートアップス株式会社エンジニアの田畑です。

最近の業務で、サーバーレス構成を AWS SAM(以下、SAM)で実装する機会がありました。

サーバーレス構成を実装するならSAMが良い、という話は以前から耳にしており、実際の開発でも深く考えずにSAMを選択していました。しかし、なぜSAMが良いのか、他のIaCツールと比べてどのようなメリットがあるのかについては、正直あまり理解できていませんでした。

そこで今回は、サーバーレス構成をSAMを含めた3つのIaCツールで実装し、それぞれを比較しながらSAMの特徴やメリットを探っていきます。

目次

IaCツール

比較に入る前に、今回検証対象とするIaCツールの特徴を簡単に整理しておきます。

AWS CloudFormation

AWS CloudFormation(以下、CloudFormation)は、AWSのインフラリソースをYAMLまたはJSON形式で定義するIaCサービスです。定義したファイルはテンプレートと呼ばれ、インフラ構成の設計図となります。

AWSネイティブのため、複数アカウント・リージョンへ一括デプロイができたり、新機能やサービスに対応するまでの期間がTerraformなどのIaCと比較して短い傾向にあります。

参考:CloudFormationとは?

AWS SAM

AWS Serverless Application Model(以下、SAM)は、オープンソースのフレームワークで、CloudFormationを拡張しサーバーレスアプリケーションのリソース定義を簡潔に記述できます。

AWS::Serverless::Api(API Gateway)、AWS::Serverless::Function(Lambda)などの独自リソースタイプを用いることで、CloudFormationと比較してリソース定義を大幅に短縮できます。

参考:AWS Serverless Application Model (AWS SAM) とは

Terraform

Terraformは、HashiCorp社によって開発されたIaCツールです。HCL(HashiCorp Configuration Language)という独自の宣言的言語を用いてインフラ構成を定義します。

Providerと呼ばれるプラグインによってAWS、Microsoft Azure、Google Cloud Platformのほか、数千を超える各種サービスと連携できる点が特徴です。

参考:

S3 + Lambda + CloudWatch構成におけるIaCツールの比較

構成概要

今回はサンプルとして、S3にファイルをアップロードすると、Lambdaが自動で起動しCloudWatch Logsのロググループにファイル名を出力するという要件で実装します。

S3 + Lambda + CloudWatch Logs構成

作成するリソース

  • S3バケット
  • Lambda関数
  • CloudWatch LogGroup
  • IAMロール

SAM

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  S3 trigger -> Lambda -> CloudWatch Logs
Resources:

  # S3バケット(入力用)
  SampleInputBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: sample-sam-input-bucket-demo

  # Lambda関数
  SampleFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        EntryPoints:
          - handlers/sampleHandler.ts
        Minify: true
        Sourcemap: true
        Target: es2022
        Format: cjs
    Properties:
      FunctionName: sample-sam-function-demo
      CodeUri: src
      Timeout: 60
      MemorySize: 256
      Architectures:
        - arm64
      PackageType: Zip
      Runtime: nodejs24.x
      Handler: handlers/sampleHandler.handler
      Environment:
        Variables:
          SAMPLE_KEY: 'sample_value'
      Events:
        S3Event:
          Type: S3
          Properties:
            Bucket: !Ref SampleInputBucket
            Events: s3:ObjectCreated:*
      Policies:
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - s3:GetObject
              Resource: arn:aws:s3:::sample-sam-input-bucket-demo/*

  # CloudWatch LogGroup
  SampleFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/sample-sam-function-demo
      RetentionInDays: 7

samconfig.toml

version = 0.1

[default.build.parameters]
cached = true
parallel = true

[default.deploy.parameters]
stack_name = "sample-sam-stack-demo"
resolve_s3 = true
confirm_changeset = false
capabilities = "CAPABILITY_IAM"

デプロイまで

1. ビルド

$ sam build

2. デプロイ

$ sam deploy

所感

コード量の少なさや、ビルドからデプロイまでのステップが非常にシンプルである点が特に印象的で、sam buildsam deploy だけでデプロイまでを一貫して実行できる点に、運用のしやすさを感じました。

また、SAM特有のリソース連携に関する記述量の少なさも大きなメリットだと感じました。通常、CloudFormationで同様の構成を定義する場合、Lambdaの実行ロールやS3からLambdaを呼び出すための権限設定、Lambdaコードの配置先となるS3バケットなどを明示的に定義する必要があります。

しかし、SAMではこれらを省略し簡潔に記述できるため、意識する必要のない設定に煩わされることがなく、主要な構成に集中できると感じました。

CloudFormation

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  S3 trigger -> Lambda -> CloudWatch Logs

Resources:

  # IAM Role (Lambda実行ロール)
  SampleFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: sample-cfn-function-role-demo
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: sample-cfn-function-policy-demo
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource:
                  - !Sub "${SampleInputBucket.Arn}/*"

  # S3 Bucket
  SampleInputBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: sample-cfn-input-bucket-demo
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Function: !GetAtt SampleFunction.Arn
    DependsOn:
      - SampleFunctionPermission

  # Lambda Function
  SampleFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: sample-cfn-function-demo
      Code:
        S3Bucket:
          Fn::ImportValue: sample-cfn-deploy-bucket-name
        S3Key: 'sample-handler-function.zip'
      Timeout: 60
      MemorySize: 256
      Architectures:
        - arm64
      PackageType: Zip
      Runtime: nodejs24.x
      Handler: handlers/sampleHandler.handler
      Role: !GetAtt SampleFunctionRole.Arn
      Environment:
        Variables:
          SAMPLE_KEY: 'sample_value'
      Tags:
        - Key: STAGE
          Value: sample

  # Lambda Permission (S3からのInvoke許可)
  SampleFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref SampleFunction
      Principal: s3.amazonaws.com
      SourceArn: !Sub "arn:aws:s3:::${SampleInputBucket}"

  # CloudWatch LogGroup
  SampleFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${SampleFunction}"
      RetentionInDays: 7

deploy-bucket.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: >
    Lambdaのコードを配置するS3バケット
Resources:

  # S3バケット(デプロイ用)
  SampleDeployBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: sample-cfn-deploy-bucket-demo
Outputs:
  DeployBucketName:
    Value: !Ref SampleDeployBucket
    Export:
      Name: sample-cfn-deploy-bucket-name

デプロイまで

1. デプロイ用S3バケットの作成

$ aws cloudformation deploy \
  --template-file deploy-bucket.yaml \
  --stack-name sample-cfn-deploy-bucket-stack \
  --capabilities CAPABILITY_IAM \

2. Lambda関数のビルド

$ npx esbuild handlers/sampleHandler.ts \
  --bundle \
  --platform=node \
  --target=es2022 \
  --format=cjs \
  --outfile=dist/handlers/sampleHandler.js \
  --minify \
  --sourcemap

3. zipファイルを作成

# distディレクトリ内のビルド成果物だけをzip化
$ cd dist && zip -r ../sample-handler-function.zip . && cd ..

4. zipファイルをS3にアップロード

$ aws s3 cp sample-handler-function.zip s3://sample-cfn-deploy-bucket-demo/

5. CloudFormationスタックをデプロイ

$ aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name sample-cfn-stack-demo \
  --capabilities CAPABILITY_NAMED_IAM

所感

特にビルドプロセスには煩雑さがありました。Lambdaのコードを配置するためのS3バケットの定義が必要であることに加え、事前にそのS3バケットを作成しておく必要があるなど手順が多く、デプロイまでの負担は比較的大きいと感じました。

また、今回はLambdaのコードをビルドする際にメタデータをコマンドオプションとして指定しましたが、この方法では設定をコードベースで管理できません。そのため、将来的に設定変更が発生した際に、どのように管理していくかも考慮する必要があると感じました。

さらに、SAMの書き方に慣れた後だと、Lambda実行ロールをはじめとするリソース間の依存関係を明示的に管理する点も、運用上のハードルになりそうです。

Terraform

shared/main.tf

provider "aws" {
  region  = "ap-northeast-1"
}

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.3"
    }
  }
}

resource "aws_s3_bucket" "deploy_bucket" {
  bucket = "sample-tf-deploy-bucket-demo"
}

main.tf

provider "aws" {
  region  = "ap-northeast-1"
}

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.3"
    }
  }
}

resource "aws_s3_bucket" "input" {
  bucket = var.input_bucket_name
}

resource "aws_iam_role" "lambda_role" {
  name = "sample-tf-function-role-${var.name_suffix}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_policy" {
  name = "sample-tf-function-policy-${var.name_suffix}"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["s3:GetObject"]
        Resource = [
          "${aws_s3_bucket.input.arn}/*"
        ]
      }
    ]
  })
}

resource "aws_lambda_function" "this" {
  function_name = "sample-tf-function-${var.name_suffix}"
  role          = aws_iam_role.lambda_role.arn
  handler       = "handlers/sampleHandler.handler"
  runtime       = "nodejs24.x"
  timeout       = 60
  memory_size   = 256

  architectures = ["arm64"]
  package_type  = "Zip"

  s3_bucket = var.deploy_bucket_name
  s3_key    = var.deploy_bucket_key

  environment {
    variables = var.lambda_environment
  }

  depends_on = [
    aws_iam_role_policy.lambda_policy,
    aws_iam_role_policy_attachment.lambda_basic_execution
  ]
}

resource "aws_cloudwatch_log_group" "lambda" {
  name              = "/aws/lambda/${aws_lambda_function.this.function_name}"
  retention_in_days = 7
}

resource "aws_lambda_permission" "allow_input_bucket" {
  statement_id  = "AllowExecutionFromS3"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.this.arn
  principal     = "s3.amazonaws.com"
  source_arn    = aws_s3_bucket.input.arn
}

resource "aws_s3_bucket_notification" "input" {
  bucket = aws_s3_bucket.input.id

  lambda_function {
    lambda_function_arn = aws_lambda_function.this.arn
    events              = ["s3:ObjectCreated:*"]
    filter_suffix       = ".txt"
  }

  depends_on = [aws_lambda_permission.allow_input_bucket]
}

variables.tf

variable "name_suffix" {
  type    = string
  default = "demo"
}

variable "input_bucket_name" {
  type = string
  default = "sample-tf-input-bucket-demo"
}

variable "deploy_bucket_name" {
  type    = string
  default = "sample-tf-deploy-bucket-demo"
}

variable "deploy_bucket_key" {
  type    = string
  default = "sample-handler-function.zip"
}

variable "lambda_environment" {
  type = map(string)
  default = {
    SAMPLE_KEY = "sample_value"
  }
}

デプロイまで

1. デプロイ用S3バケットの作成

$ cd shared
$ terraform init
$ terraform apply
$ cd ..

2. Lambda関数のビルド

$ npx esbuild handlers/sampleHandler.ts \
  --bundle \
  --platform=node \
  --target=es2022 \
  --format=cjs \
  --outfile=dist/handlers/sampleHandler.js \
  --minify \
  --sourcemap

3. zipファイルを作成

$ cd dist && zip -r ../sample-handler-function.zip . && cd ..

4. zipファイルをS3にアップロード

$ aws s3 cp sample-handler-function.zip s3://sample-tf-deploy-bucket-demo/

5. 各リソースをデプロイ

# sharedとは別ディレクトリで管理しているため、ここでもterraform initを実行します。
$ terraform init
$ terraform apply

所感

Terraformはリソース単位でブロックが分かれているため、コードの見通しの良さは感じました。

ただし、あくまでインフラの構成管理に特化したツールであるため、Lambdaコードのビルドやzip化といったプロセスを別途行う必要があり、その点は手間に感じました。

また、prodやstgなどで環境を分ける場合、ディレクトリやファイルを環境単位で分けて管理することになると思います。そうすると管理対象のファイルやディレクトリが増え、構成全体の把握や変更影響の確認には、一定の運用コストがかかると感じました。

結論

サーバーレス構成を3つのIaCで実装し比較してみましたが、少なくとも今回検証した範囲では、SAMが最も適していると感じました。

今回はシンプルな構成での検証でしたが、ある程度規模が大きくなった場合でも、記述量の少なさや開発からビルド、デプロイまでのフローがシンプルにまとまっている点は、TerraformやCloudFormationと比較して優位になりやすいのではないかと感じました。

終わりに

サーバーレス構成をそれぞれのIaCで実装し比較してみたことで、SAMがなぜサーバーレス構成と相性が良いのかについて、理解が深まりました。

普段使っているツールにおいても、別の選択肢と比較してみることでその良さが見えてくると思います。今回の検証も、SAMの使いやすさを改めて実感する良い機会になりました。