基于Packer与GitHub Actions构建Oracle只读副本的不可变基础设施


手动搭建和维护Oracle Data Guard物理备库是一项极其繁琐且易错的工作。在生产环境中,一次微小的配置疏忽,比如tnsnames.ora的一个拼写错误,或listener.ora的参数遗漏,都可能导致数小时的故障排查。当业务需要为读写分离架构快速扩容多个只读节点时,这种依赖DBA手工操作的模式,其交付周期和稳定性都无法满足现代应用的需求。我们的目标是彻底改变这一现状,将Oracle只读副本的交付流程从手工作坊式的“艺术创作”转变为工业化的、可预测的“流水线生产”。

定义问题:可重复性灾难与配置漂移

在引入自动化之前,我们面临的核心挑战可以归结为两点:

  1. 不可重复性: 每次搭建备库都像一次全新的冒险。尽管有详尽的文档(Runbook),但执行者、操作系统补丁版本、基础环境的细微差异都会导致最终结果不一致。灾难恢复演练的成功,往往依赖于某位资深DBA的“肌肉记忆”。
  2. 配置漂移 (Configuration Drift): 随着时间推移,运行中的备库会因为紧急修复、手动调整等原因,其配置逐渐偏离最初的基线。这使得新旧节点之间存在差异,为故障排查和后续的统一管理埋下隐患。

传统的自动化方案,例如使用Ansible或Shell脚本,虽然能在一定程度上解决问题,但它们本质上是“配置管理”工具,在目标机器上执行一系列命令。这种方式无法根除配置漂移,因为它们作用于一个“可变”的基础设施之上。如果脚本执行失败,系统可能处于一个不确定的中间状态,修复过程更为复杂。

架构决策:拥抱不可变基础设施

我们最终选择了基于不可变基础设施(Immutable Infrastructure)的方案。其核心理念是,任何基础设施的组件(虚拟机、容器等)在部署后都不能被修改。如果需要更新或修复,我们不会在现有组件上打补丁,而是用一个包含新版本代码和配置的新组件替换掉旧的。

这个模式的优势在数据库副本场景下尤为突出:

  • 绝对一致性: 每个副本都从同一个“黄金镜像”(Golden Image)实例化而来,从操作系统到数据库软件,再到核心配置文件,完全一致。
  • 原子化部署: 部署一个新副本,就是启动一个虚拟机。回滚一个有问题的版本,就是销毁这个虚拟机,然后用旧版本的镜像重新启动一个。操作是原子化的,不存在中间状态。
  • 简化运维: 无需再关心运行中实例的配置维护,所有变更都从源头——镜像构建过程开始。

为了实现这一目标,我们选择了Packer作为镜像构建工具,GitHub Actions作为CI/CD流水线,配合一个自研的Go语言数据库代理框架,实现对上层应用的透明读写分离。

graph TD
    subgraph "CI/CD Pipeline (GitHub Actions)"
        A[Code Push: Packer templates, setup scripts] --> B{Run Packer Build};
        B --> C[Create VM from base ISO];
        C --> D[Run Provisioning Scripts];
        D --> E{Oracle Install & DG Config};
        E --> F[Create Golden Image];
        F --> G[Store in vSphere Content Library];
    end

    subgraph "Application Runtime"
        H[Application] -- SQL --> I{Go DB Proxy};
        I -- Write Traffic --> J[Oracle Primary DB];
        I -- Read Traffic --> K1[Replica VM 1];
        I -- Read Traffic --> K2[Replica VM 2];
        I -- Read Traffic --> Kn[Replica VM n];
    end
    
    subgraph "Infrastructure Layer"
        G -- Deploy --> K1;
        G -- Deploy --> K2;
        G -- Deploy --> Kn;
        J <--> K1;
        J <--> K2;
        J <--> Kn;
    end

    style G fill:#f9f,stroke:#333,stroke-width:2px
    style K1 fill:#bbf,stroke:#333,stroke-width:2px
    style K2 fill:#bbf,stroke:#333,stroke-width:2px
    style Kn fill:#bbf,stroke:#333,stroke-width:2px

核心实现一:使用Packer固化Oracle备库

Packer的核心是一个JSON或HCL格式的模板文件,它定义了如何构建镜像。我们的实现主要包含三部分:builders, provisionersvariables

1. Packer模板 (oracle-standby.pkr.hcl)

这里的挑战在于Oracle的安装过程本身很复杂,并且需要图形界面或静默响应文件。我们将整个过程通过shell脚本自动化,并由Packer调用。

// oracle-standby.pkr.hcl

variable "vsphere_server" {
  type    = string
  sensitive = true
}
variable "vsphere_user" {
  type    = string
  sensitive = true
}
variable "vsphere_password" {
  type    = string
  sensitive = true
}
// ... 其他 vSphere 相关变量: datacenter, cluster, datastore etc.

variable "iso_path" {
  type    = string
  default = "[datastore1] ISO/CentOS-7-x86_64-Minimal-2009.iso"
}

variable "vm_name" {
  type    = string
  default = "oracle-19c-standby-template"
}

source "vsphere-iso" "oracle-standby" {
  // vCenter 连接信息
  vcenter_server      = var.vsphere_server
  username            = var.vsphere_user
  password            = var.vsphere_password
  insecure_connection = true

  // 虚拟机配置
  vm_name              = var.vm_name
  guest_os_type        = "centos7_64Guest"
  cluster              = "MyCluster"
  datastore            = "MyDatastore"
  
  CPUs                 = 4
  RAM                  = 16384 // 16 GB RAM
  RAM_reserve_all      = true

  // 磁盘配置
  disk_controller_type = ["pvscsi"]
  storage {
    disk_size             = 102400 // 100 GB for OS
    disk_thin_provisioned = true
  }
  storage {
    disk_size             = 204800 // 200 GB for Oracle Home & Data
    disk_thin_provisioned = true
    disk_name             = "hard_disk_1"
  }

  // 网络配置
  network_adapters {
    network      = "VM Network"
    network_card = "vmxnet3"
  }

  // 操作系统自动化安装 (Kickstart)
  iso_paths = [var.iso_path]
  http_directory = "./http"
  boot_command = [
    "<tab> text ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg<enter>"
  ]

  // SSH 连接信息
  ssh_username = "packer"
  ssh_password = "packer_password"
  ssh_timeout  = "30m"
  
  // 转换为模板
  convert_to_template = true
}

build {
  sources = ["source.vsphere-iso.oracle-standby"]

  // 步骤1: 准备环境
  provisioner "shell" {
    scripts = [
      "./scripts/01-prepare-os.sh",
      "./scripts/02-setup-oracle-user.sh"
    ]
  }

  // 步骤2: 上传Oracle安装介质和响应文件
  provisioner "file" {
    source      = "./install_files/LINUX.X64_193000_db_home.zip"
    destination = "/tmp/oracle_install.zip"
  }
  provisioner "file" {
    source      = "./install_files/db_install.rsp"
    destination = "/tmp/db_install.rsp"
  }

  // 步骤3: 执行Oracle静默安装和配置
  provisioner "shell" {
    environment_vars = [
      "ORACLE_PRIMARY_CONN_STR=your_primary_db:1521/ORCLPDB1", // 主库连接串
      "ORACLE_SYS_PASSWORD={{env `ORACLE_SYS_PASS`}}" // 从环境变量获取密码
    ]
    scripts = [
      "./scripts/03-install-oracle.sh",
      "./scripts/04-configure-dataguard.sh",
      "./scripts/05-cleanup.sh"
    ]
  }
}

2. 核心供应脚本 (04-configure-dataguard.sh)

这个脚本是整个流程的灵魂。它不是简单地安装软件,而是将这台机器配置成一个“待命”的物理备库。

#!/bin/bash
set -e # 任何命令失败立即退出

# 配置环境变量
export ORACLE_HOME=/u01/app/oracle/product/19.0.0/dbhome_1
export ORACLE_SID=ORCL
export PATH=$ORACLE_HOME/bin:$PATH

# 1. 配置监听 (Listener)
# 确保 listener.ora 包含主库和备库的静态注册信息
# 这是为了在数据库未启动时,DGMGRL (Data Guard Broker) 也能连接
cat > $ORACLE_HOME/network/admin/listener.ora <<EOF
LISTENER =
  (DESCRIPTION_LIST =
    (DESCRIPTION =
      (ADDRESS = (PROTOCOL = TCP)(HOST = 0.0.0.0)(PORT = 1521))
    )
  )

SID_LIST_LISTENER =
  (SID_LIST =
    (SID_DESC =
      (GLOBAL_DBNAME = ORCL_DGMGRL)
      (ORACLE_HOME = ${ORACLE_HOME})
      (SID_NAME = ORCL)
    )
  )
EOF

# 2. 配置 tnsnames.ora
# 必须包含主库和当前备库的连接别名
PRIMARY_HOST=$(echo $ORACLE_PRIMARY_CONN_STR | cut -d':' -f1)
STANDBY_HOST=$(hostname -f)
cat > $ORACLE_HOME/network/admin/tnsnames.ora <<EOF
PRIMARY_DB =
  (DESCRIPTION =
    (ADDRESS = (PROTOCOL = TCP)(HOST = ${PRIMARY_HOST})(PORT = 1521))
    (CONNECT_DATA =
      (SERVER = DEDICATED)
      (SERVICE_NAME = ORCLPDB1)
    )
  )

STANDBY_DB =
  (DESCRIPTION =
    (ADDRESS = (PROTOCOL = TCP)(HOST = ${STANDBY_HOST})(PORT = 1521))
    (CONNECT_DATA =
      (SERVER = DEDICATED)
      (SERVICE_NAME = ORCL_DGMGRL)
    )
  )
EOF

# 启动监听
lsnrctl start

# 3. 使用 RMAN 从主库 'active duplicate' 恢复数据库
# 这是Data Guard设置的关键步骤,它会在线克隆主库到备库
# 注意:这里需要主库开启 FORCE LOGGING,并设置好密码文件
rman TARGET sys/${ORACLE_SYS_PASSWORD}@PRIMARY_DB AUXILIARY sys/${ORACLE_SYS_PASSWORD}@STANDBY_DB <<EOF
DUPLICATE TARGET DATABASE FOR STANDBY FROM ACTIVE DATABASE
DORECOVER
SPFILE
  SET DB_UNIQUE_NAME='ORCL_STBY'
  SET LOG_ARCHIVE_CONFIG='DG_CONFIG=(ORCL,ORCL_STBY)'
  SET LOG_ARCHIVE_DEST_2='SERVICE=PRIMARY_DB ASYNC VALID_FOR=(ONLINE_LOGFILES,PRIMARY_ROLE) DB_UNIQUE_NAME=ORCL'
  SET FAL_SERVER='PRIMARY_DB'
  SET FAL_CLIENT='STANDBY_DB'
;
EOF

# 4. 启动数据库到 MOUNT 状态
sqlplus / as sysdba <<EOF
STARTUP MOUNT;
ALTER DATABASE RECOVER MANAGED STANDBY DATABASE USING CURRENT LOGFILE DISCONNECT FROM SESSION;
-- 这里不启用broker,最终的启用和切换由DBA或自动化平台在副本启动后完成
-- SHUTDOWN IMMEDIATE;
EOF

echo "Data Guard standby configuration finished successfully."

这个脚本完成后,我们得到的镜像里包含一个已经完整克隆了主库数据、配置好了监听和连接、并处于MOUNT状态的备库。当从这个镜像启动虚拟机后,只需要一条命令就能让它开始接收和应用主库的归档日志。

核心实现二:GitHub Actions自动化构建流水线

流水线的目标是当Packer模板或相关脚本发生变更时,自动触发镜像构建过程。

# .github/workflows/build-oracle-image.yml

name: Build Oracle Standby Golden Image

on:
  workflow_dispatch: # 手动触发
  push:
    branches:
      - main
    paths:
      - 'packer/**' # 只在packer相关文件变更时触发

jobs:
  build-image:
    runs-on: self-hosted # 必须使用可以连接到vSphere的自建Runner
    name: Packer Build for Oracle Standby
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup Packer
        uses: hashicorp/setup-packer@main
        with:
          version: '1.8.5'

      - name: Validate Packer Template
        id: validate
        run: |
          cd packer/oracle-standby
          packer validate .

      - name: Build Image with Packer
        id: build
        env:
          # 使用GitHub Secrets管理敏感信息
          PACKER_VAR_vsphere_server: ${{ secrets.VSPHERE_SERVER }}
          PACKER_VAR_vsphere_user: ${{ secrets.VSPHERE_USER }}
          PACKER_VAR_vsphere_password: ${{ secrets.VSPHERE_PASSWORD }}
          ORACLE_SYS_PASS: ${{ secrets.ORACLE_SYS_PASSWORD }}
        run: |
          cd packer/oracle-standby
          packer build -on-error=abort .

这里的关键点是runs-on: self-hosted。由于Packer需要直接与我们的vCenter Server通信来创建、配置虚拟机,因此必须在一个能够访问内部网络环境的Runner上执行。

核心实现三:Go语言读写分离代理框架

有了可按需部署的只读副本后,还需要让应用能够透明地使用它们。我们开发了一个轻量级的Go代理框架,它封装了标准库database/sql,并在此之上实现了读写路由和会话一致性保障。

1. 代理核心逻辑

代理维护两个连接池:一个指向主库(写),一个或多个指向只读副本(读)。

package dbproxy

import (
	"context"
	"database/sql"
	"errors"
	"strings"
	"sync"
	
	_ "github.com/godror/godror" // Oracle driver
)

// DB is a proxy that wraps a write master and multiple read replicas.
type DB struct {
	mu       sync.RWMutex
	master   *sql.DB
	replicas []*sql.DB
	next     int // For simple round-robin
}

// New creates a new DB proxy.
func New(masterDSN string, replicaDSNs []string) (*DB, error) {
	master, err := sql.Open("godror", masterDSN)
	if err != nil {
		return nil, err
	}
	if err := master.Ping(); err != nil {
		return nil, err
	}

	replicas := make([]*sql.DB, len(replicaDSNs))
	for i, dsn := range replicaDSNs {
		r, err := sql.Open("godror", dsn)
		if err != nil {
			// In a real project, handle this more gracefully (e.g., mark as down and retry)
			return nil, err
		}
		if err := r.Ping(); err != nil {
			return nil, err
		}
		replicas[i] = r
	}

	return &DB{master: master, replicas: replicas}, nil
}

// isWriteQuery checks if the query is a write operation.
// This is a naive implementation; a production one would be more robust.
func isWriteQuery(query string) bool {
	q := strings.TrimSpace(strings.ToUpper(query))
	return strings.HasPrefix(q, "INSERT") || strings.HasPrefix(q, "UPDATE") || strings.HasPrefix(q, "DELETE")
}


// getReader returns a read replica connection pool using round-robin.
func (db *DB) getReader() *sql.DB {
	db.mu.RLock()
	defer db.mu.RUnlock()

	if len(db.replicas) == 0 {
		return db.master // Fallback to master if no replicas
	}
	
	// Simple Round Robin
	reader := db.replicas[db.next%len(db.replicas)]
	db.next++
	return reader
}

// QueryContext routes read queries to replicas.
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
	// In a transaction, all queries must go to the master.
	if tx, ok := ctx.Value(txKey).(*sql.Tx); ok {
		return tx.QueryContext(ctx, query, args...)
	}
	
	// Simple check for write operations disguised as queries (e.g., some stored procs).
	if isWriteQuery(query) {
		return db.master.QueryContext(ctx, query, args...)
	}
	return db.getReader().QueryContext(ctx, query, args...)
}

// ExecContext routes all write operations to the master.
func (db *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
	if tx, ok := ctx.Value(txKey).(*sql.Tx); ok {
		return tx.ExecContext(ctx, query, args...)
	}
	return db.master.ExecContext(ctx, query, args...)
}

2. 解决会话一致性问题

读写分离最大的坑在于复制延迟。一个常见的场景是:用户注册成功(写入主库),然后立即查询用户信息(读取从库),结果发现用户不存在,因为数据还没同步到从库。

我们的解决方案是“写后读会话绑定”(Session Affinity after Write)。当一个会话(例如一个HTTP请求)执行了写操作后,我们将该会话在一定时间内“钉”在主库上,后续的所有读请求也发往主库,从而保证读到最新的数据。

// A simple context key for transaction management and session pinning.
type contextKey string
const txKey contextKey = "tx"
const sessionPinKey contextKey = "pin_to_master"

// BeginTx starts a transaction. All operations within this transaction will be on the master.
func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (context.Context, error) {
	tx, err := db.master.BeginTx(ctx, opts)
	if err != nil {
		return nil, err
	}
	// Store the transaction in the context.
	return context.WithValue(ctx, txKey, tx), nil
}

// Commit commits the transaction.
func Commit(ctx context.Context) error {
    tx, ok := ctx.Value(txKey).(*sql.Tx)
    if !ok || tx == nil {
        return errors.New("not in a transaction")
    }
    return tx.Commit()
}

// A middleware example for a web framework like Gin
func SessionPinningMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Assume some session logic sets this flag after a POST/PUT/DELETE request
        // For simplicity, we just check the method here.
        if c.Request.Method != "GET" {
            // Pin this session to the master for subsequent reads in this request.
            ctx := context.WithValue(c.Request.Context(), sessionPinKey, true)
            c.Request = c.Request.WithContext(ctx)
        }
        c.Next()
    }
}

// QueryContext with session pinning logic
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
	if tx, ok := ctx.Value(txKey).(*sql.Tx); ok {
		return tx.QueryContext(ctx, query, args...)
	}

    // Check if the session is pinned to master
    if pinned, ok := ctx.Value(sessionPinKey).(bool); ok && pinned {
        return db.master.QueryContext(ctx, query, args...)
    }
	
	if isWriteQuery(query) {
		return db.master.QueryContext(ctx, query, args...)
	}
	return db.getReader().QueryContext(ctx, query, args...)
}

这个框架对应用层代码的侵入性很小,应用只需要将原来的*sql.DB替换成我们的*dbproxy.DB,并在事务和需要强一致性的地方使用我们提供的上下文管理即可。

当前方案的局限性与未来路径

此套方案极大地提升了Oracle只读副本的交付效率和一致性,但并非银弹。首先,Packer构建Oracle镜像的过程相当耗时(通常在1-2小时),这不适用于需要秒级弹性伸缩的场景。其次,物理复制(Data Guard)的延迟虽然通常在秒级,但在网络抖动或主库高负载情况下可能增大,对于要求强实时一致性的业务,读写分离架构本身可能就不适用。

未来的优化方向包括:

  1. 构建过程优化: 探索将Oracle安装文件预置在基础镜像中,或者利用虚拟机快照技术,来缩短每次构建的时间。
  2. 更智能的代理: 当前的Go代理路由策略相对简单。可以引入基于副本延迟的动态路由,自动将读请求转发到延迟最低的节点,并能更优雅地处理副本故障。
  3. 容器化探索: 虽然在生产环境大规模使用Oracle容器实例仍有争议,但将其用于构建流程,可以进一步提升环境的一致性和构建速度,值得研究。
  4. 与GitOps集成: 将虚拟机实例的声明周期也纳入Git管理,通过ArgoCD或Flux之类的工具,实现从镜像构建到实例部署的全流程自动化。

  目录