AWS CI/CD 实战系列 06:mfmsapp 版本演进实战(v1→v2→v3)

系列导读: 上一篇我们解决了 CodeCommit 模式的常见陷阱,终于有了一个稳定的 CI/CD 水线。现在,是时候实战演练了!本文将以 mfmsapp 为案例,演示三个版本的代码演进:从内存存储到 SQLite 持久化,再到蓝绿部署零停机升级。全程在 CodePipeline 上操作,展示真实的 CI/CD 工作流。


演进路线图

演进概览

为什么要演进?

版本 数据存储 部署策略 停机时间 适用场景
v1 内存 map 就地部署 5-10秒 开发测试、PoC
v2 SQLite 文件 就地部署 5-10秒 中小规模生产
v3 共享 RDS 蓝绿部署 0秒 大规模、高可用
  • v1 → v2: 数据不能持久化,服务重启就丢失所有作物记录,无法用于生产
  • v2 → v3: 就地部署需要停机(停止旧进程 → 启动新进程),用户会看到服务暂时不可用

版本一:内存存储的单体 Go 应用(v1.0.0)

功能概述

最简单的 mfmsapp:REST API 端口 8080,数据存储在内存 map[string]Crop(应用重启清空),支持 CRUD 操作。

项目结构

mfmsapp-v1/
├── main.go              # 入口文件(纯标准库,无外部依赖)
├── go.mod
├── appspec.yml          # CodeDeploy 部署规范
├── buildspec.yml        # CodeBuild 构建配置
└── scripts/
    ├── before_install.sh
    ├── after_install.sh
    ├── start_server.sh
    ├── validate_service.sh
    └── mfmsapp.service

核心代码(main.go)

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"
)

type Crop struct {
    ID           string  `json:"id"`
    Name         string  `json:"name"`
    Type         string  `json:"type"`
    Area         float64 `json:"area"`
    PlantingDate string  `json:"planting_date"`
    Status       string  `json:"status"`
}

type Server struct {
    crops  map[string]Crop
    mu     sync.RWMutex
}

type Response struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
}

func main() {
    s := &Server{crops: make(map[string]Crop)}
    http.HandleFunc("/crops", s.cropsHandler)
    http.HandleFunc("/crops/", s.cropDetailHandler)
    log.Println("mfmsapp v1.0.0 starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func (s *Server) cropsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    switch r.Method {
    case http.MethodGet:
        s.mu.RLock()
        crops := make([]Crop, 0, len(s.crops))
        for _, c := range s.crops { crops = append(crops, c) }
        s.mu.RUnlock()
        json.NewEncoder(w).Encode(Response{Success: true, Data: crops})

    case http.MethodPost:
        var crop Crop
        if err := json.NewDecoder(r.Body).Decode(&crop); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            return
        }
        s.mu.Lock()
        s.crops[crop.ID] = crop
        s.mu.Unlock()
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(Response{Success: true, Data: crop})

    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func (s *Server) cropDetailHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    id := r.URL.Path[len("/crops/"):]
    s.mu.RLock()
    crop, exists := s.crops[id]
    s.mu.RUnlock()
    if !exists { http.Error(w, "Crop not found", http.StatusNotFound); return }

    switch r.Method {
    case http.MethodGet:
        json.NewEncoder(w).Encode(Response{Success: true, Data: crop})
    case http.MethodPut:
        var updated Crop
        if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest); return
        }
        updated.ID = id
        s.mu.Lock()
        s.crops[id] = updated
        s.mu.Unlock()
        json.NewEncoder(w).Encode(Response{Success: true, Data: updated})
    case http.MethodDelete:
        s.mu.Lock()
        delete(s.crops, id)
        s.mu.Unlock()
        w.WriteHeader(http.StatusNoContent)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

关键配置文件

go.mod(纯标准库,无外部依赖)

module mfmsapp
go 1.21

appspec.yml(CodeDeploy 部署规范)

version: 0.0
os: linux
files:
  - source: /
    destination: /opt/mfmsapp
hooks:
  BeforeInstall:
    - location: scripts/before_install.sh
      timeout: 60
      runas: root
  AfterInstall:
    - location: scripts/after_install.sh
      timeout: 60
      runas: root
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 60
      runas: root
  ValidateService:
    - location: scripts/validate_service.sh
      timeout: 300
      runas: root

scripts/before_install.sh

#!/bin/bash
systemctl stop mfmsapp || true
sleep 3

scripts/after_install.sh

#!/bin/bash
chmod +x /opt/mfmsapp/mfmsapp
cp /opt/mfmsapp/scripts/mfmsapp.service /etc/systemd/system/
systemctl daemon-reload

scripts/start_server.sh

#!/bin/bash
systemctl start mfmsapp
sleep 2

scripts/validate_service.sh

#!/bin/bash
for i in {1..15}; do
    if curl -sf http://localhost:8080/crops > /dev/null; then
        echo "mfmsapp v1 is healthy"
        exit 0
    fi
    sleep 2
done
echo "mfmsapp v1 failed to start"
exit 1

scripts/mfmsapp.service(systemd 服务文件)

[Unit]
Description=mfmsapp v1.0.0 - Modern Farm Management System
After=network.target

[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/mfmsapp
ExecStart=/opt/mfmsapp/mfmsapp
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

buildspec.yml(CodeBuild 构建配置)

version: 0.2
phases:
  install:
    runtime-versions:
      go: 1.21
  build:
    commands:
      - go build -o mfmsapp .
      - mkdir -p artifact
      - cp mfmsapp appspec.yml artifact/
      - cp -r scripts artifact/scripts
      - chmod +x artifact/scripts/*.sh
artifacts:
  files:
    - '**/*'
  base-directory: artifact

部署验证

# 1. 添加作物
curl -X POST http://localhost:8080/crops \
  -H "Content-Type: application/json" \
  -d '{"id":"crop001","name":"水稻","type":"rice","area":150.5,"planting_date":"2025-04-01","status":"growing"}'

# 2. 查询所有作物
curl http://localhost:8080/crops

# 3. 重启验证数据丢失(内存存储特性)
sudo systemctl restart mfmsapp
curl http://localhost:8080/crops  # 返回空数组 []

版本二:引入 SQLite 持久化(v2.0.0)

为什么需要数据库?

v1 的数据存在内存里,一重启就丢。生产环境必须持久化。

技术选型:SQLite

  • 零配置,单文件数据库,适合中小规模应用
  • Go 标准库 database/sql + github.com/mattn/go-sqlite3 驱动
  • 与现有单体架构兼容性好,无需额外数据库服务

新增文件结构

mfmsapp-v2/
├── main.go                    # 修改:改用数据库替代内存存储
├── database/
│   ├── db.go                  # 数据库连接和初始化
│   ├── schema.sql             # 表结构定义
│   └── models.go              # 数据访问层
├── go.mod                     # 添加 sqlite3 依赖
└── appspec.yml                # 微调:增加数据库目录创建

database/schema.sql

CREATE TABLE IF NOT EXISTS crops (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    type TEXT NOT NULL,
    area REAL NOT NULL,
    planting_date TEXT NOT NULL,
    status TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_crops_type ON crops(type);
CREATE INDEX IF NOT EXISTS idx_crops_status ON crops(status);

database/db.go

package database

import (
    "database/sql"
    "log"
    "os"
    _ "github.com/mattn/go-sqlite3"
)

var DB *sql.DB

func InitDB(dataSource string) error {
    var err error
    DB, err = sql.Open("sqlite3", dataSource)
    if err != nil {
        return err
    }
    if err = DB.Ping(); err != nil {
        return err
    }
    schema, err := os.ReadFile("database/schema.sql")
    if err != nil {
        return err
    }
    _, err = DB.Exec(string(schema))
    if err != nil {
        return err
    }
    log.Println("Database initialized at:", dataSource)
    return nil
}

database/models.go

package database

type Crop struct {
    ID           string  `json:"id"`
    Name         string  `json:"name"`
    Type         string  `json:"type"`
    Area         float64 `json:"area"`
    PlantingDate string  `json:"planting_date"`
    Status       string  `json:"status"`
}

type CropModel struct{}

func (m *CropModel) Create(crop *Crop) error {
    _, err := DB.Exec(
        `INSERT INTO crops (id, name, type, area, planting_date, status)
         VALUES (?, ?, ?, ?, ?, ?)`,
        crop.ID, crop.Name, crop.Type, crop.Area, crop.PlantingDate, crop.Status,
    )
    return err
}

func (m *CropModel) GetAll() ([]Crop, error) {
    rows, err := DB.Query(`SELECT id, name, type, area, planting_date, status FROM crops`)
    if err != nil { return nil, err }
    defer rows.Close()

    var crops []Crop
    for rows.Next() {
        var c Crop
        if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Area, &c.PlantingDate, &c.Status); err != nil {
            return nil, err
        }
        crops = append(crops, c)
    }
    return crops, nil
}

func (m *CropModel) GetByID(id string) (*Crop, error) {
    row := DB.QueryRow(
        `SELECT id, name, type, area, planting_date, status FROM crops WHERE id = ?`, id)
    var crop Crop
    err := row.Scan(&crop.ID, &crop.Name, &crop.Type, &crop.Area, &crop.PlantingDate, &crop.Status)
    if err != nil { return nil, err }
    return &crop, nil
}

func (m *CropModel) Update(crop *Crop) error {
    _, err := DB.Exec(
        `UPDATE crops SET name=?, type=?, area=?, planting_date=?, status=?, updated_at=CURRENT_TIMESTAMP
         WHERE id=?`,
        crop.Name, crop.Type, crop.Area, crop.PlantingDate, crop.Status, crop.ID)
    return err
}

func (m *CropModel) Delete(id string) error {
    _, err := DB.Exec(`DELETE FROM crops WHERE id = ?`, id)
    return err
}

main.go(v2 改造)

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "mfmsapp/database"
)

type Server struct {
    model *database.CropModel
}

type Response struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
}

func main() {
    if err := database.InitDB("/opt/mfmsapp/data/mfmsapp.db"); err != nil {
        log.Fatal("Failed to init database:", err)
    }
    s := &Server{model: &database.CropModel{}}
    http.HandleFunc("/crops", s.cropsHandler)
    http.HandleFunc("/crops/", s.cropDetailHandler)
    log.Println("mfmsapp v2.0.0 starting on :8080 (SQLite)")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func (s *Server) cropsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    switch r.Method {
    case http.MethodGet:
        crops, err := s.model.GetAll()
        if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
        json.NewEncoder(w).Encode(Response{Success: true, Data: crops})

    case http.MethodPost:
        var crop Crop
        if err := json.NewDecoder(r.Body).Decode(&crop); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest); return
        }
        if err := s.model.Create(&crop); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError); return
        }
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(Response{Success: true, Data: crop})

    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func (s *Server) cropDetailHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    id := r.URL.Path[len("/crops/"):]

    crop, err := s.model.GetByID(id)
    if err != nil { http.Error(w, "Crop not found", http.StatusNotFound); return }

    switch r.Method {
    case http.MethodGet:
        json.NewEncoder(w).Encode(Response{Success: true, Data: crop})
    case http.MethodPut:
        var updated Crop
        if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest); return
        }
        updated.ID = id
        if err := s.model.Update(&updated); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError); return
        }
        json.NewEncoder(w).Encode(Response{Success: true, Data: updated})
    case http.MethodDelete:
        if err := s.model.Delete(id); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError); return
        }
        w.WriteHeader(http.StatusNoContent)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

go.mod

module mfmsapp

go 1.21

require github.com/mattn/go-sqlite3 v1.14.22

scripts/after_install.sh(新增数据库目录创建)

#!/bin/bash
chmod +x /opt/mfmsapp/mfmsapp
mkdir -p /opt/mfmsapp/data
chown ec2-user:ec2-user /opt/mfmsapp/data
cp /opt/mfmsapp/scripts/mfmsapp.service /etc/systemd/system/
systemctl daemon-reload

验证v2部署

# 1. 添加数据
curl -X POST http://localhost:8080/crops \
  -d '{"id":"crop001","name":"小麦","type":"wheat","area":200,"planting_date":"2025-03-15","status":"growing"}'

# 2. 重启服务
sudo systemctl restart mfmsapp

# 3. 再次查询,数据应该还在(SQLite持久化)
curl http://localhost:8080/crops

版本三:蓝绿部署零停机升级(v3.0.0)

v2 的问题:部署需要停机

v2 使用就地部署(In-place Deployment)

  1. CodeDeploy 停止旧版本进程
  2. 安装新版本
  3. 启动新版本

停机窗口 = 服务停止 + 安装 + 启动时间(几秒到几十秒)。生产环境无法接受,尤其是 7×24 小时运行的农业管理系统。

解决方案:蓝绿部署

根据 AWS CodeDeploy 官方文档,CodeDeploy 支持三种计算平台:

计算平台 支持的部署类型 流量切换方式
EC2/On-Premises 就地部署 或 蓝绿部署 注册/注销 ELB 实例
AWS Lambda 仅蓝绿部署 canary / linear / all-at-once
Amazon ECS 仅蓝绿部署 canary / linear / all-at-once

重要: EC2/On-Premises 蓝绿部署仅支持 Amazon EC2 实例,不支持本地(on-premises)服务器。

蓝绿部署工作流程(EC2/On-Premises 计算平台)

  1. 使用现有 Auto Scaling Group 作为模板创建替换环境的新实例
  2. 在新实例上安装应用程序修订版
  3. 可选等待时间:进行应用程序测试和系统验证
  4. 替换环境中的实例注册到一个或多个负载均衡器,流量开始路由到新实例
  5. 原始环境中的实例被注销,可终止或保留继续使用

![蓝绿部署流程](https://mermaid.ink/img/pako:eNptUU1v2zAM_SuETw2w1knaJsegYcB2GHC7eZcOjiPLH2plkeQ4DZr_fZTstGmHHRyEh498fOQjPb2CBAzOzCCxsQ_c88cK0iA-nU1nw_F8Mh1N5-PBaD4fjWfzyWg6HY1nk_F8Pp3Np9PZYjafL2aL5Xw-m03nj_PZfD4ePh7ni8V89nRcLBfLxdNxuVoul6_nxWK5fD0tltvl63W1Xi9fXy_L9Xr9el2s1-vX93K9Xr9el8v1-vWzXG6Wr9f1cr1-vS9Xq-XrbbFcrV9vq-Xq7fO2Wm42b5_VcrN5v682q83bfbVer95fq_Vm8_55Xa232-fPerPZvN9Xm83m47ParDa798t6s95-XBer7fb9tl6vN2-3xXrz9nldrTfv98V6s_x8X683r7fVevP2WS43m4_bcrXePh-L5Xr9dl2sVuv362K1Wr-fF6vl-uO2WC7XH9fFcrXe_lyul5uP63K5_XhdLLfrt_Vis9x9XBar7fp9vdhs3m-L9XrzfV8st6uP22K93X5cV5v19-O6WK7Wn9fFZrP9vCzW2-3bdbXdfj1W6_XXfbHe7r4ui_X6-ZgsNpuv22Kz_XpcFpvt9_Oy3G6_b8vN5uu22Gw-X8vt9ut1WW7XH4_ldr_9uC422-_nZbndft2Wm83Xfbne7r5vy8128_1Ybrbbr-dyt91-vy632-3Xc7nbbn9ul6vt7-u23G1_P5f77fb7dVlsd9-vy33f4bHv8zj0eR77Po9D3-dx6Ps8Dn2fx77v8zj0fR7Hvs_j0Pd5HPo-j2Pf53Hs-zyOQ5_HcRzyOA5Dn8dh6PM4Dn0ex6HP4zj0eRyHPo_j0OdxHPo8jkOfx3Ho8zgO-QfXv89j2_d5HIOj57Hv8zj2fR7Hvs_j2Pd5HPs-j2Pf53Ec-jyOQ5_HcejzOA59Hseh7_M4Dn0fx6HP4zj0eRyHPo_j0OdxHPo-z2N_z2P_z2P_z3M49H0ex77P49j3eRyHPo_j0PdxHPo8jkOfx3EY-jwOQ5_HcejzOA5Dn8dh6Ps8jkOfR6Hv8zgMfR6Hoc_jMPR5HIc-j8PQ53EY-jwOQ5_HcejzOA59Hseh7_M49H0eh77P4zj0eRyHPo_j0PdxHPo8DkOfx2Ho8zgMfR6Hoc_jOPR5HIY-j8PQ53EY-jwOQ5_HcejzOA59Hseh7_M49H0eh77P4zj0eRyHPo_j0PdxHPo8DkOfx2Ho8zgMfR6Hoc_jOPR5HIY-j8PQ53EY-jwOQ5_HcejzOA59Hseh7_M49H0eh77P4zj0eRyHPo_j0PdxHPo8DkOfx2Ho8zgMfR6Hoc_jOPR5HIa-z-PQ93kch77P49j3eRz7Po9j3- dx7Ps8jn2fx9HP4zj2fR6HPo_jMPR5HIehz-M49Hkchj6P49DncRz6PA5Dn8dh6PM4Dn0eh6HP4zj0eRyGvo_jOPR5HIahz-M49HkchqHPo9D3eRyHvo_jOPR5HIahz-M49HkchqHPotD3eRyHvo_jOPR5HIahz6Iw9Hkch6HPojD0WRyGPs-iMPR5FIY-i8LQZ1EY-iwKQ59Foe-zKPRtFoW-zqLQt1kU-jqLQl9nUeirLAqzH4WhLwV9m0WhrwV9mUWhb7MozH4Uhr4W9HUWhb4V9HUWhb4V9HUWhb4W9HUWhb4W9HUWhb4W9HUWhb4W9GUWhb4U9H0Whb4U9H0Whb4U9HUWhb4U9H0Whb4U9H0Whb4U9F0WZT_LstDnWRb6PItCn2dR6PMsC32eZaHP0yz0eRqFPkej0OdxGPo8DkOfx2Ho8zgMfR6HoQfXv89l2_d5HMOj57Hv8zj2eRyHPo_j0PdxHPs9j-OQ53EY-jwOQ5_HcejzOPR5HMY-j8PY53EY-jwOY5-Hoc9j7PMw9HkY-jwMQ5_D2Odh6PM49HkY-jwOY5_F2Gdh6LMw9FkY-ixMfRaGPgtDn4Whz8LQZ6HvszD0WRi6LgxNFoYuC0OXhaHLwtBlYei6MHQ5GLosDF0Whi4LQ5eD0GVh6LIwdDkIuhwMXRZmLgtBl4Wgy8LQZWHosjB1WQi6LAxdFoIuC1OXhZDLwtRlIeSyMHVZmLgsTF0W5i4Lc5eFucvC3GWh7bLQd1louyz0XRb6LguDLotCl4Wq64Kh64Ki64Ku64Ki64Ku2wWDLuu6Yui6ruiq4Oii4Omi4Oii4Oii4Oii4Oii4Oii4Oii4OiiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-ijY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-iiY-aOQ5-Hoc9j7PM49Hkeh7Efh7HP49DncRz6PA5Dn8dh6Ps8jn0ex6HP4zD2fR7Hvs_j2OdxGPs-j2Pf53Hs-zwOfZ_Hse_zOA59Hseh7_M49n0ex77P49j3eRyHPo_j0Pd5HPs-j2Pf53Ec-jwOQ5_HcejzOA59Hseh7_M49n0exz6P49j3eRz6PA5Dn8dh6Ps8jn0fh6HP4zD2fR6HPo_j0OdxGPs8D2Ofx3Hs8zgOfR6Hsc_jOPZ9Hse-z-PQ93kch77P49j3eRyHPo9j3-dx6Ps8j32fx1Hs-DyKQsfnURR6Po-i0PR5FIWuz6Mo9H0ehaHP8ygMfR5HoeuzKAp9n0dh6PM4Dn0eh6Efx77P49j3eRyHPo_j0PdxHPs-j-OQ53EY-jwOQ9_HcejzOPR5HIY-j8PY53EY-jwOfR-Hsc_j0OdxHPs-D2Ofx6HP4zj2eRyHPo_j0PdxHPo8Dn2fh7HP4zj2fR6HPo_j0Pd5HPs-j2Pf53Hs8ziKfZ_Hoe_zOPY9j0Ofx2Ho8zj0PR6HPo_D1GdxmPocDn0Oh77P4zD0eRz6PA5Dn8dh6PM49H0eh6HP4zD2fR6Hvs_j2Pd5HPo8DmOfx3Ho8ziLfR6Hsc_jOPZ5HIc-j8PY93kchz6P49j3eRz7Po9j3-dxHPs-j2Pf53EY-jyOYt_HoRj6PI5i38ex6PM4in0fx3Ho8ziKfR-Hoe_zOPZ9Hse-z-M49n0ex77P4zj2fR7Hvs_jOPR9Hse-z-Yx9X0ej6HP5zH0-RyGPp_D0Pd5HMc-j-Mw9H0cj6HP53Ho-zwOfZ_HcezzOA59n8dh7PM49n0eh77P4zj2fR7Hvs_jOPZ5HMehz-PQ93kch77P4zj2fR7Hvs_jOPZ5HMehz-OI_f8)

v3 架构变更

需要的基础设施:

                    ┌─────────────┐
                    │    ALB      │
                    │ (负载均衡器) │
                    └──────┬──────┘
                           │ 流量路由
              ┌────────────┼────────────┐
              │                         │
    ┌─────────┴─────────┐   ┌──────────┴──────────┐
    │   蓝色目标组        │   │    绿色目标组         │
    │ tf-mfmsapp-blue   │   │ tf-mfmsapp-green     │
    └─────────┬─────────┘   └──────────┬──────────┘
              │                         │
    ┌─────────┴─────────┐   ┌──────────┴──────────┐
    │  ASG (blue)       │   │   ASG (green)        │
    │  Min=2,Desired=2  │   │  Min=2,Desired=2     │
    │  [EC2] [EC2]      │   │  [EC2] [EC2]         │
    └───────────────────┘   └─────────────────────┘
              │                         │
              └────────────┬────────────┘
                           │
                    ┌──────┴──────┐
                    │    RDS      │
                    │ (PostgreSQL)│
                    └─────────────┘

v3 AppSpec 文件

# Amazon ECS 蓝绿部署
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: mfmsapp-task-def:3
        LoadBalancerInfo:
          ContainerName: mfmsapp
          ContainerPort: 8080
        PlatformVersion: LATEST
Hooks:
  - BeforeInstall: "LambdaFunctionToValidateBeforeInstall"
  - AfterInstall: "LambdaFunctionToValidateAfterInstall"
  - AfterAllowTestTraffic: "LambdaFunctionToValidateTestTraffic"
  - BeforeAllowTraffic: "LambdaFunctionToValidateBeforeShiftTraffic"
  - AfterAllowTraffic: "LambdaFunctionToValidateAfterShiftTraffic"

注意: EC2/On-Premises 蓝绿部署和 ECS 蓝绿部署的 AppSpec 格式不同,以上为 ECS 蓝绿的 AppSpec 格式。EC2/On-Premises 蓝绿部署使用与之前类似的格式(files + hooks)。

v3 部署组配置(AWS CLI)

aws deploy create-deployment-group \
    --application-name mfmsapp \
    --deployment-group-name mfmsapp-bluegreen \
    --service-role-arn "$CODEDEPLOY_ROLE_ARN" \
    --auto-scaling-groups "asg-mfmsapp-blue" \
    --deployment-config-name CodeDeployDefault.OneAtATime \
    --load-balancer-info "{
        \"elbInfoList\": [{ \"name\": \"mfmsapp-alb\" }],
        \"targetGroupInfoList\": [
            { \"name\": \"tf-mfmsapp-blue\" },
            { \"name\": \"tf-mfmsapp-green\" }
        ]
    }" \
    --blue-green-deployment-configuration '{
        "terminateBlueInstancesOnDeploymentSuccess": {
            "action": "TERMINATE",
            "terminationWaitTimeInMinutes": 10
        },
        "deploymentReadyOption": {
            "actionOnTimeout": "CONTINUE_DEPLOYMENT",
            "waitTimeInMinutes": 5
        }
    }'

v3 部署流程

  1. Source 阶段: CodeCommit 检测到新代码推送
  2. Build 阶段: CodeBuild 编译生成 mfmsapp 可执行文件
  3. Deploy 阶段(蓝绿部署):
    • CodeDeploy 以现有 Auto Scaling Group 为模板创建新实例(绿色环境)
    • 在绿色实例上部署新版本应用(v3)
    • 等待期(可配置 5 分钟):进行应用程序测试和系统验证
    • 绿色实例注册到负载均衡器,流量从蓝色逐步切换到绿色
    • 蓝色实例从 ELB 注销
    • 10 分钟后(可配置),蓝色实例被终止

整个升级过程:0 秒停机。

回滚机制

如果绿色实例验证失败:

  • CodeDeploy 自动停止流量切换
  • 保持蓝色 100% 流量
  • 绿色实例被终止

如果绿色已经上线但发现问题:

  • 在 CodeDeploy 控制台点击 "Rollback"
  • 流量瞬间切回蓝色(LB 重新指向蓝色 Target Group)
  • 只要蓝色实例未被终止,回滚是秒级的

数据库策略

蓝绿部署的最大挑战是数据库一致性

方案 优点 缺点 适用场景
共享 RDS 简单,蓝绿用同一数据库 连接数翻倍 推荐生产使用
DynamoDB 天然多可用区复制 需改变数据模型 高扩展场景
S3 备份同步 灵活 有数据丢失风险 开发测试
EFS 共享 文件系统共享 并发写有锁问题 不推荐

推荐方案: 使用 Amazon RDS(PostgreSQL/MySQL)替代 SQLite。

  • 蓝绿两组实例共享同一个 RDS 实例
  • RDS 提供自动备份、高可用、监控
  • 连接数 = ASG 实例数 × 每个应用连接数 + 缓冲

go.mod 改动(v3 需要添加 AWS SDK 依赖)

module mfmsapp

go 1.21

require (
    github.com/mattn/go-sqlite3 v1.14.22
    github.com/aws/aws-sdk-go v1.55.0
)

环境变量配置(v3 改进)

port := os.Getenv("PORT")
if port == "" { port = "8080" }

dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" { dbURL = "/opt/mfmsapp/data/mfmsapp.db" }

start_server.sh 中注入环境变量:

#!/bin/bash
export PORT=8080
export DATABASE_URL="/opt/mfmsapp/data/mfmsapp.db"
systemctl start mfmsapp

三种部署模式对比总结

对比项 v1(内存存储) v2(SQLite 就地) v3(蓝绿部署)
数据持久化 ❌ 重启丢失 ✅ SQLite 文件 ✅ 共享 RDS
部署策略 就地部署 就地部署 蓝绿部署
停机时间 5-10秒 5-10秒 0秒
资源成本 1 组 EC2 1 组 EC2 2 组 EC2 + ALB
回滚速度 重新部署 重新部署 秒级(LB 切换)
复杂度 ⭐ 简单 ⭐⭐ 中等 ⭐⭐⭐⭐ 复杂
适用场景 开发测试、PoC 中小规模生产 大规模、高可用

实际部署命令速查

v1/v2 就地部署

# 查看部署历史
aws deploy list-deployments \
    --application-name mfmsapp \
    --deployment-group-name mfmsapp-production

# 创建新部署
aws deploy create-deployment \
    --application-name mfmsapp \
    --deployment-group-name mfmsapp-production \
    --revision revisionType=CodeCommit,gitCommitId=abc123def

v3 蓝绿部署

# 查看蓝绿部署状态
aws deploy get-deployment --deployment-id d-XXXXXXXX

# 手动停止部署(紧急回滚)
aws deploy stop-deployment --deployment-id d-XXXXXXXX

创建 RDS PostgreSQL

aws rds create-db-instance \
    --db-instance-identifier mfmsapp-db \
    --db-instance-class db.t3.micro \
    --engine postgres \
    --master-username admin \
    --master-user-password YOUR_PASSWORD \
    --allocated-storage 20

常见问题与排障

Q1: 蓝绿部署时,绿色实例健康检查失败怎么办?

原因: 应用启动失败或配置错误。

解决:

  1. SSH 登录绿色实例(通过 ASG 找到实例 ID)
  2. 查看应用日志:sudo journalctl -u mfmsapp -f
  3. 检查健康检查端点:curl http://localhost:8080/crops
  4. 修复代码,重新部署

Q2: 蓝绿部署后,数据库连接数暴增?

原因: 蓝绿两组实例同时连接数据库,连接数翻倍。

解决:

  • RDS 配置 max_connections = ASG总数 × 每应用连接数 + 缓冲
  • 例如:蓝 2 实例 + 绿 2 实例,每个 10 连接 → max_connections = 50

Q3: CodeDeploy 蓝绿部署的流量切换太慢?

在创建部署组时调整配置:

--blue-green-deployment-configuration '{
    "terminateBlueInstancesOnDeploymentSuccess": {
        "action": "TERMINATE",
        "terminationWaitTimeInMinutes": 5
    },
    "deploymentReadyOption": {
        "actionOnTimeout": "CONTINUE_DEPLOYMENT",
        "waitTimeInMinutes": 1
    }
}'

后续演进建议

1. 数据库升级:SQLite → RDS PostgreSQL

  • SQLite 发性能差,不适合多实例
  • 蓝绿部署需要共享数据存储
  • 迁移步骤:导出 SQLite → 转换 SQL → 导入 RDS → 修改 DATABASE_URL

2. 无服务器化:EC2 → Lambda + API Gateway

  • 零运维,自动扩缩容
  • 成本更低(按执行次数计费)
  • 注意冷启动延迟(几秒)

3. 容器化:Docker + ECS

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o mfmsapp .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/mfmsapp .
COPY --from=builder /app/database/schema.sql ./database/
EXPOSE 8080
CMD ["./mfmsapp"]

本文小结

  1. v1 → v2(内存 → SQLite): 解决数据持久化,增加数据库初始化、schema、数据访问层
  2. v2 → v3(就地 → 蓝绿): 解决零停机,引入 ALB + 双 ASG + CodeDeploy 蓝绿配置
  3. 关键要点:
    • 数据库迁移注意向后兼容(schema 变更时字段允许 NULL)
    • 蓝绿部署需要共享数据库(推荐 RDS)
    • 回滚窗口内可秒级恢复
  4. 生产建议: 定期演练回滚,确保灾难恢复能力

下一篇预告

第 07 篇:权限安全深度解析——IAM 最小权限、KMS 加密、VPC 构建

本文专注功能实现,但安全不能忽视!下一篇详细拆解:

  • IAM 角色权限最小化(Pipeline、CodeBuild、CodeDeploy 各需要什么权限?)
  • KMS 加密 S3 artifact 和 RDS 数据库
  • 将 CI/CD 流水线放入 VPC,禁止公网访问
  • 审计日志(CloudTrail)与合规检查

打造一个企业级安全的 AWS CI/CD 流水线。


本文档内容基于 2026 年 4 月 AWS 官方文档整理。部署类型、计算平台支持、API 参数等均已核对 AWS CodeDeploy 用户指南。如遇差异请以 AWS 官方文档为准。