iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Build on AWS

30 天將工作室 SaaS 產品部署起來系列 第 7

Day 7: 現代化前後端分離部署架構

  • 分享至 

  • xImage
  •  

前情提要

昨天我們建立了 AWS 基礎設施,今天我們要實作現代化的前後端分離部署架構。相較於傳統的容器化前端,我們將採用更優化的方案:S3 Static Web Hosting + CloudFront 搭配 ECS 後端服務

為什麼選擇前後端分離架構?

🚀 性能優勢

傳統 Docker 前端:
User → ALB → ECS Container → nginx → React App
延遲: ~200-500ms

S3 + CloudFront:
User → CloudFront Edge → S3 Bucket
延遲: ~10-50ms (全球 CDN)

💰 成本比較

Docker 前端 (每月):
- ECS Fargate: ~$30-50
- ALB: ~$20
- 總計: ~$50-70

S3 + CloudFront (每月):
- S3 Storage: ~$1-3
- CloudFront: ~$5-15
- 總計: ~$6-18 (節省 70%+)

架構設計

整體架構圖

┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│   Browser   │───▶│ CloudFront   │───▶│ S3 Bucket   │
│             │    │   (CDN)      │    │ (Frontend)  │
└─────────────┘    └──────────────┘    └─────────────┘
       │
       │ API Calls
       ▼
┌──────────────┐    ┌─────────────┐
│     ALB      │───▶│ ECS Fargate │
│ (API Gateway)│    │ (Backend)   │
└──────────────┘    └─────────────┘
       │
       ▼
┌──────────────┐    ┌─────────────┐
│     RDS      │    │ ElastiCache │
│ (Database)   │    │   (Redis)   │
└──────────────┘    └─────────────┘

實作步驟

1. 建立 S3 + CloudFront CDK Stack

// infrastructure/lib/kyo-frontend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

interface FrontendStackProps extends cdk.StackProps {
  backendUrl: string;
}

export class KyoFrontendStack extends cdk.Stack {
  public readonly distributionDomainName: string;
  public readonly bucketName: string;

  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    // 建立 S3 Bucket
    const websiteBucket = new s3.Bucket(this, 'KyoWebsiteBucket', {
      bucketName: `kyo-dashboard-${this.account}-${this.region}`,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,

      // 靜態網站配置
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html', // SPA 路由支援

      // 禁用公開讀取(由 CloudFront 存取)
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    // 建立 Origin Access Control
    const originAccessControl = new cloudfront.OriginAccessControl(this, 'OAC', {
      description: 'Kyo Dashboard OAC',
      originAccessControlOriginType: cloudfront.OriginAccessControlOriginType.S3,
      signing: cloudfront.Signing.SIGV4_ALWAYS,
    });

    // 建立 CloudFront Response Headers Policy
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'SecurityHeaders', {
      responseHeadersPolicyName: 'KyoSecurityHeaders',
      securityHeadersBehavior: {
        strictTransportSecurity: {
          accessControlMaxAge: cdk.Duration.days(365),
          includeSubdomains: true,
          preload: true,
        },
        contentTypeOptions: { override: true },
        frameOptions: { frameOption: cloudfront.FrameOptions.DENY },
        xssProtection: {
          protection: true,
          modeBlock: true
        },
        referrerPolicy: {
          referrerPolicy: cloudfront.ReferrerPolicyHeaderValue.STRICT_ORIGIN_WHEN_CROSS_ORIGIN
        },
      },
      customHeadersBehavior: {
        customHeaders: [
          {
            header: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
            override: false,
          },
        ],
      },
    });

    // 建立 CloudFront Distribution
    const distribution = new cloudfront.Distribution(this, 'KyoDistribution', {
      comment: 'Kyo Dashboard Distribution',
      defaultRootObject: 'index.html',

      defaultBehavior: {
        origin: new origins.S3Origin(websiteBucket, {
          originAccessControl,
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        responseHeadersPolicy,
        compress: true,
      },

      // 靜態資源快取策略
      additionalBehaviors: {
        '/static/*': {
          origin: new origins.S3Origin(websiteBucket, {
            originAccessControl,
          }),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED_FOR_UNCOMPRESSED_OBJECTS,
          responseHeadersPolicy,
          compress: true,
        },

        // API 請求轉發到後端
        '/api/*': {
          origin: new origins.HttpOrigin(props.backendUrl, {
            protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
          }),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          compress: true,
        },
      },

      // SPA 路由支援:404 重定向到 index.html
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],

      // 啟用 HTTP/2 和 IPv6
      httpVersion: cloudfront.HttpVersion.HTTP2,
      enableIpv6: true,

      // 日誌配置
      enableLogging: true,
      logBucket: new s3.Bucket(this, 'CloudFrontLogsBucket', {
        bucketName: `kyo-cloudfront-logs-${this.account}-${this.region}`,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
        autoDeleteObjects: true,
        lifecycleRules: [{
          id: 'DeleteOldLogs',
          expiration: cdk.Duration.days(90),
        }],
      }),
      logFilePrefix: 'cloudfront-logs/',
    });

    // 授權 CloudFront 存取 S3
    websiteBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
        conditions: {
          StringEquals: {
            'AWS:SourceArn': distribution.distributionArn,
          },
        },
      })
    );

    // 輸出重要資訊
    this.distributionDomainName = distribution.distributionDomainName;
    this.bucketName = websiteBucket.bucketName;

    new cdk.CfnOutput(this, 'DistributionDomainName', {
      value: distribution.distributionDomainName,
      description: 'CloudFront Distribution Domain Name',
    });

    new cdk.CfnOutput(this, 'DistributionId', {
      value: distribution.distributionId,
      description: 'CloudFront Distribution ID',
    });

    new cdk.CfnOutput(this, 'S3BucketName', {
      value: websiteBucket.bucketName,
      description: 'S3 Bucket Name for Website',
    });
  }
}

2. 優化的後端 ECS Stack

// infrastructure/lib/kyo-backend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as certificatemanager from 'aws-cdk-lib/aws-certificatemanager';

interface BackendStackProps extends cdk.StackProps {
  vpc: ec2.Vpc;
  rdsEndpoint: string;
  redisEndpoint: string;
  domainName?: string; // 可選的自訂域名
}

export class KyoBackendStack extends cdk.Stack {
  public readonly loadBalancerDnsName: string;

  constructor(scope: Construct, id: string, props: BackendStackProps) {
    super(scope, id, props);

    // 建立 ECS 叢集
    const cluster = new ecs.Cluster(this, 'KyoBackendCluster', {
      vpc: props.vpc,
      clusterName: 'kyo-backend-cluster',
      containerInsights: true, // 啟用 Container Insights
    });

    // 建立 ALB
    const alb = new elbv2.ApplicationLoadBalancer(this, 'KyoBackendALB', {
      vpc: props.vpc,
      internetFacing: true,
      loadBalancerName: 'kyo-backend-alb',
    });

    // SSL 證書(如果有自訂域名)
    let certificate;
    if (props.domainName) {
      certificate = new certificatemanager.Certificate(this, 'Certificate', {
        domainName: props.domainName,
        validation: certificatemanager.CertificateValidation.fromDns(),
      });
    }

    // 建立目標群組
    const targetGroup = new elbv2.ApplicationTargetGroup(this, 'BackendTargetGroup', {
      vpc: props.vpc,
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      healthCheck: {
        path: '/api/health',
        healthyHttpCodes: '200',
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
        healthyThresholdCount: 2,
        unhealthyThresholdCount: 3,
      },
    });

    // 建立監聽器
    const listener = alb.addListener('Listener', {
      port: certificate ? 443 : 80,
      protocol: certificate ? elbv2.ApplicationProtocol.HTTPS : elbv2.ApplicationProtocol.HTTP,
      certificates: certificate ? [certificate] : undefined,
      defaultTargetGroups: [targetGroup],
    });

    // HTTPS 重定向(如果有 SSL)
    if (certificate) {
      alb.addListener('HttpRedirect', {
        port: 80,
        protocol: elbv2.ApplicationProtocol.HTTP,
        defaultAction: elbv2.ListenerAction.redirect({
          protocol: 'HTTPS',
          port: '443',
          permanent: true,
        }),
      });
    }

    // 建立任務定義
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'BackendTaskDef', {
      memoryLimitMiB: 1024,
      cpu: 512,
    });

    // 後端容器
    const container = taskDefinition.addContainer('backend', {
      image: ecs.ContainerImage.fromRegistry('your-backend-image:latest'),
      environment: {
        NODE_ENV: 'production',
        PORT: '3000',
        DATABASE_URL: `postgresql://kyouser:kyopass@${props.rdsEndpoint}:5432/kyodb`,
        REDIS_URL: `redis://${props.redisEndpoint}:6379`,
      },
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'kyo-backend',
        logRetention: 7, // 保留 7 天日誌
      }),
    });

    container.addPortMappings({
      containerPort: 3000,
      protocol: ecs.Protocol.TCP,
    });

    // 建立 ECS 服務
    const service = new ecs.FargateService(this, 'BackendService', {
      cluster,
      taskDefinition,
      serviceName: 'kyo-backend-service',
      desiredCount: 2,
      assignPublicIp: true,

      // 自動擴展配置
      minHealthyPercent: 50,
      maxHealthyPercent: 200,
    });

    // 註冊到目標群組
    service.attachToApplicationTargetGroup(targetGroup);

    // 自動擴展
    const scaling = service.autoScaleTaskCount({
      minCapacity: 1,
      maxCapacity: 10,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
      scaleInCooldown: cdk.Duration.minutes(5),
      scaleOutCooldown: cdk.Duration.minutes(2),
    });

    scaling.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 80,
      scaleInCooldown: cdk.Duration.minutes(5),
      scaleOutCooldown: cdk.Duration.minutes(2),
    });

    // Route53 記錄(如果有自訂域名)
    if (props.domainName) {
      const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
        domainName: props.domainName,
      });

      new route53.ARecord(this, 'ARecord', {
        zone: hostedZone,
        recordName: 'api',
        target: route53.RecordTarget.fromAlias(
          new targets.LoadBalancerTarget(alb)
        ),
      });
    }

    this.loadBalancerDnsName = alb.loadBalancerDnsName;

    // 輸出
    new cdk.CfnOutput(this, 'BackendUrl', {
      value: certificate ? `https://${props.domainName}` : `http://${alb.loadBalancerDnsName}`,
      description: 'Backend API URL',
    });
  }
}

3. 前端建構優化

#!/bin/bash
# scripts/build-frontend.sh

set -e

echo "🚀 Building optimized frontend for S3 deployment..."

cd apps/kyo-dashboard

# 設定環境變數
export NODE_ENV=production
export GENERATE_SOURCEMAP=false
export REACT_APP_API_URL=https://api.your-domain.com

# 安裝依賴
pnpm install --frozen-lockfile

# 建構應用
pnpm build

# 最佳化建構結果
echo "📦 Optimizing build output..."

# 壓縮 JS 和 CSS
find dist -name "*.js" -type f -exec gzip -9 -k {} \;
find dist -name "*.css" -type f -exec gzip -9 -k {} \;
find dist -name "*.html" -type f -exec gzip -9 -k {} \;

# 建立快取策略用的檔案結構
mkdir -p dist/static-versioned
mv dist/static/js/* dist/static-versioned/ 2>/dev/null || true
mv dist/static/css/* dist/static-versioned/ 2>/dev/null || true

echo "✅ Frontend build completed!"
echo "📊 Build statistics:"
du -sh dist/
find dist -name "*.js" -type f | wc -l | xargs echo "JS files:"
find dist -name "*.css" -type f | wc -l | xargs echo "CSS files:"

4. 自動化部署流程

// scripts/deploy-frontend.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { lookup } from 'mime-types';

interface DeployConfig {
  bucketName: string;
  distributionId: string;
  buildDir: string;
}

class FrontendDeployer {
  private s3: S3Client;
  private cloudfront: CloudFrontClient;

  constructor(private config: DeployConfig) {
    this.s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
    this.cloudfront = new CloudFrontClient({ region: 'us-east-1' });
  }

  async deploy(): Promise<void> {
    console.log('🚀 Starting frontend deployment...');

    const files = this.getAllFiles(this.config.buildDir);

    console.log(`📁 Found ${files.length} files to upload`);

    // 並行上傳檔案
    const uploadPromises = files.map(file => this.uploadFile(file));
    await Promise.all(uploadPromises);

    console.log('✅ All files uploaded successfully');

    // 建立 CloudFront invalidation
    await this.invalidateCache();

    console.log('🎉 Deployment completed successfully!');
  }

  private getAllFiles(dir: string, base = ''): string[] {
    const files: string[] = [];
    const items = readdirSync(dir);

    for (const item of items) {
      const fullPath = join(dir, item);
      const relativePath = join(base, item);

      if (statSync(fullPath).isDirectory()) {
        files.push(...this.getAllFiles(fullPath, relativePath));
      } else {
        files.push(relativePath);
      }
    }

    return files;
  }

  private async uploadFile(filePath: string): Promise<void> {
    const fullPath = join(this.config.buildDir, filePath);
    const content = readFileSync(fullPath);
    const contentType = lookup(filePath) || 'application/octet-stream';

    // 設定快取策略
    let cacheControl = 'public, max-age=31536000'; // 1 年
    if (filePath === 'index.html' || filePath.endsWith('.html')) {
      cacheControl = 'public, max-age=0, must-revalidate'; // HTML 不快取
    } else if (filePath.includes('service-worker')) {
      cacheControl = 'public, max-age=0'; // Service Worker 不快取
    }

    const command = new PutObjectCommand({
      Bucket: this.config.bucketName,
      Key: filePath,
      Body: content,
      ContentType: contentType,
      CacheControl: cacheControl,
      ContentEncoding: filePath.endsWith('.gz') ? 'gzip' : undefined,
    });

    await this.s3.send(command);
    console.log(`📤 Uploaded: ${filePath}`);
  }

  private async invalidateCache(): Promise<void> {
    console.log('🔄 Creating CloudFront invalidation...');

    const command = new CreateInvalidationCommand({
      DistributionId: this.config.distributionId,
      InvalidationBatch: {
        CallerReference: Date.now().toString(),
        Paths: {
          Quantity: 2,
          Items: ['/', '/index.html'],
        },
      },
    });

    const result = await this.cloudfront.send(command);
    console.log(`✅ Invalidation created: ${result.Invalidation?.Id}`);
  }
}

// 執行部署
async function main() {
  const config: DeployConfig = {
    bucketName: process.env.S3_BUCKET_NAME!,
    distributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID!,
    buildDir: './dist',
  };

  const deployer = new FrontendDeployer(config);
  await deployer.deploy();
}

if (require.main === module) {
  main().catch(console.error);
}

5. CI/CD 整合

# .github/workflows/deploy-frontend.yml
name: Deploy Frontend

on:
  push:
    branches: [main]
    paths: ['apps/kyo-dashboard/**']

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build frontend
        run: |
          chmod +x scripts/build-frontend.sh
          ./scripts/build-frontend.sh

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Deploy to S3
        run: |
          npm install -g tsx
          tsx scripts/deploy-frontend.ts
        env:
          S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
          CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}

      - name: Notify deployment
        run: |
          echo "🎉 Frontend deployed successfully!"
          echo "🌍 URL: https://${{ secrets.CLOUDFRONT_DOMAIN_NAME }}"

效能比較

載入速度測試

# Docker 前端
curl -w "%{time_total}\n" -o /dev/null -s http://your-alb.com
# 平均: 800-1200ms

# S3 + CloudFront
curl -w "%{time_total}\n" -o /dev/null -s https://your-cloudfront.net
# 平均: 150-300ms (快 3-4 倍)

可用性比較

Docker ECS:
- 單一 AZ 故障: 可能影響服務
- 容器重啟時間: 30-60 秒
- 需要健康檢查和自動恢復

S3 + CloudFront:
- 99.999999999% 持久性
- 全球多個邊緣節點自動容錯
- 零停機時間

今天成果

採用現代化前後端分離架構,我們獲得了:

極致性能:全球 CDN 加速,載入速度提升 3-4 倍
大幅降低成本:減少 70%+ 的基礎設施費用
更高可用性:99.99%+ 可用性,自動容錯
零維護負擔:AWS 管理所有基礎設施
自動擴展:無需管理容器擴展
更好的開發體驗:前後端獨立部署和版本控制


上一篇
Day 6: 從零開始建立 AWS 環境 - 帳號申請到 CDK 部署實戰
系列文
30 天將工作室 SaaS 產品部署起來7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言