先日、AWS で大規模な障害が発生しました。この障害により、McDonald’s のモバイルオーダーシステムの一部や Netflix の一部機能をはじめ、AWS のインフラストラクチャだけに依存していた多くのアプリケーションが停止しました。1つのクラウドプラットフォームだけに頼ることがいかにリスクを伴うかを、この出来事ははっきりと示しています。
マルチクラウド戦略が解決するのは、障害への耐性だけではありません。現実のさまざまな課題にも対応できます。たとえば、次のような状況を想像してみてください。あなたのスタートアップが大手企業の顧客を獲得しましたが、その顧客はコンプライアンス上の理由から、データを特定のリージョン内に保持することを求めています。現在のシングルクラウド構成では、もはや規制を満たせません。そこで、同じアプリケーションを AWS(米国の業務向け)と Google Cloud Platform(EU の業務向け)にデプロイし、一貫性とセキュリティを保ちながら、安心して運用できるようにします。
Spotify や Netflix、そして多くの金融機関が、現在マルチクラウド戦略を採用しています。こうした戦略は冗長性のためだけのものではありません。各プロバイダーの優れた機能を活用しながら、ベンダーロックインを避けるためにも役立ちます。
クラウドプロバイダーごとに別々のデプロイプロセスを管理する代わりに、単一の設定で複数のクラウドにアプリケーションを一貫してデプロイする、統合された CircleCI パイプラインを作成できます。
このチュートリアルでは、Node.js アプリケーションを AWS ECS Fargate と Google Cloud Run に同時にデプロイする、実際に動くマルチクラウドデプロイパイプラインを構築します。最終的には、クラウドに依存しない CI/CD の威力を実感できる、動作するシステムが完成します。
このチュートリアルで学べること
複数のクラウドプロバイダーをシームレスに扱う、統合されたデプロイパイプラインの作成方法を学びます。コンテナ化した Node.js アプリケーションを構築し、単一の CircleCI 設定を使って AWS ECS Fargate と Google Cloud Run の両方にデプロイします。クラウドごとの認証の管理、環境固有の設定の扱い、並列デプロイの実装、複数のプロバイダーにまたがるデプロイの監視について学びます。また、デプロイの失敗に対処し、クラウド環境間の一貫性を保つための戦略についても取り上げます。
前提条件
このチュートリアルを進めるには、次のツールとアカウントを用意してください。
- コンピューターにインストールされた Node.js バージョン 18 以降
- コンテナをビルドするためにローカルにインストールされた Docker
- コードリポジトリをホストするための GitHub アカウント
- CI/CD パイプラインを実行するための CircleCI アカウント
- 課金が有効になっている AWS アカウント と、次のもの:
- ECS、ECR、CloudWatch 用のポリシーを持つ、プログラムによるアクセスが可能な IAM ユーザー
- ECS タスク実行ロールを一度だけ設定するための管理者アクセス
- AWS アカウント ID と、使用する AWS リージョン
- 課金が有効になっている Google Cloud Platform アカウント
- 課金がリンクされた GCP プロジェクト
- 有効化済みの必須 API: Cloud Run Admin API、Google Container Registry API、Cloud Build API
- Artifact Registry Create-on-Push Repository Administrator、Artifact Registry Writer、Cloud Run Admin、Service Account User、Storage Admin の各ロールを持つサービスアカウント
- ダウンロード済みのサービスアカウントの JSON キー
- GCP プロジェクト ID と、使用する GCP リージョン
- Docker、CI/CD の概念、クラウドサービスに関する基本的な知識
マルチクラウドのメリット
実装を始める前に、マルチクラウドがなぜ重要なのかを少し考えてみましょう。従来のシングルクラウドのデプロイでは、ベンダーロックインと単一障害点が生まれます。マルチクラウド戦略には、ベンダーの冗長化、地理的な最適化、コストの柔軟性、そしてプロバイダーごとに異なる機能を活用できるというメリットがあります。
複数のクラウドプロバイダーにデプロイすることで、運用面のレジリエンスと戦略的な柔軟性が得られます。リージョンごとの要件に応じてワークロードを分散したり、プロバイダー間の価格競争を活用したり、単一ベンダーのエコシステムに縛られるのを避けたりできます。このアプローチにより、アーキテクチャ上の制約を受けずに、多様なコンプライアンス要件を満たし、特定のユースケースに合わせて最適化することもできます。
デモアプリケーションのセットアップ
まずは、両方のクラウドにデプロイする、シンプルながら実践的な Node.js アプリケーションを用意します。このアプリケーションには、本番環境のマルチクラウドデプロイに欠かせないヘルスチェック、環境固有の設定、ログ出力が含まれます。
プロジェクトの構成
マルチクラウドのデモ用に新しいディレクトリを作成します。
mkdir multi-cloud-demo
cd multi-cloud-demo
アプリケーションの作成
最初のステップとして、適切なヘルスモニタリングを備えた Node.js の Web アプリケーションを構築します。package.json ファイルを作成し、次の内容を追加します。
{
"name": "multi-cloud-demo",
"version": "1.0.0",
"description": "Demo app for multi-cloud deployment",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "jest",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^6.3.3",
"nodemon": "^3.0.1"
}
}
package.json では、Web サーバーとして Express を、クロスオリジンリクエストのために CORS を使う Node.js プロジェクトを定義しています。テスト用の依存関係には、ユニットテスト用の Jest と HTTP テスト用の Supertest が含まれます。nodemon は、開発中の自動リロードを可能にします。
メインのアプリケーションを server.js に作成し、次のコードを貼り付けます。
const express = require("express");
const cors = require("cors");
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Environment info
const cloudProvider = process.env.CLOUD_PROVIDER || "unknown";
const deploymentTime = process.env.DEPLOYMENT_TIME || new Date().toISOString();
const appVersion = process.env.APP_VERSION || "1.0.0";
// Routes
app.get("/", (req, res) => {
res.json({
message: "Multi-Cloud Demo Application",
cloud: cloudProvider,
version: appVersion,
deployedAt: deploymentTime,
timestamp: new Date().toISOString(),
});
});
app.get("/health", (req, res) => {
res.status(200).json({
status: "healthy",
cloud: cloudProvider,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
app.get("/info", (req, res) => {
res.json({
environment: {
nodeVersion: process.version,
platform: process.platform,
cloud: cloudProvider,
region: process.env.CLOUD_REGION || "unknown",
},
deployment: {
version: appVersion,
deployedAt: deploymentTime,
},
});
});
// Error handling
app.use((err, req, res, next) => {
console.error("Error:", err.message);
res.status(500).json({
error: "Internal Server Error",
cloud: cloudProvider,
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: "Not Found",
path: req.path,
cloud: cloudProvider,
});
});
// Only start server if this file is run directly
if (require.main === module) {
app.listen(port, "0.0.0.0", () => {
console.log(`Multi-cloud demo app running on port ${port}`);
console.log(`Cloud Provider: ${cloudProvider}`);
console.log(`Version: ${appVersion}`);
});
}
module.exports = app;
この Express アプリケーションは、クラウドを認識する Web サーバーを作成し、3つの主要なエンドポイントを提供します。デプロイ情報を表示するルートエンドポイント、監視用のヘルスチェックエンドポイント、環境の詳細を返す info エンドポイントです。アプリケーションは環境変数を使って、どのクラウドプロバイダー上で動作しているかを識別し、適切なエラー処理も備えています。
test/app.test.js にテストを作成し、次のコードを使用します。
const request = require("supertest");
const app = require("../server");
describe("Multi-Cloud Demo App", () => {
test("GET / returns app info", async () => {
const response = await request(app).get("/").expect(200);
expect(response.body).toHaveProperty("message");
expect(response.body).toHaveProperty("cloud");
expect(response.body).toHaveProperty("version");
});
test("GET /health returns health status", async () => {
const response = await request(app).get("/health").expect(200);
expect(response.body.status).toBe("healthy");
expect(response.body).toHaveProperty("uptime");
});
test("GET /info returns environment info", async () => {
const response = await request(app).get("/info").expect(200);
expect(response.body).toHaveProperty("environment");
expect(response.body).toHaveProperty("deployment");
});
test("GET /nonexistent returns 404", async () => {
await request(app).get("/nonexistent").expect(404);
});
});
これらの Jest のテストで、アプリケーションのエンドポイントが正しく動作することを確認します。メインのルートが適切な JSON 構造を返すこと、ヘルスエンドポイントが healthy ステータスで応答すること、info エンドポイントが環境データを返すこと、404 エラーが適切に処理されることをテストします。
アプリケーションのコンテナ化
AWS と GCP の両方へのデプロイに最適化した Dockerfile を作成します。
# Use official Node.js runtime as base image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --omit=dev
# Copy application code
COPY . .
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Change ownership of app directory
RUN chown -R nodejs:nodejs /app
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "const http = require('http'); \
const options = { hostname: 'localhost', port: 3000, path: '/health', timeout: 2000 }; \
const req = http.request(options, (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }); \
req.on('error', () => process.exit(1)); \
req.end();"
# Start the application
CMD ["npm", "start"]
この Dockerfile は、Node.js 18 Alpine を使って、安全で本番環境に対応したコンテナを作成します。npm install --omit=dev を使って本番用の依存関係だけをインストールし(npm ci --only=production よりも npm のバージョン間の互換性が高い方法です)、セキュリティのために root 以外のユーザーで実行し、組み込みのヘルスチェックを備え、レイヤー最適化に関する Docker のベストプラクティスに従っています。
.dockerignore ファイルを追加します。
node_modules
npm-debug.log
.git
.gitignore
README.md
.nyc_output
coverage
.env
この .dockerignore ファイルは、不要なファイルが Docker イメージにコピーされるのを防ぎます。これにより、ビルド時間とイメージサイズを削減しつつ、.env のような機密ファイルをコンテナに含めないようにできます。
クラウドインフラストラクチャのセットアップ
次に、AWS と GCP の両方のインフラストラクチャ設定を準備します。Infrastructure as Code を使って、環境間の一貫性と再現性を確保します。これらの設定ファイルでは、リソースの割り当て、ネットワーク、環境変数など、コンテナ化したアプリケーションを各クラウドプラットフォームでどのように実行するかを定義します。
クラウドごとの設定は、プロジェクトのルートにある別々のディレクトリにまとめます。
multi-cloud-demo/
├── aws/
│ ├── task-definition.json
│ └── ecs-task-execution-role.yaml
├── gcp/
│ └── service.yaml
├── server.js
├── package.json
└── Dockerfile
この構成により、クラウドの設定が整理され、メインのアプリケーションコードを散らかすことなく、プラットフォームごとの設定を簡単に管理できます。
AWS ECS のセットアップ
AWS ECS(Elastic Container Service)では、コンテナの実行方法を記述したタスク定義が必要です。この JSON ファイルでは、使用する Docker イメージ、リソース要件、ネットワーク設定、環境変数を指定します。ECS はこの定義を使って、Fargate 上でアプリケーションのコンテナを起動・管理します。
aws ディレクトリと、タスク定義ファイル aws/task-definition.json を作成します。
{
"family": "multi-cloud-demo",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::{AWS_ACCOUNT_ID}:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "multi-cloud-demo",
"image": "{ECR_REPOSITORY_URI}:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "CLOUD_PROVIDER",
"value": "AWS"
},
{
"name": "CLOUD_REGION",
"value": "{AWS_REGION}"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/multi-cloud-demo",
"awslogs-region": "{AWS_REGION}",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": [
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1"
],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 10
}
}
]
}
この ECS タスク定義では、256 CPU ユニットと 512MB のメモリでコンテナを実行するように Fargate を設定しています。クラウドプロバイダーを識別するための環境変数、CloudWatch のログ設定、wget を使ってアプリケーションの可用性を監視するヘルスチェックが含まれています。
GCP Cloud Run のセットアップ
Google Cloud Run は、AWS ECS とは異なるアプローチを取ります。Cloud Run では、タスク定義の代わりに、YAML で記述した Knative のサービス設定を使います。このサービス定義では、自動スケーリングのルール、リソースの上限、トラフィック管理など、Cloud Run がコンテナ化したアプリケーションをどのようにデプロイ・管理するかを指定します。
Cloud Run は、負荷分散、アイドル時のゼロへのスケールダウン、受信リクエストに応じたスケールアップを自動的に処理します。次の設定では、適切なリソースの上限とヘルスチェックを使ってアプリケーションをセットアップします。
gcp ディレクトリと、サービス定義ファイル gcp/service.yaml を作成します。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: multi-cloud-demo
annotations:
run.googleapis.com/ingress: all
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/maxScale: "10"
run.googleapis.com/cpu-throttling: "false"
run.googleapis.com/memory: "512Mi"
run.googleapis.com/cpu: "0.25"
spec:
containerConcurrency: 100
containers:
- image: gcr.io/{GCP_PROJECT_ID}/multi-cloud-demo:latest
ports:
- containerPort: 3000
env:
- name: CLOUD_PROVIDER
value: "GCP"
- name: CLOUD_REGION
value: "{GCP_REGION}"
resources:
limits:
cpu: 250m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
この Cloud Run のサービス設定では、Knative Serving を使ってコンテナをデプロイし、最大 10 インスタンスまで自動的にスケーリングします。ECS の設定に合わせたリソースの上限を設定し、ヘルスモニタリング用に liveness プローブと readiness プローブの両方を備え、一般公開の Web トラフィック向けに未認証のアクセスを許可しています。
AWS の前提条件のセットアップ
CircleCI パイプラインを作成する前に、ECS へのデプロイに必要な IAM ロールを設定し、IAM ユーザーに必要な権限があることを確認する必要があります。このセクションは、プログラムによるアクセスが可能な IAM ユーザーがすでにあることを前提としています。このユーザーをまだ作成していない場合は、AWS のドキュメントに従ってプログラムによるアクセスが可能な IAM ユーザーを作成し、アクセスキー ID とシークレットアクセスキーを控えておいてください。
ECS タスク実行ロールの作成
AWS ECS では、ECR からコンテナイメージを取得し、CloudWatch にログを書き込むための専用の IAM ロールが必要です。プログラムによるアクセス用に作成した IAM ユーザーには(セキュリティのベストプラクティスに従い)IAM ロールを作成する権限がないため、この一度だけの設定には管理者権限が必要です。
このロールを自動的に作成する CloudFormation テンプレートを aws フォルダに用意しました。このアプローチにより、一貫性と再現性のあるインフラストラクチャのセットアップが可能になり、IAM リソース管理に関する AWS のベストプラクティスにも沿うことができます。
まず、CloudFormation テンプレートファイルを作成します。aws ディレクトリに ecs-task-execution-role.yaml という名前の新しいファイルを作成し、次の内容を追加します。
AWSTemplateFormatVersion: "2010-09-09"
Description: "Creates the required ecsTaskExecutionRole for ECS Fargate tasks in the multi-cloud demo"
Resources:
EcsTaskExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: ecsTaskExecutionRole
Description: "Allows ECS tasks to pull container images and publish logs"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service:
- ecs-tasks.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
Tags:
- Key: Project
Value: multi-cloud-demo
- Key: Purpose
Value: tutorial
Outputs:
RoleArn:
Description: "ARN of the created ECS Task Execution Role"
Value: !GetAtt EcsTaskExecutionRole.Arn
Export:
Name: !Sub "${AWS::StackName}-EcsTaskExecutionRoleArn"
RoleName:
Description: "Name of the created ECS Task Execution Role"
Value: !Ref EcsTaskExecutionRole
Export:
Name: !Sub "${AWS::StackName}-EcsTaskExecutionRoleName"
この CloudFormation テンプレートは、ECS が ECR からコンテナイメージを取得し、CloudWatch にログを書き込むのに必要な権限だけを持つ IAM ロールを作成します。テンプレートには、ロールの ARN と名前をエクスポートする出力が含まれており、ほかの CloudFormation スタックで利用することもできます。
AWS コンソールを使ったロールの設定
root アカウント、または管理者権限を持つ IAM ユーザーで AWS コンソールにログインします。CloudFormation サービスに移動し、「Create Stack」、続いて「With new resources」をクリックします。プロジェクトディレクトリから aws/ecs-task-execution-role.yaml ファイルをアップロードし、スタック名には multi-cloud-demo-ecs-role を使用します。
作成ウィザードを進め、確認ページで CloudFormation が IAM リソースを作成する可能性があることを承認します。スタックの作成は通常 1~2 分で完了します。ステータスが「CREATE_COMPLETE」になれば、ECS タスク実行ロールの準備は完了です。
CI ユーザーへの PassRole 権限の付与
ECS タスク実行ロールを作成したら、プログラムによるアクセス用のユーザーが、このロールを ECS サービスに「渡す(pass)」ことを許可する必要があります。これにより、最小権限の原則に従いながら、CI パイプラインに必要な権限だけを付与できます。
AWS コンソールで IAM サービスに移動し、Users セクションで CircleCI 用のユーザーを探します。ユーザーをクリックし、Permissions タブに移動して「Add permissions」をクリックします。「Create inline policy」を選択し、JSON タブに切り替えます。
デフォルトのポリシーを、次の PassRole ポリシーに置き換えます。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::*:role/ecsTaskExecutionRole"
}
]
}
ポリシーに ECSTaskExecutionPassRole という名前を付けて作成します。このポリシーにより、CI ユーザーは完全な管理者権限を持たなくても、ECS に使用する IAM ロールを指定できます。
必要な AWS マネージドポリシーの追加
PassRole ポリシーに加えて、CircleCI 用の IAM ユーザーが ECS、ECR、CloudWatch の各サービスとやり取りするには、いくつかの AWS マネージドポリシーが必要です。次のポリシーを IAM ユーザーにアタッチします。
AmazonECS_FullAccessは、Amazon ECS のリソースへのフルアクセスを提供します。AmazonEC2ContainerRegistryFullAccessは、ECR との間で Docker イメージをプッシュ・プルすることを許可します。CloudWatchActionsEC2Accessは、ECS タスクの CloudWatch の操作を可能にします。AmazonCloudWatchEvidentlyFullAccessは、CloudWatch のログ機能を提供します。
これらのポリシーをアタッチするには、次の手順に従います。
- AWS コンソールで IAM サービスに移動します。
- CircleCI 用のユーザーを探してクリックします。
- 「Add permissions」 ボタンをクリックします。
- 「Attach policies directly」 を選択します。
- 前の手順で挙げた各ポリシーを検索して選択します。
- 「Add permissions」 をクリックしてアタッチします。
これらのポリシーにより、CircleCI パイプラインが ECR リポジトリを作成し、ECS のクラスターとサービスを管理し、CloudWatch のログを処理し、その他の AWS リソースと自動的にやり取りするのに必要な権限が付与されます。
セットアップの確認
すべてが正しく設定されていることを確認するには、CloudFormation スタックが正常に完了し、ロールが作成されたことをチェックします。AWS コンソールで CloudFormation サービスに移動し、multi-cloud-demo-ecs-role スタックのステータスが「CREATE_COMPLETE」になっていることを確認します。
AWS コンソールで IAM サービスに移動し、サイドバーの「Roles」をクリックして ecsTaskExecutionRole を検索することでも、ロールが存在することを確認できます。ポリシー AmazonECSTaskExecutionRolePolicy がアタッチされた状態でロールが表示されるはずです。
後でパイプラインの実行中に「role not found」エラーが発生した場合は、CloudFormation スタックが正常に完了しているかどうかを再確認してください。
CircleCI パイプラインの作成
次に、両方のクラウドに同時にデプロイする、統合された CircleCI パイプラインを作成します。この設定で、両方のプロバイダーの認証、ビルド、デプロイを処理します。
.circleci/config.yml を作成します。
version: 2.1
# CircleCI Orbs for cloud deployments
orbs:
gcp-gcr: circleci/gcp-gcr@0.16.3
gcp-cloud-run: circleci/gcp-cloud-run@1.0.2
# Reusable commands for AWS and GCP setup
commands:
install-aws-cli:
description: "Install AWS CLI v2 in temporary directory to avoid file conflicts"
steps:
- run:
name: Install AWS CLI
command: |
# Install in temporary directory to avoid conflicts with project files
mkdir -p /tmp/awscli-install
cd /tmp/awscli-install
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
cd -
rm -rf /tmp/awscli-install
echo 'export PATH=/usr/local/bin:$PATH' >> $BASH_ENV
source $BASH_ENV
aws --version
- run:
name: Configure AWS
command: |
source $BASH_ENV
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
aws configure set default.region $AWS_DEFAULT_REGION
install-gcloud:
description: "Install and configure Google Cloud SDK"
steps:
- run:
name: Install gcloud
command: |
export CLOUDSDK_CORE_DISABLE_PROMPTS=1
export CLOUDSDK_INSTALL_DIR=$HOME
curl -sSL https://sdk.cloud.google.com | bash
source "$HOME/google-cloud-sdk/path.bash.inc"
echo 'source "$HOME/google-cloud-sdk/path.bash.inc"' >> $BASH_ENV
gcloud version
- run:
name: Authenticate GCP
command: |
source $BASH_ENV
echo "$GCLOUD_SERVICE_KEY" > creds.json
gcloud auth activate-service-account --key-file=creds.json
gcloud config set project "$GOOGLE_PROJECT_ID"
# Pipeline parameters for flexible deployment options
parameters:
deploy-aws:
type: boolean
default: true
deploy-gcp:
type: boolean
default: true
jobs:
# Test job: Run unit tests and verify Docker build
test:
docker:
- image: cimg/node:18.20
steps:
- checkout
- setup_remote_docker
- restore_cache:
keys:
- v1-dependencies-{{ checksum "package.json" }}
- v1-dependencies-
- run:
name: Install dependencies
command: npm install
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "package.json" }}
- run:
name: Run tests
command: npm test
- run:
name: Test Docker build
command: docker build -t multi-cloud-demo:test .
# AWS: Build and push Docker image to ECR
build-and-push-aws:
docker:
- image: cimg/node:18.20
steps:
- checkout
- setup_remote_docker
- install-aws-cli
- run:
name: Configure ECR authentication
command: |
source $BASH_ENV
aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
- run:
name: Create ECR repository if needed
command: |
source $BASH_ENV
echo "Checking if ECR repository 'multi-cloud-demo' exists..."
if ! aws ecr describe-repositories --repository-names multi-cloud-demo --region $AWS_DEFAULT_REGION > /dev/null 2>&1; then
echo "Creating ECR repository..."
aws ecr create-repository --repository-name multi-cloud-demo --region $AWS_DEFAULT_REGION
echo "ECR repository created successfully!"
else
echo "ECR repository already exists."
fi
- run:
name: Build and push Docker image to ECR
command: |
DEPLOYMENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
APP_VERSION=${CIRCLE_SHA1:0:7}
ECR_REPO="$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/multi-cloud-demo"
echo "Building Docker image..."
docker build \
--build-arg DEPLOYMENT_TIME=$DEPLOYMENT_TIME \
--build-arg APP_VERSION=$APP_VERSION \
-t $ECR_REPO:latest \
-t $ECR_REPO:${CIRCLE_SHA1} \
.
echo "Pushing Docker image..."
docker push $ECR_REPO:latest
docker push $ECR_REPO:${CIRCLE_SHA1}
# GCP: Build and push Docker image to GCR
build-and-push-gcp:
docker:
- image: cimg/node:18.20
steps:
- checkout
- setup_remote_docker
- install-gcloud
- run:
name: Configure Docker for GCR
command: |
source $BASH_ENV
gcloud auth configure-docker gcr.io
- gcp-gcr/build-image:
image: multi-cloud-demo
tag: "latest,${CIRCLE_SHA1}"
dockerfile: Dockerfile
registry-url: gcr.io
extra_build_args: |
--build-arg DEPLOYMENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg APP_VERSION=${CIRCLE_SHA1:0:7}
- gcp-gcr/push-image:
image: multi-cloud-demo
tag: "latest,${CIRCLE_SHA1}"
registry-url: gcr.io
# AWS: Deploy to ECS Fargate
deploy-to-aws:
docker:
- image: cimg/node:18.20
steps:
- checkout
- install-aws-cli
- run:
name: Replace placeholders in task definition
command: |
sed -i "s/{AWS_ACCOUNT_ID}/$AWS_ACCOUNT_ID/g" aws/task-definition.json
sed -i "s/{ECR_REPOSITORY_URI}/$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\/multi-cloud-demo/g" aws/task-definition.json
sed -i "s/{AWS_REGION}/$AWS_DEFAULT_REGION/g" aws/task-definition.json
sed -i "s/{CIRCLE_SHA1}/${CIRCLE_SHA1}/g" aws/task-definition.json
- run:
name: Ensure ECS cluster is active
command: |
echo "Checking ECS cluster status..."
CLUSTER_STATUS=$(aws ecs describe-clusters --clusters multi-cloud-cluster --region $AWS_DEFAULT_REGION --query 'clusters[0].status' --output text 2>/dev/null || echo "NOTFOUND")
echo "Current cluster status: $CLUSTER_STATUS"
if [ "$CLUSTER_STATUS" = "NOTFOUND" ] || [ "$CLUSTER_STATUS" = "None" ]; then
echo "Creating ECS cluster..."
aws ecs create-cluster --cluster-name multi-cloud-cluster --region $AWS_DEFAULT_REGION
elif [ "$CLUSTER_STATUS" = "INACTIVE" ]; then
echo "Cluster is INACTIVE. Recreating..."
aws ecs delete-cluster --cluster multi-cloud-cluster --region $AWS_DEFAULT_REGION
sleep 5
aws ecs create-cluster --cluster-name multi-cloud-cluster --region $AWS_DEFAULT_REGION
elif [ "$CLUSTER_STATUS" = "ACTIVE" ]; then
echo "ECS cluster is already active."
else
echo "Unknown status. Recreating cluster..."
aws ecs delete-cluster --cluster multi-cloud-cluster --region $AWS_DEFAULT_REGION 2>/dev/null || true
sleep 5
aws ecs create-cluster --cluster-name multi-cloud-cluster --region $AWS_DEFAULT_REGION
fi
echo "ECS cluster is ready."
sleep 10
- run:
name: Create CloudWatch log group
command: |
source $BASH_ENV
echo "Setting up CloudWatch logging..."
if ! aws logs describe-log-groups \
--log-group-name-prefix "/ecs/multi-cloud-demo" \
--region $AWS_DEFAULT_REGION \
--query 'logGroups[?logGroupName==`/ecs/multi-cloud-demo`]' \
--output text | grep -q "/ecs/multi-cloud-demo"; then
echo "Creating log group /ecs/multi-cloud-demo..."
aws logs create-log-group \
--log-group-name "/ecs/multi-cloud-demo" \
--region $AWS_DEFAULT_REGION
# Try to set retention policy (optional)
if aws logs put-retention-policy \
--log-group-name "/ecs/multi-cloud-demo" \
--retention-in-days 7 \
--region $AWS_DEFAULT_REGION 2>/dev/null; then
echo "Retention policy set to 7 days."
else
echo "Note: Retention policy not set (insufficient permissions)."
fi
else
echo "CloudWatch log group already exists."
fi
- run:
name: Register task definition
command: |
source $BASH_ENV
echo "Registering ECS task definition..."
aws ecs register-task-definition \
--cli-input-json file://aws/task-definition.json \
--region $AWS_DEFAULT_REGION
- run:
name: Create or update ECS service
command: |
source $BASH_ENV
echo "Managing ECS service..."
SERVICE_STATUS=$(aws ecs describe-services \
--cluster multi-cloud-cluster \
--services multi-cloud-demo-service \
--region $AWS_DEFAULT_REGION \
--query 'services[0].status' \
--output text 2>/dev/null || echo "NOTFOUND")
if [ "$SERVICE_STATUS" = "ACTIVE" ]; then
echo "Updating existing service..."
aws ecs update-service \
--cluster multi-cloud-cluster \
--service multi-cloud-demo-service \
--task-definition multi-cloud-demo \
--region $AWS_DEFAULT_REGION
else
echo "Creating new service..."
# Get default VPC and subnet
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=is-default,Values=true" --query 'Vpcs[0].VpcId' --output text --region $AWS_DEFAULT_REGION)
SUBNET_ID=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" --query 'Subnets[0].SubnetId' --output text --region $AWS_DEFAULT_REGION)
# Create or get security group
SG_ID=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=multi-cloud-demo-sg" --query 'SecurityGroups[0].GroupId' --output text --region $AWS_DEFAULT_REGION 2>/dev/null)
if [ "$SG_ID" == "None" ] || [ -z "$SG_ID" ]; then
echo "Creating security group..."
SG_ID=$(aws ec2 create-security-group \
--group-name multi-cloud-demo-sg \
--description "Security group for multi-cloud demo" \
--vpc-id $VPC_ID \
--region $AWS_DEFAULT_REGION \
--query 'GroupId' --output text)
# Allow HTTP traffic on port 3000
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol tcp \
--port 3000 \
--cidr 0.0.0.0/0 \
--region $AWS_DEFAULT_REGION
fi
# Create ECS service
aws ecs create-service \
--cluster multi-cloud-cluster \
--service-name multi-cloud-demo-service \
--task-definition multi-cloud-demo \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNET_ID],securityGroups=[$SG_ID],assignPublicIp=ENABLED}" \
--region $AWS_DEFAULT_REGION
fi
# GCP: Deploy to Cloud Run
deploy-to-gcp:
docker:
- image: cimg/node:18.20
steps:
- checkout
- install-gcloud
- run:
name: Replace placeholders in service definition
command: |
sed -i "s/{GCP_PROJECT_ID}/$GOOGLE_PROJECT_ID/g" gcp/service.yaml
sed -i "s/{GCP_REGION}/$GOOGLE_COMPUTE_REGION/g" gcp/service.yaml
sed -i "s/:latest/:${CIRCLE_SHA1}/g" gcp/service.yaml
- gcp-cloud-run/deploy:
platform: managed
image: gcr.io/$GOOGLE_PROJECT_ID/multi-cloud-demo:${CIRCLE_SHA1}
service-name: multi-cloud-demo
region: $GOOGLE_COMPUTE_REGION
unauthenticated: true
# Verify both deployments are successful
verify-deployments:
docker:
- image: cimg/node:18.20
steps:
- install-aws-cli
- install-gcloud
- run:
name: Verify AWS ECS deployment
command: |
source $BASH_ENV
echo "Verifying AWS ECS deployment..."
SERVICE_STATUS=$(aws ecs describe-services \
--cluster multi-cloud-cluster \
--services multi-cloud-demo-service \
--region $AWS_DEFAULT_REGION \
--query 'services[0].status' \
--output text)
if [ "$SERVICE_STATUS" = "ACTIVE" ]; then
echo "AWS ECS service is active and running"
echo ""
echo "Access your AWS application:"
echo "1. Go to ECS Console > Clusters > multi-cloud-cluster"
echo "2. Click on the service > Tasks tab"
echo "3. Click on the running task to find the public IP"
echo "4. Access via http://[PUBLIC_IP]:3000"
echo ""
else
echo "AWS ECS service verification failed"
exit 1
fi
- run:
name: Verify GCP Cloud Run deployment
command: |
source $BASH_ENV
echo "Verifying GCP Cloud Run deployment..."
gcloud run services describe multi-cloud-demo \
--region=$GOOGLE_COMPUTE_REGION \
--format="value(status.conditions[0].status)" > /tmp/status.txt
GCP_STATUS=$(cat /tmp/status.txt)
if [ "$GCP_STATUS" = "True" ]; then
SERVICE_URL=$(gcloud run services describe multi-cloud-demo \
--region=$GOOGLE_COMPUTE_REGION \
--format="value(status.url)")
echo "GCP Cloud Run service is ready and running"
echo ""
echo "Access your GCP application:"
echo "Service URL: $SERVICE_URL"
echo ""
else
echo "GCP Cloud Run service verification failed"
exit 1
fi
- run:
name: Deployment summary
command: |
echo "Multi-cloud deployment completed successfully!"
echo ""
echo "Your application is now running on both:"
echo "• AWS ECS Fargate (check console for public IP)"
echo "• GCP Cloud Run (URL shown above)"
echo ""
echo "Both services are running the same containerized Node.js app!"
# Workflow definitions for different deployment scenarios
workflows:
# Test only (no deployment)
test-only:
when:
and:
- not: << pipeline.parameters.deploy-aws >>
- not: << pipeline.parameters.deploy-gcp >>
jobs:
- test
# AWS only deployment
aws-deploy:
when:
and:
- << pipeline.parameters.deploy-aws >>
- not: << pipeline.parameters.deploy-gcp >>
jobs:
- test
- build-and-push-aws:
requires:
- test
filters:
branches:
only: main
- deploy-to-aws:
requires:
- build-and-push-aws
# GCP only deployment
gcp-deploy:
when:
and:
- << pipeline.parameters.deploy-gcp >>
- not: << pipeline.parameters.deploy-aws >>
jobs:
- test
- build-and-push-gcp:
requires:
- test
filters:
branches:
only: main
- deploy-to-gcp:
requires:
- build-and-push-gcp
# Multi-cloud deployment (default)
multi-cloud-deploy:
when:
and:
- << pipeline.parameters.deploy-aws >>
- << pipeline.parameters.deploy-gcp >>
jobs:
- test
- build-and-push-aws:
requires:
- test
filters:
branches:
only: main
- build-and-push-gcp:
requires:
- test
filters:
branches:
only: main
- deploy-to-aws:
requires:
- build-and-push-aws
- deploy-to-gcp:
requires:
- build-and-push-gcp
- verify-deployments:
requires:
- deploy-to-aws
- deploy-to-gcp
この CircleCI 設定は、エラー処理と信頼性を強化した、本番環境に対応したマルチクラウドデプロイパイプラインを作成します。パイプラインには、分離と認証の処理を改善した、クラウド CLI の包括的なインストールコマンドが含まれています。
この設定では、パイプラインパラメーターで制御される柔軟なワークフローモードを定義しています。test-only は両方のデプロイパラメーターが false のときに実行され、aws-deploy と gcp-deploy はシングルクラウドへのデプロイを処理し、multi-cloud-deploy は検証を含むマルチクラウドへのデプロイ全体を管理します。
AWS へのデプロイでは、パイプラインが ECS クラスターの状態を適切に処理し、ECR リポジトリと CloudWatch のロググループが存在しない場合は自動的に作成し、VPC、サブネット、セキュリティグループを含む適切なネットワーク設定で ECS サービスを管理します。
GCP へのデプロイでは、パイプラインは CircleCI の Orb とカスタムの gcloud コマンドを組み合わせて使い、適切な認証と設定管理のもとで、コンテナのビルド、プッシュ、Cloud Run へのデプロイを行います。
検証ジョブは、デプロイのステータスに関する詳細なフィードバックを提供し、両方のプラットフォームにデプロイしたアプリケーションへのアクセス方法をわかりやすく示します。
CircleCI でのプロジェクトのセットアップ
アプリケーションと CircleCI の設定がすべて揃ったので、CircleCI で実行してみましょう。
GitHub へのコードのプッシュ
Git リポジトリを初期化し、すべてのコードを GitHub にプッシュします。
# Initialize git repository
git init
git add .
git commit -m "Initial commit: Multi-cloud deployment demo"
# Add remote and push (replace with your GitHub username and repo name)
git remote add origin https://github.com/your-username/multi-cloud-demo.git
git branch -M main
git push -u origin main
CircleCI でのプロジェクトのセットアップ
CircleCI ダッシュボードの Projects ページに移動します。関連付けられた GitHub アカウントを選択して、プロジェクトを追加します。Set Up Project をクリックします。
設定ファイルが保存されているブランチを入力するよう求められます。CircleCI が .circleci/config.yml ファイルを検出し、プロジェクトのビルドを開始します。
認証情報用の環境変数をまだ設定していないため、パイプラインは失敗します。
これは CircleCI の通常の進め方です。
- パイプラインをトリガーして、設定を検証します。
- 不足している部分を追加します。
クラウドの認証情報の準備
環境変数を設定する前に、AWS と GCP の両方から必要な認証情報を集めておく必要があります。チュートリアルの前半で説明した AWS の前提条件のセットアップを完了していることを確認してください。この前提条件には、ECS タスク実行ロールの作成と、CircleCI 用のユーザーへの PassRole 権限の付与が含まれます。
AWS の場合
- AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY: ECS、ECR、CloudWatch 用のポリシーを持つ、プログラムによるアクセスが可能な IAM ユーザーを作成します
- AWS_ACCOUNT_ID: AWS アカウントの設定で確認できます
- AWS_DEFAULT_REGION: 使用するリージョンを選択します(例:
us-east-1)
GCP の場合
- 必須 API を有効にします: Cloud Run Admin API、Google Container Registry API、Cloud Build API
- GOOGLE_PROJECT_ID: プロジェクトダッシュボードに表示される GCP プロジェクト ID
- GOOGLE_COMPUTE_REGION: 使用するリージョン(例:
us-central1) GCLOUD_SERVICE_KEYには、次のロールを持つサービスアカウントを使用します。- Artifact Registry Create-on-Push Repository Administrator
- Artifact Registry Writer
- Cloud Run Admin
- Service Account User
- Storage Admin
- JSON キーファイルをダウンロードし、その内容をそのまま環境変数の値としてコピーします。
環境変数の追加
認証情報の準備ができたら、CircleCI プロジェクトに環境変数を追加します。CircleCI でプロジェクトを開きます。
- Project Settings をクリックします。
- サイドバーの Environment Variables をクリックします。
- 変数ごとに Add Environment Variable をクリックします。
AWS の環境変数:
AWS_ACCESS_KEY_ID=your-aws-access-key-id
AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key
AWS_DEFAULT_REGION=us-east-1
AWS_ACCOUNT_ID=your-aws-account-id
GCP の環境変数:
GOOGLE_PROJECT_ID=your-gcp-project-id
GOOGLE_COMPUTE_REGION=us-central1
GCLOUD_SERVICE_KEY={"type":"service_account","project_id":"your-project",...}
マルチクラウドデプロイのテスト
すべての環境変数を追加したら、マルチクラウドデプロイパイプラインをテストする準備は完了です。設定をテストする最も簡単な方法は、小さな変更を加えてリポジトリにプッシュすることです。コードに小さな変更(server.js のコメントの更新など)を加え、その変更をコミットしてプッシュします。
git add .
git commit -m "Update config - test pipeline with credentials"
git push
プロジェクトの Pipelines ページに戻り、新しい実行を確認します。この方法では、すべてのワークフローが新たにトリガーされ、環境変数が確実に読み込まれます。複数のワークフローを使うこの構成では、失敗したワークフローを個別に再実行するよりも、この方法のほうが確実です。
すべての設定が完了したら、CircleCI が AWS と GCP の両方に同時にデプロイする様子を確認しましょう。
デプロイの進行状況の監視
CircleCI ダッシュボードには、両方のクラウドへのデプロイが並列で実行される様子が表示されます。ワークフローはまずテストを実行し、次に AWS ECR と Google Container Registry の両方に対して、コンテナイメージのビルドとプッシュを同時に行います。最後に、ECS Fargate と Cloud Run の両方に並列でデプロイします。
パイプラインが正常に完了すると、両方のクラウドでデプロイしたアプリケーションが動作します。CircleCI の検証ジョブでは、両方のプラットフォームのデプロイステータスとアクセス情報が表示されます。
Google Cloud Platform(GCP)へのデプロイの確認
GCP の場合、検証ステップによって Cloud Run サービスの URL がパイプラインの出力に直接表示されます。
Verifying GCP Cloud Run deployment...
GCP Cloud Run service is ready and running
Access your GCP application:
Service URL: https://multi-cloud-demo-uhjn4wqanq-uc.a.run.app
ヘルスエンドポイントにリクエストを送信して、GCP へのデプロイをテストできます。
curl https://multi-cloud-demo-uhjn4wqanq-uc.a.run.app/health
{"status":"healthy","cloud":"GCP","uptime":0.349758271,"timestamp":"2025-10-21T19:13:25.875Z"}
Amazon Web Services(AWS)へのデプロイの確認
AWS の場合、Fargate のタスクには動的な IP アドレスが割り当てられるため、ECS コンソールでパブリック IP を確認する必要があります。
Verifying AWS ECS deployment...
AWS ECS service is active and running
Access your AWS application:
1. Go to ECS Console > Clusters > multi-cloud-cluster
2. Click on the service > Tasks tab
3. Click on the running task to find the public IP
4. Access via http://[PUBLIC_IP]:3000
パブリック IP がわかったら、AWS へのデプロイをテストします。
curl http://13.59.19.161:3000/health
{"status":"healthy","cloud":"AWS","uptime":1367.422680464,"timestamp":"2025-10-21T19:14:26.403Z"}
デプロイ結果の比較
どちらの curl コマンドも、パブリックエンドポイントにアクセスしてデプロイをテストしています。レスポンスは cloud フィールド以外は同一であることに注目してください。これは、同じコンテナ化されたアプリケーションが両方のプラットフォームで一貫して動作していることを示しています。この一貫性こそが、統合されたマルチクラウドデプロイのアプローチの大きなメリットです。
まとめ
クラウドに依存しない CI/CD の威力を示す、本番環境に対応したマルチクラウドデプロイパイプラインを構築できました。アプリケーションは AWS と GCP の両方で動作し、両方のプラットフォームで認証、ビルド、デプロイを一貫して処理する単一の CircleCI 設定によって管理されています。
ここで学んだ主要な原則(統合された CI/CD、一貫したコンテナ化、並列デプロイ)は、より複雑なアプリケーションや、さらに多くのクラウドプロバイダーのサポートにも拡張できます。複雑な部分を CircleCI に任せることで、チームは複数のクラウドプロバイダーの優れた機能を自由に活用しながら、優れたアプリケーションの開発に集中できます。
注: 継続的な料金の発生を避けるため、このチュートリアルで作成したリソースは忘れずに削除してください。ECS サービス、クラスター、ECR リポジトリは AWS コンソールから、Cloud Run サービスは GCP コンソールから削除できます。