📍 无忆研习社 › 开源解码 › Git 与 Docker 双修指南:从版本控制原理到容器化部署的开发者实操手册

Git 与 Docker 双修指南:从版本控制原理到容器化部署的开发者实操手册

摘要:这篇长文一次性讲透开发者两大必修课:Git 与 Docker,从底层模型到实操部署全覆盖。Git部分帮你建立“三个区域+三种对象”的心智模型,深入理解分支、提交、回滚、远程协作,打通版本控制的任督二脉;Docker部分厘清容器与虚拟机的本质区别,拆解镜像、容器、仓库、数据卷、网络六大核心概念,并用完整Web案例串联docker build、run、ps、logs、exec等命令。读完你将获得可推演的Git工作流、能直接搬用的Dockerfile写法,以及多个实战避坑经验。
字号 100%
行距 2.05
当前可见 60% 的内容
本文由 莫潇羽@无忆研习社(www.wuyi.wiki) 撰写,转载请注明出处。

快速摘要

这篇长文把当下两个最绕不开的开发者基础设施——Git 与 Docker——一次性讲透。Git 部分,我会带你建立"三个区域 + 三种对象"的心智模型,然后从 git init、git add、git commit、git diff、git log、git restore、git reset 一路实操到分支管理与远程协作;Docker 部分,先把"容器和虚拟机到底差在哪"讲明白,再拆解镜像、容器、仓库、Dockerfile、数据卷、网络这六个核心概念,最后用一个完整的 Web 服务部署案例,把 docker build、docker run、docker ps、docker logs、docker exec、docker stats 这些日常命令穿成一条线。

读完之后你大概能拿到这些:

  • 一套能在脑子里推演的 Git 工作流模型,以后看到 git status 输出不再发懵;
  • 一份能照着照搬到自己项目里的 Dockerfile 写法,适配 Python/Node 等常见技术栈;
  • 一组踩过坑的实操经验,涵盖端口映射、数据卷挂载、镜像版本管理、提交回滚等常见场景。

想看完整拆解,往下翻。


一、缘起:为什么 Git 和 Docker 几乎是每个开发者的入门必修课

我写代码这些年,有两样东西是无论换公司、换团队、换技术栈都得带着走的——一个是 Git,一个是 Docker。它俩看起来八竿子打不着,但本质上做的是同一件事:把"乱"这件事,管住。

Git 管的是代码的乱。你有没有过这种经历:写一篇毕业论文,文件夹里躺着"最终版"、"最终版2"、"最终版_真的最终"、"最终版_老师改过的"、"最终版_答辩用"……写代码也是一个意思。你今天改了登录逻辑,明天又重构了数据库连接,后天发现昨天的改动有 bug 要回滚,光是凭文件名根本管不过来。Git 的出现就是为了解决这个问题——它能记录每一次你说"OK,这一版我满意了"的状态,然后任意时刻让你回到其中任何一版。

Docker 管的是环境的乱。这事儿莫潇羽我自己踩过特别多坑。最经典的场景就是:在我的笔记本上跑得好好的一个项目,推到测试服务器上死活起不来。要么是 Python 版本对不上,要么是某个 C 扩展找不到底层库,要么是环境变量没配齐。每一次都得花半天甚至一天去对环境,生不如死。Docker 干的就是把"应用 + 它需要的一切环境"打包成一个能拎走的盒子,你在哪儿打开都是同一个味儿。

1.1 这两样东西到底解决了哪些"看不见的成本"

很多新手会觉得,Git 和 Docker 是"工程师才用的高级工具",自己写点小项目根本没必要。这是一个特别常见的误解。我自己的观察是:越早把这两套基础设施搞清楚,越早能从"折腾环境"的泥潭里爬出来,把时间花到真正的业务逻辑上。

不夸张地说,我估算过自己早期没有用好 Git 和 Docker 的时候,大概每周有 8 到 10 个小时是浪费在"找回上次的版本"、"重新配环境"、"在两台机器之间手动同步"这种事上的。用顺手之后,这部分时间直接砍掉一大半。

1.2 这篇文章的阅读路径

为了照顾不同阶段的读者,文章我是按这样的层次去铺的:

  • 概念层:讲清楚 Git 和 Docker 在解决什么、它们的核心模型是什么。
  • 操作层:把常用命令一条条过一遍,每条都给出"什么时候用、不用会怎样、用错了怎么救"。
  • 场景层:把命令组合成真实开发场景,比如"我改坏了代码怎么回到上一个版本"、"我想把这个项目部署到一台新服务器上"。

如果你是完全零基础,建议从头到尾顺着看;如果你已经会一些基本操作,只是想补齐底层原理,可以直接跳到第三章和第七章;如果你只是来查命令,那目录是好朋友。

二、Git 的底层心智模型:版本控制到底在做一件什么事

很多教程一上来就让你敲 git init、git add、git commit,我自己学的时候觉得这种节奏太快了——命令是记住了,但脑子里压根没建立起 Git 是怎么工作的图景,结果一遇到 detached HEAD、fast-forward、merge conflict 这种稍微复杂一点的提示就直接懵。

所以我想先慢一点,把 Git 最底层的设计理念聊清楚,后面看具体命令的时候你就会觉得"哦,原来如此",而不是死记硬背。

2.1 版本控制系统在解决什么问题

把镜头拉远一点,版本控制系统(Version Control System,简称 VCS)解决的是"如何随时间追踪一个项目的变化"这个问题。听上去抽象,但落到日常开发里其实非常具体:

  • 我两个月前实现的那个功能,今天突然报错了,我能不能精准定位是哪一次改动引入了这个 bug?
  • 我和另外两个同事在并行开发不同的功能,我们怎么把各自的成果合到一起,不互相覆盖?
  • 我尝试了一个新的实现方案,改了 30 个文件,发现不靠谱,能不能一键回到尝试之前的状态?
  • 我们团队上线了一个版本,现在客户报告问题,我能不能精确还原出客户那台机器上跑的是哪一份代码?

这些场景没有 VCS 的支持,基本是地狱难度。

2.2 Git 和它的"前辈":两种截然不同的设计哲学

理解 Git 之前,有一个对比绕不开。在 Git 之前的主流 VCS 是 SVN(Subversion)这类集中式系统,它们的工作方式是:

  • 服务端有一个唯一的中心仓库,记录所有历史;
  • 每个开发者从中心仓库 checkout 一份当前版本到本地;
  • 改完之后 commit 直接打到中心仓库;
  • 如果服务端挂了,大家都没法干活。

Git 是 Linux 之父 Linus Torvalds 在 2005 年写的,设计哲学完全相反——分布式:

  • 每个开发者本地都有一份完整的仓库,包含全部历史;
  • 提交先到本地仓库,然后通过推送(push)同步到远端;
  • 远端仓库挂了也不影响本地干活;
  • 任何一份本地仓库都可以在需要的时候充当新的远端。

这个设计差异带来的最大好处是:Git 几乎所有操作都是本地完成的,速度极快,而且离线可用。莫潇羽@无忆研习社 在地铁上、飞机上写代码的时候,完全不依赖网络就能正常 commit、查历史、切分支,这种体验是集中式系统给不了的。

2.3 Git 的核心数据模型:不是差异,是快照

接下来这一段非常关键,理解了它,你对 Git 的认知就上了一个台阶。

很多人以为 Git 存的是"每次改动的差异"(像我们常说的 diff 那样)。其实不是。Git 存的是"快照"——每一次提交,它都把当时整个项目的完整状态,像拍照片一样存下来。

为什么这么设计?第一,查询任意历史版本极快——找到对应的快照直接读出来就行,不需要从头一个 diff 一个 diff 地拼;第二,数据完整性极高——每一张快照都有自己独立的哈希指纹,改一个字节哈希就完全不一样,几乎不可能被神不知鬼不觉地篡改。

那 diff 还有用吗?当然有,但它是展示给人看的派生产物,不是底层存储。当你 git diff 看两个版本之间的差异时,Git 是临时算给你看,不是直接从存储里掏出来的。

心智模型对照表:

  传统 VCS:        Git:
  ┌────────┐      ┌─────────┐
  │ 初始版本│      │ 快照 #1 │ ← 完整的项目状态
  ├────────┤      ├─────────┤
  │ + 差异 1│      │ 快照 #2 │ ← 又一份完整的项目状态
  ├────────┤      ├─────────┤
  │ + 差异 2│      │ 快照 #3 │ ← 又一份......
  ├────────┤      ├─────────┤
  │ + 差异 3│      │ 快照 #4 │
  └────────┘      └─────────┘

听到这儿可能有人会担心:那存储不会爆炸吗?每次都存完整快照,磁盘吃得消吗?Git 在这里做了一个非常聪明的优化——对没有变化的文件,它不会真的再存一份,而是让新的快照里"指向"上一个快照里那份文件的引用。所以实际占用的空间,基本只跟"真正变了的文件"成正比,不会因为提交次数多就线性膨胀。

2.4 提交是不可变的,但分支是流动的

再补一个心智模型:Git 的每一次提交都是不可变的(immutable)。一旦你 commit 出去,那个快照、那个哈希就钉在那里了,后面的所有操作——无论你怎么 reset、怎么 revert——都不会真的去"修改"那个旧提交,而是基于它生成新的提交、新的引用关系。

而分支(branch)是可变的——分支本质上只是一个"指向某次提交的指针",这个指针可以随时被移动。所以"切换分支"、"创建分支"、"删除分支"这些操作,在 Git 内部都极其轻量,几乎是瞬间完成的。这一点和 SVN 那种"分支等于一个完整目录拷贝"的设计完全不同。

这个差异决定了一件事:在 Git 里开分支几乎是没有代价的,所以"用分支隔离每一个新功能、每一个实验性改动"才能成为日常实践。后面讲分支策略的时候我们还会回到这点。

三、三个区域 + 三种对象:吃透 Git 工作原理

如果说前一章是"望远镜",这一章就是"显微镜"——我们要把 Git 的内部结构拆开,看看它到底是怎么记录你的每一次改动的。理解了这一节,后面所有命令都会变得"自动可推理",而不是孤立地记忆。

3.1 三个区域:工作区、暂存区、版本库

任何在你电脑上跑的 Git 仓库,都可以被分成三个区域。这是莫潇羽@无忆研习社推荐你刻进 DNA 的概念。

  • 工作区(Working Directory):就是你能直接看到、能直接用编辑器打开改的那些文件。它就是你项目目录下除 .git 之外的所有内容。
  • 暂存区(Staging Area / Index):这是 Git 独有的一个中间层,可以想成"打包待发清单"。你已经改好的、准备提交的文件,先放进这里"过一道",而不是直接进版本库。
  • 版本库(Repository):就是那个隐藏的 .git 目录,Git 真正保存所有快照、对象、引用的地方。

为什么要有暂存区?这是新手最常问的问题。答案是:它给了你"挑选"的能力。

举个具体场景。我今天上午改了 5 个文件,其中 3 个是"修登录 bug",另外 2 个是"加了一些日志方便调试"。这两件事在语义上是独立的,理想的提交应该是分两次,各自带各自的提交说明。如果没有暂存区,你只能要么一次性全部提交、要么一个一个文件单独 commit,非常笨拙。有了暂存区,我可以先 git add 那 3 个 bug 修复的文件、commit 一次,然后再 git add 剩下 2 个、commit 一次。

工作区 → 暂存区 → 版本库 的数据流向

  ┌─────────┐  git add   ┌─────────┐  git commit   ┌─────────┐
  │ 工作区  │ ─────────→ │ 暂存区  │ ────────────→ │ 版本库  │
  │(改文件) │            │(待提交) │               │(快照)   │
  └─────────┘  git restore└─────────┘ git restore   └─────────┘
       ↑                       --staged                  
       └───────────── git checkout ─────────────────────┘

3.2 三种对象:blob、tree、commit

知道了三个区域,再往里挖一层,你会发现 Git 存的所有东西,本质上都是这三种对象。

  • blob 对象:存的是文件的内容(不是文件名,只是内容)。两个文件如果内容一模一样,在 Git 里就是同一个 blob。
  • tree 对象:存的是一个目录的结构——这个目录下有哪些文件、每个文件名对应哪个 blob、哪些子目录对应哪个子 tree。可以理解成"目录树的拍照"。
  • commit 对象:存的是一次提交的元信息——指向那一次快照的根 tree、上一次提交是哪一个(父提交)、是谁提交的、提交说明是什么、提交时间。

把这三种对象串起来,你就得到了 Git 的完整存储结构:

commit #abc123          ← 一次提交
  ├─ parent: #xyz789    ← 上一次提交是谁
  ├─ author: 莫潇羽     ← 谁提交的
  ├─ message: "修复登录" ← 提交说明
  └─ tree: #t001        ← 指向这次快照的目录结构
              │
              ├─ blob #b001 → src/main.py 的内容
              ├─ blob #b002 → README.md 的内容
              └─ tree #t002 → docs/ 目录
                      │
                      └─ blob #b003 → docs/intro.md

理解了这个,你就明白了几个长期困扰新手的现象:

现象 1:为什么改一个字符就是一次新的 blob? 因为 blob 的 ID 是内容的哈希,内容只要变一个字节,哈希就完全不同,自然就是新对象。

现象 2:为什么 Git 历史几乎不可能被神不知鬼不觉地篡改? 因为每个 commit 的 ID 是基于它的 tree、parent、message 等一起算出来的哈希,你想改任何一个,这个 commit 自身的 ID 就变了,接下来所有指向它的子 commit 也全得跟着改,整个链断掉。

现象 3:为什么分支切换那么快? 因为分支只是一个指针,指向某个 commit ID,切换分支就是改一下"这个指针指向谁",不涉及任何对象层面的搬运。Git 顺手会把工作区调整成对应那个 commit 的快照内容。

3.3 HEAD:你"现在在哪儿"的标记

还有一个概念配套来理解——HEAD。HEAD 就是一个特殊的指针,标记"你现在的工作位置在哪个 commit 上"。大多数情况下,HEAD 指向某个分支,而那个分支又指向某个 commit。

打个比方,如果分支是地铁线路,commit 是站点,那 HEAD 就是你身上的定位。你切换线路(切分支),你的定位就跳到那条线路的当前站点;你坐到下一站(新提交一次),整条线路的"末站"指针往前走一格,你也跟着走。

这套模型刻进脑子之后,后面所有让人头疼的高级操作——detached HEAD、reset 的三种模式、rebase 的本质、cherry-pick 的原理——都会变得一目了然。

3.4 工作区脏了、暂存区脏了、版本库脏了,你能区分吗

最后做一个小练习,确认你真的理解了。我描述四个状态,你试着对应到三个区域:

  • 状态 A:你刚改完一个文件,还没保存——这时候 Git 啥都不知道,因为编辑器没把改动落盘。
  • 状态 B:你改完了,保存了,但还没 git add——这时候工作区和暂存区不一致,git status 会用红色提示"Changes not staged for commit"。
  • 状态 C:你 git add 了,但还没 git commit——这时候暂存区和版本库不一致,git status 会用绿色提示"Changes to be committed"。
  • 状态 D:你 git commit 了——这时候三个区域全部一致,git status 会说"nothing to commit, working tree clean"。

往后看 git status 的输出,你脑子里要能立刻把它翻译成这四种状态之一。

四、Git 实操全流程:从初始化到提交、对比、撤销

理论部分到此为止。这一章我们老老实实从零开始,模拟一个真实项目的开发过程,把 Git 最常用的命令一条条过一遍。如果你边看边在自己电脑上敲一遍,效果最好。

4.1 安装与首次配置

Git 在主流操作系统上都有现成的安装包,直接去 git-scm.com 下载对应版本即可,Windows、macOS、Linux 都覆盖。安装完之后第一件事是验证:

git --version

只要能输出版本号,比如 git version 2.43.0,就说明安装成功了。

接下来是全局身份配置。这一步必做,因为 Git 在每一次 commit 的时候,都会把"是谁提交的"记录到 commit 对象里:

git config --global user.name "莫潇羽"
git config --global user.email "youremail@example.com"

--global 表示这套配置应用到当前操作系统用户的所有 Git 仓库,如果你想给某个特定仓库单独设置,可以去掉 --global 在那个仓库目录下执行。

顺手再开一个建议设置——给 git log 一个更好用的别名,后面会经常用到:

git config --global alias.lg "log --oneline --graph --decorate --all"

之后你输 git lg 就等于输入了一长串参数,显示效果是一棵漂亮的提交树。

4.2 初始化仓库

进入你的项目目录,执行:

git init

成功之后,这个目录里会多出一个隐藏的 .git 子目录——它就是版本库的真身。之后所有的暂存区状态、对象、引用,都存在这里面。不要手动去改 .git 里的东西,除非你知道自己在干什么。

如果你是从远端拷一个已有仓库,用的是另一个命令:

git clone https://github.com/some/repo.git

clone 等价于"先 init 一个空仓库 + 把远端的所有历史下载下来 + 把远端最新版的文件 checkout 到工作区"三件事的合并版本。

4.3 第一次提交:add 和 commit

在项目目录里随便建一个文件,比如 hello.py,内容写一行:

print("Hello, wuyi")

现在用 git status 看一下当前状态:

git status

输出会告诉你:hello.py 是一个"未跟踪文件"(Untracked file),意思是 Git 还不知道它的存在。我们把它加到暂存区:

git add hello.py

如果文件多,想一次性把所有改动都加进暂存区,可以用:

git add .

这个 . 代表当前目录下所有变化(包括子目录)。再 git status,你会看到 hello.py 现在变成了绿色的 "Changes to be committed"——它从工作区进入了暂存区。

接下来,提交到版本库:

git commit -m "init: 添加 hello.py,实现基础问候功能"

-m 后面跟的是提交说明,这一行非常重要,以后你回头看历史的时候,完全靠这一行字判断每次提交做了什么。莫潇羽我的建议是:每次提交,提交说明都用一句完整的话写清楚"这次改了什么、为什么改",不要写 update、fix、修改一下 这种没营养的内容,几个月后的你会感谢现在的你。

业界比较流行的提交说明规范叫 Conventional Commits,简单说就是 <类型>: <描述>,常见类型有:

类型

含义

例子

feat

新功能

feat: 增加用户注册接口

fix

修 bug

fix: 修复登录后跳转失败

refactor

重构,不改行为

refactor: 抽取通用工具函数

docs

改文档

docs: 补充 README 安装步骤

style

代码风格

style: 统一缩进和换行

test

测试相关

test: 增加边界条件用例

chore

杂项

chore: 更新依赖版本

4.4 看变化:status、diff、log

提交完之后,我们来感受一下 Git 提供的几个"观察工具"。

git status:看当前三个区域之间的差异,新手最该养成的习惯是"敲任何命令之前先 status 一下"。

git diff:看具体改了哪些行。不带参数时,它对比的是工作区和暂存区;git diff --staged(或 --cached)对比的是暂存区和最近一次提交;git diff HEAD 对比的是工作区和最近一次提交。

diff 的输出符号一定要看懂:

- print("Hello")
+ print("Hello, wuyi")

减号开头是被删掉的行,加号开头是新增的行,前面没符号的是上下文(没变)。

git log:看提交历史。不加参数时输出比较啰嗦,常用的几种姿势:

git log                       # 完整历史
git log --oneline             # 一行一条,简洁
git log --oneline --graph     # 带提交图,看分支结构
git log -p hello.py           # 看某个文件每次提交的具体改动
git log --author="莫潇羽"     # 看某个人的提交
git log --since="2 weeks ago" # 看最近两周的提交

如果你按 4.1 节的建议加了 git lg 别名,直接敲 git lg 一步到位。

4.5 修改、对比、再提交:常规循环

现在改一下 hello.py,把内容改成:

print("Hello, wuyi")
print("欢迎来到无忆研习社")

保存之后,git status 会显示这个文件是 modified(已修改),git diff 会显示具体加了哪一行。然后:

git add hello.py
git commit -m "feat: 支持中文问候"

这就是 Git 日常用得最多的循环:改 → status → diff → add → commit。习惯成自然之后,你会发现这个循环的节奏感非常舒服。

补一个进阶用法。如果你只想把"add + commit 已跟踪文件"一步搞定,可以用:

git commit -am "feat: 支持中文问候"

-a 的意思是"自动把所有已跟踪文件的修改加入暂存区然后提交"——注意它只对已跟踪文件生效,对全新的、从没 add 过的文件无效。

4.6 撤销和回滚:Git 给你的三种"后悔药"

这是新手最容易翻车的部分,我会慢一点讲。Git 的撤销能力非常强,但前提是你要分清自己"想撤销什么、撤到哪儿"。

场景 1:工作区改乱了,想丢掉所有未暂存的改动

也就是说,你改了几个文件,还没 git add,现在后悔了,想恢复成最近一次提交的样子:

git restore hello.py        # 丢弃 hello.py 的工作区改动
git restore .               # 丢弃所有文件的工作区改动

场景 2:已经 add 到暂存区了,想从暂存区里撤出来,但保留工作区改动

git restore --staged hello.py

这条命令把 hello.py 从暂存区"撤回"到工作区——文件本身的内容还是你刚才改完的样子,只是不再处于"待提交"状态。如果你撤完之后还想把工作区的改动也一起丢掉,再加一条 git restore hello.py。

场景 3:已经 commit 了,想撤销这次提交

这就是大家最头疼的 git reset 登场的时候了。reset 有三种模式,核心区别在于"撤销之后,工作区和暂存区怎么处理":

模式

版本库

暂存区

工作区

适用场景

--soft

回退

不变

不变

提交说明写错了,想合并到上一次重新提交

--mixed(默认)

回退

回退

不变

想重新选择哪些文件进入这次提交

--hard

回退

回退

回退

改的东西不想要了,彻底回到某个版本

具体怎么用:

git reset --soft HEAD~1     # 撤销上一次提交,改动保留在暂存区
git reset --mixed HEAD~1    # 撤销上一次提交,改动保留在工作区(默认)
git reset --hard HEAD~1     # 撤销上一次提交,所有改动都丢弃【危险!】

HEAD~1 表示"当前位置往前数一个提交"。同理 HEAD~3 就是往前三个。

--hard 是真的会丢东西的,用之前一定确认你不再需要那些改动。莫潇羽我自己的习惯是,任何 --hard 操作之前先 git status 看一眼,确认工作区没有自己还没提交的、有价值的改动。

场景 4:已经 push 到远端的提交,想撤销

这种情况就不能用 reset 了——reset 会改写历史,你本地改完了远端还是老样子,push 不上去;强推上去又会破坏队友的本地仓库。正确做法是用 revert:

git revert HEAD             # 新生成一个"反向操作"的提交,抵消上一次提交

revert 的关键特点是:它不删除任何历史,而是新生成一个提交,效果上抵消掉之前那次提交的改动。这样历史是连续的、可追溯的,适合在公共分支上用。

一句话记忆:reset 是改写历史,revert 是叠加历史。在私有分支、本地未推送的情况下,reset 更干脆;在公共分支、已推送的情况下,revert 更安全。

4.7 .gitignore:让 Git 闭上眼睛

实际项目里,工作目录下经常会有一堆你不想让 Git 管的东西——比如编译产物、依赖目录、本地配置文件、密码。这些东西通过一个叫 .gitignore 的文件告诉 Git "看到这些当没看见":

# Python
__pycache__/
*.pyc
.venv/

# Node
node_modules/

# 编辑器
.vscode/
.idea/

# 系统垃圾文件
.DS_Store
Thumbs.db

# 敏感信息
.env
config.local.json

.gitignore 文件本身要被 Git 跟踪并提交,这样团队所有成员才能共享同一份忽略规则。

五、Git 分支与远程协作:从单人开发到团队配合

到目前为止,我们都是在主干上写代码。一旦项目复杂起来、人多起来,你就必须用分支了。这一章我会把分支的本质、常用操作、以及和远程仓库联动的工作流梳理清楚。

5.1 分支到底是什么

回到第三章那个心智模型——分支只是一个指向某次提交的指针,本质上就是一个文件,内容就是某个 commit 的哈希值。所以"创建分支"就是新建一个指针、"切换分支"就是 HEAD 指过去、"删除分支"就是把指针文件删掉。

这就解释了为什么在 Git 里开分支是"白菜价"——成本几乎是零。我自己折腾下来的经验是:任何不确定的尝试,都先开一个分支去做。改坏了大不了把分支删掉,主干上一点痕迹都不留。

git branch                  # 列出本地所有分支,带星号的是当前分支
git branch feature/login    # 创建一个叫 feature/login 的新分支
git switch feature/login    # 切换到那个分支(Git 2.23+)
git checkout feature/login  # 老版本写法,等价

也可以一步到位"创建并切换":

git switch -c feature/login
# 等价于:
git checkout -b feature/login

切完分支之后,你做的所有 commit 都打在这条分支上,不会影响其他分支。

5.2 合并:把分支的成果汇回主干

功能开发完了、测试通过了,你想把这条分支的内容合并回主干(比如 main 分支)。常规做法:

git switch main             # 先切回主干
git merge feature/login     # 把 feature/login 合并进来

合并的结果有两种情况:

第一种,Fast-Forward(快进合并)。如果从你开分支到现在,主干没动过,那合并就是把主干指针往前挪到分支当前位置,不会产生新的 commit,历史是一条直线。

第二种,三方合并(Three-way Merge)。如果你开分支之后主干也有了新提交,Git 会自动找到两条分支的共同祖先,把双方各自的改动合到一起,生成一个新的"合并提交"。

如果两条分支动了同一个文件的同一个区域,就会出现冲突(merge conflict)。Git 会停下来让你手动解决,具体做法是:

  1. git status 找出冲突的文件;
  2. 打开文件,你会看到 <<<<<<<、=======、>>>>>>> 这样的标记,Git 用它们划出双方的版本;
  3. 手动编辑文件,决定最终保留什么内容,删掉那些标记;
  4. git add 标记冲突已解决;
  5. git commit 完成合并。

5.3 远程协作:本地仓库怎么和远端联动

到这里我们一直在本地玩。要和团队协作,就得引入远端仓库(remote)。

git remote -v                          # 看当前关联了哪些远端
git remote add origin git@xxx:xxx.git  # 把本地仓库关联一个叫 origin 的远端

origin 是约定俗成的默认远端名称,不是关键字,你想叫别的名字也可以。

把本地提交同步到远端:

git push origin main          # 把本地的 main 推到 origin 的 main
git push -u origin main       # 第一次推送时加 -u,以后直接 git push 就行

从远端拉取最新内容:

git fetch origin              # 只下载,不合并
git pull origin main          # 下载并合并到当前分支(= fetch + merge)

新手最容易混淆的就是 fetch 和 pull 的区别。我的口诀是:fetch 是"看一眼远端有啥新东西",pull 是"看一眼并立刻应用到我本地"。如果你不确定远端的新提交会不会和自己本地的改动打架,先 fetch 再用 git log 看一下情况,确认无误再 merge,会比 pull 一把梭安全很多。

5.4 一套自用的工作流:基于 feature 分支的小团队协作

讲完命令,讲一下我自己用得最顺手的流程,适合三五个人到十几个人的小团队:

  • main 分支:始终保持可发布状态,只接受经过审核的合并。
  • 每个功能开一条独立 feature 分支,命名规范化,比如 feature/<jira单号>-<简短描述>。
  • 开发者在自己的 feature 分支上自由提交,提交说明按 Conventional Commits 走。
  • 完成后提交 Pull Request(或 Merge Request),代码评审通过后由维护者合并回 main。
  • 合并后立刻删掉对应的 feature 分支,保持仓库整洁。

这个流程的好处是:main 分支永远干净,任何时候都能发布;feature 分支是最小可独立 review 的单元;出问题只需要回退对应的合并提交,不会牵连无关功能。莫潇羽@无忆研习社 在自己几个长期维护的项目里都是这套流程,跑得很顺。

5.5 实用小技巧合集

最后塞几个我用得最多的小技巧:

# 临时把工作区改动收起来,做完别的事再恢复
git stash
git stash pop

# 看某一次提交具体改了什么
git show <commit-hash>

# 把别的分支上某一个 commit 单独拿过来
git cherry-pick <commit-hash>

# 修正上一次提交(改提交说明、追加遗漏的文件)
git commit --amend

# 看每一行代码是谁、什么时候写的
git blame <file>

# 撤销 reset --hard 之后的"无敌救命招"
git reflog       # 找到丢失的提交哈希,然后 git reset --hard <hash> 救回来

git reflog 这条特别想强调——很多人不知道,Git 默认会把你所有的 HEAD 移动记录留 90 天,哪怕你 --hard 把提交"删了",只要还没过期,reflog 里都能找回来。所以真出事的时候,别慌,先 reflog 看看。

六、Docker 的本质:为什么我们需要"集装箱"

讲完 Git,我们切到 Docker 这条线。还是老规矩,先把"为什么"讲清楚,再讲"怎么做"。

6.1 一个让无数开发者破防的场景

我相信几乎每个写代码三年以上的人,都有过这种崩溃时刻——

你本地环境上跑得好好的一个项目,推到测试服务器上死活起不来。你开始排查:Python 版本?好像是一样的。依赖装齐了吗?好像是齐的。环境变量?嗯,这台机器上少了一个。改完之后还是不行,因为底层的 C 库版本不对,系统又是公司基础架构组管的,你没权限随便升。然后你不得不去和运维拉群、开会、对齐环境……一上午就没了。

这就是软件交付里最经典的"在我电脑上是好的"问题。它的根本原因是:软件运行需要的不只是源代码,还有它依赖的整个环境——操作系统库、语言运行时、第三方包、配置文件、环境变量。这些东西在每台机器上都不一样,差一点就跑不起来。

Docker 在解决的事情,本质上就是:把"应用 + 它需要的所有运行环境"打包成一个标准化的、可移植的单元,在任何支持 Docker 的机器上都能以一模一样的方式跑起来。

6.2 "集装箱"这个隐喻为什么贴切

Docker 这个英文单词的本意是"码头工人"。这个命名不是随便起的,它背后有一个非常优雅的类比——集装箱革命。

在集装箱发明之前,海运货物五花八门:一袋大米、一桶油、一箱苹果、一捆钢筋。每种货物的搬运方式都不一样,装船、卸船效率极低,而且经常磕碰损坏。集装箱出现之后,所有货物都先装进标准尺寸的箱子,船、起重机、卡车、火车的接驳口都按这个标准设计——全球货运效率一下子起飞。

软件世界长期面临着同样的问题:每个应用需要的环境都不一样,部署方式也不一样,运维就像码头工人一样累。Docker 就是软件领域的集装箱——你把应用打包进一个标准的"箱子",任何能识别这个箱子的"机器"(也就是 Docker 引擎)都能拿来就跑,不用关心箱子里具体是 Python 还是 Node、是 MySQL 还是 Redis。

6.3 容器和虚拟机的区别:吃透这道经典面试题

聊 Docker 绕不开和虚拟机的对比。这两者都能"在一台物理机上跑出多个互相隔离的环境",但底层机制完全不同,直接决定了它们的性能差异。

虚拟机(VM):在物理硬件之上,有一个叫 Hypervisor 的虚拟化管理层,Hypervisor 之上可以跑多个完整的客户操作系统。每个虚拟机都有自己独立的内核、独立的内存、独立的文件系统、独立的网络栈。隔离非常彻底,但代价是开销大——启动慢、占内存多、CPU 利用率有损耗。

容器(Container):不再虚拟整套操作系统,而是利用 Linux 内核自身的隔离能力(namespace + cgroups),让多个进程在共享同一个宿主机内核的前提下,看起来像在各自独立的系统里运行。

我经常用一个房子的比方解释这两者:

  • 虚拟机像一栋公寓楼,每户都是独立户型,各有厨房、卫生间、卧室。隔音好、隐私强,但占地大、造价高。
  • 容器像一套合租房,每个室友有自己的房间,但厨房、卫生间共用。空间利用率高、成本低,但隔离没那么彻底。

对比维度

虚拟机

容器

隔离机制

完整 OS 内核

共享宿主机内核

启动速度

几十秒到几分钟

秒级、毫秒级

资源开销

大(每个 VM 都是完整系统)

小(只多了一层隔离)

镜像大小

GB 级

几十 MB 到几百 MB

隔离强度

强

弱(共享内核是代价)

适用场景

需要强隔离、跨内核

应用部署、微服务、CI/CD

理解了这个差异,你就明白为什么容器特别适合用来跑应用代码——轻、快、便宜,而且足够安全。

6.4 一个常见误区:容器不是"小型虚拟机"

很多新手以为容器就是个轻量级虚拟机,这个理解会带来不少误导。最关键的区别是:容器没有自己的内核。

举个例子,Linux 内核里没有的东西,容器里也不会凭空有。你在 macOS 上用 Docker 跑 Linux 容器,看起来好像"在 macOS 上有了 Linux 系统",其实底层是 Docker Desktop 偷偷起了一个轻量级的 Linux 虚拟机,真正的容器是跑在那个虚拟机的 Linux 内核上的。

所以下一个推论是:Windows 上的 Docker 没法直接跑 Windows 容器和 Linux 容器,因为它俩需要的内核完全不同。你要么用 Linux 容器(底层是 Linux VM),要么用 Windows 容器(需要 Windows Server 那一套机制),不能混着来。

6.5 Docker 解决了什么,以及它没解决什么

我喜欢在介绍工具的时候同时讲它的边界,免得给新手过度承诺。Docker 解决得很好的几件事:

  • 开发、测试、生产环境一致性问题——一份镜像走天下;
  • 应用快速分发——镜像一推一拉,远比手搓部署脚本省事;
  • 资源隔离——多个应用挤一台机器互不打架;
  • 微服务架构的天然载体——每个服务一个容器,职责清晰。

Docker 没有直接解决的问题:

  • 多容器编排——一两个容器还行,几十上百个容器的协调、调度、负载均衡、滚动更新,需要 Kubernetes 这种编排工具;
  • 数据持久化——容器本身是无状态的,数据要落盘必须借助数据卷(后面会讲);
  • 跨主机网络——单机内的容器之间通信很简单,跨主机就要引入 overlay 网络或者直接上 K8s;
  • 安全隔离——共享内核意味着容器逃逸的风险天然存在,安全要求极高的场景该上虚拟机还是得上。

把这条线划清楚之后,你就不会被"Docker 万能论"带跑偏。它是个非常好的工具,但它解决的是特定层面的问题,配合 K8s、CI/CD 流水线、监控告警体系一起用,才是完整的现代部署栈。

七、Docker 六大核心概念全景图

要真正上手 Docker,有六个概念必须烂熟于心,而且不是孤立记忆,要建立它们之间的关系图。这一章我把它们一次铺开。

7.1 镜像(Image):静态的"类"

镜像是 Docker 体系里最基础的一块,可以把它类比成面向对象里的"类"——一个只读的模板,封装了应用运行所需的一切:代码、运行时(比如 Python、Node.js)、第三方依赖、环境变量、配置文件,甚至包括一份精简的 Linux 文件系统。

镜像的几个关键特性:

  • 只读:一旦构建出来,内容就固定了,任何运行时的修改都不会落到镜像上;
  • 分层(Layered):镜像是由一层一层叠加而成的,每一层对应构建过程中的一个步骤——比如"基于 Python 3.11"是一层,"安装 requirements 里的依赖"是一层,"拷贝源代码进去"又是一层;
  • 可共享:多个镜像如果共用某些底层(比如都基于同一个 Python 3.11),那些层在磁盘上只存一份,新建镜像非常省空间;
  • 不可变(Immutable):你不会"改一个镜像",而是基于它构建一个新的镜像。

这种分层设计带来的好处是巨大的。举个例子,你有一个 Python 项目,基础镜像 300MB,依赖装完 500MB,代码 5MB。每次发版只有代码层在变,前面 800MB 一动不动,推送/拉取的时候只需要传输那 5MB 的变化层,效率极高。

7.2 容器(Container):动态的"实例"

容器是镜像的运行实例,等价于"类的对象"。一个镜像可以同时被启动成多个容器,它们彼此完全隔离、互不感知。

容器有两层结构:

  • 下面是镜像层(只读,共享);
  • 上面是容器自己的可写层(读写,每个容器独有)。

你在容器里写文件、改文件,改动都发生在可写层。一旦容器被删除,可写层的内容就消失了——这一点非常关键,直接影响后面我们要讲的"数据卷"为什么存在。

容器的生命周期可以简化成几个状态:

   ┌─────────┐  docker run  ┌─────────┐  docker stop  ┌─────────┐
   │  镜像   │ ───────────→ │ 运行中  │ ─────────────→ │ 已停止  │
   └─────────┘              └─────────┘ ←─────────────└─────────┘
                                  ↓     docker start       
                            docker rm
                                  ↓
                            ┌─────────┐
                            │   销毁  │
                            └─────────┘

7.3 仓库(Repository):镜像的"图书馆"

仓库就是集中存放和分发镜像的地方。它分两种:

  • 公有仓库:任何人都能上传、下载,典型代表是 Docker Hub(hub.docker.com)。常用的官方镜像比如 nginx、redis、mysql、python、node 都在上面;
  • 私有仓库:企业自建,用来存内部不公开的镜像。常见的有 Harbor、自建 registry,以及各家云厂商提供的镜像服务。

每个仓库下可以放同一个镜像的多个版本,版本通过标签(tag)区分。镜像的完整名字格式是 <仓库名>:<标签>,比如:

  • nginx:latest(最新版,默认标签)
  • nginx:1.25.3(具体版本)
  • python:3.11-slim(精简版的 Python 3.11)
  • myregistry.com/myteam/myapp:v1.0.0(私有仓库下的特定版本)

latest 是个很有迷惑性的标签,它不是"永远拉最新的"的意思,而是字面上一个叫 latest 的标签,通常约定指向最新版。生产环境强烈不建议用 latest,因为它会随时间漂移,你今天部的和明天部的可能根本不是一个版本。生产环境一律用具体版本号,这是我自己折腾下来最深的一个教训。

7.4 Dockerfile:构建镜像的"菜谱"

Dockerfile 是一个文本文件,里面写的是"怎么从零构建出一个镜像"的指令。我喜欢的比喻是菜谱——告诉你第一步用什么食材、第二步怎么处理、第三步加什么调料……最终做出来的那盘菜就是镜像,而容器是你把这盘菜从真空包装里拿出来加热享用的样子。

一份典型的 Python 应用 Dockerfile 长这样:

# 选基础镜像
FROM python:3.11-slim

# 设置工作目录
WORKDIR /app

# 先拷贝依赖描述文件,利用层缓存
COPY requirements.txt .

# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt

# 再拷贝源代码
COPY . .

# 声明容器对外暴露的端口
EXPOSE 5000

# 容器启动时执行的命令
CMD ["python", "app.py"]

每一行 Dockerfile 指令都会生成镜像的一个层。理解这一点之后,你就能写出更高效的 Dockerfile——把变动频率低的指令放在前面、变动频率高的放在后面。比如依赖文件一般几个月才变一次,源代码每天都在变,所以先拷 requirements、装依赖,再拷源代码,这样改源代码不影响依赖那一层的缓存,构建速度会快很多。

Dockerfile 最常用的指令我列一份小抄:

指令

作用

例子

FROM

指定基础镜像

FROM python:3.11-slim

WORKDIR

设置后续指令的工作目录

WORKDIR /app

COPY

把宿主机文件拷贝进镜像

COPY . /app

ADD

类似 COPY,但能解压、能下载 URL

ADD app.tar.gz /app

RUN

在构建时执行一条命令

RUN pip install -r requirements.txt

ENV

设置环境变量

ENV LANG=zh_CN.UTF-8

EXPOSE

声明容器内监听的端口

EXPOSE 5000

CMD

容器启动时默认运行的命令

CMD ["python", "app.py"]

ENTRYPOINT

容器启动时强制运行的命令

ENTRYPOINT ["python"]

VOLUME

🔒
该内容仅对更高等级社区用户开放
请谨慎解锁时效性强且发布日期较早的文章
单篇解锁后若未显示全文请刷新页面
您当前:游客 · 可见 60% 内容 · 升级至 注册用户 可见 70%
👀
游客
可见 60%
✓ 当前
注册用户
注册用户
可见 70%
社区精英
社区精英
可见 100%
社区守护
社区守护
可见 100%
仅解锁本文,永久有效。如需PDF珍藏版,请联系站长获取。 当前单篇价格 ¥9.9
✏️ 发表评论

请先登录后发表评论

前往登录
📊 站点统计
今日发布0 篇
文章总数1107 篇
昨日发布1 篇
本月发布31 篇
建站时间456 天
🔍 搜索
站长微语

联系站长

QQ:2805463528
AIGC 技术社区
致力于解码 AI前沿技术 与经验分享
纯粹的技术应用社区

💡 欢迎您的建议与反馈,让社区变得更好

快速通道
联系站长
站长QQ二维码
AI交流群
AI交流群
仍在路上

那些寒夜里追赶过的方向

那些冷眼下没放弃的理想

一篇一篇写到现在

仍在路上

"不羁放纵爱自由"

—— 致敬 Beyond
持续创作中 莫潇羽 · 无忆研习社
▲