新規サービス開発で起こったインフラ移行の舞台裏
こんにちは、sugar-catです。
AIShiftでは、昨年11月から「AI Worker」[1](以下、AI Worker)という新しいサービスの開発を開始しました。
約10ヶ月が経過し様々なことがありましたが、今回は私が行ったインフラ移行や諸々の改善活動について、外部公開が可能な取り組みをざっくりとまとめます。まだまだ開発途上の部分も多く、至らない点が多いですが、なんらか役立つと嬉しいです。
3ヶ月時点については、以下の記事をご覧ください。どのような技術を使い、何を作っているかについて説明しています。
Google Cloudへのサービス展開
弊チームでは、もともとMicrosoft Azureのスタックをベースにアプリケーションを構築・運用していました。しかし、ビジネス要件の変更に伴い、Google Cloudと併用してサービスを展開することになりました。
既存のAzure構成
もともと、バックエンドはAzure Container Appsを基盤に構成しており、インフラ構築の手間を最小限に抑え、アプリケーション開発に集中できるようにしていました。
しかし、開発の途中でIaC化
に伴う問題やAzure固有の知識
による問題に直面する場面が多くあり、これらの課題を解決するため、Kubernetes
(以下、k8s)への移行を決定しました。
そこで、もともとAI Shiftで利用していたGoogle Kubernetes Engine
(以下、GKE
)を活用し、Google Cloud上での展開と合わせてk8sへの移行を進めることにしました。
Google Cloudへの移行手順
Google Cloudへの展開にあたり、Azureでの動作環境をGoogle Cloud上で再現するため、以下の手順で作業を進めました。
- 定義済みのAzureリソースから対応するGoogle Cloudおよびk8sリソースの調査
- Terraformやマニフェストを利用してリソースを作成
- アプリケーションレイヤーの修正
- 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つのリポジトリでTerraformとk8sのマニフェストを管理されており、現在は1リポジトリ=1アプリケーションの形になっています。
現在のTerraformのディレクトリ構成は以下の通りです。このリポジトリには1つのアプリケーションしかないため、rootにmodules
を配置し、環境固有のサブディレクトリ(environments/{env})
でアプリケーションを分割しています。
.
└── terraform
├── environments
│ ├── dev
│ ├── prod
│ └── stage
└── modules
└── gcp
├── xxx
└── yyy
基本的には、Google Cloudが推奨するベストプラクティスに従ってリソースを作成しています。
この方法だと、単一クラウドプロバイダーでの管理は簡単ですが、将来的にマルチクラウド展開を行う場合、ライフサイクルが異なるプロバイダーごとのリソースを扱う際のState管理が課題になると思っています。
また、k8sのマニフェスト管理にはKustomize
を使用し、リソースをアプリケーションごとに(例: API、DB Migration用のJobなど)シンプルに分割しています。
.
└── manifests
├── argocd
│ ├── base
│ └── overlays
│ ├── dev
│ ├── prod
│ └── stage
└── app1
├── base
└── overlays
├── dev
├── prod
└── stage
特筆する点はあまりないですが、CRDとしてExternalSecretOperatorを使用してSecretの管理を行い、ArgoCDを活用してリソースのデプロイを行っています。
3. アプリケーションレイヤーの修正
Google Cloud環境でもAzure環境と同様の動作を実現するために、アプリケーションレイヤーの修正を行いました。
幸い、インターフェースを通じて抽象化していたため、インフラ層の実装部分を修正するだけで完了しました。
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にプッシュするように変更しました。
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を指定して並列実行するようにしています。
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に対する操作権限を管理することで、リソースを論理的に区分しています。
さらに、ArgoCDのApplication管理にはApplicationSetを利用しています。
dev環境ではデプロイが頻繁に行われ、短時間のダウンタイムが許容されるため、AutoSyncを有効化してリソースの同期を自動化しています。
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を実行でき、柔軟なデプロイフローに対応可能です。
Organization Repositoryにおける自動マージ
弊チームでは、アプリケーションのリリースフローにGit Flowを採用しています。その過程で、例えばdevelopmentブランチ
からstagingブランチ
へのマージを行う際に、以下の2つの作業を自動化する必要がありました。
- コンフリクトが起きないようにマージコミットを積み、マージを行う
- 複数のワークフローをトリガーする
これを実現するために、GitHub Appsを導入しました。
なぜGitHub Appsが必要か
GitHubにはデフォルトでGITHUB_TOKEN
シークレットが提供されており、このシークレットを使用してGitHub Actionsで自動マージが可能です。しかし、GITHUB_TOKEN
を使用したタスクでは、再帰的なワークフロートリガーが無効化されています。
そのため、GitHub Appsを作成し、そのトークンを使用して自動マージを行い、次のワークフローをトリガーする必要がありました。
GitHub Appsを組織に導入後、取得したトークンを指定することで、GitHub Appsを使用したマージが可能です。
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のみでの運用を続けています。
また、DNSレコード自体も現状は手動管理となってしまっているのでExternal DNS
を使用して自動的にレコードを動的に管理できるように修正が必要です。
ロギングとモニタリングの改善と課題
正直なところ、モニタリングにはまだ多くの改善の余地があります。現状、各チームでモニタリングの取り組みが異なるため、統一したアプローチが必要と感じています。
弊チームでは、Google Cloud Observabilityを基に最低限のログ収集を行っています。
構造化ロギングとtslog
アプリケーションでは、tslog
を使用して構造化ロギングを行っています。
pino
やwinston
とは異なり、TypeScriptで記述されているため、問題発生時の本家のコードの追跡が容易であり、ログマスキング等も簡単に行える点が利点です。
さて、現状ログ基盤はGoogle Cloud Loggingを使用しており、このサービスでは特定のJSONフィールドが定義されており、それに従いログを出力することでUI上のログの視認性を向上させることが可能になっています。
以下のコード例では、Cloud Providerに応じてログのフォーマットをカスタマイズしています。
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で引き回しています。
このContextは今まで各処理に伝搬する必要がありましたが、Hono v4.6.0で追加されたContext Storage Middleware
を利用することで、Contextをグローバルに扱えるようになっています。
このContextにLoggerを詰め込むことで、リクエストに一意のリクエストIDを付与し、グローバルにLoggerを利用可能にしています。
リソースの最適化 (Memory)
アプリケーションがある程度完成すると、次に必要なのはリソースの最適化です。運用上特に問題となったのは以下の2点でした。
- コンテナ起動時のメモリ使用量が高い
- 特定のAPIを叩いた際にCPUとMemoryが急上昇する
1. コンテナ起動時のメモリ使用量が高い
この問題の原因は、ベースとなるDockerイメージが大きすぎることでした。そのため、愚直にビルドプロセスの見直しや、マルチステージビルドの改善を行いました。
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 /app/node_modules/tiktoken/tiktoken_bg.wasm /app/node_modules/tiktoken/tiktoken_bg.wasm
COPY /app/out/server /app
ENV NODE_ENV=production \
TZ=UTC
EXPOSE 3000
ENTRYPOINT ["/tini", "--"]
CMD ["bun", "/app/index.js"]
また、Node.js同様、PID1
問題が存在するため、tini
を使用してサブプロセスの管理を行っています。
ベースイメージのサイズを削減できましたが、そもそもBunはNode.jsに比べて、HTTPサーバー起動時のメモリ使用量が高く、またTestcontainers
など開発上有用なライブラリが動作しないなど制約が多いため、いずれNode.jsに切り替えることを検討しています(個人的な意見)。
2. 特定のAPIを叩いた際にCPUとMemoryが急上昇する
AI関連のサービスを開発する中で、特定のLLMモデルを使用する際にトークン使用量を計算する必要があります。その際、TikToken
というライブラリを使用します。
このライブラリが生成するTiktokenオブジェクトのサイズが非常に大きく、使用するモデルごとにgpt-4o
やgpt-4o-mini
といった異なるオブジェクトを生成する必要があり、メモリ消費が急増する原因となっていました。
この問題の根本解決は難しいため、不必要なオブジェクト生成を抑えるロジックの設計や、全体的なサービス設計を見直し、Tiktokenを使用する責務を特定のサービスに委譲することを検討しています。
DevOps
インフラ周りのLinterの導入
弊チームでは、1つのリポジトリにフロントエンド、バックエンド、インフラのコードが混在しています。 アプリケーション周りのコードはBiome
を使用して統一的にフォーマットとLintを行っていますが、インフラに関しては手動チェックが多かったため、Terraform Linter
の導入を行いました。
Terraformのチェックは、PR作成時に以下のコマンドで行っています。
terraform fmt -check -recursive
terraform validate -no-color
terraform plan
将来的にはtflint
を導入し、より厳密なチェックを行い、ベストプラクティスに沿った運用を目指しています。
k8sのマニフェストに関しては、kustomize build
を実行し、構文エラーがないかのチェックを行っています。将来的にはkubeconform
を導入し、スキーマチェックも行える環境の構築を考えています。
Renovateの導入
ライブラリの自動更新を行うためにRenovate
を導入しました。フロントエンドやバックエンドでライブラリアップデートの周期が異なるため、Dependabot
などと比べパッケージ単位の細かな設定ができ、さらにBunにも対応しているため、Renovateを採用しました。
yarn.lock
が更新されない問題
Bunでは、パッケージインストール時にbun.lockb
とyarn.lock
の両方を生成できますが、Renovateでライブラリを更新する際、yarn.lock
が更新されないという問題(仕様)がありました。
この問題を解決するために、Renovateが生成したPRに対してyarn.lock
を更新するCIワークフローを組み込み、対応しています。
セキュリティ
Cloudflare AccessとClerkの導入
開発環境のWebアプリケーションにはCloudflare Access
を導入し、社内環境からのアクセスのみを許可しています。
Cloudflare Accessは、複数のIdPとの統合が可能で、50人までは無料で利用できます。
また、アプリケーション内の認証機能として、社内環境ではClerk
をIdPとして使用しています。
基本的にはClerkが提供する一般的な認証フローを使用しており、フロントエンド-バックエンド間のCross-Originリクエストに対しては、手動でJWTの検証を行っています。
このフローで使用されるSession Tokensは、RS256で署名された非対称トークン形式のJWTです。そのためJWKを取得してトークンの検証を行う必要があります。
検証自体はClerkがBackendのSDKを提供しているためその機能を使用することで簡単に実装できます。
自前実装する場合はjose
等のライブラリを組み合わせると良いと思います。
SAML Federation
ClerkはEnterprise向けの連携としてSAML Federationをサポートしています(Proプランおよび追加のPluginが必要)。
弊チームではSP-initiated
でのSSOを採用し、社内の認証基盤との連携が可能となっています。
Connectionの設定を行う上で個人的に便利だと感じたのは、Identity Providerのメタデータ設定がファイルアップロードで簡単に行える点です。Auth0などと比較しても設定が非常に簡単だと感じました。
SAML検証の段階ではMock SAML
を使用して動作確認を行っていました。
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のロードマップは以下で確認できます。利用を検討している方は参考にしてください。
その他取り組み
まとめ
半年間の改善の取り組みをまとめました。公開が難しい内容も多く、一部のみの公開となりましたが、何かしらの参考になれば幸いです。
Discussion