接手一个项目,发现代码到处复制粘贴——同样的工具函数在三个 repo 里各有一份,改个 bug 要手动同步到所有地方。团队之前想用 Monorepo 统一管理,但迁移成本太高;发布到私有 PyPI,又嫌流程太重。
最后选了最快的方案:Git SSH 直接依赖。就像 Swift Package Manager 那样,pip 直接从 GitHub 拉代码。但在 CircleCI 上跑 CI/CD 时踩了两个坑:测试跑不过、Docker build 失败。这篇记录解决过程。
问题全景
项目有多个 Python repo(platform-api、data-pipeline、notification-service 等),之前把通用代码(比如一个叫 common-utils 的库)抽成独立仓库,用 Git SSH 依赖:
# pyproject.toml
dependencies = [
"common-utils @ git+ssh://git@github.com/myorg/common-utils.git@v1.0.0",
]本地开发没问题,但推到 CircleCI 后炸了:
- Test Job 装不上依赖 → SSH 没权限访问私有仓库
- 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,直接挂掉。后续依赖也装不上。

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

两种配置方式:
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):
- 生成一对专用 SSH key(Key B)
- 把公钥 B 加到所有需要访问的 GitHub 仓库的 Deploy Keys 里
- 把私钥 B 加到 CircleCI 的 SSH Keys(记下 fingerprint)
- 在
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 转发机制。

两种 Executor 对比:
| 对比项 | Docker Executor | Machine 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。

步骤 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 解法:
- 切到
machineexecutor(支持 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-test | 加 add_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 = trueMonorepo 方案介绍
其实对于我们这种规模的项目, 不应该这么早拆这么多个仓库,导致维护成本很高。: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),但配置简单、零成本,能快速解决代码复制粘贴的痛点。