一个.pth
模型文件在生产环境中就像一个黑盒。我们知道它的输入和输出,但它的训练过程、依赖环境、甚至是训练数据的确切版本,往往随着时间的推移而变得模糊不清。当需要审计或复现一个模型的行为时,这种信息的缺失是致命的。在真实项目中,我们曾面临一个棘手的挑战:一个线上服务的模型效果出现微小偏差,但没人能确切说出部署的那个模型文件是基于哪个版本的代码、哪个批次的数据、以及哪个Python依赖环境构建的。这导致了长达数天的排查,最终定位到一个间接依赖库的次版本号变更。这次事件暴露了我们 MLOps 流程中的一个巨大漏洞:缺乏对模型产物的可验证性和透明度。
我们的初步构想是,必须将软件供应链安全的理念引入到模型构建流程中。每个模型文件在交付时,必须附带一份“身份证明”,清晰地说明其“血统”。这份证明至少应包含:
- 软件物料清单 (SBOM): 构建环境中所有Python包及其确切版本。
- 数据溯源: 用于训练该模型的数据集的哈希摘要。
- 代码溯源: 生成该模型的源代码的 Git commit hash。
- 防篡改机制: 一种加密签名,确保上述元数据和模型文件本身未被篡改。
技术选型上,容器化是第一步。但我们决定放弃传统的 docker build
,因为它依赖于一个守护进程,这在CI/CD环境中不仅是安全隐患,也使得构建过程的精细化控制变得困难。Buildah
成为了我们的首选,它无守护进程、脚本友好,并且能让我们像操作文件系统一样操作容器镜像的构建过程,这对于注入复杂的元数据和签名流程至关重要。
整个流程的核心将围绕一个多阶段的Containerfile
展开。它将不仅仅是安装依赖和复制文件,而是成为一个自动化的、可审计的“模型认证工厂”。
graph TD subgraph "CI/CD Pipeline on Runner" A(源代码 Checkout) --> B(Buildah: 启动构建流程); B --> C{Stage 1: Builder}; C --> D[安装 Python 依赖]; D --> E[生成 SBOM.json]; C --> F{Stage 2: Trainer}; F --> G[复制源代码与数据]; G --> H(运行 Pytorch 训练脚本 process_data.py); H --> I[生成 model.pth 与 data_hash.txt]; C --> J{Stage 3: Attestor}; J --> K[复制 SBOM, model.pth, data_hash.txt]; K --> L(运行签名脚本 generate_attestation.py); L --> M[生成 model.attestation]; C --> N{Stage 4: Final Image}; N --> O[从 UBI Minimal 基础镜像开始]; O --> P[--from=Trainer 复制 app 代码和 model.pth]; P --> Q[--from=Attestor 复制 SBOM.json 和 model.attestation]; Q --> R(设置 Entrypoint); R --> S[Buildah commit -> 推送至镜像仓库]; end subgraph "Verification & Presentation" T(镜像仓库) --> U{部署环境}; U --> V[拉取镜像并验证签名]; T --> W{Gatsby 站点}; W --> X[拉取 SBOM 和 Attestation 文件进行展示]; end S --> T;
第一步:项目结构与核心代码
我们先定义项目的文件结构。这种结构将代码、数据、构建逻辑和安全产物清晰地分离开来。
.
├── build/ # 构建相关脚本与配置
│ ├── Containerfile
│ ├── build.sh
│ └── signing_key.pem # 用于签名的私钥 (示例,生产中应由Vault等管理)
├── data/ # 训练数据
│ └── feature_set_v1.2.csv
├── site/ # Gatsby 前端展示站点
│ ├── gatsby-config.js
│ ├── package.json
│ └── src/
│ └── pages/
│ └── index.js
└── src/ # Python 应用源代码
├── process_data.py # 数据处理与模型训练脚本
├── generate_attestation.py # 生成签名的脚本
└── requirements.txt # Python 依赖
数据处理与模型训练脚本 (src/process_data.py
)
这个脚本是整个流程的起点。它不仅仅是训练模型,更关键的是,它必须负责计算输入数据的哈希值,并将这个哈希值作为模型溯源的一部分。在生产环境中,这可能是一个复杂的数据预处理管道,但这里的核心是“记录证据”。
# src/process_data.py
import torch
import torch.nn as nn
import pandas as pd
import hashlib
import os
import logging
import json
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- 参数定义 ---
DATA_PATH = os.getenv("DATA_PATH", "/data/feature_set_v1.2.csv")
MODEL_OUTPUT_PATH = os.getenv("MODEL_OUTPUT_PATH", "/app/model.pth")
METADATA_OUTPUT_PATH = os.getenv("METADATA_OUTPUT_PATH", "/app/training_metadata.json")
EPOCHS = 10
LEARNING_RATE = 0.01
# --- 一个简单的神经网络模型 ---
class SimpleNet(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_size, output_size)
def forward(self, x):
out = self.fc1(x)
out = self.relu(out)
out = self.fc2(out)
return out
# --- 核心函数 ---
def calculate_file_hash(filepath: str) -> str:
"""计算文件的 SHA256 哈希值"""
sha256_hash = hashlib.sha256()
try:
with open(filepath, "rb") as f:
# 逐块读取以防文件过大
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
except FileNotFoundError:
logging.error(f"Data file not found at: {filepath}")
raise
except Exception as e:
logging.error(f"Error calculating hash for {filepath}: {e}")
raise
def main():
"""主执行函数:加载数据、训练模型、保存产物"""
logging.info("Starting model training process...")
# 1. 计算数据哈希作为溯源凭证
try:
data_hash = calculate_file_hash(DATA_PATH)
logging.info(f"Data file hash (SHA256): {data_hash}")
except FileNotFoundError:
exit(1)
# 2. 加载和准备数据 (示例)
df = pd.read_csv(DATA_PATH)
# 假设前4列是特征,最后一列是标签
X = torch.tensor(df.iloc[:, :-1].values, dtype=torch.float32)
y = torch.tensor(df.iloc[:, -1].values, dtype=torch.float32).view(-1, 1)
input_size = X.shape[1]
output_size = 1
hidden_size = 10
# 3. 初始化模型、损失函数和优化器
model = SimpleNet(input_size, hidden_size, output_size)
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=LEARNING_RATE)
# 4. 训练模型
logging.info(f"Training for {EPOCHS} epochs...")
for epoch in range(EPOCHS):
# 前向传播
outputs = model(X)
loss = criterion(outputs, y)
# 反向传播和优化
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch+1) % 2 == 0:
logging.info(f'Epoch [{epoch+1}/{EPOCHS}], Loss: {loss.item():.4f}')
# 5. 保存模型
try:
torch.save(model.state_dict(), MODEL_OUTPUT_PATH)
logging.info(f"Model saved to {MODEL_OUTPUT_PATH}")
except Exception as e:
logging.error(f"Failed to save model: {e}")
exit(1)
# 6. 保存元数据,包括数据哈希和git commit(从环境变量获取)
git_commit = os.getenv("GIT_COMMIT", "unknown")
metadata = {
"data_source_path": DATA_PATH,
"data_source_sha256": data_hash,
"git_commit": git_commit,
"model_framework": "PyTorch",
"torch_version": torch.__version__
}
try:
with open(METADATA_OUTPUT_PATH, 'w') as f:
json.dump(metadata, f, indent=4)
logging.info(f"Training metadata saved to {METADATA_OUTPUT_PATH}")
except Exception as e:
logging.error(f"Failed to save metadata: {e}")
exit(1)
logging.info("Training process completed successfully.")
if __name__ == "__main__":
main()
这个脚本的设计考虑了在容器化环境中运行,所有路径和关键信息都通过环境变量传入,增强了其灵活性和可配置性。
第二步:构建可验证的容器镜像 (Containerfile
)
这是整个方案的灵魂。我们使用多阶段构建来隔离不同阶段的关注点,并确保最终镜像是最小化且干净的。
# build/Containerfile
# --- STAGE 1: Builder ---
# 这个阶段负责准备构建环境和生成 SBOM
FROM python:3.9-slim as builder
WORKDIR /build
# 安装 SBOM 生成工具
# cyclonedx-bom 是一个标准的 SBOM 格式生成器
RUN pip install --no-cache-dir cyclonedx-bom
# 复制依赖文件并安装,以便为它们生成 SBOM
COPY ../src/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 生成 SBOM 文件
# --format json 指定输出为 JSON 格式
# -o /build/sbom.json 指定输出文件
# 这里的关键是,SBOM 是基于已安装的包生成的,确保了准确性
RUN cyclonedx-py --format json -o /build/sbom.json
# --- STAGE 2: Trainer ---
# 这个阶段负责运行训练脚本,生成模型和元数据
FROM builder as trainer
WORKDIR /app
# 从 builder 阶段复制已安装的依赖环境
COPY /usr/local/lib/python3.9/site-packages/ /usr/local/lib/python3.9/site-packages/
COPY /usr/local/bin/ /usr/local/bin/
# 复制源代码和数据
COPY ../src/ .
COPY ../data/ /data/
# 运行训练脚本
# GIT_COMMIT 应该在 CI/CD 系统中通过 build-arg 传入
ARG GIT_COMMIT=not-provided
ENV GIT_COMMIT=${GIT_COMMIT}
RUN python process_data.py
# --- STAGE 3: Attestor ---
# 这个阶段负责对产物进行签名,生成 attestation
FROM builder as attestor
WORKDIR /attestation
# 安装签名所需的库
# securesystemslib 是 in-toto 规范的核心库之一,用于处理签名和元数据
RUN pip install --no-cache-dir securesystemslib
# 复制签名脚本和私钥
COPY ../src/generate_attestation.py .
COPY ./signing_key.pem .
# 从 trainer 阶段复制需要签名的产物:模型、训练元数据
COPY /app/model.pth .
COPY /app/training_metadata.json .
# 从 builder 阶段复制 SBOM
COPY /build/sbom.json .
# 运行签名脚本,生成 attestation 文件
# KEY_PATH 和 OUTPUT_PATH 作为环境变量传入,增强脚本通用性
ENV KEY_PATH=./signing_key.pem
ENV OUTPUT_PATH=./model.attestation
RUN python generate_attestation.py
# --- STAGE 4: Final Image ---
# 最终的生产镜像,只包含必要的文件
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest
WORKDIR /app
# 设置非 root 用户,这是生产环境的最佳实践
RUN useradd -u 1001 -r -g 0 -s /sbin/nologin appuser && \
chown -R 1001:0 /app
USER 1001
# 从 trainer 阶段复制应用代码(不包括训练脚本)和模型
# 我们假设有一个 app.py 用于提供模型服务
# COPY --from=trainer /app/app.py .
COPY /app/model.pth .
# 从 attestor 阶段复制 SBOM 和签名文件
# 这些文件对于安全审计和验证至关重要
COPY /attestation/sbom.json .
COPY /attestation/model.attestation .
# 设置入口点(例如一个 FastAPI 服务)
# CMD ["python", "app.py"]
CMD ["/bin/bash", "-c", "echo 'Model and attestations are located in /app'; ls -l /app; sleep infinity"]
第三步:签名与证明 (generate_attestation.py
)
这个脚本是安全性的核心。它使用in-toto
的思想,创建一个包含主体(模型文件)、材料(SBOM,训练元数据)和谓词(构建过程)的结构化声明,并用私钥对其进行签名。
# src/generate_attestation.py
import json
import os
import hashlib
from securesystemslib.signer import Signature, Signer
from securesystemslib.hash import digest_filename
from datetime import datetime, timezone
# --- 配置 ---
KEY_PATH = os.getenv("KEY_PATH")
OUTPUT_PATH = os.getenv("OUTPUT_PATH")
PREDICATE_TYPE = "https://example.com/ml-model/v1"
def create_subject(filepath: str) -> dict:
"""为文件创建一个 in-toto subject"""
try:
digest = digest_filename(filepath, ["sha256"])
return {"name": os.path.basename(filepath), "digest": digest}
except FileNotFoundError:
print(f"Error: Subject file not found at {filepath}")
raise
except Exception as e:
print(f"Error creating subject for {filepath}: {e}")
raise
def main():
if not KEY_PATH or not OUTPUT_PATH:
print("Error: KEY_PATH and OUTPUT_PATH environment variables must be set.")
exit(1)
print("Generating attestation...")
# 1. 加载签名者(私钥)
try:
signer: Signer = Signer.from_priv_key_uri(f"file:{KEY_PATH}")
except Exception as e:
print(f"Error loading private key from {KEY_PATH}: {e}")
exit(1)
# 2. 定义 attestation 的主体 (Subject),即我们正在证明的产物
subjects = [create_subject("model.pth")]
# 3. 定义谓词 (Predicate),即我们对主体的声明内容
# 这里我们把 SBOM 和训练元数据作为证据包含进来
with open("sbom.json", "r") as f:
sbom_content = json.load(f)
with open("training_metadata.json", "r") as f:
training_metadata_content = json.load(f)
predicate = {
"builder": {"id": "buildah-ci-pipeline"},
"buildStartedOn": datetime.now(timezone.utc).isoformat(),
"materials": [
{
"uri": f"sha256:{digest_filename('sbom.json')['sha256']}",
"digest": {"sha256": digest_filename('sbom.json')['sha256']}
},
{
"uri": f"sha256:{digest_filename('training_metadata.json')['sha256']}",
"digest": {"sha256": digest_filename('training_metadata.json')['sha256']}
}
],
"sbom": sbom_content,
"trainingMetadata": training_metadata_content
}
# 4. 构建完整的 in-toto statement
statement = {
"_type": "https://in-toto.io/Statement/v1",
"subject": subjects,
"predicateType": PREDICATE_TYPE,
"predicate": predicate
}
# 5. 签名 statement
signature: Signature = signer.sign(json.dumps(statement, separators=(",", ":")).encode("utf-8"))
# 6. 组合成 DSSE (Dead Simple Signing Envelope) 格式的 attestation
attestation = {
"payload": json.dumps(statement).encode("utf-8").decode('latin1').encode('utf-8').decode('unicode_escape').encode('latin1').decode('utf-8'), # A bit of a hack for proper JSON string escaping
"payloadType": "application/vnd.in-toto+json",
"signatures": [signature.to_dict()]
}
# 7. 保存到文件
try:
with open(OUTPUT_PATH, "w") as f:
json.dump(attestation, f, indent=2)
print(f"Attestation successfully saved to {OUTPUT_PATH}")
except Exception as e:
print(f"Error saving attestation to {OUTPUT_PATH}: {e}")
exit(1)
if __name__ == "__main__":
main()
注意:json.dumps
的编码处理部分是为了确保最终的payload是符合规范的JSON字符串,这是一个在实践中常见的坑。
第四步:自动化构建脚本 (build.sh
)
这个脚本将所有 Buildah
命令串联起来,模拟一个CI/CD的执行过程。
#!/bin/bash
# 确保在脚本目录执行
cd "$(dirname "$0")"
set -e # 任何命令失败则立即退出
# --- 定义变量 ---
IMAGE_NAME="pytorch-secure-app"
IMAGE_TAG=$(date +%Y%m%d)-$(git rev-parse --short HEAD)
REGISTRY="quay.io/my-repo" # 替换为你的镜像仓库
FINAL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
# --- 生成临时的签名密钥 (仅用于演示) ---
if [ ! -f "signing_key.pem" ]; then
echo "Generating temporary signing key..."
openssl genpkey -algorithm ed25519 -outform PEM -out signing_key.pem
fi
# --- 执行 Buildah 构建 ---
echo "Starting buildah build for ${FINAL_IMAGE}..."
# 获取 Git Commit Hash
GIT_COMMIT_HASH=$(git rev-parse HEAD)
if [ -z "$GIT_COMMIT_HASH" ]; then
echo "Warning: Not a git repository. Setting GIT_COMMIT to 'unknown'."
GIT_COMMIT_HASH="unknown"
fi
# 使用 buildah bud (build-using-dockerfile) 命令
# --tag: 指定最终的镜像名称
# --file: 指定 Containerfile 的路径
# --build-arg: 向 Containerfile 传递参数
buildah bud \
--tag "${FINAL_IMAGE}" \
--file ./Containerfile \
--build-arg GIT_COMMIT="${GIT_COMMIT_HASH}" \
../
echo "Build complete. Image created: ${FINAL_IMAGE}"
# --- (可选) 推送镜像 ---
# 需要先登录到你的镜像仓库,例如: buildah login quay.io
# echo "Pushing image to registry..."
# buildah push "${FINAL_IMAGE}"
# echo "Push complete."
# --- 清理与验证 ---
echo "Verifying content of the built image..."
# 挂载镜像的文件系统到一个临时目录
MOUNT_POINT=$(buildah mount "${FINAL_IMAGE}")
# 检查关键文件是否存在
if [ -f "${MOUNT_POINT}/app/model.pth" ] && \
[ -f "${MOUNT_POINT}/app/sbom.json" ] && \
[ -f "${MOUNT_POINT}/app/model.attestation" ]; then
echo "Verification successful: model, SBOM, and attestation found."
else
echo "Verification failed: Missing critical files in the final image."
buildah unmount "${FINAL_IMAGE}"
exit 1
fi
echo "Content of sbom.json:"
cat "${MOUNT_POINT}/app/sbom.json" | head -n 10
echo "..."
echo "Content of model.attestation:"
cat "${MOUNT_POINT}/app/model.attestation"
# 卸载文件系统
buildah unmount "${FINAL_IMAGE}"
echo "Cleanup complete."
第五步:结果展示 (Gatsby)
虽然Gatsby不是这个流程的核心,但它提供了一个直观的方式来消费和展示我们的安全产物。一个简单的页面组件就可以解析并呈现sbom.json
和model.attestation
。
// site/src/pages/index.js
import React, { useState, useEffect } from 'react';
// 假设这些文件是从 CI 产物中获取并放置在 static 目录
const SBOM_URL = '/sbom.json';
const ATTESTATION_URL = '/model.attestation';
const IndexPage = () => {
const [sbom, setSbom] = useState(null);
const [attestation, setAttestation] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
const fetchData = async () => {
try {
const sbomRes = await fetch(SBOM_URL);
const attestationRes = await fetch(ATTESTATION_URL);
if (!sbomRes.ok || !attestationRes.ok) {
throw new Error('Failed to fetch security artifacts');
}
setSbom(await sbomRes.json());
setAttestation(await attestationRes.json());
} catch (e) {
setError(e.message);
}
};
fetchData();
}, []);
if (error) return <p>Error loading data: {error}</p>;
if (!sbom || !attestation) return <p>Loading artifacts...</p>;
// 解析 attestation 中的 payload
const statement = JSON.parse(attestation.payload);
return (
<main style={{ fontFamily: 'sans-serif', padding: '20px' }}>
<h1>PyTorch Model Supply Chain Verification</h1>
<section>
<h2>Attestation Details</h2>
<pre style={{ background: '#f4f4f4', padding: '10px', overflowX: 'auto' }}>
<code>
Predicate Type: {statement.predicateType}<br />
Builder ID: {statement.predicate.builder.id}<br />
Data Source Hash (SHA256): {statement.predicate.trainingMetadata.data_source_sha256}<br />
Git Commit: {statement.predicate.trainingMetadata.git_commit}<br/>
Signature (Key ID): {attestation.signatures[0].keyid.substring(0, 16)}...
</code>
</pre>
</section>
<section>
<h2>Software Bill of Materials (SBOM) - Top 5 Dependencies</h2>
<table border="1" cellPadding="5" style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr>
<th>Component</th>
<th>Version</th>
<th>BOM-Ref</th>
</tr>
</thead>
<tbody>
{sbom.components.slice(0, 5).map(comp => (
<tr key={comp['bom-ref']}>
<td>{comp.name}</td>
<td>{comp.version}</td>
<td>{comp['bom-ref']}</td>
</tr>
))}
</tbody>
</table>
</section>
</main>
);
};
export default IndexPage;
这个方案的当前实现并非终点。密钥管理是一个显著的弱点;在生产环境中,私钥绝不能存在于代码仓库中,而应通过 HashiCorp Vault
或云厂商的 KMS 服务在构建时动态注入。其次,attestation的验证流程需要集成到Kubernetes的准入控制器(Admission Controller)中,例如使用Kyverno
或Gatekeeper
,在部署前强制校验镜像的签名和attestation内容,不符合策略的镜像将被拒绝部署。最后,整个流程可以进一步与Tekton
或Argo Workflows
等云原生CI/CD工具链深度整合,实现完全自动化的、基于事件触发的模型构建、认证与部署闭环。