🐙

新規サービス開発で起こったインフラ移行の舞台裏

2024/09/19に公開

こんにちは、sugar-catです。

AIShiftでは、昨年11月から「AI Worker」[1](以下、AI Worker)という新しいサービスの開発を開始しました。
約10ヶ月が経過し様々なことがありましたが、今回は私が行ったインフラ移行や諸々の改善活動について、外部公開が可能な取り組みをざっくりとまとめます。まだまだ開発途上の部分も多く、至らない点が多いですが、なんらか役立つと嬉しいです。

3ヶ月時点については、以下の記事をご覧ください。どのような技術を使い、何を作っているかについて説明しています。
https://zenn.dev/aishift/articles/ce9783a0d7acd0

Google Cloudへのサービス展開

弊チームでは、もともとMicrosoft Azureのスタックをベースにアプリケーションを構築・運用していました。しかし、ビジネス要件の変更に伴い、Google Cloudと併用してサービスを展開することになりました。

既存のAzure構成

もともと、バックエンドはAzure Container Appsを基盤に構成しており、インフラ構築の手間を最小限に抑え、アプリケーション開発に集中できるようにしていました。

alt text

しかし、開発の途中でIaC化に伴う問題やAzure固有の知識による問題に直面する場面が多くあり、これらの課題を解決するため、Kubernetes(以下、k8s)への移行を決定しました。

そこで、もともとAI Shiftで利用していたGoogle Kubernetes Engine(以下、GKE)を活用し、Google Cloud上での展開と合わせてk8sへの移行を進めることにしました。

Google Cloudへの移行手順

Google Cloudへの展開にあたり、Azureでの動作環境をGoogle Cloud上で再現するため、以下の手順で作業を進めました。

  1. 定義済みのAzureリソースから対応するGoogle Cloudおよびk8sリソースの調査
  2. Terraformやマニフェストを利用してリソースを作成
  3. アプリケーションレイヤーの修正
  4. CI/CDの修正

1. AzureリソースからGoogle Cloudおよびk8sリソースの調査

k8sを利用するにあたり、リソースをTerraformで管理するか、マニフェストで管理するかの棲み分けが必要となりました。弊チームでは、他のクラウドプロバイダーへの移行も見据えて、次の方針を採用しました。

  • k8s側で用意されているリソース(例: Deployment, Service, Ingressなど): マニフェストで管理
  • Google Cloudの基盤リソース(例: Database, Storage, k8sのNode Poolなど): Terraformで管理

また、元々AI Shiftで運用していたクラスタにリソースを同居させ、基本的にリソース類はアプリケーションのデプロイのライフサイクルに合わせて管理できるような形にしています。


2. Terraform/マニフェストを利用したリソース作成

弊チームでは、1つのリポジトリTerraformk8sのマニフェストを管理されており、現在は1リポジトリ=1アプリケーションの形になっています。

現在のTerraformのディレクトリ構成は以下の通りです。このリポジトリには1つのアプリケーションしかないため、rootにmodulesを配置し、環境固有のサブディレクトリ(environments/{env})でアプリケーションを分割しています。

.
└── terraform
    ├── environments
    │   ├── dev
    │   ├── prod
    │   └── stage
    └── modules
        └── gcp
            ├── xxx
            └── yyy

基本的には、Google Cloudが推奨するベストプラクティスに従ってリソースを作成しています。
https://cloud.google.com/docs/terraform/best-practices-for-terraform?hl=ja#subdirectories

この方法だと、単一クラウドプロバイダーでの管理は簡単ですが、将来的にマルチクラウド展開を行う場合、ライフサイクルが異なるプロバイダーごとのリソースを扱う際のState管理が課題になると思っています。

また、k8sのマニフェスト管理にはKustomizeを使用し、リソースをアプリケーションごとに(例: API、DB Migration用のJobなど)シンプルに分割しています。

.
└── manifests
    ├── argocd
    │   ├── base
    │   └── overlays
    │       ├── dev
    │       ├── prod
    │       └── stage
    └── app1
        ├── base
        └── overlays
            ├── dev
            ├── prod
            └── stage

特筆する点はあまりないですが、CRDとしてExternalSecretOperatorを使用してSecretの管理を行い、ArgoCDを活用してリソースのデプロイを行っています。

alt text


3. アプリケーションレイヤーの修正

Google Cloud環境でもAzure環境と同様の動作を実現するために、アプリケーションレイヤーの修正を行いました。
幸い、インターフェースを通じて抽象化していたため、インフラ層の実装部分を修正するだけで完了しました。

interface-example.ts
interface IStorageClient {
  retrieveBlob(params: RetrieveBlobParams): Promise<Result<Buffer>>
  uploadBlobsInBatch(params: UploadBlobBatchParams): Promise<Result<void>>
  deleteBlobsInBatch(params: DeleteBlobParams): Promise<Result<void>>
  generatePresignedUrls(
    params: GeneratePresignedUrlsParams
  ): Promise<Result<GetPresignedUrlsResponse>>
}

4. CI/CDの修正

弊社ではGitHub Actionsを使用してCI/CDパイプラインを構築しています。Azure向けにビルドしたイメージをAzure Container Registryにプッシュし、Rolloutしていましたが、Google Cloudへの移行に伴い、Artifact Registryにプッシュするように変更しました。

sample.yaml
name: 'Deploy to GKE'
description: 'Composite action to deploy to GKE'
inputs:
   #  ....

runs:
  using: 'composite'
  steps:
    - name: Get Bun version
      id: get_bun_version
      shell: bash
      run: echo "BUN_VERSION=$(grep 'bun = ' .prototools | cut -d '"' -f 2)" >> $GITHUB_ENV

    - name: Authorize Docker
      shell: bash
      run: gcloud auth configure-docker ${{ inputs.artifact_repository }} --quiet

    - name: Build and push Docker image to Artifact Registry
      shell: bash
      run: |
        IMAGE_TAG=$(git rev-parse --short "$GITHUB_SHA")
        docker build --target ${{ inputs.target }} --build-arg BUN_VERSION=${{ env.BUN_VERSION }} -t ${{ inputs.artifact_repository }}/${{ inputs.gcp_project }}/${{ inputs.repository_name }}/${{ inputs.image_name }}:${{ inputs.image_tag }} -f ${{ inputs.dockerfile }} .
        docker tag ${{ inputs.artifact_repository }}/${{ inputs.gcp_project }}/${{ inputs.repository_name }}/${{ inputs.image_name }}:${{ inputs.image_tag }} ${{ inputs.artifact_repository }}/${{ inputs.gcp_project }}/${{ inputs.repository_name }}/${{ inputs.image_name }}:latest
        docker push ${{ inputs.artifact_repository }}/${{ inputs.gcp_project }}/${{ inputs.repository_name }}/${{ inputs.image_name }}:${{ inputs.image_tag }}
      working-directory: ${{ inputs.working_directory }}

複数のビルドプロセスを並列で実行するために、Composite Actionに切り出し、利用側でmatrixを指定して並列実行するようにしています。

sample.yaml
  gke-deploy-dev:
    name: Deploy to GKE
    runs-on: ubuntu-latest
    needs: generate-image-tag
    strategy:
      matrix:
        include:
          - image_name: app1
            target: app1
            directory: packages/xxx
          - image_name: app2
            target: app2
            directory: packages/yyy

    permissions:
      contents: "read"
      id-token: "write"
    environment: Development
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref_name }}
      - uses: ./.github/actions/gcloud-setup
        with:
          workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ env.GHA_SERVICE_ACCOUNT }}
      - uses: ./.github/actions/deploy-to-gke
        with:
          image_name: ${{ matrix.image_name }}
          image_tag: ${{ needs.generate-image-tag.outputs.image_tag }}
          target: ${{ matrix.target }}
          working_directory: ${{ github.workspace }}/${{ matrix.directory }}
        #   ...

基本的にCI/CD用にWorkload Identityを割り当て最小権限の原則に従い、Service Accountを使用しています。


k8sリソースをArgoCDで管理

Google Cloudへの展開に際し、k8s上にリソースを構築しました。弊社のアプリケーションは、既存のアプリケーションクラスタ上でNamespaceを分けて運用しています。
また、ArgoCDではProjectごとにNamespaceを管理し、特定のNamespaceに対する操作権限を管理することで、リソースを論理的に区分しています。

alt text

さらに、ArgoCDのApplication管理にはApplicationSetを利用しています。
https://argo-cd.readthedocs.io/en/stable/user-guide/application-set/

dev環境ではデプロイが頻繁に行われ、短時間のダウンタイムが許容されるため、AutoSyncを有効化してリソースの同期を自動化しています。

sample.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: hoge-namespace-dev-application-set
spec:
  generators:
    - list:
        elements:
          - path: app1

  template:
    metadata:
      name: '{{path}}-dev'
      namespace: default
    spec:
      source:
        path: deployment/k8s/manifests/{{path}}/overlays/dev
        repoURL: xxx
        targetRevision: development
      destination:
        namespace: hoge-namespace
        server: xxx
      project: 'hoge-namespace-dev'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

デプロイの自動化について

もともと、アプリケーションはlatestタグを使用して運用していましたが、障害時のロールバックやリソースの状態確認が難しかったため、タグを固定する方式に変更しました。

現在は、GitHub Actionsを使用してタグを更新し、自動的にPull Requestを作成するワークフローを導入することで、ロールバックが容易になるよう構成を変更しました。これにより、ArgoCDが自動的にRolloutを実行でき、柔軟なデプロイフローに対応可能です。

alt text

Organization Repositoryにおける自動マージ

弊チームでは、アプリケーションのリリースフローにGit Flowを採用しています。その過程で、例えばdevelopmentブランチからstagingブランチへのマージを行う際に、以下の2つの作業を自動化する必要がありました。

  1. コンフリクトが起きないようにマージコミットを積み、マージを行う
  2. 複数のワークフローをトリガーする

これを実現するために、GitHub Appsを導入しました。

なぜGitHub Appsが必要か

GitHubにはデフォルトでGITHUB_TOKENシークレットが提供されており、このシークレットを使用してGitHub Actionsで自動マージが可能です。しかし、GITHUB_TOKENを使用したタスクでは、再帰的なワークフロートリガーが無効化されています。

そのため、GitHub Appsを作成し、そのトークンを使用して自動マージを行い、次のワークフローをトリガーする必要がありました。
https://docs.github.com/ja/enterprise-cloud@latest/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow

GitHub Appsを組織に導入後、取得したトークンを指定することで、GitHub Appsを使用したマージが可能です。

sample.yaml
name: Auto Merge

on:
  pull_request:
    branches:
      - development

permissions:
  pull-requests: write

jobs:
  auto-merger-dev:
    runs-on: ubuntu-latest
    if: ${{ github.head_ref == 'staging' || github.head_ref == 'main' }}
    steps:
      - name: generate access token
        id: create
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.TEST_APP_ID }}
          private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }}
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: staging
          token: ${{ steps.create.outputs.token }}
      - name: Auto-merge
        run: gh pr merge --auto --merge "$PR_URL"
        env:
          PR_URL: ${{github.event.pull_request.html_url}}
          GH_TOKEN: ${{ steps.create.outputs.token }} # <- ここ

GitHub Actionsから自動マージを行い、他のワークフローをトリガーするためのセットアップは多少手間がかかりますが、一度導入すれば非常に便利です。

証明書の管理

弊チームではDNSをCloudflare証明書をGoogle Managed Certificateで管理しています。現時点では、CloudflareでDNS管理のみを行い、Proxyモードは使用していません。 最終的にはネットワークレイヤーをよりセキュアにするためにProxyモードの使用が望ましいですが、Ingress周りの構成を大幅に変更する必要があるため、現状はDNSのみでの運用を続けています。

alt text

また、DNSレコード自体も現状は手動管理となってしまっているのでExternal DNSを使用して自動的にレコードを動的に管理できるように修正が必要です。
https://github.com/kubernetes-sigs/external-dns


ロギングとモニタリングの改善と課題

正直なところ、モニタリングにはまだ多くの改善の余地があります。現状、各チームでモニタリングの取り組みが異なるため、統一したアプローチが必要と感じています。

弊チームでは、Google Cloud Observabilityを基に最低限のログ収集を行っています。
https://cloud.google.com/stackdriver/docs?hl=ja

構造化ロギングとtslog

アプリケーションでは、tslogを使用して構造化ロギングを行っています。
pinowinstonとは異なり、TypeScriptで記述されているため、問題発生時の本家のコードの追跡が容易であり、ログマスキング等も簡単に行える点が利点です。
https://tslog.js.org/

さて、現状ログ基盤はGoogle Cloud Loggingを使用しており、このサービスでは特定のJSONフィールドが定義されており、それに従いログを出力することでUI上のログの視認性を向上させることが可能になっています。
https://cloud.google.com/logging/docs/structured-logging?hl=ja

alt text

以下のコード例では、Cloud Providerに応じてログのフォーマットをカスタマイズしています。

example.ts
interface CustomLogger {
  debug(logObj: InputLogObject): void
  info(logObj: InputLogObject): void
  warn(logObj: InputLogObject): void
  error(logObj: InputLogObject): void
}

interface InputLogObject {
  message: string
  labels?: { [key: string]: string }
  stackTrace?: string
  [key: string]: unknown
}

interface FormattedLogObject {
  message: string
  severity: string
  time: string // RFC3339
  requestId: string
  stack_trace?: string // Error stack trace
}

const createLoggerConfig = (env: LogEnv) =>
  ({
    name: 'app',
    type: env.LOG_TYPE,
    minLevel: env.LOG_MINLEVEL,
    hideLogPositionForProduction: env.LOG_HIDE_POSITION,
  }) satisfies ISettingsParam<FormattedLogObject>

class AppLogger implements CustomLogger {
  private loggerInstance: TSLogger<FormattedLogObject>
  private requestId: string

  constructor(opts: { env: LogEnv; requestId: string }) {
    const loggerConfig = createLoggerConfig(opts.env)
    this.loggerInstance = new TSLogger(loggerConfig)
    this.requestId = opts?.requestId || ''
  }

  private formatLogObject(inputLogObj: InputLogObject, severity: string): FormattedLogObject {
    return {
      ...inputLogObj,
      severity,
      time: getCurrentUTCDate().toISOString(),
      requestId: this.requestId,
      stack_trace: inputLogObj.stackTrace,
    }
  }

  debug(logObj: InputLogObject): void {
    const formattedLogObject = this.formatLogObject(logObj, 'DEBUG')
    this.loggerInstance.debug(formattedLogObject)
  }

  info(logObj: InputLogObject): void {
    const formattedLogObject = this.formatLogObject(logObj, 'INFO')
    this.loggerInstance.info(formattedLogObject)
  }

  warn(logObj: InputLogObject): void {
    const formattedLogObject = this.formatLogObject(logObj, 'WARNING')
    this.loggerInstance.warn(formattedLogObject)
  }

  error(logObj: InputLogObject): void {
    const formattedLogObject = this.formatLogObject(logObj, 'ERROR')
    this.loggerInstance.error(formattedLogObject)
  }
}

トレーサビリティを向上させるためには、特定のリクエストに関連するログを一括管理する必要があります。これを実現するために、リクエストIDをLoggerのコンテキストに含めています。

弊チームでは、WebフレームワークにHonoを使用しており、middlewareを活用してLoggerにリクエストIDを付与し、Contextで引き回しています。
https://zenn.dev/aishift/articles/a3dc8dcaac6bfa#構造化ロギングについて

このContextは今まで各処理に伝搬する必要がありましたが、Hono v4.6.0で追加されたContext Storage Middlewareを利用することで、Contextをグローバルに扱えるようになっています。
https://hono.dev/docs/middleware/builtin/context-storage

このContextにLoggerを詰め込むことで、リクエストに一意のリクエストIDを付与し、グローバルにLoggerを利用可能にしています。

リソースの最適化 (Memory)

アプリケーションがある程度完成すると、次に必要なのはリソースの最適化です。運用上特に問題となったのは以下の2点でした。

  1. コンテナ起動時のメモリ使用量が高い
  2. 特定のAPIを叩いた際にCPUとMemoryが急上昇する

1. コンテナ起動時のメモリ使用量が高い

この問題の原因は、ベースとなるDockerイメージが大きすぎることでした。そのため、愚直にビルドプロセスの見直しや、マルチステージビルドの改善を行いました。

sample
ARG BUN_VERSION
FROM oven/bun:${BUN_VERSION} as builder
WORKDIR /app
COPY . .
RUN bun install --production --frozen-lockfile
RUN bun build --entrypoints cmd/server/index.ts --target bun --outdir ./out/server

FROM oven/bun:${BUN_VERSION}-slim AS app
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
COPY --from=builder --chown=nonroot:nonroot /app/node_modules/tiktoken/tiktoken_bg.wasm /app/node_modules/tiktoken/tiktoken_bg.wasm
COPY --from=builder --chown=nonroot:nonroot /app/out/server /app
ENV NODE_ENV=production \
  TZ=UTC
EXPOSE 3000
ENTRYPOINT ["/tini", "--"]
CMD ["bun", "/app/index.js"]

また、Node.js同様、PID1問題が存在するため、tiniを使用してサブプロセスの管理を行っています。
https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals

ベースイメージのサイズを削減できましたが、そもそもBunはNode.jsに比べて、HTTPサーバー起動時のメモリ使用量が高く、またTestcontainersなど開発上有用なライブラリが動作しないなど制約が多いため、いずれNode.jsに切り替えることを検討しています(個人的な意見)。

2. 特定のAPIを叩いた際にCPUとMemoryが急上昇する

AI関連のサービスを開発する中で、特定のLLMモデルを使用する際にトークン使用量を計算する必要があります。その際、TikTokenというライブラリを使用します。

https://github.com/dqbd/tiktoken

このライブラリが生成するTiktokenオブジェクトのサイズが非常に大きく、使用するモデルごとにgpt-4ogpt-4o-miniといった異なるオブジェクトを生成する必要があり、メモリ消費が急増する原因となっていました。

この問題の根本解決は難しいため、不必要なオブジェクト生成を抑えるロジックの設計や、全体的なサービス設計を見直し、Tiktokenを使用する責務を特定のサービスに委譲することを検討しています。

DevOps

インフラ周りのLinterの導入

弊チームでは、1つのリポジトリにフロントエンド、バックエンド、インフラのコードが混在しています。 アプリケーション周りのコードはBiomeを使用して統一的にフォーマットとLintを行っていますが、インフラに関しては手動チェックが多かったため、Terraform Linterの導入を行いました。

Terraformのチェックは、PR作成時に以下のコマンドで行っています。

  • terraform fmt -check -recursive
  • terraform validate -no-color
  • terraform plan

将来的にはtflintを導入し、より厳密なチェックを行い、ベストプラクティスに沿った運用を目指しています。
https://developer.hashicorp.com/terraform/language/style

k8sのマニフェストに関しては、kustomize buildを実行し、構文エラーがないかのチェックを行っています。将来的にはkubeconformを導入し、スキーマチェックも行える環境の構築を考えています。
https://github.com/yannh/kubeconform

Renovateの導入

ライブラリの自動更新を行うためにRenovateを導入しました。フロントエンドやバックエンドでライブラリアップデートの周期が異なるため、Dependabotなどと比べパッケージ単位の細かな設定ができ、さらにBunにも対応しているため、Renovateを採用しました。
https://docs.renovatebot.com/modules/manager/bun/

yarn.lockが更新されない問題

Bunでは、パッケージインストール時にbun.lockbyarn.lockの両方を生成できますが、Renovateでライブラリを更新する際、yarn.lockが更新されないという問題(仕様)がありました。
https://github.com/renovatebot/renovate/issues/20065#issuecomment-1712582023

この問題を解決するために、Renovateが生成したPRに対してyarn.lockを更新するCIワークフローを組み込み、対応しています。

セキュリティ

Cloudflare AccessとClerkの導入

開発環境のWebアプリケーションにはCloudflare Accessを導入し、社内環境からのアクセスのみを許可しています。

alt text

Cloudflare Accessは、複数のIdPとの統合が可能で、50人までは無料で利用できます。
https://developers.cloudflare.com/cloudflare-one/identity/idp-integration/

また、アプリケーション内の認証機能として、社内環境ではClerkをIdPとして使用しています。
基本的にはClerkが提供する一般的な認証フローを使用しており、フロントエンド-バックエンド間のCross-Originリクエストに対しては、手動でJWTの検証を行っています。
https://clerk.com/docs/backend-requests/handling/manual-jwt

alt text

このフローで使用されるSession Tokensは、RS256で署名された非対称トークン形式のJWTです。そのためJWKを取得してトークンの検証を行う必要があります。
検証自体はClerkがBackendのSDKを提供しているためその機能を使用することで簡単に実装できます。
https://clerk.com/docs/references/backend/overview

自前実装する場合はjose等のライブラリを組み合わせると良いと思います。
https://zenn.dev/aishift/articles/a3dc8dcaac6bfa#jwt

SAML Federation

ClerkはEnterprise向けの連携としてSAML Federationをサポートしています(Proプランおよび追加のPluginが必要)。
https://clerk.com/docs/authentication/saml/overview

弊チームではSP-initiatedでのSSOを採用し、社内の認証基盤との連携が可能となっています。
Connectionの設定を行う上で個人的に便利だと感じたのは、Identity Providerのメタデータ設定がファイルアップロードで簡単に行える点です。Auth0などと比較しても設定が非常に簡単だと感じました。

alt text

SAML検証の段階ではMock SAMLを使用して動作確認を行っていました。
https://mocksaml.com/

Clerkの制約

Clerkは、1テナントに収まる範囲での認証機能が充実していますが、ビジネス上の利用においては以下の点で制約があります。

データの保管場所

ClerkのPrivacy Policyによると、データは米国やその他の国に転送・保存される可能性があり、日本国内でのデータ保存要件には対応していません。

All information processed by us may be transferred, processed, and stored anywhere in the world, including, but not limited to, the United States or other countries, which may have data protection laws that are different from the laws where you live. We endeavor to safeguard your information consistent with the requirements of applicable laws.
https://clerk.com/legal/privacy

監査ログの取得

現状、Clerkは監査ログを保持していますが、ユーザー側で確認する手段は提供されていません。今後のロードマップには含まれていますが、実装にはまだ時間がかかりそうです。

Clerkのロードマップは以下で確認できます。利用を検討している方は参考にしてください。
https://feedback.clerk.com/roadmap


その他取り組み

https://zenn.dev/aishift/articles/f82ec60b1762a0
https://zenn.dev/aishift/articles/1df36e912eb271


まとめ

半年間の改善の取り組みをまとめました。公開が難しい内容も多く、一部のみの公開となりましたが、何かしらの参考になれば幸いです。

脚注
  1. https://www.ai-shift.co.jp/3958 ↩︎

AI Shift Tech Blog

Discussion