AWS ECS + hono + nextでコンテナ環境構築

AWS

前提条件

  • mac book Pro(M3 Pro)
  • node version(>= v20.10.0)
  • npm version(>= 10.2.3)

概要

  • 使用する技術スタックは下記の通り
    • githubactions(CI/CD)
    • AWS ECR
    • AWS ECS(fargate)
    • AWS ALB
    • Next.js(frontend)
    • hono(API)
    • postgreSQL(DB)

ローカル開発環境構築手順

  1. Next.js構築
  2. hono構築
  3. docker-compose.yaml作成
  4. Dockerfile作成
  5. cicd.yaml作成(githubActions)

ディレクトリ構成

.
├── backend
│   ├── Dockerfile
│   ├── README.md
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   ├── src
│   └── tsconfig.json
├── docker-compose.yml
├── frontend
│   ├── Dockerfile
│   ├── README.md
│   ├── app
│   ├── next-env.d.ts
│   ├── next.config.js
│   ├── next.config.ts
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   ├── postcss.config.mjs
│   ├── public
│   ├── tailwind.config.ts
│   └── tsconfig.json
└── infrastructure
    ├── ecr.yaml
    ├── github-action-deploy-role.yaml
    ├── main.yaml
    └── pipeline.yaml

Next.js構築

frontendディレクトリで、下記の作成コマンド実行

npx create-next-app@latest .

下記のような選択肢が表示されるが、基本デフォルトで進める

What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like your code inside a `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to use Turbopack for `next dev`?  No / Yes
Would you like to customize the import alias (`@/*` by default)? No / Yes
What import alias would you like configured? @/*

正常に完了したら、Dockerfileを作成
ビルドはdocker composeコマンドで実行するので一旦ここまでで完了

FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "server.js"] 

hono構築

backendディレクトリで、下記の作成コマンド実行

npm create hono@latest .

無事作成完了すると、src/index.tsが作成されるので、port番号を4000に変更しておく(next.jsと被るため)

import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

const port = 4000; // ポート番号を4000に変更
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});

Dockerfileを作成
開発環境では、nodemon、tsxをインストールしてホットリロードするようにマルチステージビルドしている

FROM node:20-alpine AS base

RUN apk add --no-cache gcompat
WORKDIR /app

# 共通準備
COPY package*json tsconfig.json src ./
ARG NODE_ENV=development
RUN if [ "$NODE_ENV" = "development" ]; then \
  npm install; \
  else \
  npm ci; \
  fi

########################################################################
# 開発ステージ
########################################################################
FROM base as dev
# ローカルで使用するnodemon等を追加インストール
RUN npm install -g nodemon tsx
CMD ["nodemon", "--exec", "tsx", "src/index.ts"]

########################################################################
# 本番ビルドステージ
########################################################################
FROM base as builder
COPY . . 
RUN npm run build && npm prune --production

########################################################################
# ランナーステージ (本番)
########################################################################
FROM base AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono

COPY --from=builder --chown=hono:nodejs /app/node_modules /app/node_modules
COPY --from=builder --chown=hono:nodejs /app/dist /app/dist
COPY --from=builder --chown=hono:nodejs /app/package.json /app/package.json

USER hono
EXPOSE 4000

CMD ["node", "/app/dist/index.js"]

Docker-compose.yaml作成

version: "3.8"

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    depends_on:
      - backend
    networks:
      - app-network

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
      target: dev # devステージをターゲットに
      args:
        - NODE_ENV=development
    ports:
      - "4000:4000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:password@db:5432/mydatabase
    depends_on:
      - db
    networks:
      - app-network
    volumes:
      - ./backend/src:/app/src
      - /app/node_modules # ボリュームを追加し、ホストの node_modules を上書きしないようにする

  db:
    image: postgres:16
    restart: always
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydatabase
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  db-data:

frontend,backend,dbのイメージビルド

docker-compose build --no-cache

コンテナ立ち上げ

 docker-compose up 

エラーなく実行されれば、下記のようなログが表示されて立ち上がる

WARN[0000] /Users/takahiro_o/Documents/Dev/Projects/share-cal/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion 
WARN[0000] Found orphan containers ([share-cal-web-1]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up. 
[+] Running 3/3
 ✔ Container share-cal-db-1        Created                                                                                                                                                              0.0s 
 ✔ Container share-cal-frontend-1  Recreated                                                                                                                                                            0.1s 
 ✔ Container share-cal-backend-1   Recreated                                                                                                                                                            0.1s 
Attaching to backend-1, db-1, frontend-1
db-1        | 
db-1        | PostgreSQL Database directory appears to contain a database; Skipping initialization
db-1        | 
db-1        | 2025-01-04 09:14:56.154 UTC [1] LOG:  starting PostgreSQL 16.6 (Debian 16.6-1.pgdg120+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db-1        | 2025-01-04 09:14:56.154 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
db-1        | 2025-01-04 09:14:56.154 UTC [1] LOG:  listening on IPv6 address "::", port 5432
db-1        | 2025-01-04 09:14:56.156 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db-1        | 2025-01-04 09:14:56.158 UTC [29] LOG:  database system was shut down at 2025-01-04 09:14:20 UTC
db-1        | 2025-01-04 09:14:56.164 UTC [1] LOG:  database system is ready to accept connections
backend-1   | [nodemon] 3.1.9
backend-1   | [nodemon] to restart at any time, enter `rs`
backend-1   | [nodemon] watching path(s): *.*
backend-1   | [nodemon] watching extensions: ts,json
backend-1   | [nodemon] starting `tsx src/index.ts`
frontend-1  |    ▲ Next.js 15.0.3
frontend-1  |    - Local:        http://localhost:3000
frontend-1  |    - Network:      http://0.0.0.0:3000
frontend-1  | 
frontend-1  |  ✓ Starting...
backend-1   | Server is running on http://localhost:4000
frontend-1  |  ✓ Ready in 28ms
backend-1   | [nodemon] restarting due to changes...
backend-1   | [nodemon] starting `tsx src/index.ts`
backend-1   | Server is running on http://localhost:4000

http://localhost:3000でnextの初期画面、http://localhost:4000でhonoの初期画面が実行されていれば完了

cicd.yaml作成(githubActions)

~/.github/workflowsにyamlファイルを作成すると、github側で解析してActionsを作成してくれる

name: Build and Deploy via OIDC

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    # ここで OIDC を使う場合は必須
    permissions:
      id-token: write # OIDCトークンの発行権限
      contents: read # リポジトリコードの読み取り等、必要に応じて

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      # 1) GitHub提供のOIDC連携用アクションでAssumeRole
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          # ここにCloudFormation出力の role ARN を記載
          role-to-assume: arn:aws:iam::1234567890:role/{ロール名}
          aws-region:{リージョン}

      # 2) 以降、このロール権限が有効になった状態でawsコマンド・アクションが利用できる
      - name: ECR login
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build & push Frontend Docker
        run: |
          FRONTEND_REPO_URI="${{ steps.ecr-login.outputs.registry }}/{ECRのURI}"
          docker build -t $FRONTEND_REPO_URI:frontend-${{ github.sha }} ./frontend
          docker push $FRONTEND_REPO_URI:frontend-${{ github.sha }}

      - name: Build & push backend Docker
        run: |
          REPO_URI="${{ steps.ecr-login.outputs.registry }}/{ECRのURI}
          docker build -t $REPO_URI:backend-${{ github.sha }} ./backend
          docker push $REPO_URI:backend-${{ github.sha }}

      # - name: ECS Deploy
      #   uses: aws-actions/amazon-ecs-deploy@v1
      #   with:

ECR,githubaction-deploy-role作成

cloudformationを使用して、ECR,githubaction-deploy-roleを作成する。
予めテンプレート配置用のS3は作成しておき、そこにyamlファイルを配置してstackを作成する。

ECR作成用のyaml

AWSTemplateFormatVersion: "2010-09-09"
Description: Create ECR Repository

Parameters:
  EnvironmentName:
    Type: String
    Default: "dev"
    Description: "development Name(example: dev, staging, prod)"
  ECRRepositoryName:
    Type: String
    Default: "my-node-app"
    Description: "create ECR repository name"

Resources:
  MyECRRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${EnvironmentName}-${ECRRepositoryName}
      ImageScanningConfiguration:
        ScanOnPush: true
      ImageTagMutability: IMMUTABLE
      # 任意で暗号化設定 (AES256 or KMS) を行う例
      EncryptionConfiguration:
        EncryptionType: AES256
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: Project
          Value: !Ref ECRRepositoryName
        - Key: Owner
          Value: "DevTeam"
      # 任意でライフサイクルポリシーを適用する例
      # LifecyclePolicy:
      #   LifecyclePolicyText: !Sub |
      #     {
      #       "rules": [
      #         {
      #           "rulePriority": 1,
      #           "description": "Expire untagged images after 30 days",
      #           "selection": {
      #             "tagStatus": "untagged",
      #             "countType": "sinceImagePushed",
      #             "countUnit": "days",
      #             "countNumber": 30
      #           },
      #           "action": {
      #             "type": "expire"
      #           }
      #         }
      #       ]
      #     }
      #   RegistryId: !Ref "AWS::AccountId"

Outputs:
  ECRRepositoryUri:
    Description: "ECR repository URI"
    Value: !GetAtt MyECRRepository.RepositoryUri

上記のファイルをS3にアップロードし、そのURLを使用してcloudformationからstackを作成する。

github-action-deploy-role.yaml

githubActionsからECRへのpushや、ECSへのデプロイができるような権限を付与したいので、IAMロールを作成する

AWSTemplateFormatVersion: "2010-09-09"
Description: >
  Example - Register GitHub OIDC Provider & create an IAM role with restricted trust policy.

Parameters:
  GitHubOrgOrUser:
    Type: String
    Default: "my-github-user" # GitHubのユーザー名 or 組織名
    Description: GitHub Organization or Username
  GitHubRepoName:
    Type: String
    Default: "my-repo"
    Description: GitHub Repository Name
  GitHubBranch:
    Type: String
    Default: "main"
    Description: Branch to restrict (e.g. main, develop, etc.)

Resources:
  #############################################################
  # 1) GitHub Actions 用 OIDC プロバイダを登録
  #############################################################
  GitHubOIDCProvider:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: "https://token.actions.githubusercontent.com"
      ClientIdList:
        - "sts.amazonaws.com" # 必須
      ThumbprintList:
        - "6938fd4d98bab03faadb97b34396831e3780aea1" # GitHubのOIDC証明書のThumbprint (2023現在)

  #############################################################
  # 2) GitHub Actions からAssumeRoleする用のIAMロール
  #    - トラストポリシーで、GitHubのOIDC Issuer + リポジトリ/ブランチを制限
  #############################################################
  GitHubActionsDeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "GitHubActionsDeployRole-${GitHubOrgOrUser}-${GitHubRepoName}"
      Path: "/"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Federated: !Ref GitHubOIDCProvider
            Action:
              - sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                # sub の形式: "repo:{owner}/{repo}:ref:refs/heads/{branch}"
                token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubOrgOrUser}/${GitHubRepoName}:ref:refs/heads/${GitHubBranch}"
      Description: "IAM Role for GitHub Actions OIDC. Restricted to a specific repo and branch."
      ManagedPolicyArns:
        # 例: 必要に応じてAWS管理ポリシーを付与
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
      Policies:
        - PolicyName: "GitHubActionsECRandECSPermissions"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ecr:GetAuthorizationToken"
                  - "ecr:BatchCheckLayerAvailability"
                  - "ecr:PutImage"
                  - "ecr:InitiateLayerUpload"
                  - "ecr:BatchGetImage"
                  - "ecr:CompleteLayerUpload"
                  - "ecr:UploadLayerPart"
                  - "ecs:UpdateService"
                  - "ecs:RegisterTaskDefinition"
                  - "ecs:DescribeServices"
                  - "ecs:DescribeTaskDefinition"
                  - "ecs:DescribeTasks"
                  - "iam:PassRole" # ECSタスクで executionRoleArn/taskRoleArn を引き渡すなら必要
                Resource: "*"
      Tags:
        - Key: Project
          Value: "GitHubActionsOIDC"

Outputs:
  OIDCProviderArn:
    Description: "ARN of the GitHub OIDC Provider"
    Value: !Ref GitHubOIDCProvider

  DeployRoleArn:
    Description: "ARN of the IAM Role for GitHub Actions (OIDC)"
    Value: !GetAtt GitHubActionsDeployRole.Arn

上記のファイルをS3にアップロードし、そのURLを使用してcloudformationからstackを作成する。

githubのmainブランチへのpush

上記のリソース作成まで全て完了したら、githubへpushする。
frontend、backendのイメージがECRにpushされてActionsが正常終了したら成功。

node_module追加時の注意事項

コンテナイメージを作成した後にコンテナの外でnode_modulesをインポートすると、停止済みのコンテナを削除してからイメージを再作成しないと、追加packageが見つからない。
イメージを削除してから、下記のコマンドを使用すると、既存コンテナを再作成してくれます。

docker compose up --build --force-recreate

dockerコンテナへのログイン

docker compose exec backend sh

SSEを利用してOpenAIのレスポンスをストリームする

下記のindex.tsを作成して、POSTのレスポンスをconsole.logで出力するとストリームで返却される

import dotenv from "dotenv";
dotenv.config();
import { serve } from "@hono/node-server";
import { Hono } from "hono";
const app = new Hono();

import { streamText } from "hono/streaming";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

app.post("/chat", async (c) => {
  // ボディリクエストからメッセージを取得
  const body = await c.req.json<{ message: string }>();
  return streamText(c, async (stream) => {
    const chatStream = openai.beta.chat.completions.stream({
      model: "gpt-3.5-turbo",
      messages: [{ role: "user", content: body.message }],
      stream: true,
    });

    for await (const message of chatStream) {
      // OpenAI API からのレスポンスが返ってくるたびにレスポンスを返す
      console.log(message.choices[0].delta.content);
      await stream.write(message.choices[0].delta.content || "");
    }

    // ストリームを終了
    stream.close();
  });
});

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/api/health", (c) => {
  return c.text("OK");
});

const port = 4000; // ポート番号を4000に変更
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});

コメント

タイトルとURLをコピーしました