某公司用Docker+Jenkins搭CI/CD后部署效率提升10倍,但镜像安全、权限管理和数据持久化这些问题你必须提前了解
先给你讲个真实的场景。
有一家做电商系统的公司,以前每周发布一次版本,光是部署就要折腾半天。部署工程师老张每天早上先登录服务器,检查服务状态,然后手动拉代码、编译、打包,再一个个重启服务。有一次周五晚上要紧急上线,老张在服务器前折腾到凌晨两点,最后发现是某个配置文件写错了。这种日子他过了三年。
后来他们引入了 Docker 加 Jenkins 的 CI/CD 方案,情况完全不一样了。代码提交到 Git 仓库,Jenkins 自动触发构建,Docker 镜像自动打包推送,然后一键部署到测试环境和生产环境。现在老张每天早上到办公室,喝口咖啡,看看 Jenkins 的构建状态,下午三点准时下班。部署频率从每周一次变成了每天三次,而且出问题的时候,一键回滚只要十秒钟。
他们说部署效率提升了10倍,这不是夸张,是真真切切的变化。
但事情没那么简单。镜像安全、权限管理、数据持久化,这三个问题如果你不在搭建之前就想清楚,后续会给你带来一堆麻烦。
先聊聊 Jenkins 和 Docker 是怎么配合的
CI/CD 的核心思路是”自动化”。你只需要关注一件事:代码写好了提交上去。剩下的构建、测试、打包、部署,全部交给机器来完成。
Jenkins 是那个”调度员”,它负责监听代码仓库的变化,一旦检测到新的提交,就自动触发一系列流水线任务。Docker 是那个”打包工人”,它负责把代码和依赖一起打包成一个标准化的镜像,然后在任何地方都能跑。
一个典型的流程是这样的:
代码提交 → Jenkins 监听 → 拉取代码 → 构建 → 运行测试 → 打包镜像 → 推送镜像 → 部署到服务器
我们用一个实际的项目来展示。假设你们公司有一个 Spring Boot 的电商服务,代码结构是这样的:
ecommerce-service/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/
│ │ └── resources/
│ └── test/
├── Dockerfile
├── Jenkinsfile
└── docker-compose.yml
Jenkinsfile 是流水线的神
Jenkinsfile 是整个 CI/CD 的核心配置文件,它定义了从代码提交到最终部署的每一个步骤。我们用声明式语法来写一个完整的流水线:
pipeline {
agent any
environment {
DOCKER_REGISTRY = 'registry.example.com'
IMAGE_NAME = 'ecommerce-service'
IMAGE_TAG = "${BUILD_NUMBER}"
}
stages {
stage('拉取代码') {
steps {
checkout scm
}
}
stage('编译打包') {
steps {
sh 'mvn clean package -DskipTests'
}
}
stage('运行单元测试') {
steps {
sh 'mvn test'
}
post {
always {
junit 'target/surefire-reports/*.xml'
}
}
}
stage('构建 Docker 镜像') {
steps {
script {
docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}")
}
}
}
stage('推送镜像到仓库') {
steps {
script {
def image = docker.image("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}")
withCredentials([usernamePassword(
credentialsId: 'docker-registry-credentials',
usernameVariable: 'DOCKER_USER',
passwordVariable: 'DOCKER_PASS'
)]) {
image.push()
}
}
}
}
stage('部署到测试环境') {
steps {
sh '''
ssh deploy-user@staging-server "docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
ssh deploy-user@staging-server "docker-compose down && docker-compose up -d"
'''
}
}
stage('部署到生产环境') {
when {
branch 'main'
}
steps {
input '确认部署到生产环境?'
sh '''
ssh deploy-user@prod-server "docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
ssh deploy-user@prod-server "docker-compose down && docker-compose up -d"
'''
}
}
}
post {
success {
echo '构建和部署全部成功!'
}
failure {
echo '出问题了,赶紧查看日志!'
mail to: 'dev-team@example.com',
subject: "构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "请访问 ${env.BUILD_URL} 查看详情"
}
}
}
这个流水线看起来复杂,但拆开来看其实很直观。每一阶段只做一件事,出了问题也能快速定位是哪一步出的错。
Dockerfile 是镜像的灵魂
Dockerfile 定义了你的应用如何被打包成镜像。一个好的 Dockerfile 不仅能让镜像更小、更快,还能提升安全性。
# 使用轻量级的基础镜像,减少攻击面
FROM eclipse-temurin:17-jre-alpine
# 设置环境变量
ENV APP_HOME=/app
ENV TZ=Asia/Shanghai
# 创建非 root 用户,避免容器以 root 运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 设置工作目录
WORKDIR ${APP_HOME}
# 先复制依赖文件,利用 Docker 层缓存
COPY --chown=appuser:appgroup pom.xml ./
RUN mvn dependency:go-offline -B
# 复制应用代码
COPY --chown=appuser:appgroup src ./src
COPY --chown=appuser:appgroup target/*.jar ./app.jar
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
# 启动应用
ENTRYPOINT ["java", "-jar", "app.jar"]
注意几个关键点:我们用 jre 而不是 jdk 基础镜像,因为生产环境不需要编译工具;我们用 Alpine 版本来减小镜像体积;我们用非 root 用户运行应用,这是安全最佳实践;我们利用了 Docker 的层缓存机制,先复制 pom.xml 再复制整个源码,这样依赖没变的时候不需要重新下载。
docker-compose 让多服务部署变得简单
电商系统通常不止一个服务,可能有用户服务、订单服务、支付服务等等。docker-compose.yml 帮我们一键启动所有服务:
version: '3.8'
services:
ecommerce-service:
image: registry.example.com/ecommerce-service:latest
container_name: ecommerce-service
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_HOST=postgres
- REDIS_HOST=redis
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
postgres:
image: postgres:15-alpine
container_name: postgres
environment:
- POSTGRES_DB=ecommerce
- POSTGRES_USER=dbuser
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dbuser -d ecommerce"]
interval: 10s
timeout: 5s
retries: 5
secrets:
- db_password
redis:
image: redis:7-alpine
container_name: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
networks:
- app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
redis-data:
networks:
app-network:
driver: bridge
secrets:
db_password:
file: ./secrets/db_password.txt
这里用到了 Docker 的 secrets 机制来管理敏感信息,避免了密码硬编码在配置文件里。volumes 用来做数据持久化,后面会详细讲这个问题。
镜像安全:别让你的容器变成攻击者的跳板
容器安全是个大问题。我见过太多公司,把 Docker 当玩具用,镜像里跑着 root 权限,基础镜像用的是十年前的旧版本,漏洞扫都不扫就直接上线了。
漏洞扫描是必须做的
Jenkins 流水线里加入镜像扫描步骤:
stage('扫描镜像漏洞') {
steps {
script {
// 使用 Trivy 进行漏洞扫描
sh '''
trivy image --severity HIGH,CRITICAL \
--format table \
registry.example.com/ecommerce-service:${IMAGE_TAG}
'''
}
}
post {
failure {
error '发现高危或严重漏洞,禁止部署!'
}
}
}
Trivy 是目前最流行的容器漏洞扫描工具之一,它扫描速度快、规则库更新及时,能识别出基础镜像和依赖包中的已知漏洞。
镜像签名确保来源可信
光有漏洞扫描还不够,你还得确保你部署的镜像是”干净的”,没有被篡改过。Docker 内容信任(Docker Content Trust)和 Notary 就是干这个的:
# 启用内容信任
export DOCKER_CONTENT_TRUST=1
# 推送镜像时会自动签名
docker push registry.example.com/ecommerce-service:1.0.0
# 拉取时会验证签名
docker pull registry.example.com/ecommerce-service:1.0.0
在 Kubernetes 集群中,你可以配置 admission controller 来拒绝未签名的镜像:
apiVersion: policies.kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-docker-trust
spec:
validationFailureAction: Enforce
rules:
- name: require-docker-content-trust
match:
resources:
kinds: ["Pod"]
verifyImages:
- imageReferences: ["registry.example.com/*"]
proceedExceptions: []
key: ""
mirror: ""
mirrorRegistry: ""
rejectedAction: Enforce
最小化镜像减少攻击面
基础镜像越小,能藏的安全漏洞就越少。Alpine 是一个好选择,但要注意它用的是 musl libc 而不是 glibc,有些 Java 应用可能会有兼容性问题。如果你的应用对性能要求很高,也可以考虑 Distroless 镜像:
# Google 的 Distroless 镜像,只包含应用运行所需的文件
FROM gcr.io/distroless/java17-debian11
COPY --from=build /app/target/*.jar /app.jar
# 非 root 用户运行
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app.jar"]
Distroless 镜像里连 shell 都没有,攻击者即使进入了容器,也没有任何工具可用。这对于安全敏感的应用来说是非常好的选择。
定期重建镜像
不要复用旧镜像。每次构建都用最新的基礎镜像,确保你已经应用了所有的安全补丁:
# 每次构建前确保使用最新的基礎镜像
FROM eclipse-temurin:17-jre-alpine AS base
# 构建阶段
FROM base AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
# 运行阶段
FROM base
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
这种多阶段构建的方式,编译工具不会出现在最终镜像中,进一步减小了攻击面。
权限管理:Jenkins 的权限是最容易踩坑的地方
Jenkins 的权限模型比较复杂,有 Global、Folder、Job 三个层级。很多人一开始随便给权限,后来发现整个系统乱成一锅粥,想收都收不回来。
推荐采用项目矩阵授权策略
在 Jenkins 的 Manage Jenkins → Configure Global Security 中,选择”项目矩阵授权策略”:
管理员(你):Full control
开发组:Read + Work(可以查看和触发构建)
测试组:Read(只能查看,不能触发构建)
运维组:Read + Build + Cancel(可以查看和触发构建,也能取消)
只读用户:Read(只能查看构建结果)
使用 Credentials 管理敏感信息
不要把密码、密钥硬编码在 Jenkinsfile 里。Jenkins 的 Credentials 插件就是专门做这件事的:
// 在 Jenkinsfile 中使用凭证
withCredentials([
usernamePassword(
credentialsId: 'docker-registry',
usernameVariable: 'DOCKER_USER',
passwordVariable: 'DOCKER_PASS'
),
string(
credentialsId: 'ssh-deploy-key',
variable: 'SSH_KEY'
),
secretText(
credentialsId: 'db-password',
variable: 'DB_PASSWORD'
)
]) {
// 这些环境变量只能在这个 withCredentials 块内使用
sh "docker login -u ${DOCKER_USER} -p ${DOCKER_PASS} registry.example.com"
sh "ssh -i ${SSH_KEY} deploy-user@prod-server 'docker-compose up -d'"
}
在 Jenkins 界面上添加凭证:Manage Jenkins → Credentials → System → Global credentials → Add Credentials。支持多种类型:Username with password、SSH Username with private key、Secret file、Secret text 等。
限定每个 Job 能访问的权限
如果你的 Jenkins 上有几十上百个 Job,每个 Job 应该只拥有它需要的最小权限。可以通过 Folder 来组织 Job,不同 Folder 设置不同的权限:
Jenkins 根目录
├── frontend/ → 前端团队权限
│ ├── web-app/
│ └── admin-panel/
├── backend/ → 后端团队权限
│ ├── user-service/
│ ├── order-service/
│ └── payment-service/
├── infrastructure/ → 运维团队权限
│ ├── docker-compose/
│ └── kubernetes/
└── shared/ → 公共权限
├── notifications/
└── monitoring/
每个 Folder 可以设置独立的权限矩阵,这样即使前端开发人员访问了后端 Folder,也看不到不该看的内容。
Pipeline 权限控制
在较新的 Jenkins 版本中,Pipeline 本身的权限也可以精细化控制:
pipeline {
agent any
options {
// 限制并发构建数量
buildDiscarder(logRotator(numToKeepStr: '10'))
// 防止并行触发同一分支的构建
disableConcurrentBuilds()
}
parameters {
choice(name: 'ENVIRONMENT', choices: ['staging', 'production'], description: '选择部署环境')
string(name: 'VERSION', defaultValue: 'latest', description: '指定部署版本')
}
stages {
stage('构建') {
steps {
// 所有用户都可以执行
sh 'mvn clean package'
}
}
stage('部署生产环境') {
when {
anyOf {
parameter('ENVIRONMENT') == 'production'
branch 'main'
}
}
steps {
// 生产部署需要额外权限
script {
if (currentBuild.rawBuild.getPermission(jenkins.model.Jenkins.READ).check(
Jenkins.get().getAuthentication(),
Jenkins.get().getACL().getImpersonation()
)) {
// 只有管理员能部署到生产环境
sh 'deploy-to-production.sh'
} else {
error '你没有权限部署到生产环境'
}
}
}
}
}
}
审计日志非常重要
权限管理不是一劳永逸的事情,你需要定期审查谁在做什么。Jenkins 有审计日志插件,可以记录所有的操作:
2024-01-15 09:23:45 | user: zhangsan | action: BUILD | job: backend/order-service #42 | result: SUCCESS
2024-01-15 09:30:12 | user: lisi | action: CONFIGURE | job: backend/order-service | detail: 修改了 Jenkinsfile
2024-01-15 10:15:33 | user: admin | action: DELETE | job: legacy/old-service | result: SUCCESS
有了这些日志,你可以清楚地知道谁在什么时候对什么做了什么操作,出了问题能迅速定位责任人。
数据持久化:容器一删,数据就没了
这是很多初学者的盲区。Docker 容器是无状态的,容器一旦被删除,里面的数据就全部消失了。但你的数据库、文件上传、日志这些东西,总不能也跟着容器一起消失吧?
三种持久化方式
方式一:Volumes(卷)
这是官方推荐的方式,Docker 管理卷的生命周期,数据安全,性能好:
volumes:
postgres-data:
driver: local
redis-data:
driver: local
# 查看所有卷
docker volume ls
# 查看卷的详细信息
docker volume inspect postgres-data
# 挂载卷到容器
docker run -d \
--name postgres \
-v postgres-data:/var/lib/postgresql/data \
postgres:15-alpine
在 docker-compose.yml 中使用:
services:
postgres:
image: postgres:15-alpine
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
environment:
- POSTGRES_DB=ecommerce
- POSTGRES_USER=dbuser
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
volumes:
postgres-data:
name: ecommerce-postgres-data # 给卷起一个有意义的名字
方式二:Bind Mounts(绑定挂载)
将宿主机的目录直接挂载到容器中,适合需要经常编辑配置文件的场景:
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro # 只读挂载配置文件
- ./html:/usr/share/nginx/html # 挂载网站文件
- ./logs:/var/log/nginx # 挂载日志
- ./certs:/etc/nginx/certs # 挂载证书
# 运行时挂载
docker run -d \
--name nginx \
-v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
-v $(pwd)/html:/usr/share/nginx/html \
nginx:alpine
:ro 表示只读,防止容器内进程修改宿主机的文件。
方式三:Tmpfs Mounts(临时文件系统)
有些数据不需要持久化,但又不能放在容器的可写层里(比如临时文件和敏感信息):
services:
app:
image: myapp:latest
tmpfs:
- /tmp:noexec,nosuid,size=100m # 临时目录,不执行二进制,不设 suid,最大 100MB
- /app/data # 普通临时目录
数据库数据怎么持久化
数据库是最需要持久化的场景。你的电商系统有订单数据、用户数据、商品信息,这些数据绝对不能因为容器重启就丢失:
services:
postgres:
image: postgres:15-alpine
volumes:
- postgres-data:/var/lib/postgresql/data
# 定期备份脚本
- ./scripts/backup.sh:/backup.sh:ro
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
sh -c "/backup.sh && exec postgres"
# 备份容器(定期运行)
backup:
image: postgres:15-alpine
volumes:
- postgres-data:/var/lib/postgresql/data:ro
- ./backups:/backups
environment:
- PGHOST=postgres
- PGUSER=dbuser
- PGPASSWORD=${DB_PASSWORD}
entrypoint: ["/bin/bash", "-c"]
command: |
while true; do
pg_dump ecommerce > /backups/ecommerce-$(date +%Y%m%d-%H%M%S).sql
# 删除 7 天前的备份
find /backups -name "*.sql" -mtime +7 -delete
sleep 86400
done
这个备份方案虽然简单,但很实用。每天自动备份一次,保留 7 天。如果需要更专业的备份方案,可以考虑 pgBackRest 或 Barman。
文件上传怎么持久化
电商系统的图片、视频文件也是重要数据:
services:
ecommerce-service:
image: registry.example.com/ecommerce-service:latest
volumes:
# 文件上传目录持久化
- uploads-data:/app/uploads
# 日志持久化(方便排查问题)
- app-logs:/app/logs
environment:
- UPLOAD_PATH=/app/uploads
- LOG_PATH=/app/logs
volumes:
uploads-data:
name: ecommerce-uploads
app-logs:
name: ecommerce-logs
在应用代码中,文件上传路径配置为 /app/uploads,这样即使容器重建,上传的文件也不会丢失。
多节点部署时的持久化问题
如果你们的 Docker 环境有多个节点,简单的 Volume 就不够了,需要用到网络存储:
services:
postgres:
image: postgres:15-alpine
volumes:
# 使用 NFS 网络存储
- type: volume
source: postgres-data
target: /var/lib/postgresql/data
volume:
nocopy: true
deploy:
resources:
limits:
cpus: '4'
memory: 4G
placement:
constraints:
- node.role == manager # 数据库只部署在 manager 节点
volumes:
postgres-data:
driver: local
driver_opts:
type: nfs
o: addr=nfs-server,nolock,soft,rw
device: ":/export/postgres-data"
这种情况通常意味着你们已经在使用 Docker Swarm 或 Kubernetes 了,持久化的问题会复杂很多,需要用到 PVC(Persistent Volume Claim)之类的机制。
把这三件事串起来
上面讲的内容看起来是三个独立的问题,但在实际项目中它们是紧密关联的。
想象一下这个场景:你构建了一个新的电商服务镜像,Jenkins 在构建前扫描了漏洞(安全),确认没有问题后才开始部署。部署过程中,Jenkins 使用凭证登录 Docker 仓库(权限),拉取镜像并启动容器。容器启动时,数据库数据和用户上传的文件都挂载到了持久化的卷中(持久化)。整个过程不需要你手动操作任何服务器,一杯咖啡的时间就完成了。
但如果任何一个环节出问题,后果可能很严重。镜像有漏洞,攻击者可能拿到服务器权限;权限管理混乱,不该能触发构建的人也能触发,可能导致生产环境被错误部署;数据没有持久化,一次意外的容器删除可能导致全部数据丢失。
一个完整的部署脚本示例
#!/bin/bash
# deploy.sh - 一键部署脚本(在 Jenkins 中调用)
set -e # 遇到错误立即退出
IMAGE_NAME="ecommerce-service"
IMAGE_TAG="${BUILD_NUMBER}"
REGISTRY="registry.example.com"
DEPLOY_USER="deploy"
echo "=========================================="
echo "开始部署: ${IMAGE_NAME}:${IMAGE_TAG}"
echo "=========================================="
# 1. 扫描镜像漏洞
echo "[1/5] 扫描镜像漏洞..."
trivy image --severity HIGH,CRITICAL \
--format table \
${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} || {
echo "❌ 发现高危漏洞,部署中止!"
exit 1
}
echo "✅ 漏洞扫描通过"
# 2. 检查镜像是否已存在(避免重复推送)
echo "[2/5] 检查镜像状态..."
if docker manifest inspect ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} > /dev/null 2>&1; then
echo "✅ 镜像已存在,跳过推送"
else
echo "📤 推送镜像到仓库..."
docker push ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
echo "✅ 镜像推送完成"
fi
# 3. 部署到测试环境
echo "[3/5] 部署到测试环境..."
ssh ${DEPLOY_USER}@staging-server "
docker pull ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} &&
docker tag ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ${REGISTRY}/${IMAGE_NAME}:staging &&
cd /opt/ecommerce &&
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up -d &&
docker system prune -f
"
echo "✅ 测试环境部署完成"
# 4. 运行冒烟测试
echo "[4/5] 运行冒烟测试..."
sleep 10 # 等待服务启动
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://staging-server:8080/actuator/health)
if [ "${HTTP_CODE}" != "200" ]; then
echo "❌ 冒烟测试失败,HTTP 状态码: ${HTTP_CODE}"
exit 1
fi
echo "✅ 冒烟测试通过"
# 5. 部署到生产环境(需要人工确认)
echo "[5/5] 等待人工确认部署到生产环境..."
# 这里在 Jenkins Pipeline 中会使用 input 步骤来等待确认
ssh ${DEPLOY_USER}@prod-server "
docker pull ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} &&
docker tag ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ${REGISTRY}/${IMAGE_NAME}:latest &&
cd /opt/ecommerce &&
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d &&
docker system prune -f
"
echo "✅ 生产环境部署完成"
echo "=========================================="
echo "部署完成: ${IMAGE_NAME}:${IMAGE_TAG}"
echo "=========================================="
给小白的建议:从简单开始
如果你是第一次接触 Docker + Jenkins,不要试图一步到位把上面所有东西都搭好。那样只会让你被吓退。
第一步,先让 Jenkins 能自动构建 Docker 镜像并推送到仓库。这一步能解决”代码提交后自动打包”的需求。
第二步,让 Docker 容器能自动部署到服务器。这一步能解决”一键部署”的需求。
第三步,再加漏洞扫描、权限控制、数据持久化这些安全和管理相关的功能。一步一步来,每一步都能带来实际的价值。
老张说,他们公司花了一个月才把这套系统搭好,但前两周几乎每天都在填坑。如果你能提前知道这些坑在哪里,可能两周就够了。
