前提
### ローカル開発環境(開発時)
- **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
TypeScript設定
1. TypeScriptのインストール
npm install typescript ts-node --save-dev
ts-nodeはローカルでさっと立ち上げたいときに使います。
2. 初期化設定
npx tsc --init
3. tsconfig.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のインストール
npm install fastify
2. サーバー起動コマンド設定
package.json
に以下を追加
{
...
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
},
...
}
ConnectRPC設定
1. 必要パッケージのインストール
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
を作成
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
# 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
version: v2
plugins:
- remote: buf.build/bufbuild/es
out: src/buf
include_imports: true
opt: target=ts
4. コード生成
npx buf lint
npx buf generate
5. サービスの実装
src
フォルダを作成し、各種実装を進めます。
todoService.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
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
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
エンドポイントをここで追加しておきます。
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());
稼働確認
ここまでできたら、いったんローカルで立ち上げてみます。
npm run build
num run start
以下のようなログが出ればOK。
healthエンドポイントの稼働も確認しておきます。
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
# 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
# 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
# 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
# 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
# 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 リポジトリのみを作成します。
# 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 リポジトリにプッシュします。
# 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 サービスを作成します。
# App Runnerおよび関連リソースの作成
terraform -chdir=terraform apply
# 出力値を環境変数に設定
export APP_RUNNER_ARN=$(terraform -chdir=terraform output -raw app_runner_service_arn)
5. デプロイの確認
App Runner サービスのデプロイ状況を確認します。
# デプロイステータスの確認
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経由で作成したリソースを一括削除します。
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サーバーとつなげて、ベストプラクティスに沿うような構成に常になるように何かできないか模索したい