CircleCI Orbs สำหรับ SaaS
SaaS Architecture มักประกอบด้วยหลาย Microservices, Database, Message Queue, Cache และ External Services การสร้าง CI/CD Pipeline ที่มีประสิทธิภาพต้องจัดการ Build, Test และ Deploy หลาย Service พร้อมกัน CircleCI Orbs ช่วยลดความซับซ้อนด้วย Reusable Configuration
บทความนี้แสดงวิธีสร้าง CI/CD Pipeline สำหรับ SaaS ที่ครอบคลุม Multi-service Build, Database Migration, Feature Flags, Environment Promotion และ Deployment Strategies ต่างๆ
Multi-service CI/CD Pipeline
# .circleci/config.yml — SaaS Multi-service Pipeline
version: 2.1
orbs:
aws-ecr: circleci/aws-ecr@9.0.4
aws-eks: circleci/aws-eks@2.2.0
slack: circleci/slack@4.13.3
node: circleci/node@5.2.0
python: circleci/python@2.1.1
# Parameters สำหรับ Dynamic Configuration
parameters:
run-api:
type: boolean
default: false
run-web:
type: boolean
default: false
run-worker:
type: boolean
default: false
run-all:
type: boolean
default: false
# Executors
executors:
node-executor:
docker:
- image: cimg/node:20.11
resource_class: medium
python-executor:
docker:
- image: cimg/python:3.12
- image: cimg/postgres:16.1 # Test Database
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test
POSTGRES_PASSWORD: test
- image: cimg/redis:7.2 # Test Redis
resource_class: medium
# Reusable Commands
commands:
setup-env:
parameters:
service:
type: string
steps:
- checkout
- run:
name: Setup Environment
command: |
echo "SERVICE=<< parameters.service >>" >> $BASH_ENV
echo "IMAGE_TAG=" >> $BASH_ENV
echo "REGISTRY=.dkr.ecr..amazonaws.com" >> $BASH_ENV
run-migrations:
parameters:
environment:
type: string
steps:
- run:
name: Run Database Migrations
command: |
cd services/api
# Dry-run migration ก่อน
python manage.py migrate --check
# รัน Migration จริง
python manage.py migrate --no-input
environment:
DATABASE_URL: << parameters.environment >>
notify-slack:
parameters:
status:
type: string
service:
type: string
steps:
- slack/notify:
channel: deployments
event: << parameters.status >>
template: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "<< parameters.status >> | *<< parameters.service >>* deployed to \nCommit: by "
}
}
]
}
# Jobs
jobs:
# Path Filtering — ตรวจสอบว่า Service ไหนเปลี่ยน
detect-changes:
docker:
- image: cimg/base:current
steps:
- checkout
- run:
name: Detect Changed Services
command: |
# เปรียบเทียบกับ main branch
CHANGED=$(git diff --name-only origin/main...HEAD)
echo "Changed files:"
echo "$CHANGED"
API_CHANGED=$(echo "$CHANGED" | grep -c "services/api/" || true)
WEB_CHANGED=$(echo "$CHANGED" | grep -c "services/web/" || true)
WORKER_CHANGED=$(echo "$CHANGED" | grep -c "services/worker/" || true)
SHARED_CHANGED=$(echo "$CHANGED" | grep -c "shared/" || true)
# ถ้า Shared เปลี่ยน Build ทุก Service
if [ "$SHARED_CHANGED" -gt 0 ]; then
echo '{"run-all": true}' > /tmp/pipeline-params.json
else
echo "{\"run-api\": $([ $API_CHANGED -gt 0 ] && echo true || echo false), \"run-web\": $([ $WEB_CHANGED -gt 0 ] && echo true || echo false), \"run-worker\": $([ $WORKER_CHANGED -gt 0 ] && echo true || echo false)}" > /tmp/pipeline-params.json
fi
cat /tmp/pipeline-params.json
- persist_to_workspace:
root: /tmp
paths: [pipeline-params.json]
# Build & Test API Service
build-api:
executor: python-executor
steps:
- setup-env:
service: api
- python/install-packages:
pkg-manager: pip
app-dir: services/api
- run:
name: Run Linting
command: |
cd services/api
ruff check .
mypy .
- run:
name: Run Tests
command: |
cd services/api
pytest --cov=. --cov-report=xml --junitxml=test-results/results.xml -v
- store_test_results:
path: services/api/test-results
- store_artifacts:
path: services/api/coverage.xml
# Build & Test Web Service
build-web:
executor: node-executor
steps:
- setup-env:
service: web
- node/install-packages:
app-dir: services/web
- run:
name: Lint & Type Check
command: |
cd services/web
npm run lint
npm run type-check
- run:
name: Run Tests
command: |
cd services/web
npm run test -- --coverage --ci
- run:
name: Build
command: |
cd services/web
npm run build
- persist_to_workspace:
root: .
paths: [services/web/dist]
# Docker Build & Push
docker-build:
parameters:
service:
type: string
docker:
- image: cimg/base:current
steps:
- setup-env:
service: << parameters.service >>
- setup_remote_docker:
docker_layer_caching: true
- run:
name: Build & Push Docker Image
command: |
aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY
docker build -t $REGISTRY/<< parameters.service >>:$IMAGE_TAG \
-f services/<< parameters.service >>/Dockerfile \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
--build-arg VCS_REF=$CIRCLE_SHA1 \
.
docker push $REGISTRY/<< parameters.service >>:$IMAGE_TAG
# Deploy to Environment
deploy:
parameters:
service:
type: string
environment:
type: string
docker:
- image: cimg/base:current
steps:
- checkout
- run:
name: Deploy << parameters.service >> to << parameters.environment >>
command: |
# Update Kubernetes Deployment
kubectl set image deployment/<< parameters.service >> \
<< parameters.service >>=$REGISTRY/<< parameters.service >>:$IMAGE_TAG \
-n << parameters.environment >>
# Wait for Rollout
kubectl rollout status deployment/<< parameters.service >> \
-n << parameters.environment >> --timeout=300s
- notify-slack:
status: pass
service: << parameters.service >>
# Workflows
workflows:
build-test-deploy:
jobs:
- detect-changes:
filters:
branches:
ignore: main
- build-api:
requires: [detect-changes]
filters:
branches:
ignore: main
- build-web:
requires: [detect-changes]
- docker-build:
name: docker-api
service: api
requires: [build-api]
context: aws-prod
- docker-build:
name: docker-web
service: web
requires: [build-web]
context: aws-prod
- deploy:
name: deploy-staging-api
service: api
environment: staging
requires: [docker-api]
context: aws-staging
- deploy:
name: deploy-staging-web
service: web
environment: staging
requires: [docker-web]
context: aws-staging
# Production Deploy — ต้อง Approve
- hold-production:
type: approval
requires:
- deploy-staging-api
- deploy-staging-web
- deploy:
name: deploy-prod-api
service: api
environment: production
requires: [hold-production]
context: aws-prod
- deploy:
name: deploy-prod-web
service: web
environment: production
requires: [hold-production]
context: aws-prod
Database Migration Pipeline
# migration-check.py — ตรวจสอบ Migration ก่อน Deploy
import subprocess
import sys
import json
def check_migration_safety(migration_file):
"""ตรวจสอบว่า Migration ปลอดภัยสำหรับ Zero-downtime"""
dangerous_ops = [
"DROP TABLE",
"DROP COLUMN",
"ALTER COLUMN", # เปลี่ยน Type อาจ Lock Table
"RENAME TABLE",
"RENAME COLUMN",
"NOT NULL", # เพิ่ม NOT NULL constraint อาจ Fail
]
with open(migration_file, "r") as f:
content = f.read().upper()
issues = []
for op in dangerous_ops:
if op in content:
issues.append(f"Dangerous operation found: {op}")
if issues:
print(f"MIGRATION SAFETY CHECK FAILED: {migration_file}")
for issue in issues:
print(f" - {issue}")
print("\nUse Expand-Contract pattern:")
print(" Phase 1 (Expand): Add new columns, keep old ones")
print(" Phase 2 (Deploy): Update code to use both")
print(" Phase 3 (Contract): Remove old columns")
return False
print(f"MIGRATION SAFE: {migration_file}")
return True
# ตรวจสอบ Migration ใน CI
def ci_check():
result = subprocess.run(
["python", "manage.py", "showmigrations", "--plan", "--format", "json"],
capture_output=True, text=True,
)
pending = json.loads(result.stdout)
all_safe = True
for migration in pending:
if not migration.get("applied"):
sql = subprocess.run(
["python", "manage.py", "sqlmigrate",
migration["app"], migration["name"]],
capture_output=True, text=True,
)
# เขียน SQL ไปไฟล์ชั่วคราว
tmp = f"/tmp/{migration['name']}.sql"
with open(tmp, "w") as f:
f.write(sql.stdout)
if not check_migration_safety(tmp):
all_safe = False
if not all_safe:
sys.exit(1)
print("All migrations are safe for zero-downtime deployment")
ci_check()
Feature Flags Integration
# feature_flags.py — Feature Flags สำหรับ SaaS Deployment
import os
import json
import hashlib
from datetime import datetime
class FeatureFlagManager:
"""จัดการ Feature Flags สำหรับ Gradual Rollout"""
def __init__(self, config_path="feature_flags.json"):
with open(config_path) as f:
self.flags = json.load(f)
def is_enabled(self, flag_name, user_id=None, tenant_id=None):
"""ตรวจสอบว่า Feature Flag เปิดหรือไม่"""
flag = self.flags.get(flag_name)
if not flag:
return False
# Global kill switch
if not flag.get("enabled", False):
return False
# Check rollout percentage
percentage = flag.get("rollout_percentage", 100)
if percentage < 100 and user_id:
# Consistent hashing — User เดิมได้ผลเดิมเสมอ
hash_input = f"{flag_name}:{user_id}"
hash_val = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
if (hash_val % 100) >= percentage:
return False
# Check tenant allowlist
allowed_tenants = flag.get("allowed_tenants", [])
if allowed_tenants and tenant_id:
if tenant_id not in allowed_tenants:
return False
# Check date range
start = flag.get("start_date")
end = flag.get("end_date")
now = datetime.now().isoformat()
if start and now < start:
return False
if end and now > end:
return False
return True
def get_all_flags(self, user_id=None, tenant_id=None):
"""ดึง Flag ทั้งหมดพร้อมสถานะ"""
result = {}
for name in self.flags:
result[name] = self.is_enabled(name, user_id, tenant_id)
return result
# feature_flags.json
# {
# "new_dashboard": {
# "enabled": true,
# "rollout_percentage": 25,
# "allowed_tenants": ["tenant_001", "tenant_002"],
# "description": "New dashboard UI"
# },
# "ai_assistant": {
# "enabled": true,
# "rollout_percentage": 10,
# "start_date": "2025-01-01",
# "description": "AI-powered assistant feature"
# }
# }
# ใช้ใน CI/CD — Deploy แล้วเปิด Feature Flag ทีละ %
# Step 1: Deploy code (Feature Flag off)
# Step 2: Enable 5% → Monitor
# Step 3: Enable 25% → Monitor
# Step 4: Enable 100% → Stable
Deployment Strategies สำหรับ SaaS
- Blue-Green Deployment: มี 2 Environment (Blue/Green) Deploy ไป Green แล้ว Switch Traffic ทั้งหมด Rollback ง่ายแค่ Switch กลับ
- Canary Deployment: Deploy Version ใหม่ให้ 5% ของ Traffic ก่อน Monitor Error Rate และ Latency แล้วค่อยขยายเป็น 25%, 50%, 100%
- Rolling Update: อัปเดต Pod ทีละตัว ค่อยเป็นค่อยไป ไม่ต้องมี Environment เพิ่ม แต่ Rollback ช้ากว่า
- Feature Flags: Deploy Code ทั้งหมดแต่ซ่อนหลัง Feature Flag เปิดให้ User ทีละกลุ่ม ยืดหยุ่นที่สุดแต่ซับซ้อนกว่า
- Ring Deployment: แบ่ง Users เป็น Rings (Internal → Beta → GA) Deploy ไปทีละ Ring เหมาะกับ SaaS ที่มี Multi-tenant
CircleCI Orbs คืออะไร
CircleCI Orbs เป็น Reusable Configuration Packages รวม Jobs, Commands และ Executors ลดการเขียน Config ซ้ำ มี Orbs สำเร็จรูปสำหรับ AWS, Docker, Kubernetes, Slack ใช้แค่ Reference แล้ว Config ไม่กี่บรรทัด ช่วยให้สร้าง Pipeline ได้เร็ว
SaaS CI/CD ต่างจาก CI/CD ทั่วไปอย่างไร
SaaS CI/CD จัดการหลาย Service พร้อมกัน มี Database Migration แบบ Zero-downtime, Feature Flags, Multi-tenant Testing, Environment Promotion และ Canary/Blue-Green Deployment ซับซ้อนกว่า Single App CI/CD มาก
วิธีจัดการ Multi-service Build ใน CircleCI ทำอย่างไร
ใช้ Dynamic Configuration กับ Path Filtering ตรวจสอบว่า Service ไหนเปลี่ยนแล้ว Build เฉพาะ Service นั้น ใช้ Matrix Jobs สำหรับ Build หลาย Service พร้อมกัน Workspace ส่ง Artifacts ระหว่าง Jobs ประหยัด Build Minutes
Database Migration แบบ Zero-downtime ทำอย่างไร
ใช้ Expand-Contract Pattern แยก Migration เป็น 2 Phase Expand เพิ่ม Column ใหม่ Deploy Code ที่ใช้ทั้งเก่าและใหม่ Contract ลบ Column เก่า ไม่ทำ Destructive Changes ใน Migration เดียว ตรวจสอบ Migration Safety ใน CI
สรุป
CircleCI Orbs ช่วยให้สร้าง CI/CD Pipeline สำหรับ SaaS ได้อย่างมีประสิทธิภาพ ด้วย Multi-service Build ที่ Build เฉพาะ Service ที่เปลี่ยน, Database Migration ที่ตรวจสอบความปลอดภัยอัตโนมัติ, Feature Flags สำหรับ Gradual Rollout และ Deployment Strategies เช่น Canary, Blue-Green สิ่งสำคัญคือ Automate ทุกอย่าง ตรวจสอบ Migration Safety ใน CI และ Monitor หลัง Deploy
