写代码只是第一步。代码写完之后怎么管、怎么打包、怎么部署、怎么自动上线——这才是企业真正在用的技术栈。这一周我把整个DevOps工具链从头到尾走了一遍,从Git分支管理到Docker容器化,再到K8s编排和GitHub Actions自动化。这篇文章记录我这一周学到的所有东西,附带踩坑经验和个人感悟。


为什么要学这些?

学完Spring Boot + Redis + 多线程之后,我遇到了一个很尴尬的问题:我的项目只能在我的电脑上跑。换个同学想看一下,得帮他装JDK、装MySQL、配Redis、改配置文件……搞了半天还没跑起来。

更离谱的是,我改了一版代码觉得没问题就push到main分支了,结果单元测试是挂的,把整个仓库的CI搞红了。

这两件事让我意识到:会写代码只是程序员的基本功,能把代码管好、部署好、持续交付才是真正的工作能力。 所以我花了一周时间集中攻克Git、Docker、K8s和CI/CD这四块内容。

下面按时间线分享。


一、Git:不只是 add/commit/push

之前我用Git就三板斧:git add .git commit -m "xxx"git push。学了之后才发现,企业里用Git远不止这么简单。

1.1 Git Flow:五个分支各司其职

Git Flow是一套分支管理模型,核心思想是不同用途的代码放在不同分支上

master(主分支)    ← 永远是线上稳定版本,不能直接在master上开发
  ↑
release(发布分支) ← 准备上线的版本,做最后的测试和bug修复
  ↑
develop(开发分支) ← 所有feature合并到这里,日常开发的主战场
  ↑
feature(功能分支) ← 每个新功能一个分支,做完merge回develop
hotfix(热修复分支) ← 线上紧急bug,从master切出来,修完同时merge到master和develop

我之前都是直接在master上写代码,push完事。这样做的问题显而易见——万一写了个bug直接就上生产了,连个缓冲都没有。有了Git Flow,feature分支开发完要先merge到develop做测试,再从develop切release做预发布,最后才到master。层层把关,出问题的概率小很多。

1.2 merge vs rebase:面试必问

这两个命令都能把分支合到一起,但历史长得不一样:

# merge:保留完整历史,会多一个合并提交
git checkout develop
git merge feature-xxx

# rebase:把提交"搬"到目标分支最新提交的后面,历史是直线
git checkout feature-xxx
git rebase develop

merge 像是两条河流汇合,保留所有交汇痕迹。历史图上有个"菱形"。

rebase 像是把你的提交"摘"下来,重新"嫁接"到目标分支的最新位置。历史图是一条干净的直线。

我的使用原则:个人分支用rebase保持历史干净,团队协作用merge保留真相。 rebase会改写commit hash,如果别人也在你的分支上开发,rebase会导致历史冲突。

1.3 stash:救急神器

正在feature分支写代码,写到一半,突然要切到hotfix修bug。但代码写到一半没法commit(commit了就是半成品)。怎么办?

# 暂存当前修改
git stash

# 切到hotfix分支干活
git checkout hotfix-xxx
# ...修bug、commit、push...

# 切回feature分支
git checkout feature-xxx

# 恢复暂存的修改
git stash pop

stash就像把桌上的文件一股脑塞进抽屉,桌面干净了。回来之后从抽屉里拿出来继续干。简单粗暴但很实用。

1.4 Conventional Commits:提交信息规范化

以前我写commit message都是"修复bug"、“改了个东西”、“update”……自己回头看根本不知道改了什么。

Conventional Commits是一套提交信息规范:

feat: 新增用户注册接口
fix: 修复登录时密码校验逻辑错误
refactor: 重构订单服务的超时重试机制
docs: 更新API文档中的参数说明
test: 补充UserService的单元测试
chore: 升级Spring Boot版本到3.2.1

格式是 类型(可选范围): 简短描述。好处是:看commit列表一眼就知道哪些是新功能、哪些是bug修复、哪些是重构。配合 git log --oneline 输出非常清爽。


二、Docker:终结"在我电脑上能跑"

Git管好了代码,下一个问题是环境一致性。Docker就是干这个的。

2.1 Docker到底是什么

Docker官网的定义是"容器化平台",但我觉得最直观的比喻是集装箱

国际贸易以前运货,电视、衣服、食品混着装,每个港口搬运方式不一样。后来用标准集装箱——不管装什么,外面都一样,轮船火车卡车都能运。

Docker对软件做了同样的事。你写的代码、JDK、MySQL、Redis、配置文件……全部打包进一个"容器"。这个容器在任何装了Docker的机器上都以完全相同的方式运行。

2.2 Docker vs 虚拟机

面试必问的一个点。我总结的比喻是独栋别墅 vs 公寓楼

虚拟机像独栋——每栋有独立的地基和水电(完整OS内核),资源开销大(GB级)、启动慢(分钟级)。Docker像公寓——所有房间共用地基和水电(共享宿主机内核),但每个房间内部独立隔离,资源开销小(MB级)、启动快(秒级)。

对比虚拟机Docker容器
内核各自完整内核共享宿主机内核
启动分钟级秒级
空间GB级MB级
隔离较弱(进程级)

2.3 四大核心概念

这四个概念是Docker的基础,我用自己的话串一下:

Image(镜像) = 菜谱。只读的模板,包含运行应用所需的一切。你不能改菜谱上已经印好的内容,但你可以按菜谱做出一道菜。

Container(容器) = 炒出来的菜。镜像的运行实例,可以启动、停止、删除。同一份菜谱可以炒出很多盘(同一镜像可以启动多个容器)。

Registry(仓库) = 应用商店。Docker Hub是最大的公共仓库,几乎所有软件都有官方镜像。docker pull mysql:8.0 就是从仓库下载镜像。

Volume(数据卷) = U盘。容器删了里面的文件全没了,Volume把数据挂载到宿主机上,容器重建数据不丢。任何有状态服务(数据库)都必须用Volume。

Docker Hub → docker pull → Image → docker run → Container
                                           ↕
                                        Volume(持久化)

2.4 docker run 参数详解

这个命令是日常用得最多的,每个参数都得搞懂:

docker run -d --name my-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -v mysql-data:/var/lib/mysql mysql:8.0
#          ↑      ↑            ↑              ↑                          ↑                     ↑
#          |      |            |              |                          |                     镜像名:版本
#          |      |            |              |                          数据卷挂载
#          |      |            |              环境变量
#          |      |            端口映射(宿主机:容器)
#          |      容器名
#          后台运行

端口映射特别容易搞混。-p 3306:3306宿主机端口:容器内端口。宿主机就是你自己电脑。Spring Boot连 localhost:3306,Docker自动转发到容器里的MySQL。

2.5 数据持久化验证

这一步我亲手做了之后才真正理解Volume的意义:

# 插入数据
docker exec -it my-mysql mysql -uroot -p123456 -e "INSERT INTO bookstore.book (title) VALUES ('test');"

# 删掉容器
docker stop my-mysql && docker rm my-mysql

# 用同一个Volume重建容器
docker run -d --name my-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -v mysql-data:/var/lib/mysql mysql:8.0

# 查询数据——还在!
docker exec -it my-mysql mysql -uroot -p123456 -e "SELECT * FROM bookstore.book;"

容器删了又重建,数据一条没丢。这就是Volume的作用。

2.6 容器间通信的小坑

我在让Spring Boot连Docker里的MySQL时踩了一个坑:容器里的 localhost 指向容器自己,不是宿主机。

所以如果Spring Boot也在Docker里跑,application.yml 里写 localhost:3306 是连不上MySQL容器的。

解决方案:Docker自定义网络。

docker network create my-network
docker network connect my-network my-mysql
docker network connect my-network my-redis

同一网络内,容器可以用容器名互相访问。jdbc:mysql://my-mysql:3306 就行了。


三、Dockerfile:把自己的项目装进镜像

跑MySQL和Redis用的是别人做好的镜像,但我自己写的Spring Boot项目怎么打包成镜像? 答案就是Dockerfile。

3.1 最小可用的Dockerfile

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

就五行,逐行解释:

FROM — 选地基。基于Java 17 JRE镜像。用JRE而不是JDK,因为项目已经打成jar包了,运行时不需要编译工具。JRE镜像比JDK小一半(200MB vs 400MB),传输快、启动快、攻击面小。

WORKDIR — 选工作台。后续操作都以 /app 为基准目录。

COPY — 搬材料。把Maven打好的jar包复制进容器,重命名为app.jar。

EXPOSE — 声明端口。注意这只是个"文档声明",真正的端口映射靠 docker run -p

ENTRYPOINT — 开机自启。容器启动时自动执行 java -jar app.jar

3.2 ENTRYPOINT vs CMD

面试常考。简单说:ENTRYPOINT不容易被覆盖(需要 --entrypoint 参数),CMD容易被覆盖(docker run后面跟的命令会替换CMD)。Spring Boot推荐只用ENTRYPOINT,因为启动命令不需要运行时替换。

3.3 镜像分层与缓存(面试高频)

Docker镜像是一层一层叠加的,每条Dockerfile指令产生一层。构建时Docker逐层检查缓存:没变的层直接用缓存跳过,某层变了后面所有层都得重建。

所以指令顺序很重要

# ❌ 反例:变化的文件放前面,导致后面缓存全失效
COPY target/app.jar app.jar           # jar包每次变 → 缓存失效
RUN apt-get update && apt-get install -y curl  # 这层也得重建

# ✅ 正例:不变的放前面,变化的放后面
RUN apt-get update && apt-get install -y curl  # 不变 → 用缓存
COPY target/app.jar app.jar           # 变了 → 只从这层开始重建

3.4 多阶段构建

这是个进阶技巧,解决了"构建环境和运行环境分离"的问题:

# 第一阶段:编译(用包含Maven的大镜像)
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests

# 第二阶段:运行(只用小的JRE镜像)
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

第一阶段编译出jar包,第二阶段只把jar包"拷贝"到小的JRE镜像里。最终镜像不包含Maven、JDK和源代码,干干净净。好处是宿主机不需要装Maven,docker build 一步到位。

3.5 .dockerignore 别忘了

不写 .dockerignore,Docker构建时会把 .git(几百MB提交历史)、node_modules、IDE配置等全发过去,构建又慢又大。

.git
.idea
.vscode
node_modules
target/classes
*.log
.DS_Store

四、Docker Compose:一键编排多个容器

4.1 为什么需要Compose

手动 docker run 三个服务,每个都要记一堆参数——端口、网络、环境变量、启动顺序。三个服务就写三条长命令,项目有十个服务呢?

Docker Compose就是把所有容器的配置写进一个YAML文件,一条命令全部启动

4.2 完整的 docker-compose.yml

version: "3.8"

services:
  mysql:
    image: mysql:8.0
    container_name: my-mysql
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: "bookstore"
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - app-network
    restart: unless-stopped

  redis:
    image: redis:7
    container_name: my-redis
    ports:
      - "6379:6379"
    networks:
      - app-network
    restart: unless-stopped

  bookstore:
    build: .
    container_name: my-bookstore
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/bookstore?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai"
      SPRING_DATA_REDIS_HOST: "redis"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network
    restart: unless-stopped

volumes:
  mysql-data:

networks:
  app-network:

几个关键点:

image vs build:别人的软件(MySQL、Redis)用 image 直接拉镜像;自己的项目用 build: . 现场构建。

depends_on 的坑depends_on 只保证容器启动顺序,不保证服务就绪。MySQL容器启动了但可能还在初始化,bookstore这时候去连会报Connection refused。解决方案:给MySQL配healthcheck,depends_on用 condition: service_healthy这是面试高频坑。

环境变量覆盖:注意 SPRING_DATASOURCE_URL 里的host写的是 mysql(容器名),不是localhost。在Compose定义的同一networks下,服务之间直接用服务名互相访问,DNS自动解析。用环境变量而不是改 application.yml 的好处是:本地开发时 application.yml 保持localhost配置不变,部署到Docker时通过环境变量切换。

Volume声明:文件底部 volumes: mysql-data: 声明数据卷,mysql服务里引用 mysql-data:/var/lib/mysqldocker-compose down 只删容器不删卷(数据不丢),docker-compose down -v 连卷一起删(数据全没)。

.env文件:密码等敏感配置放 .env 文件,compose里用 ${MYSQL_ROOT_PASSWORD} 引用。.env 加到 .gitignore 不提交到Git。

4.3 一条命令启动全部

docker-compose up -d
# 自动:拉镜像 → 构建 → 创建网络 → 创建数据卷 → 按顺序启动容器

docker-compose ps        # 查看状态
docker-compose logs -f   # 看日志
docker-compose down      # 停掉(数据保留)

我验证了数据持久化:docker-compose downdocker-compose up -d → 查MySQL数据,还在。Volume确实管用。


五、Kubernetes:当一台机器不够用时

Docker Compose只能在一台机器上管理容器。如果你的项目需要多台服务器、自动扩缩容、滚动更新不中断服务——就需要K8s了。

5.1 K8s的定位

我的理解是:K8s是容器的"超级管理员"。 你告诉它"我要3个bookstore的Pod随时在线",它就会自己维护这个状态——一个挂了立刻补一个新的,流量大了自动扩容,代码更新了逐个替换不中断服务。

这种模式叫声明式API:你声明"期望状态"(我要3个Pod),K8s自动确保现实等于期望。面试很爱考这个概念。

5.2 五大核心资源

Pod — K8s管理的最小单元。一个Pod就像一个工位,通常坐一个容器(偶尔坐两三个紧密合作的)。你几乎不会直接创建Pod,而是通过Deployment管理。

Deployment — Pod的团队管理器。控制副本数(replicas)、自愈(挂了自动补)、滚动更新(逐个替换不中断)。这是K8s最核心的资源。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookstore-deployment
spec:
  replicas: 3          # 始终保持3个Pod在线
  selector:
    matchLabels:
      app: bookstore
  template:
    metadata:
      labels:
        app: bookstore
    spec:
      containers:
        - name: bookstore
          image: bookstore:1.0
          ports:
            - containerPort: 8080

Service — 固定入口。Pod的IP会变(重建就变),Service提供固定地址,自动负载均衡到后面的Pod。类似公司前台——不管后面员工怎么换,前台电话永远不变。

类型谁能访问场景
ClusterIP集群内部微服务间内部通信
NodePort外部通过节点IP:端口开发测试
LoadBalancer通过云厂商负载均衡器生产环境

ConfigMap — 配置中心。存放配置键值对,Pod可以引用。和Docker Compose的environment类似,但更灵活(可以按Namespace隔离不同环境的配置)。

Namespace — 逻辑隔离。不同Namespace的资源互不影响。开发阶段用default就够了。

5.3 体验K8s自愈能力

这是我亲手做的最酷的一个实验:

# 确认2个Pod在运行
kubectl get pods
# bookstore-deployment-xxx-aaa   1/1   Running
# bookstore-deployment-xxx-bbb   1/1   Running

# 手动杀掉一个
kubectl delete pod bookstore-deployment-xxx-aaa

# 立刻再看
kubectl get pods
# bookstore-deployment-xxx-bbb   1/1   Running
# bookstore-deployment-xxx-ccc   0/1   ContainerCreating   ← K8s秒级补了一个新的!

你杀了一个Pod,K8s立刻自动补上。因为 replicas: 2 是声明式配置,K8s拼命维护"2个Pod在线"这个状态。


六、CI/CD:让流水线替你干活

6.1 CI和CD是什么

CI(持续集成) = 代码一提交,自动编译+自动测试。有问题立刻标红,阻止合并到主分支。

CD(持续部署) = 测试通过后,自动部署到服务器。

以前我的流程是:写完代码 → 手动push → 手动ssh到服务器 → 手动拉代码 → 手动打包 → 手动启动。每一步都是手动的,忘了哪步就可能翻车。有了CI/CD,push代码之后的一切都是自动的。

6.2 GitHub Actions实战

GitHub Actions是GitHub自带的CI/CD服务,不需要装任何东西。在仓库里放一个配置文件就行:

# .github/workflows/ci.yml

name: Java CI

on:
  push:
    branches: [ "main", "develop" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'maven'

      - name: Compile
        run: mvn compile

      - name: Run tests
        run: mvn test

      - name: Package
        run: mvn package -DskipTests

核心语法:on 定义触发条件(push到哪些分支),jobs 定义任务(在什么虚拟机上跑),steps 定义具体步骤(每步要么用 uses 引用现成Action,要么用 run 执行命令)。

push之后打开GitHub仓库的Actions页面,看到绿色勾就是全部通过,红色叉就是某步失败了——点进去看日志就能定位问题。

✅ Java CI
  ├── ✅ Checkout code    5s
  ├── ✅ Set up JDK 17    15s
  ├── ✅ Compile          30s
  ├── ✅ Run tests        45s
  └── ✅ Package          20s

我加了一个构建状态徽章到README顶部:[✅ build passing]。面试官一看就知道项目有CI,好感度直接+1。


七、整体串联:这一周我学到了什么

把这一周的东西串起来,其实是一条完整的工具链:

Git(管代码)
  → Docker(打包环境)
    → Docker Compose(编排多容器)
      → K8s(管理大规模容器)
        → CI/CD(自动化全流程)

每一层解决一个问题:Git解决"代码版本管理";Docker解决"环境一致性";Docker Compose解决"多容器协同";K8s解决"大规模容器编排和自愈";CI/CD解决"从代码到上线的自动化"。

个人感悟

这一周最大的感触是:工具的价值不在于它有多复杂,而在于它解决了什么真实痛点。

Docker的伟大之处不是它技术多牛,而是它终结了"在我电脑上能跑"这个困扰所有程序员的问题。Git Flow的价值不是分支多好看,而是让团队协作时代码不再互相打架。CI的价值不是多了条流水线,而是让你敢于频繁提交——因为你知道,有bug会被自动拦下来。

之前我觉得"运维"是别人的事,写好自己的代码就行了。但现在我明白了,在微服务和云原生的时代,开发和运维的边界越来越模糊。一个后端工程师不懂Docker、不懂CI/CD,就像一个厨师不知道怎么用烤箱——你菜谱写得再好,东西做不出来也白搭。

还有一个小感悟:面试问这些不是为了考你会不会敲命令,而是看你理解不理解背后的思想。 面试官问你"K8s的声明式API是什么",不是让你背定义,而是看你有没有真正用过、踩过坑、理解了它为什么这么设计。所以我这一周每个概念都亲手做了一遍,不是看看文档就过去了。

下周开始进入微服务和中间件的学习,会继续记录。如果这篇文章对你有帮助,点个赞再走吧。

更多推荐