本文由 莫潇羽@无忆研习社(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 |
新功能 |
|
|
fix |
修 bug |
|
|
refactor |
重构,不改行为 |
|
|
docs |
改文档 |
|
|
style |
代码风格 |
|
|
test |
测试相关 |
|
|
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 有三种模式,核心区别在于"撤销之后,工作区和暂存区怎么处理":
|
模式 |
版本库 |
暂存区 |
工作区 |
适用场景 |
|
|
回退 |
不变 |
不变 |
提交说明写错了,想合并到上一次重新提交 |
|
|
回退 |
回退 |
不变 |
想重新选择哪些文件进入这次提交 |
|
|
回退 |
回退 |
回退 |
改的东西不想要了,彻底回到某个版本 |
具体怎么用:
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 会停下来让你手动解决,具体做法是:
git status找出冲突的文件;- 打开文件,你会看到
<<<<<<<、=======、>>>>>>>这样的标记,Git 用它们划出双方的版本; - 手动编辑文件,决定最终保留什么内容,删掉那些标记;
git add标记冲突已解决;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 最常用的指令我列一份小抄:
|
指令 |
作用 |
例子 |
|
|
指定基础镜像 |
|
|
|
设置后续指令的工作目录 |
|
|
|
把宿主机文件拷贝进镜像 |
|
|
|
类似 COPY,但能解压、能下载 URL |
|
|
|
在构建时执行一条命令 |
|
|
|
设置环境变量 |
|
|
|
声明容器内监听的端口 |
|
|
|
容器启动时默认运行的命令 |
|
|
|
容器启动时强制运行的命令 |
|
|
|