Files
ilovintit cea9b941cf
Build and Push / build (push) Failing after 13m20s
Initial commit: docker-compose-updater
Go 项目,包含:
- 服务端 updater:两阶段协议,ECDSA 签名验证,AES-GCM 加密
- 发送端 dcu-send:Gitea Action CLI
- internal/auth:加解密/签名/会话管理
- internal/docker:Docker CLI 容器查找/拉取/重建
- action/:Gitea Action 定义
- deploy/Dockerfile:多阶段构建
- .gitea/workflows/build.yaml:CI/CD
2026-06-08 15:16:46 +08:00

20 KiB
Raw Permalink Blame History

docker-compose-updater 实施方案 (v2)

1. 概述

Go 编写的 Webhook 工具,部署在使用 docker-compose 的服务器上。Gitea Actions 通过 Action 调用,签名验证后执行指定容器的拉取/重启操作。

核心特性:

  • 非对称签名(ECDSA P-256)验证请求来源
  • 对称加密(AES-256-GCM)加密请求体,防中间人窃听容器名
  • 时间戳 + Nonce 防重放攻击
  • 三种操作模式:拉取+重启、仅拉取、仅重启
  • 一次只操作一个容器
  • 一个 updater 管理多个 docker-compose 项目

2. 通讯协议设计

2.1 ECDH 密钥协商(类似 TLS 握手)

每次请求自动协商 AES 会话密钥,无需预共享对称密钥。原理类似 TLS:

发送端 (dcu-send)                          接收端 (updater)
─────────────────                         ────────────────
内置: ECDH公钥(server_public)              内置: ECDH私钥(server_private)
      ECDSA私钥(sender_private)                  ECDSA公钥(sender_public)
                      
1. 生成临时 ECDH 密钥对
   (ephemeral_private, ephemeral_public)
2. shared_secret = ECDH(ephemeral_private,
                        server_public)
3. aes_key = HKDF(shared_secret, nonce)
4. AES-256-GCM 加密 payload → ciphertext
5. 签名内容 = "v.ts.nonce.ek.data"
6. ECDSA 签名 → sig
                                        7. 收到 {v, ts, nonce, ek, data, sig}
                                        8. 校验 ts 在 ±30s 内
                                        9. 校验 nonce 未使用
                                        10. 验证 ECDSA 签名
                                        11. shared_secret = ECDH(server_private,
                                                                 ephemeral_public)
                                        12. aes_key = HKDF(shared_secret, nonce)
                                        13. AES-256-GCM 解密 → 明文
                                        14. 执行操作

每次请求的临时 ECDH 密钥对 + nonce 共同推导出唯一的 AES 会话密钥,即使服务器私钥泄露也无法解密历史请求(前向安全)。

2.2 请求格式

{
  "v":  1,
  "ts": 1717000000,
  "nonce": "a1b2c3d4e5f6g7h8",
  "ek": "base64-ephemeral-ecdh-public-key",
  "data": "base64-aes-gcm-ciphertext",
  "sig": "base64-ecdsa-signature"
}
字段 说明
v 协议版本,当前为 1
ts Unix 时间戳(秒),服务端检查 ±30s 内
nonce 16 字节随机数 Base64,服务端缓存 60s 防重放
ek 临时 ECDH 公钥(P-256 未压缩格式 65 字节 → Base64),每次请求生成新密钥对
data AES-256-GCM 加密后的 payloadkey = HKDF(ECDH_shared_secret, nonce)
sig ECDSA P-256 签名,签名内容 = v.ts.nonce.ek.data 点号拼接

2.3 明文 Payload

{
  "project": "my-app",
  "service": "api",
  "action": "update"
}
字段 必填 说明
project docker-compose 项目名(对应 projects_dir 下的子目录名)
service compose 中定义的 service 名(如 apifrontend),每次只操作一个容器
action update(拉取+重启)/ pull(仅拉取)/ restart(仅重启)

2.4 密钥体系

密钥对 算法 用途 发送端(Gitea Action 接收端(updater
签名密钥对 ECDSA P-256 请求认证(证明 sender 身份) 私钥(Gitea Secret 公钥(内嵌二进制)
协商密钥对 ECDH P-256 密钥协商(建立 AES 会话密钥) 公钥(内嵌 dcu-send 私钥(内嵌二进制)

签名和协商使用不同的密钥对,避免同一密钥被用于两种密码学原语的安全风险。

2.5 密钥生成

# 1. ECDSA 签名密钥对(认证用)
openssl ecparam -genkey -name prime256v1 -out signing-private.pem
openssl ec -in signing-private.pem -pubout -out signing-public.pem

# 2. ECDH 协商密钥对(加密用)
openssl ecparam -genkey -name prime256v1 -out ecdh-private.pem
openssl ec -in ecdh-private.pem -pubout -out ecdh-public.pem

Gitea Secrets 中配置:

  • UPDATER_SIGNING_KEYsigning-private.pem 文件内容

编译二进制时嵌入:

  • updater 二进制:signing-public.pem(验签)+ ecdh-private.pem(解密)
  • dcu-send 二进制:ecdh-public.pem(加密)

3. 系统架构

┌─────────────────────────────────────────────────────────┐
│ Gitea Actions Runner                                    │
│                                                         │
│ Workflow:                                               │
│   steps:                                                │
│     - uses: user/docker-compose-updater/action@v1       │
│       with:                                             │
│         url: https://updater.example.com                │
│         project: my-app                                 │
│         service: api                                    │
│         action: update                                  │
│           │                                             │
│           ▼                                             │
│   ┌─────────────────┐   private.key + aes.key          │
│   │  dcu-send        │◄── from Gitea Secrets           │
│   │  1. encrypt      │                                  │
│   │  2. sign         │                                  │
│   │  3. HTTP POST    │                                  │
│   └────────┬────────┘                                  │
└────────────┼────────────────────────────────────────────┘
             │ HTTPS
             ▼
┌─────────────────────────────────────────────────────────┐
│ Docker Container (updater)                              │
│  ┌──────────────┐  ┌──────────────────┐                │
│  │ HTTP Server  │  │  Auth Module     │                │
│  │ POST /hook   │──► 1. timestamp     │                │
│  │ GET /health  │  │ 2. nonce check   │                │
│  └──────────────┘  │ 3. verify sig    │                │
│                    │ 4. decrypt data  │                │
│                    └────────┬─────────┘                │
│                             │                          │
│                    ┌────────▼─────────┐                │
│                    │ Docker Module    │                │
│                    │ docker compose   │                │
│                    │ pull api         │                │
│                    │ docker compose   │                │
│                    │ up -d api        │                │
│                    └────────┬─────────┘                │
│                             │                          │
│                    ╔════════╧══════════╗               │
│                    ║ /var/run/docker   ║               │
│                    ║ .sock (host)      ║               │
│                    ╚════════╤══════════╝               │
└─────────────────────────────┼───────────────────────────┘
                              │
                              ▼
                     ┌─────────────────┐
                     │ Docker Daemon   │
                     │ (Host)          │
                     └────────┬────────┘
                              │
                     ┌────────▼────────┐
                     │ docker compose  │
                     │ pull api        │
                     │ up -d --no-deps │
                     └────────┬────────┘
                              │
                     ┌────────▼────────┐
                     │ api 容器 (新版)   │
                     └─────────────────┘

4. 技术选型

选型 理由
语言 Go 1.22+ 单二进制编译,跨平台,标准库丰富
HTTP 框架 标准库 net/http + chi 轻路由 极小依赖,安全可控
签名算法 ECDSA P-256 + SHA256 非对称签名,密钥短、性能好、安全性高
加密算法 AES-256-GCM 身份认证加密,防篡改防窃听
Docker 操作 os/exec 调用 docker compose 接口稳定,复用 compose 原生能力
配置 YAML + 环境变量覆盖 容器部署友好
日志 slogGo 1.21+ 标准库) 结构化日志,零依赖

为什么 compose 不直接用 Go SDK Docker Compose v2 有 Go SDK,但依赖 docker/cli 内部包,API 不稳定。docker compose CLI 是稳定接口,通过 os/exec 调用更可靠。

5. 项目结构

docker-compose-updater/
├── cmd/
│   ├── updater/                    # 服务端入口
│   │   └── main.go
│   └── dcu-send/                   # Gitea Action 用发送端 CLI
│       └── main.go
├── internal/
│   ├── auth/                       # 加解密 + 签名 (两端共用库)
│   │   ├── crypto.go               # AES-256-GCM 加解密
│   │   ├── sign.go                 # ECDSA 签名 + 验签
│   │   └── replay.go               # Nonce 追踪器
│   ├── server/
│   │   ├── server.go               # HTTP 服务器
│   │   ├── handler.go              # /hook 处理器
│   │   └── middleware.go           # 日志 / 恢复 / 限体
│   ├── docker/
│   │   └── updater.go             # docker compose 操作封装
│   └── config/
│       └── config.go              # 配置加载
├── action/                         # Gitea Action 定义
│   ├── action.yml                  # 动作元数据
│   └── Dockerfile                  # Action 运行时镜像
├── deploy/
│   ├── Dockerfile                  # 服务端多阶段构建
│   ├── docker-compose.yml          # 部署示例
│   └── config.example.yaml
├── Makefile
├── go.mod / go.sum
└── PROPOSAL.md

6. API 设计

方法 路径 说明
POST /hook 接收加密请求,执行更新操作
GET /health 存活检查
GET /ready 就绪检查(Docker socket 连通性)

POST /hook

请求 body 始终为加密格式(第 2.1 节)。所有参数(project、service、action)都在加密 payload 中。

成功后响应 (200)

{
  "status": "ok",
  "project": "my-app",
  "service": "api",
  "action": "update",
  "message": "镜像已拉取,容器已重启",
  "logs": [
    "Pulling api...",
    "Digest: sha256:abc123...",
    "Recreating api ... done",
    "Container api started"
  ],
  "ts": 1717000000
}

失败响应 (401/403/500)

{
  "status": "error",
  "message": "签名验证失败 / 时间戳超出窗口 / Nonce 已使用 / 解密失败 / 项目不存在 / 容器未找到"
}

7. Gitea Action(发送端)

7.1 action.yml

name: 'Docker Compose Updater'
description: '触发远程 docker-compose 服务更新(拉取/重启)'
author: 'your-org'
branding:
  icon: 'refresh-cw'
  color: 'blue'
inputs:
  url:
    description: 'Updater webhook 地址,如 https://updater.example.com/hook'
    required: true
  project:
    description: 'docker-compose 项目名'
    required: true
  service:
    description: '要操作的 service 名(如 api、frontend),必填'
    required: true
  action:
    description: '操作类型: update(拉取+重启), pull(仅拉取), restart(仅重启)'
    required: false
    default: 'update'
runs:
  using: 'docker'
  image: 'Dockerfile'
  env:
    # 无需加密密钥——通过 ECDH 每次自动协商 AES 会话密钥
    SIGNING_KEY: ${{ inputs.signing_key }}
    UPDATER_URL: ${{ inputs.url }}
    UPDATER_PROJECT: ${{ inputs.project }}
    UPDATER_SERVICE: ${{ inputs.service }}
    UPDATER_ACTION: ${{ inputs.action }}

7.2 Action Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
  -ldflags="-X main.ecdhPubKey=$(cat deploy/keys/ecdh-public.pem | base64 -w0)" \
  -o /dcu-send ./cmd/dcu-send

FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /dcu-send /usr/local/bin/dcu-send
ENTRYPOINT ["dcu-send"]

ECDH 公钥通过 -ldflags 在编译时嵌入 dcu-send 二进制,发送端无需任何密钥配置。

7.3 dcu-send 内部流程

内置: ECDH 公钥(服务端协商公钥)
环境变量:
  SIGNING_KEY          ECDSA 签名私钥 PEM(来自 Gitea Secret
  UPDATER_URL          Webhook 地址
  UPDATER_PROJECT      项目名
  UPDATER_SERVICE      service 名
  UPDATER_ACTION       操作类型

1. 构建明文 payload  {project, service, action}
2. 生成 16 字节随机 nonce
3. 取当前 Unix 时间戳 ts
4. 生成临时 ECDH 密钥对 (ephemeral_private, ephemeral_public)
5. shared_secret = ECDH(ephemeral_private, 内置的 ECDH 公钥)
6. aes_key = HKDF-SHA256(shared_secret, nonce, "dcu-updater/v1")
7. AES-256-GCM 加密 payload → ciphertext
8. 组装 envelope  {v:1, ts, nonce, ek: ephemeral_public, data: ciphertext}
9. 签名内容 = "v.ts.nonce.ek.data"
10. ECDSA P-256 签名 → sig (Base64)
11. HTTP POST UPDATER_URL, body = envelope
12. 将响应日志输出到 stdout
13. 非零退出码表示失败

7.4 用户在工作流中的使用方式

name: 构建并部署 API
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: 构建并推送镜像
        run: |
          docker build -t registry.example.com/my-app/api:latest .
          docker push registry.example.com/my-app/api:latest

      - name: 触发远程更新
        uses: your-org/docker-compose-updater/action@v1
        with:
          url: https://updater.example.com/hook
          project: my-app
          service: api
          action: update
        secrets:
          signing_key: ${{ secrets.UPDATER_SIGNING_KEY }}

8. 服务端配置

# deploy/config.example.yaml
server:
  listen: ":8080"
  max_body_size: "1MB"

auth:
  # 所有密钥内嵌在二进制中,无需文件配置:
  #   - ECDSA 公钥(验签)
  #   - ECDH 私钥(密钥协商解密)
  timestamp_window: 30    # 时间戳容忍窗口(秒)
  nonce_ttl: 60           # Nonce 缓存时间(秒)

docker:
  compose_command: "docker compose"
  projects_dir: "/data/projects"   # 每个子目录 = 一个 docker-compose 项目
  pull_timeout: "5m"
  restart_timeout: "30s"

log:
  level: "info"
  format: "json"           # json 或 text

项目目录结构约定

/data/projects/
├── my-app/
│   ├── compose.yaml
│   └── .env
├── blog/
│   ├── compose.yaml
│   └── .env
└── ...

POST /hook 解密后的 project 字段对应子目录名service 对应 compose 文件中定义的 service 名称。

9. Docker 部署

9.1 服务端 Dockerfile

# deploy/Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
  -ldflags="-X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y%m%d)" \
  -o /updater ./cmd/updater

FROM alpine:3.19
RUN apk add --no-cache \
  docker-cli \
  docker-cli-compose \
  ca-certificates \
  tzdata

COPY --from=builder /updater /usr/local/bin/updater
EXPOSE 8080
ENTRYPOINT ["updater"]

9.2 docker-compose.yml

version: "3.8"
services:
  updater:
    build:
      context: ..
      dockerfile: deploy/Dockerfile
    container_name: docker-compose-updater
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /data/projects:/data/projects:ro
      - ./config.yaml:/etc/updater/config.yaml:ro
    environment:
      - TZ=Asia/Shanghai

9.3 安全注意事项

风险 缓解措施
Docker socket 暴露 容器内无 docker exec 等交互能力;仅安装所需 CLI
重放攻击 Nonce 60s 去重 + 时间戳 ±30s,内存缓存
中间人窃听 ECDH 协商 AES 会话密钥加密 payload + HTTPS 传输(双层保险)
恶意请求 ECDSA 签名验证,仅持私钥者能通过
历史请求泄露 每次请求使用临时 ECDH 密钥对,具备前向安全性

10. 无缝重启策略

快速模式:--no-deps(立即返回)

# 进入项目目录
cd /data/projects/my-app

# 拉取指定服务的最新镜像
docker compose pull api

# 仅重启指定服务,不等待健康检查,立即返回
docker compose up -d --no-deps api
  • --no-deps:不重启依赖容器(如数据库)
  • 不传 --wait,容器启动即返回,不等待 healthcheck
  • 服务短暂断连由 Docker 自身的重启策略处理

多副本滚动更新(进阶)

若 compose 中使用 deploy.replicas: 2,可实现真正零宕机:

# 先扩容一个新副本(使用新镜像)
docker compose up -d --no-deps --scale api=2 --no-recreate api
# 等待新副本就绪后,缩容掉老副本
docker compose up -d --no-deps --scale api=1 api

初始版本实现 --no-deps 快速模式,后续可视需求扩展多副本滚动更新。

11. 项目体积估算

组件 体积
Go 二进制(updater ~8MB
Go 二进制(dcu-send ~6MB
alpine + docker-cli ~35MB
服务端镜像总计 ~45MB
Action 镜像总计 ~15MB

12. 实施路线

阶段 内容 产出
P0 项目骨架 Go module、internal/auth 加解密+签名、internal/config、Makefile 可编译,加解密单元测试通过
P1 dcu-send cmd/dcu-send CLI,读取 env → 加密 → 签名 → HTTP POST 可独立使用的命令行工具
P2 updater cmd/updaterinternal/server handler、internal/docker 操作 端到端联调通过
P3 Gitea Action action/action.yml + action/Dockerfile 可在 Gitea workflow 中引用
P4 部署配置 deploy/Dockerfiledeploy/docker-compose.yml、文档 可部署上线
P5 测试+压测 单元测试、并发防重放测试 CI 绿色

13. 待确认

  1. Action 镜像发布:推到 Docker Hub / Gitea 容器 registry?还是用户 make docker-action 自行构建?
  2. 密钥目录结构deploy/keys/ 下放密钥文件,构建时嵌入,这个布局 OK 吗?