昨天我們建立了 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) │
└──────────────┘ └─────────────┘
// 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',
});
}
}
// 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',
});
}
}
#!/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:"
// 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);
}
# .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 管理所有基礎設施
✅ 自動擴展:無需管理容器擴展
✅ 更好的開發體驗:前後端獨立部署和版本控制