接手一个项目,发现代码到处复制粘贴——同样的工具函数在三个 repo 里各有一份,改个 bug 要手动同步到所有地方。团队之前想用 Monorepo 统一管理,但迁移成本太高;发布到私有 PyPI,又嫌流程太重。

最后选了最快的方案:Git SSH 直接依赖。就像 Swift Package Manager 那样,pip 直接从 GitHub 拉代码。但在 CircleCI 上跑 CI/CD 时踩了两个坑:测试跑不过、Docker build 失败。这篇记录解决过程。

问题全景

项目有多个 Python repo(platform-apidata-pipelinenotification-service 等),之前把通用代码(比如一个叫 common-utils 的库)抽成独立仓库,用 Git SSH 依赖:

# pyproject.toml
dependencies = [
    "common-utils @ git+ssh://git@github.com/myorg/common-utils.git@v1.0.0",
]

本地开发没问题,但推到 CircleCI 后炸了:

  1. Test Job 装不上依赖 → SSH 没权限访问私有仓库
  2. Docker build 装不上依赖 → Docker 容器访问不到宿主机的 SSH agent

核心矛盾:都需要访问私有 Git 仓库,但遇到的技术卡点不同。

问题 1:Test Job 装不上依赖

现象

# .circleci/config.yml
jobs:
  lint-and-test:
    docker:
      - image: cimg/python:3.11
    steps:
      - run: pip install -e .  # ❌ 失败

报错 Permission denied (publickey),pip 试图 clone common-utils 仓库时被拒绝。

原因

CircleCI 默认只配了当前仓库的 SSH key(用于 checkout),这个 key 访问不了依赖仓库。pip 试图 clone 依赖时,找不到有权限的 key,直接挂掉。后续依赖也装不上。

Pull dependencies failed

解法

核心思路:给 CircleCI 配一个能访问所有依赖仓库的 SSH key(Deploy Key)。

SSH Keys Options

两种配置方式:

Option 1(手动配置单对 key):复用现有的 SSH key,直接把以前团队生成的 Private Key A 添加到 CircleCI,Public Key A 已经在 GitHub 和 Backend 的 Deploy Keys 里。这样 CircleCI 可以用这个 key 访问所有依赖仓库。前提是你手上有这个私钥。

Option 2(手动配置多对 key):保留 CircleCI 自动生成的 Key A(用于 checkout 当前仓库),另外生成一对新的 Key B,把 Public Key B 加到所有依赖仓库的 Deploy Keys 里,Private Key B 加到 CircleCI。这样 CircleCI 同时有两个 key,能访问 Backend 和依赖仓库。

我们选了 Option 2,原因是团队没有保留以前 Key A 的私钥证书(Key A 是以前团队配置的,只有公钥在 GitHub 上,私钥丢失了)。所以只能生成新的 Key B,让 CircleCI 同时使用自动生成的 key(checkout 用)和 Key B(访问依赖用)。

配置步骤(Option 2):

  1. 生成一对专用 SSH key(Key B)
  2. 把公钥 B 加到所有需要访问的 GitHub 仓库的 Deploy Keys 里
  3. 把私钥 B 加到 CircleCI 的 SSH Keys(记下 fingerprint)
  4. config.yml 里加 add_ssh_keys

改完长这样:

jobs:
  lint-and-test:
    docker:
      - image: cimg/python:3.11
    steps:
      - checkout  # 用默认 key
      
      # ✅ 加载 Deploy Key(访问依赖仓库)
      - add_ssh_keys:
          fingerprints:
            - "SHA256:abc123..."
      
      - run: pip install -e .  # ✅ 能访问所有依赖了

SSH 会轮询所有 key 直到找到能认证成功的,所以多个 key 不冲突。

问题 2:Docker build 装不上依赖

现象

# .circleci/config.yml
jobs:
  build-and-push:
    docker:
      - image: cimg/base:stable
    steps:
      - setup_remote_docker
      - run: docker build -t platform-api .  # ❌ 失败
# Dockerfile
RUN pip install -e .  # ❌ 访问不了私有仓库

原因

即使 CircleCI 配了 SSH key,Docker build 容器也访问不到宿主机的 SSH agent。setup_remote_docker 是在远程机器上跑 Docker daemon,CircleCI 容器里的 SSH agent 隔着网络传不过去,需要 SSH 转发机制。

Docker build failed

两种 Executor 对比:

对比项Docker ExecutorMachine Executor
运行环境容器内虚拟机内
Docker 位置远程 Docker Daemon本地 Docker Daemon
SSH Agent 访问❌ 传不过去✅ 能转发
启动速度快(~5s)慢(~10-15s)
适用场景普通构建需要 SSH 转发

Machine Executor 的优势:Docker daemon 和 SSH agent 在同一台机器,可以通过 Unix socket 把 SSH agent 转发给 build 容器。

SSH 转发原理

Docker build 容器需要访问 GitHub 私有仓库,但不能直接把 SSH 私钥复制进容器(会泄露到镜像层)。通过 SSH 转发,容器临时使用宿主机的 SSH agent 认证,build 完自动断开。

sequenceDiagram
    participant Host as 宿主机(CircleCI VM)
    participant Agent as SSH Agent
    participant Docker as Docker Daemon
    participant Container as Build 容器
    participant GitHub as GitHub
    
    Note over Host: 1. add_ssh_keys 加载私钥
    Host->>Agent: 启动 SSH Agent
    
    Note over Host,Docker: 2. docker build --ssh default
    Host->>Docker: 转发 $SSH_AUTH_SOCK
    
    Note over Docker,Container: 3. RUN --mount=type=ssh
    Docker->>Container: 挂载 SSH socket(临时)
    
    Note over Container: 4. pip install git+ssh://
    Container->>Agent: 用 SSH agent 认证
    Agent->>GitHub: SSH 认证
    GitHub-->>Agent: ✅ 认证成功
    Agent-->>Container: 返回认证结果
    Container->>GitHub: Clone 代码
    
    Note over Container: 5. Build 完成
    Docker->>Container: 卸载 SSH socket

安全保证:私钥留在宿主机 SSH agent,容器只临时访问 socket,build 完自动断开,最终镜像不含 SSH key。

解法

核心思路:切换到 Machine Executor 让 SSH 转发成为可能,配置 BuildKit 启用 SSH mount。

Docker build solution

步骤 1:切换到 Machine Executor

build-and-push:
  # ✅ machine executor(本地 Docker)
  machine:
    image: ubuntu-2204:2024.01.1

步骤 2:配置 SSH key 并清理 agent

steps:
  - checkout
  - add_ssh_keys:
      fingerprints:
        - "SHA256:abc123..."
  
  # ✅ 关键:清理 SSH agent
  - run:
      name: Setup SSH agent
      command: |
        ssh-add -D  # 清掉所有 key
        ssh-add ~/.ssh/id_*  # 重新加 Deploy Key
        ssh-add -l  # 验证

为什么要清理?Docker build 时,BuildKit 的 SSH mount 不会轮询多个 key,只用第一个。如果第一个是错的(比如用于 checkout 的仓库 key),认证会失败,不会尝试其他 key。

清理目的:确保 Deploy Key 是第一个(或唯一)key,保证 Docker build 用对 key 认证。

步骤 3:用 BuildKit + SSH 转发

- run:
    name: Build Docker image
    command: |
      DOCKER_BUILDKIT=1 docker build \
        --ssh default="$SSH_AUTH_SOCK" \
        -t platform-api .
  • DOCKER_BUILDKIT=1:启用 BuildKit
  • --ssh default:转发 SSH agent(只用第一个 key)

步骤 4:改 Dockerfile

# 启用 BuildKit
# syntax=docker/dockerfile:1.4
 
FROM python:3.11-slim
 
# 装 git 和 openssh-client
RUN apt-get update && apt-get install -y git openssh-client
 
# 用 SSH mount 装依赖
RUN --mount=type=ssh \
    mkdir -p ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    export SSH_AUTH_SOCK=/run/buildkit/ssh_agent.0 && \
    pip install -e .

注意:BuildKit 把 SSH socket 挂到 /run/buildkit/ssh_agent.0,需要手动设 SSH_AUTH_SOCK 环境变量。

总结

核心改动

问题 1 解法:lint-and-test job 里加 add_ssh_keys,加载能访问依赖仓库的 Deploy Key

问题 2 解法:

  • 切到 machine executor(支持 SSH 转发)
  • add_ssh_keys 加载 Deploy Key
  • 清理 SSH agent(确保 Deploy Key 排第一,因为 Docker build 不轮询多个 key)
  • 启用 DOCKER_BUILDKIT=1 docker build --ssh default
  • Dockerfile 用 RUN --mount=type=ssh

配置改动清单

CircleCI config.yml:

Job改动
lint-and-testadd_ssh_keys(加载 Deploy Key)
build-and-push切到 machine executor
add_ssh_keys(加载 Deploy Key)
清理 SSH agent(Docker build 不轮询多个 key)
docker build 加 --ssh default

Dockerfile:

改动说明
# syntax=docker/dockerfile:1.4启用 BuildKit
git + openssh-client支持 Git SSH
RUN --mount=type=ssh挂载 SSH socket
export SSH_AUTH_SOCK=...指定 socket 路径

pyproject.toml:

[project]
dependencies = [
    "common-utils @ git+ssh://git@github.com/myorg/common-utils.git@v1.0.0",
]
 
[tool.hatch.metadata]
allow-direct-references = true

Monorepo 方案介绍

其实对于我们这种规模的项目, 不应该这么早拆这么多个仓库,导致维护成本很高。:Monorepo, 才是一种比较好的方案。把所有项目(包括共享代码)放到一个仓库,用 workspace 功能管理内部包。

核心思路

没有”发布”概念,所有代码在一个仓库,直接通过路径引用。改共享代码立刻影响所有消费方。

结构示例

graph TB
    Root(platform-monorepo)
    
    Root --> Frontend(💻 apps/frontend/)
    Root --> Backend(🔧 apps/backend/)
    Root --> Libs(📦 packages/)
    
    Frontend --> F1(web-app)
    Frontend --> F2(admin-dashboard)
    
    Backend --> B1(platform-api)
    Backend --> B2(notification-service)
    Backend --> B3(data-pipeline)
    
    Libs --> L1(common-utils)
    Libs --> L2(ui-components)
    
    B1 -.uses.-> L1
    B2 -.uses.-> L1
    B3 -.uses.-> L1
    
    F1 -.uses.-> L2
    F2 -.uses.-> L2
    
    style Root fill:#e1f5ff,stroke:#333,stroke-width:3px
    style Frontend fill:#e8f5e9,stroke:#333,stroke-width:2px
    style Backend fill:#fff4e1,stroke:#333,stroke-width:2px
    style Libs fill:#ffe8f0,stroke:#333,stroke-width:2px
    
    style L1 fill:#ffd700
    style L2 fill:#ffd700

目录结构

platform-monorepo/
├── apps/
│   ├── frontend/              # 前端应用
│   │   ├── web-app/          # 用户端 Web 应用
│   │   └── admin-dashboard/  # 管理后台
│   │
│   └── backend/               # 后端服务
│       ├── platform-api/      # 主 API 服务
│       ├── notification-service/  # 通知服务
│       └── data-pipeline/     # 数据处理任务
│
├── packages/                  # 共享库
│   ├── common-utils/         # Python 通用工具
│   └── ui-components/        # React UI 组件库
│
├── pyproject.toml            # Python workspace 配置
└── pnpm-workspace.yaml       # 前端 workspace 配置

优势:

  • common-utils,所有后端服务立刻生效
  • ui-components,所有前端应用立刻生效
  • 跨项目修改一次提交完成
  • IDE 可以全局重构

配置示例

# 根 pyproject.toml(Python workspace)
[tool.uv.workspace]
members = ["packages/*", "apps/backend/*"]
 
# apps/backend/platform-api/pyproject.toml
[project]
dependencies = ["common-utils"]  # 内部包,无需版本号
# pnpm-workspace.yaml(前端 workspace)
packages:
  - 'apps/frontend/*'
  - 'packages/ui-components'

为什么不用 Monorepo

其实这个项目更适合 Monorepo(把所有 repo 合到一起,代码直接引用):

  • 改共享代码,所有消费方立刻生效
  • 一个 commit 搞定跨项目修改
  • IDE 全局搜索、跳转

但切换成本太高:

  • 需要重构 CI/CD(学 uv workspace、nx 等工具做增量构建)
  • 权限管理变复杂(GitHub 只能按整个仓库设权限)
  • git clone 变慢(仓库变大)

当时团队资源紧,选了最快的 Git SSH 方案。虽然装依赖慢点(每次 git clone),但配置简单、零成本,能快速解决代码复制粘贴的痛点。