チュートリアルOct 26, 202622 分 READ

CircleCI による AWS と GCP への同時マルチクラウドデプロイ

Olususi Oluyemi

フルスタック デベロッパー兼テクニカル ライター

先日、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 アカウント と、次のもの:
  • 課金が有効になっている 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 ユーザーにアタッチします。

  1. AmazonECS_FullAccess は、Amazon ECS のリソースへのフルアクセスを提供します。
  2. AmazonEC2ContainerRegistryFullAccess は、ECR との間で Docker イメージをプッシュ・プルすることを許可します。
  3. CloudWatchActionsEC2Access は、ECS タスクの CloudWatch の操作を可能にします。
  4. AmazonCloudWatchEvidentlyFullAccess は、CloudWatch のログ機能を提供します。

これらのポリシーをアタッチするには、次の手順に従います。

  1. AWS コンソールで IAM サービスに移動します。
  2. CircleCI 用のユーザーを探してクリックします。
  3. 「Add permissions」 ボタンをクリックします。
  4. 「Attach policies directly」 を選択します。
  5. 前の手順で挙げた各ポリシーを検索して選択します。
  6. 「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 が .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 でプロジェクトを開きます。

  1. Project Settings をクリックします。
  2. サイドバーの Environment Variables をクリックします。
  3. 変数ごとに 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",...}

CircleCI の環境変数

マルチクラウドデプロイのテスト

すべての環境変数を追加したら、マルチクラウドデプロイパイプラインをテストする準備は完了です。設定をテストする最も簡単な方法は、小さな変更を加えてリポジトリにプッシュすることです。コードに小さな変更(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 コンソールから削除できます。