TypeScript/Fastify/ConnectRPCで構築したバックエンドAPIをApp Runnerで立ち上げる

# 前提

```markdown
### ローカル開発環境（開発時）

- **Node.js**: v23.7.0
- **npm**: 10.9.2
- **TypeScript**: v5.8.3
- **Docker**: Docker version 28.0.4
- **Git**: git version 2.47.1.windows.2
- **Buf CLI**: 1.50.0

### AWS デプロイ用

- **AWS CLI**: aws-cli/2.24.18 Python/3.12.9 Windows/11 exe/AMD64
  - `aws configure` で認証情報が設定されていること
  - 必要な権限: ECR, App Runner, IAM の作成・管理権限
- **Terraform**: v1.11.4
```
https://github.com/yomogi-cat/apprunner-connectrpc

# TypeScript設定

## 1. TypeScriptのインストール

```bash
npm install typescript ts-node --save-dev
```

ts-nodeはローカルでさっと立ち上げたいときに使います。

## 2. 初期化設定

```bash
npx tsc --init
```

## 3. tsconfig.jsonの編集

デフォルトからちょっとパラメータを調整。

```json
{
  "compilerOptions": {
    "target": "ES2024" /* 出力されるJavaScriptの言語バージョンを設定し、互換性のあるライブラリ宣言を含めます。 */,
    "module": "NodeNext" /* 生成されるモジュールコードを指定します。 */,
    "outDir": "./dist" /* すべての出力ファイルの出力フォルダを指定します。 */,
    "esModuleInterop": true /* CommonJSモジュールのインポートをサポートするための追加のJavaScriptを出力します。これにより、型の互換性のために'allowSyntheticDefaultImports'が有効になります。 */,
    "forceConsistentCasingInFileNames": true /* インポートで大文字小文字が正しいことを保証します。 */,
    "strict": true /* すべての厳格な型チェックオプションを有効にします。 */,
    "skipLibCheck": true /* すべての.d.tsファイルの型チェックをスキップします。 */
  },
  "include": ["src/**/*"]
}
```

# Fastify設定

## 1. Fastifyのインストール

```bash
npm install fastify
```

## 2. サーバー起動コマンド設定

`package.json`に以下を追加

```json
{
  ...
  "scripts": {
    "clean": "rm -rf dist",
    "build": "npm run clean && tsc",
    "start": "node dist/server.js",
    "dev": "ts-node src/server.ts"
  },
  ...
}

```

# ConnectRPC設定

## 1. 必要パッケージのインストール

```bash
npm install @bufbuild/buf @bufbuild/protobuf @connectrpc/connect @connectrpc/connect-fastify pino-pretty
```

## 2. Protobufファイルの定義

プロジェクトルートディレクトリに`proto/todo/v1`フォルダを作成します。

### todo.proto

`proto/todo/v1`に`todo.proto`を作成

```protobuf
syntax = "proto3";

package todo.v1;

// TODOアイテムのメッセージ定義
message TodoItem {
  string id = 1;
  string title = 2;
  bool completed = 3;
  string created_at = 4;
  string updated_at = 5;
}

// TODOアイテムの作成リクエスト
message CreateTodoRequest {
  string title = 1;
}

// TODOアイテムの作成レスポンス
message CreateTodoResponse {
  TodoItem item = 1;
}

// TODOアイテムの取得リクエスト
message GetTodoRequest {
  string id = 1;
}

// TODOアイテムの取得レスポンス
message GetTodoResponse {
  TodoItem item = 1;
}

// TODOアイテムの一覧取得リクエスト
message ListTodosRequest {
  // ページネーションのための開始キー（オプション）
  optional string next_token = 1;
  // 1ページあたりの最大アイテム数（オプション）
  optional int32 max_results = 2;
}

// TODOアイテムの一覧取得レスポンス
message ListTodosResponse {
  repeated TodoItem items = 1;
  // 次のページがある場合の開始キー
  optional string next_token = 2;
}

// TODOアイテムの更新リクエスト
message UpdateTodoRequest {
  string id = 1;
  optional string title = 2;
  optional bool completed = 3;
}

// TODOアイテムの更新レスポンス
message UpdateTodoResponse {
  TodoItem item = 1;
}

// TODOアイテムの削除リクエスト
message DeleteTodoRequest {
  string id = 1;
}

// TODOアイテムの削除レスポンス
message DeleteTodoResponse {
  bool success = 1;
}

// TODOサービスの定義
service TodoService {
  // TODOアイテムを作成
  rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse);
  // 特定のTODOアイテムを取得
  rpc GetTodo(GetTodoRequest) returns (GetTodoResponse);
  // TODOアイテムの一覧を取得
  rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);
  // TODOアイテムを更新
  rpc UpdateTodo(UpdateTodoRequest) returns (UpdateTodoResponse);
  // TODOアイテムを削除
  rpc DeleteTodo(DeleteTodoRequest) returns (DeleteTodoResponse);
}
```

## 3. Bufの設定ファイル作成

プロジェクトルートディレクトリに以下のファイルを作成します。

### buf.yaml

```yaml
# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE

```

### buf.gen.yaml

```yaml
version: v2
plugins:
  - remote: buf.build/bufbuild/es
    out: src/buf
    include_imports: true
    opt: target=ts
```

## 4. コード生成

```bash
npx buf lint
```

```bash
npx buf generate
```

## 5. サービスの実装

`src`フォルダを作成し、各種実装を進めます。

### todoService.ts

中身の処理は適当。適宜実装を追加してください。

```ts
import {
  type CreateTodoRequest,
  type GetTodoRequest,
  type ListTodosRequest,
  type UpdateTodoRequest,
  type DeleteTodoRequest,
  type DeleteTodoResponse,
  type UpdateTodoResponse,
  type ListTodosResponse,
  type GetTodoResponse,
  CreateTodoResponseSchema,
  GetTodoResponseSchema,
  ListTodosResponseSchema,
  UpdateTodoResponseSchema,
  DeleteTodoResponseSchema,
} from "./buf/todo/v1/todo_pb";
import { create } from "@bufbuild/protobuf";
import { logger } from "./logger";

export const createTodo = async (request: CreateTodoRequest) => {
  logger.info({ request }, "Creating new todo");
  return create(CreateTodoResponseSchema, { item: undefined });
};

export const getTodo = async (request: GetTodoRequest): Promise<GetTodoResponse> => {
  logger.info({ request }, "Getting todo");
  return create(GetTodoResponseSchema, { item: undefined });
};

export const listTodos = async (request: ListTodosRequest): Promise<ListTodosResponse> => {
  logger.info({ request }, "Listing todos");
  return create(ListTodosResponseSchema, { items: undefined });
};

export const updateTodo = async (request: UpdateTodoRequest): Promise<UpdateTodoResponse> => {
  logger.info({ request }, "Updating todo");
  return create(UpdateTodoResponseSchema, { item: undefined });
};

export const deleteTodo = async (request: DeleteTodoRequest): Promise<DeleteTodoResponse> => {
  logger.info({ request }, "Deleting todo");
  return create(DeleteTodoResponseSchema, { success: true });
};

```

### connect.ts

```ts
import { ConnectRouter } from "@connectrpc/connect";
import { TodoService } from "./buf/todo/v1/todo_pb";
import { createTodo, getTodo, listTodos, updateTodo, deleteTodo } from "./todoService";
export const routes = (router: ConnectRouter) => {
  router.service(TodoService, {
    createTodo,
    getTodo,
    listTodos,
    updateTodo,
    deleteTodo,
  });
};
```

### logger.ts

```ts
import pino from "pino";

export const loggerConfig = {
  transport: {
    target: "pino-pretty",
    options: {
      translateTime: "HH:MM:ss Z",
      ignore: "pid,hostname",
    },
  },
};

export const logger = pino(loggerConfig);
```

### server.ts

AppRunnerデプロイ時にヘルスチェックのための`health`エンドポイントをここで追加しておきます。

```ts
import { fastify } from "fastify";
import type { FastifyInstance } from "fastify";
import { fastifyConnectPlugin } from "@connectrpc/connect-fastify";
import { routes } from "./connect";
import { loggerConfig } from "./logger";

const fastifyConfig = {
  port: 3000,
  logger: loggerConfig,
};

const buildServer = (): FastifyInstance => {
  const server = fastify({
    logger: fastifyConfig.logger,
  });

  // AppRunnerデプロイ時のヘルスチェックエンドポイント
  server.get("/health", async (request, reply) => {
    return reply.code(200).send({ status: "ok" });
  });

  // Connect plugin の登録
  server.register(fastifyConnectPlugin, {
    routes,
  });

  return server;
};

const start = async (server: FastifyInstance) => {
  try {
    await server.listen({
      port: fastifyConfig.port,
      host: "0.0.0.0",
    });

    server.log.info(`Server is running on port ${fastifyConfig.port}`);
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

start(buildServer());
```

# 稼働確認

ここまでできたら、いったんローカルで立ち上げてみます。

```bash
npm run build
```

```bash
num run start
```

以下のようなログが出ればOK。

![image1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072363/8e5facd2-7ee4-4d82-bc05-52f9c8f7ed9a.png)

healthエンドポイントの稼働も確認しておきます。

![image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072363/a73f414c-8128-4f9c-a66a-8807797a43a5.png)

# Dockerfile作成

```dockerfile
# ビルドステージ
FROM node:23-alpine AS build

WORKDIR /app

# パッケージファイルをコピー
COPY package*.json ./
COPY tsconfig.json ./

# 依存関係をインストール
RUN npm ci

# ソースコードをコピー
COPY src/ ./src/

# TypeScriptをビルド
RUN npm run build

# 実行ステージ
FROM node:23-alpine

WORKDIR /app

# 本番環境の依存関係のみをインストール
COPY package*.json ./
RUN npm ci --omit=dev

# ビルドステージからビルド済みのコードをコピー
COPY --from=build /app/dist ./dist

# アプリケーションを実行
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
```

# Terraformによるデプロイ

## 1. .tfファイル作成

`terraform`フォルダを作成し、その配下に.tfファイルを作成します。今回用意するのは以下のファイル。

### provider.tf

```terraform
# AWS
provider "aws" {
    region = "ap-northeast-1"

    default_tags {
        tags = {
            Project = "app-runner-connectrpc-api"
        }
    }
}

# Terraformの設定
terraform {
    required_providers {
        aws = {
            source = "hashicorp/aws"
            version = "~> 5.96.0"
        }
    }

    required_version = ">= 1.11.4"
}
```

### ecr.tf

```terraform
# ECR リポジトリを作成
resource "aws_ecr_repository" "app_runner_repo" {
  name                 = "app-runner-connectrpc-api"
  force_delete = true  # リポジトリを削除するときに、リポジトリ内のイメージも削除する
  image_tag_mutability = "MUTABLE" # 一度保存したコンテナイメージを上書きできるようにする

  image_scanning_configuration {
    scan_on_push = true # コンテナイメージを保存するたびにセキュリティスキャンを実行する
  }
}
```

### iam.tf

```terraform
# App Runner用のIAMロール
resource "aws_iam_role" "app_runner_role" {
  name = "app-runner-connectrpc-api"

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

# ECRアクセス用のIAMポリシー
resource "aws_iam_policy" "ecr_access_policy" {
  name        = "ConnectRPCAppRunnerECRAccessPolicy"
  description = "Policy for App Runner to access ECR"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability",
        ]
        Resource = aws_ecr_repository.app_runner_repo.arn
      },
      {
        Effect = "Allow"
        Action = "ecr:GetAuthorizationToken"
        Resource = "*" 
      }
    ]
  })
}

# ロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "app_runner_ecr_policy_attachment" {
  role       = aws_iam_role.app_runner_role.name
  policy_arn = aws_iam_policy.ecr_access_policy.arn
}
```

### outputs.tf

```terraform
# App Runnerサービスに関する出力変数
output "app_runner_service_arn" {
  description = "App Runnerサービスのarn"
  value       = aws_apprunner_service.app_service.arn
}

output "app_runner_service_url" {
  description = "App Runnerサービスのurl"
  value       = aws_apprunner_service.app_service.service_url
}

output "ecr_repository_url" {
  description = "ECRリポジトリのURL"
  value       = aws_ecr_repository.app_runner_repo.repository_url
}

output "aws_region" {
  description = "使用中のAWSリージョン"
  value       = "ap-northeast-1"
  depends_on = [
    aws_ecr_repository.app_runner_repo
  ]
}
```

### appRunner.tf

```terraform
# App Runnerサービス - ECRイメージから
resource "aws_apprunner_service" "app_service" {
  service_name = "app-runner-connectrpc-api"

  source_configuration {
    authentication_configuration {
      access_role_arn = aws_iam_role.app_runner_role.arn
    }
    auto_deployments_enabled = true

    image_repository {
      image_configuration {
        port = 3000
        runtime_environment_variables = {
          NODE_ENV = "production"
        }
      }
      image_identifier      = "${aws_ecr_repository.app_runner_repo.repository_url}:latest"
      image_repository_type = "ECR"
    }
  }

  auto_scaling_configuration_arn = aws_apprunner_auto_scaling_configuration_version.app_scaling.arn

  health_check_configuration {
    healthy_threshold   = 1
    unhealthy_threshold = 5
    interval            = 5
    path                = "/health"
    timeout             = 2
    protocol            = "HTTP"
  }

  instance_configuration {
    cpu    = 1024
    memory = 2048
  }

  # ECRリポジトリとIAMロールポリシーの両方が作成されていることを確認
  depends_on = [
    aws_ecr_repository.app_runner_repo,
    aws_iam_role_policy_attachment.app_runner_ecr_policy_attachment
  ]
}

# App Runnerの自動スケーリング設定
resource "aws_apprunner_auto_scaling_configuration_version" "app_scaling" {
  auto_scaling_configuration_name = "connectrpc-app-runner-scaling"
  max_concurrency                 = 50
  max_size                        = 5
  min_size                        = 1
}
```

## 2. Terraformの実行 - ECRリポジトリ作成

App Runner は空の ECR リポジトリを参照するとデプロイに失敗するため、 最初に ECR リポジトリのみを作成します。

```bash
# Terraform の初期化
terraform -chdir=terraform init

# 実行計画の確認（ECRリポジトリのみ）
terraform -chdir=terraform plan -target=aws_ecr_repository.app_runner_repo

# ECRリポジトリの作成（確認メッセージが表示されたら「yes」と入力）
terraform -chdir=terraform apply -target=aws_ecr_repository.app_runner_repo

# 出力値を環境変数に設定
export ECR_REPO_URL=$(terraform -chdir=terraform output -raw ecr_repository_url)
export AWS_REGION=$(terraform -chdir=terraform output -raw aws_region)
```

## **3. Docker イメージのビルドとプッシュ**

アプリケーションの Docker イメージをビルドして ECR リポジトリにプッシュします。

```bash
# ECR へのログイン
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REPO_URL

# Docker イメージのビルド
docker build -t $ECR_REPO_URL:latest .

# イメージのプッシュ
docker push $ECR_REPO_URL:latest
```

## **4. Terraform の実行 - App Runner 作成**

ECR にイメージがプッシュされたら、App Runner サービスを作成します。

```bash
# App Runnerおよび関連リソースの作成
terraform -chdir=terraform apply

# 出力値を環境変数に設定
export APP_RUNNER_ARN=$(terraform -chdir=terraform output -raw app_runner_service_arn)
```

## **5. デプロイの確認**

App Runner サービスのデプロイ状況を確認します。

```bash
# デプロイステータスの確認
aws apprunner describe-service --service-arn $APP_RUNNER_ARN --region $AWS_REGION

# サービスの URL を取得
export APP_RUNNER_URL=$(terraform -chdir=terraform output -raw app_runner_service_url)
echo "App Runner URL: https://$APP_RUNNER_URL"

# ヘルスチェックエンドポイントへのアクセス
curl -v https://$APP_RUNNER_URL/health
```

# 環境削除

不要な場合はdestroyコマンドでterraform経由で作成したリソースを一括削除します。

```bash
terraform -chdir=terraform destroy
```

# 詰まったところ

## （疑問）nodeコマンドやts-nodeコマンドで実行したら警告が出る

`ExperimentalWarning: Type Stripping is an experimental feature and might change at any time` という警告がでる。

Node.jsの比較的新しい実験的機能である「Type Stripping（型の削除）」に関連するもの。

実験的機能は将来変更される可能性があり、将来のNode.jsバージョンで互換性の問題が発生する可能性があるので注意。

本番ではビルドしてから実行する流れを取り、こちらは開発環境で使うことにした。

## terraform destroy時にRepositoryNotEmptyException

イメージが残っているECRリポジトリを消すには、forceパラメータを使用して強制的に削除する必要がある。`force_delete`を`true`にすることで、`terraform destroy`時にイメージごとECRリポジトリを削除させることに。

## ローカルでのコンテナ稼働は問題ないのにECRへのプッシュに失敗する

- Dockerの`container image store`機能が有効
- ECRの.tfファイル内で`image_tag_mutability`を`IMMUTABLE`にしている

と失敗する模様。Docker Desktop バージョン 4.34 以降では、初期インストール時・リセット時にデフォルトで有効になるようなので、注意。今回は`container image store`機能を無効にすることで回避。

# 追加でやりたいこと

- AWS MCPサーバーとつなげて、ベストプラクティスに沿うような構成に常になるように何かできないか模索したい