重构 改善既有代码的设计

ch1: Intro

如果你要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其比较容易添加该特性,然后再添加该特性。

无论每次重构多么简单,养成重构后即运行测试的习惯非常重要。
犯错误是很容易的——至少我知道我是很容易犯错的。做完一次修改就运行测试,这样在我真的犯了错时,只需要考虑一个很小的改动范围,这使得查错与修复问题易如反掌。
这就是重构过程的精髓所在:小步修改,每次修改后就运行测试。如果我改动了太多东西,犯错时就可能陷入麻烦的调试,并为此耗费大把时间。小步修改,以及它带来的频繁反馈,正是防止混乱的关键。

  • 这章用了巨量的篇幅来修改一个几十行的js代码,从而说明了一个良好的早期架构是有多么的重要,一旦那些架构混乱的项目开始变得复杂,就算是神仙来了也未必能够轻易看懂并重构

关键点: 尽可能多的使用OOP,通过多态,继承,接口来实现代码复用和类型统一;通过将复杂表达式拆分为工具函数并择合适的名字来增强代码的可读性

ch2: 重构的原则

重构有两种词性,一种是动词,一种是名词:

  • 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。如果丑陋的代码能被隐藏在一个 API 之下,我就可以容忍它继续保持丑陋。只有当我需要理解其工作原理时,对其进行重构才有价值。
另一种情况是,如果重写比重构还容易,就别重构了。这是个困难的决定。如果不花一点儿时间尝试,往往很难真实了解重构一块代码的难度。决定到底应该重构还是重写,需要良好的判断力与丰富的经验,我无法给出一条简单的建议。

如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作。这也是我鼓励持续集成的原因:有了 CI,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。自测试的代码也是持续集成的关键环节,所以这三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。

ch3: 代码的坏味道

需要重构的特征有以下几个:

  1. 难以捉摸的命名
  2. 重复的代码段
  3. 函数太长: 将值得用注释说明的部分拆分成函数
  4. 过长参数列表: 使用类来传入参数
  5. 全局数据: 用函数或者类来封装这个全局数据,尽量控制其作用域
  6. 可变数据: 如果一个数据有不同的用途,最好将它按照用途分成不同的类
  7. 模块边界不清晰
  8. 修改一次需要在多个地方更改

ch4: 构筑测试体系

确保所有测试都完全自动化,让它们检查自己的测试结果。

频繁地运行测试。对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。

考虑可能出错的边界条件,把测试火力集中在那儿。

每当你收到 bug 报告,请先写一个单元测试来暴露这个 bug。

ch5: 过渡章节

本书剩余的篇幅是一份重构的名录。最初这个名录只是我的个人笔记,我用它来提示自己如何以安全且高效的方式进行重构。然后我不断精炼这份名录,对一些重构的深入探索又引出了更多的重构手法。对于不太常用的重构手法,我还是会不断参阅这份名录。

ch6: 第一组

提炼函数(Extract Function)

用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();

//print details
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}

function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
printDetails(outstanding);

function printDetails(outstanding) {
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
}
}

对于“何时应该把代码放进独立的函数”这个问题,我曾经听过多种不同的意见。有的观点从代码的长度考虑,认为一个函数应该能在一屏中显示。有的观点从复用的角度考虑,认为只要被用过不止一次的代码,就应该单独放进一个函数;只用过一次的代码则保持内联(inline)的状态。但我认为最合理的观点是“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。

如果想要提炼的代码非常简单,例如只是一个函数调用,只要新函数的名称能够以更好的方式昭示代码意图,我还是会提炼它;但如果想不出一个更有意义的名称,这就是一个信号,可能我不应该提炼这块代码。不过,我不一定非得马上想出最好的名字,有时在提炼的过程中好的名字才会出现。有时我会提炼一个函数,尝试使用它,然后发现不太合适,再把它内联回去,这完全没问题。只要在这个过程中学到了东西,我的时间就没有白费。

  • “如果需要返回的变量不止一个,又该怎么办呢?”

有几种选择。最好的选择通常是:挑选另一块代码来提炼。我比较喜欢让每个函数都只返回一个值,所以我会安排多个函数,用以返回多个值。如果真的有必要提炼一个函数并返回多个值,可以构造并返回一个记录对象—不过通常更好的办法还是回过头来重新处理局部变量

内联函数(Inline Function)

这里的内联指的是将不必要的中间层删除,从而让函数更加清晰

1
2
3
4
5
6
7
8
9
10
11
12
function getRating(driver) {
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(driver) {
return driver.numberOfLateDeliveries > 5;
}


function getRating(driver) {
return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}
  • 这显然与前面说的提炼函数正好相反,从而说明重构并不是一个简单的活儿,你不好判断加入函数和删除函数这两种做法哪一种会让代码更清晰

break&总结

后面的部分都是一些具体用例了,大部分内容都需要真正去实践才能体会,所以就不建议去看了.

提炼一下本书的精华:

  1. 软件的初步架构需要是合理的,工程化的,否则后期的重构难度甚至超过推翻重写
  2. 重构一般是一次一小步进行的,如果你的重构会让项目暂时无法运行,说明你做的不是重构
  3. 重构的方法有以下几种:
    1. 提炼/删除 函数
    2. 用类来存放函数和变量
    3. 去除不必要的全局变量
    4. 改一个好的名字
  4. 重构与添加新功能可以是同时进行的

程序员自我修养

  • 讲的很深,可惜的是逻辑比较混乱,如果能再版后重构一下就真的是神书了

OUTLINE

  1. 简介
  2. 编译和链接
  3. 目标文件里有什么
  4. 静态链接
  5. windows PE/COFF
  6. exe的装载与进程
  7. 动态链接
  8. Linux的共享库
  9. Windows中的动态链接
  10. 内存
  11. 运行库
  12. 系统调用与API
  13. 运行库的实现

编译和链接

程序运行的过程

当我们使用GCC编译Hello World程序时,只需要这样写:

1
2
gcc hello.c -o ./a.out
# './a.out'是文件名和路径,后缀名可以随便起,写成tho没有后缀或者a.xyz也可以

上述过程可以分解为4个步骤:

  1. 预处理(Preprocessing)
  2. 编译(Compilation)
  3. 汇编(Assembly)
  4. 链接(Linking)

预处理

c文件和h文件会被预处理成.i文件,cpp文件和hpp文件会被预处理为.ii文件.

  • 对应的命令为gcc -E hello.c -o a.i
    该阶段主要处理源代码中以"#"打头的预编译指令,如’#include’,'#define’等,主要运行过程如下:
  1. 将所有的"#define"删除,并展开所有的宏定义,比如,将含有"#define PI 3.14"的文件中的所有PI替换为3.14
  2. 处理所有的条件预编译指令,如"#if","endif"等
  3. 处理"#include",将被包含的文件插入到文件中该预编译指令所在的行,该过程是递归执行的
  4. 删除所有的注释"//“和”/* */"
  5. 添加行号和文件名标识,如 " #2 “hello.c” 2 ",这就是我们在程序报错的时候看到的那些行号和文件名的来历,至于行尾的2,是一个给编译器看的标志位
  6. 保留所有的"#pragma"指令

因此,经过预处理后的.i文件不包含任何宏定义,包含的文件也被插入到.i文件中

编译

对.i文件进行一系列词法分析,语法分析,语义分析和优化,生成相应的汇编代码文件.

  • 对应的命令为gcc -S hello.i -o hello.s

汇编

根据汇编代码构建目标文件

  • 对应的命令为gcc -c hello.s -o hello.o
    • 或者一步完成: gcc -c hello.c -o hello.o

链接

1
ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -1gcc -1gcc_eh -1c -end-group crtend.o crtn.o

可以看到需要链接一堆文件才可以得到最终的可执行文件

编译的详细原理

下面我们来以一段简单的c语言代码为例来分析编译的全过程:

1
array[index] = (index+4)*(2+ 6)

词法分析

源代码被输入到扫描器(Scanner),产生一系列记号:关键字,标识符,数字,字符串和特殊符号(加号,等号)等.

语法分析

语法分析器(Grammar Parser)对扫描器产生的记号进行语法分析,产生由表达式组成的语法树(Syntax Tree)
alt text

语义分析

语义分析器(Semantic Analyzer)对表达式进行静态的语义分析,标识各个表达式的类型;动态语义则只能在运行期确定.
alt text

中间代码的生成

现代编译器会对源代码进行优化,将整个语法树转换成中间代码,尽管非常接近目标代码,但它与运行的操作系统无关,不包含数据尺寸,变量地址和寄存器名字等信息.
根据中间代码可以把编译器分为前端和后端.前端负责产生于操作系统无关的中间代码,后端负责将中间代码转换成目标文件
Java 编译体系 (Bytecode)

前端:javac。它将 .java 源码编译成与平台无关的 Java Bytecode (.class 文件)。这就是所谓的中间代码。

后端:JVM (Java Virtual Machine) 中的 JIT 编译器 (如 C1、C2)。当程序运行时,JIT 将字节码转换为当前运行机器(Windows x64、Linux ARM 等)的具体指令集。

目标代码的生成与优化

编译器后端主要包括代码生成器目标代码优化器二者.
代码生成器将中间代码转换成目标机器代码,例如:

1
2
3
4
5
6
7
8
t1 = y + z
x = t1

->

mov rax, QWORD PTR [rbp-8] ; 将变量 y 加载到寄存器 rax
add rax, QWORD PTR [rbp-16] ; 将变量 z 的值加到 rax 中
mov QWORD PTR [rbp-24], rax ; 将结果 rax 存回变量 x 的内存地址

然后由目标代码优化器对上述目标代码进行优化,比如选择合适的寻址方式,使用位移来代替乘法运算,删除冗余指令等

总结

经过这么多步骤后,源代码被编译成了目标代码,但有一个问题,变量的存储地址还没有确定,而且如果这个变量是来自其他模块的话又该怎么办?这就是链接派上用场的地方了

链接概览

alt text
链接有以下几个步骤:

  1. 地址和空间分配(Address and Storage Allocation)
  2. 符号决议(Symbol Resolution)
  3. 重定位(Relocation)

补充: 编译全过程;编译器的前端和后端

由于书上对这些概念没有做一个很清晰的介绍,因此我再在这里做一点辨析方便后续的阅读:
流水线解释

  • 预处理: 转换宏定义,删除注释
  • 编译(狭义): 将cpp源码翻译成汇编代码(人类可读)
  • 汇编:
    • 将汇编代码翻译成机器指令(二进制码)
    • 根据机器指令,地址位置等信息构造目标文件
  • 链接: 将目标文件与系统库,用户库关联起来,得到可执行文件
  • 编译(广义): 由于大多数人对cpp的装载过程没有一个清晰的认识,故通常使用编译代指从.cpp.exe的全过程,也就是说我们一般都用广义的编译概念,很少特指"真正的编译"

但是,我们所用的编译器如gcc,clang等都是广义上的编译器,也就是说不仅仅做的是编译,而是包揽了从.cpp.exe的全构建过程
如果用前端和后端的概念来划分的话,是这样的:

前端(Frontend)

范畴: 仅包含“编译”这一步的前半部分。

  • 输入: 预处理后的源码。
  • 任务: 词法分析(Lexical Analysis)、语法分析(Syntax Analysis)、语义分析(Semantic Analysis)、生成中间表示(IR, Intermediate Representation)
  • 特性: 与具体的硬件架构(如 x86、ARM)无关,只与 C++ 语言本身的规则有关。

后端(Backend)

范畴: 包含“编译”这一步的后半部分,以及“汇编”的全部。

  • 任务: * 中端优化(Optimizer):对 IR 进行架构无关的优化。
    • 代码生成(Code Generator):将 IR 转换为特定硬件的汇编代码
    • 汇编器(Assembler):将汇编代码转换为机器指令,产出目标文件。
  • 特性: 强依赖于硬件架构。

其他项

  • 预处理(Preprocessing):通常被视为编译前的“文本清洁工作”,不属于狭义编译器(Compiler Core)的前后端逻辑。
  • 链接(Linking):属于编译链的下游,是一个独立的二进制处理过程,不属于编译器(Compiler)的范畴。

目标文件: 汇编的产物

目标文件的格式

可执行文件的格式主要有Windows中的PE(Portable Executable,不是那个重装windows用的Preinstallation Environment)和Linux中的ELF(Executable Linkable Format),两者都是COFF(Common file format)的变种.从广义上看,可执行文件的格式与目标文件基本相同,故这里将它们看作一种类型的文件,在Windows中称为PE-COFF文件格式,在Linux中称为ELF文件格式.(也就是说我们这里把目标文件就看成是ELF文件)

  • 事实上,动态链接库(DLL,Dynamic Linking Library)(Windows.dll和Linux的.so)和静态链接库(Static Linking Library)(Windows的.lib和Linux的.a)的存储方式也是可执行文件.

更为标准的分类方法如下:

ELF 文件类型 说明 实例
可重定向文件
(Relocatable File)
包含代码和数据,可被用来链接成可执行文件或共享目标文件,静态链接库也归为此类。 Linux 的 .o
Windows 的 .obj
可执行文件
(Executable File)
包含可以直接执行的程序。 Linux 的 /bin/bash
Windows 的 .exe
共享目标文件
(Shared Object File)
包含代码和数据。可由链接器与其他可重定向/共享目标文件链接产生新目标文件;或由动态链接器与可执行文件结合,作为进程映像的一部分运行。 Linux 的 .so
Windows 的 DLL
核心转储文件
(Core Dump File)
当进程意外终止时,系统将该进程的地址空间内容及终止时的其他信息转储到该文件。 Linux 下的 core dump

ELF文件的内容

alt text

目标文件将不同类型的信息用段(section)的形式存储:

  1. File Header: 描述了整个文件的文件属性,包括文件是否可执行,目标硬件,目标操作系统等信息;还包括一个段表(section table),描述目标文件中各段的属性
    1. 使用C语言的结构体来定义
  2. .text section: 保存汇编得到的及其代码
  3. .data section: 保存已经初始化的全局变量和局部静态变量
  4. .bss section: 保存未初始化的全局变量和局部静态变量,由于它们都是0,故没有必要放入.data段中,同时,由于程序运行时需要记录这两类变量,因此需要用.bss段来额外预留位置.该段并没有内容,故在目标文件中也不占据空间.

bss的来历
BSS(Block Started by Symbol)来源于1950年代IBM大型机的汇编器中的一个伪指令,后来被引入标准汇编器FAP中,用于定义符号并且为该符号预留给定数量的未初始化空间

整体来说,源代码被编译后分成两段:程序指令(代码段),程序数据(数据段和.bss段).

事实上,ELF文件的内部结构比上述所说的四段式结构要复杂的多:
alt text

  • [ 0] (NULL):索引为 0 的段物理上必须存在且全为空,用于标识无效引用。
  • [ 1] .text代码段。存放程序经过编译后的物理机器指令。
  • [ 2] .rel.text代码重定位段。记录 .text 段中哪些物理地址需要在链接时进行修正。
  • [ 3] .data已初始化数据段。存放程序中已初始化的全局变量和局部静态变量。
  • [ 4] .bss未初始化数据段。为未初始化的全局变量预留的物理占位符,在文件中不占实际磁盘空间。
  • [ 5] .rodata只读数据段。存放常量(如字符串常量、const 修饰的变量)。
  • [ 6] .comment注释段。物理记录编译器版本信息(如 “GCC: (GNU) …”)。
  • [ 7] .note.GNU-stack堆栈属性段。物理标识堆栈是否可执行,用于系统安全防御(NX 位)。
  • [ 8] .shstrtab段表字符串表。存储所有段名(如 “.text”, “.data”)的物理字符串池。
  • [ 9] .symtab符号表。记录程序中定义和引用的所有函数名、变量名及其物理偏移。
  • [10] .strtab字符串表。存储符号表中所使用的所有名称字符串。

下面我们来一个个解析:

文件头,段表,重定位表

文件头

本书使用的示例c程序分析得到的文件头内容如下:

  • Magic (魔数)7f 45 4c 46 01 01 01 00 ...
    • 物理意义:文件开头的 16 个字节,用于标识该文件是一个 ELF 格式的可执行或目标文件。
  • Class (类别)ELF32
    • 物理意义:该文件是为 32 位 架构设计的。
  • Data (数据存储方式)2's complement, little endian
    • 物理意义:采用二补码形式,且为 小端序(低位字节存储在低地址)。
  • Version (版本)1 (current)
    • 物理意义:当前 ELF 格式的版本号。
  • OS/ABI (操作系统/接口)UNIX - System V
    • 物理意义:该文件遵循的物理调用约定标准。
  • Type (文件类型)REL (Relocatable file)
    • 物理意义:这是一个可重定位文件(通常为 .o 文件),尚未经过链接。
  • Machine (硬件平台)Intel 80386
    • 物理意义:物理运行的目标指令集架构。
  • Entry point address (入口地址)0x0
    • 物理意义:由于是可重定位文件,尚未装载,因此物理入口地址为 0。
  • Start of program headers (程序头起点)0 (bytes into file)
    • 物理意义:目标文件中通常不包含程序头表(Program Header Table),该表仅在可执行文件中存在。
  • Start of section headers (段表起点)280 (bytes into file)
    • 物理意义:**段表(Section Header Table)**在文件内部的物理偏移地址。
  • Size of this header (ELF 头大小)52 (bytes)
    • 物理意义:ELF Header 本身物理占据的字节长度。
  • Size of section headers (单段描述符大小)40 (bytes)
    • 物理意义:段表中每个条目物理占据的空间。
  • Number of section headers (段的数量)11
    • 物理意义:该文件物理包含 11 个段(如 .text, .data, .bss 等)。
  • Section header string table index (段表字符串表索引)8
    • 物理意义:存储段名字符串的表在段表中的物理下标。

事实上,这些信息是以C语言的结构体存储的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct {
unsigned char e_ident[16]; // 物理魔数区(含架构、字节序信息)
Elf32_Half e_type; // 物理文件类型
Elf32_Half e_machine; // 物理硬件平台
Elf32_Word e_version; // 物理版本
Elf32_Addr e_entry; // 物理程序入口地址
Elf32_Off e_phoff; // 程序头表物理偏移
Elf32_Off e_shoff; // 段表物理偏移
Elf32_Word e_flags; // 处理器标志位
Elf32_Half e_ehsize; // ELF 头物理大小
Elf32_Half e_phentsize; // 单个程序头描述符大小
Elf32_Half e_phnum; // 程序头描述符数量
Elf32_Half e_shentsize; // 单个段表描述符大小
Elf32_Half e_shnum; // 段表描述符数量
Elf32_Half e_shstrndx; // 段名字符串表所在段的索引
} Elf32_Ehdr;

在 ELF 文件格式定义中,为了屏蔽不同平台下 intlong 长度不一带来的物理对齐问题,官方定义了一套标准数据类型:

自定义类型 描述 原始类型 长度(字节)
Elf32_Addr 32 位版本程序地址 uint32_t 4
Elf32_Half 32 位版本无符号短整型 uint16_t 2
Elf32_Off 32 位版本偏移地址 uint32_t 4
Elf32_Sword 32 位版本有符号整型 int32_t 4
Elf32_Word 32 位版本无符号整型 uint32_t 4
Elf64_Addr 64 位版本程序地址 uint64_t 8
Elf64_Half 64 位版本无符号短整型 uint16_t 2
Elf64_Off 64 位版本偏移地址 uint64_t 8
Elf64_Sword 64 位版本有符号整型 int32_t 4
Elf64_Word 64 位版本无符号整型 uint32_t 4

带上示例来解释:

成员 内容 物理/逻辑解释
e_ident Magic: 7f 45 4c 46 01 01 01 00… ELF 魔数。包含文件机器字节长度、数据存储方式、版本、运行平台及 ABI 版本。
e_type Type: REL (Relocatable file) ELF 文件类型。标识是可重定位文件、可执行文件还是共享对象文件。
e_machine Machine: Intel 80386 CPU 平台属性。相关常量以 EM_ 开头(如 EM_386)。
e_version Version: 0x1 ELF 版本号。通常为常数 1。
e_entry Entry point address: 0x0 入口地址。规定程序开始执行的虚拟地址。可重定位文件(.o)通常为 0。
e_phoff Start of program headers: 0 程序头(Program Header)偏移。在链接视图中暂不关心,执行视图的核心。
e_shoff Start of section headers: 280 段表(Section Header)偏移。即段表在文件内的起始物理位置。
e_word Flags: 0x0 ELF 标志位。标识特定平台相关的属性,格式通常为 EF_machine_flag
e_ehsize Size of this header: 52 (bytes) ELF 文件头本身的大小。在本例中物理占据 52 字节。
e_phentsize Size of program headers: 0 程序头描述符的大小
e_phnum Number of program headers: 0 程序头描述符的数量
e_shentsize Size of section headers: 40 (bytes) 段表描述符的大小。物理上等于 sizeof(Elf32_Shdr)
e_shnum Number of section headers: 11 段的数量。物理记录了 ELF 文件中拥有的段表描述符总数。
e_shstrndx Section header string table index: 8 段表字符串表下标。存储段名字符串的表在段表中的物理索引位置。
魔数详解

e_ident成员的前四个字节7f 45 4c 46中,第一个字节对应的是ASCII中的DEL控制符,后三个字节刚好是ELF这三个字母的ASCII码,从而唯一标识了ELF文件,故被称为ELF文件的魔数.

接下来的一个字节用来标识ELF的文件类,01表示是32位的,02表示是64位的;第六个字节是字节序,规定该文件是大端存储还是小端存储;第七个字节为该文件的主版本号,一般是1,因为ELF标准从1.2版后就没有更新过.后面的9个字节ELF标准没有定义,一般填0.
至于为什么要多出来这9个字节,主要是为了兼容老编译器的考量.

自然,所有的可执行文件都有一个魔数用来标识自己,比如PE/COFF文件的最开始两个字节为4d,5a,即ASCII字符MZ.

段表

段表用于保存ELF文件中各个section(段)的基本信息,比如段的名字,长度,存储位置,读写权限等属性.编译器,链接器和装载器都是依靠段表来定位和访问各个段的属性的,至于段表的位置则是由文件头中的e_shoff字段来定义的.

  • 尽管书上讲的很详细,但我想只需要大概知道段表的作用即可

重定位表

链接器在处理目标文件的时候,需要对目标文件中的某些部分进行重定位,即.text段和.data段中对绝对地址引用的位置.

对于每个要重定位的.text段和.data段,都会有一个相应的重定位表.

链接的接口: symbol

设计数据密集型应用

数据系统

数据系统有以下几个作用:

  • 存储数据,以便自己或其他应用程序之后能再次找到 (数据库,即 databases)
  • 记住开销昂贵操作的结果,加快读取速度(缓存,即 caches)
  • 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(搜索索引,即 search indexes)
  • 向其他进程发送消息,进行异步处理(流处理,即 stream processing)
  • 定期处理累积的大批量数据(批处理,即 batch processing)
    因此,常规的数据库,消息队列等信息处理系统都可以被归类为数据系统.

我们可以从三个维度来评价一个数据系统写的怎么样:

  1. 可靠性: 出了故障仍然可以正常运行
  2. 可伸缩性: 能够应付系统的扩大和其他变化
  3. 可维护性: 架构清晰,职责分明,方便维护

可靠性

处理硬件故障

当想到系统失效的原因时,硬件故障(hardware faults) 总会第一个进入脑海。硬盘崩溃、内存出错、机房断电、有人拔错网线…… 任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情总会发生!

我们可以通过硬件冗余(redundancy of hardware)来解决这个问题,即提供后备组件来及时接替故障硬件,防止系统崩溃.

软件故障

软件故障有以下几个例子:

  • 接受特定的错误输入,便导致所有应用服务器实例崩溃的 BUG。例如 2012 年 6 月 30 日的闰秒,由于 Linux 内核中的一个错误,许多应用同时挂掉了。
  • 级联故障,一个组件中的小故障触发另一个组件中的故障,进而触发更多的故障

管理员的失误导致的故障

一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了 10-25% 的服务中断

可伸缩性

系统今天能可靠运行,并不意味未来也能可靠运行。服务 降级(degradation) 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多

负载: 以推特为例

以推特在 2012 年 11 月发布的数据为例,推特的两个主要业务是:

  • 发布推文
    • 用户可以向其粉丝发布新消息(平均 4.6k 请求 / 秒,峰值超过 12k 请求 / 秒)。
  • 主页时间线
    • 用户可以查阅他们关注的人发布的推文(300k 请求 / 秒)。

大体上讲,这一对操作有两种实现方式。

  1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 图 1-2 所示的关系型数据库中,可以编写这样的查询:
1
2
3
4
5
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
  1. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。

推特的第一个版本使用了方法 1,但系统很难跟上主页时间线查询的负载。所以公司转向了方法 2,方法 2 的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。

然而方法 2 的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约 75 个关注者,所以每秒 4.6k 的发推写入,变成了对主页时间线缓存每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过 3000 万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的 3000 万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在 5 秒内向粉丝发送推文。

推特轶事的最终转折:现在已经稳健地实现了方法 2,推特逐步转向了两种方法的混合。大多数用户发的推文会写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并

如何处理负载

适应某个级别负载的架构不太可能应付 10 倍于此的负载。如果你正在开发一个快速增长的服务,那么每次负载发生数量级的增长时,你可能都需要重新考虑架构 —— 或者更频繁。

大规模的系统架构通常是应用特定的 —— 没有一招鲜吃遍天的通用可伸缩架构(不正式的叫法:万金油(magic scaling sauce) )。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。

举个例子,用于处理每秒十万个请求(每个大小为 1 kB)的系统与用于处理每分钟 3 个请求(每个大小为 2GB)的系统看上去会非常不一样,尽管两个系统有同样的数据吞吐量。

可维护性

众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债和添加新的功能。

数据模型

多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:它是如何用低一层数据模型来 表示 的?例如:

  1. 作为一名应用开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流向、传感器等),并采用对象或数据结构,以及操控那些数据结构的 API 来进行建模。那些结构通常是特定于应用程序的。
  2. 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如 JSON 或 XML 文档、关系数据库中的表或图模型。
  3. 数据库软件的工程师选定如何以内存、磁盘或网络上的字节来表示 JSON / XML/ 关系 / 图数据。这类表示形式使数据有可能以各种方式来查询,搜索,操纵和处理。
  4. 在更低的层次上,硬件工程师已经想出了使用电流、光脉冲、磁场或者其他东西来表示字节的方法。

握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。

关系模型VS文档模型

关系模型曾是一个理论性的提议,当时很多人都怀疑是否能够有效实现它。然而到了 20 世纪 80 年代中期,关系数据库管理系统(RDBMSes)和 SQL 已成为大多数人们存储和查询某些常规结构的数据的首选工具。关系数据库已经持续称霸了大约 25~30 年 —— 这对计算机史来说是极其漫长的时间。

关系数据库起源于商业数据处理,在 20 世纪 60 年代和 70 年代用大型计算机来执行。从今天的角度来看,那些用例显得很平常:典型的 事务处理(将销售或银行交易,航空公司预订,库存管理信息记录在库)和 批处理(客户发票,工资单,报告)。

NoSQL

采用 NoSQL 数据库的背后有几个驱动因素,其中包括:

  1. 需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
  2. 相比商业数据库产品,免费和开源软件更受偏爱
  3. 关系模型不能很好地支持一些特殊的查询操作

常见的NoSQL数据库有以下几个:

  1. Redis: 基于内存的存储,核心逻辑是哈希表
  2. MongoDB: 存储格式为JSON

文档和关系数据库的融合

随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充,如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。

  • (26/4/7): 我发现这本书我现在看太早了,很难有切实的收获,还是等几年再来探索吧

Docker 从入门到实践

  • 比官方文档要简洁清晰的多

入门部分

Docker简介

无论你的应用是用 Python、Java、Node.js 还是其他语言写的,无论它需要什么样的依赖库和环境,一旦被打包成 Docker 镜像,就可以用同样的方式在任何支持 Docker 的机器上运行.

  • 也就是说,通过将依赖和软件打包在一起,我们成功实现了无缝的跨环境运行

Docker不是虚拟机

传统虚拟机技术是虚拟出一套完整的硬件,在其上运行一个完整的操作系统,再在该系统上运行应用

而 Docker 容器内的应用直接运行于宿主的内核,容器内没有自己的内核,也没有进行硬件虚拟

Docker历史

Docker 最初是 dotCloud 公司创始人 Solomon Hykes 在法国期间发起的一个公司内部项目,于 2013 年 3 月以 Apache 2.0 授权协议开源

  • 很难想象这么优秀的技术竟然只有十年多一点的历史

Docker的核心优势

环境一致性

Docker 镜像包含了应用运行所需的 一切:代码、运行时、系统工具、库、配置。这意味着:

  1. 开发环境和生产环境完全一致
  2. 不会再有 “在我机器上能跑” 的问题

快速启动

传统虚拟机启动需要几分钟 (引导操作系统),而 Docker 容器启动通常只需要 几秒甚至几百毫秒

  • 当然这得要你先构建好了镜像和容器

Docker 的核心价值可以用一句话概括:让应用的开发、测试、部署保持一致,同时极大提高资源利用效率。 笔者认为,对于现代软件开发者来说,Docker 已经不是 “要不要学” 的问题,而是 必备技能。无论你是前端、后端、运维还是全栈开发者,掌握 Docker 都能让你的工作更高效。

基本概念

Docker里有三个基本概念:

  1. 镜像(Image): Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数 (如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。
  2. 容器 (Container):镜像 (Image) 和容器 (Container) 的关系,就像是面向对象程序设计中的 类 和 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。
  3. 仓库 (Repository):镜像构建完成后,可以很容易的在当前宿主机上运行,但是,如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务。

镜像

Docker 镜像是一个只读的模板,包含了运行应用所需的一切:代码、运行时、库、环境变量和配置文件。 如果用一个类比:镜像就像是一张光盘或 ISO 文件。你可以用同一张光盘在不同电脑上安装系统,而光盘本身不会被修改。同样,一个镜像可以创建多个容器,而镜像本身保持不变。

镜像的组成部分

类别 示例
程序文件 应用二进制文件、Python/Node 解释器
库文件 libc、OpenSSL、各种依赖库
配置文件 nginx.conf、my.cnf 等
环境变量 PATH、LANG 等预设值
元数据 启动命令、暴露端口、数据卷定义
  • 镜像是只读的
  • 镜像不包含动态数据
  • 镜像构建后内容不会改变

镜像的分层存储

1
2
3
4
5
6
7
8
FROM ubuntu:24.04          
# 第 1 层:基础系统(约 78MB)
RUN apt-get update
# 第 2 层:更新包索引
RUN apt-get install nginx
# 第 3 层:安装 nginx
COPY app.conf /etc/nginx/
# 第 4 层:复制配置文件

换句话说,只要某一行命令对镜像做了修改,就被docker视为单独的一个构建层,不可以被其他构建层修改,但可以与其他镜像共享

镜像标识

镜像名称和标签

1
2
3
4
5
6
7
8
9
10
11
12
13
## 完整格式

registry.example.com/myproject/myapp:v1.2.3

## 简写(使用 Docker Hub)

nginx:1.25
ubuntu:24.04

## 省略标签(默认使用 latest)

nginx
# 等同于 nginx:latest

镜像ID

1
2
3
4
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx latest a6bd71f48f68 2 weeks ago 187MB
ubuntu 24.04 ca2b0f26964c 3 weeks ago 78.1MB

镜像摘要
镜像摘要是基于镜像内容生成的哈希码

1
2
3
$ docker images --digests
REPOSITORY TAG DIGEST IMAGE ID
nginx latest sha256:6db391d1c0cfb30588ba0bf72ea999404f2764184d8b8d10d89e8a9c6... a6bd71f48f68

容器

容器是镜像的运行实例。如果把镜像比作程序,那么容器就是进程。 用面向对象编程的术语来说:镜像是类 (Class),容器是对象 (Instance)。

容器的本质

笔者认为,理解这一点是理解 Docker 的关键:容器的本质是一个特殊的进程
alt text

这种隔离是通过 Linux 内核的 Namespace 技术实现的。具体表现为:

  • 进程空间:容器看不到宿主机上的其他进程。
  • 网络:容器拥有独立的 IP、端口等网络资源
  • 文件系统:容器拥有独立的 root 目录。
  • 用户:容器内的 root 用户不等于宿主机的 root 用户。

容器的存储层机制

当容器运行时,Docker 会在镜像的只读层之上创建一个可写层(容器存储层);

而当容器需要修改镜像层中的文件时:

  1. Docker将该文件复制到容器存储层
  2. 在容器存储层中进行修改
  3. 原始镜像层保持不变
1
2
3
4
5
6
7
8
9
10
11
## 创建容器,写入数据

$ docker run -it ubuntu bash
root@abc123:/# echo "important data" > /data.txt
root@abc123:/# exit

## 删除容器

$ docker rm abc123

## 数据丢了!没有任何办法恢复!

既然当容器被删除后数据就全部丢失,那么容器存储层就不应该保留任何重要的信息,而是只保留运行时数据.

  • 如果我们想要存储数据,可以使用数据卷(Volume)来存储数据库和应用数据,或者使用绑定到宿主机的目录.
1
2
3
4
5
6
7
8
## 使用数据卷(推荐)

$ docker run -v mydata:/var/lib/mysql mysql

## 使用绑定挂载

$ docker run -v /host/path:/container/path nginx
# 这些位置的读写会跳过容器存储层,直接写入宿主机,性能更好,也不会随容器删除而丢失

容器的生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
## 创建并启动容器(最常用)

$ docker run nginx

## 分步操作

$ docker create nginx # 创建容器(不启动)
$ docker start abc123 # 启动容器

## 停止容器

$ docker stop abc123 # 优雅停止(发送 SIGTERM,等待后发送 SIGKILL)
$ docker kill abc123 # 强制停止(直接发送 SIGKILL)

## 暂停/恢复(不常用,但有时有用)

$ docker pause abc123 # 暂停容器内所有进程
$ docker unpause abc123 # 恢复

## 删除容器

$ docker rm abc123 # 删除已停止的容器
$ docker rm -f abc123 # 强制删除运行中的容器

仓库

Docker Registry 是存储和分发 Docker 镜像的服务,类似于代码的 GitHub 或包管理的 npm。

Docker Registry 中可以包含多个 Repository,每个 Repository 可以包含多个 Tag:

概念 说明 示例
Registry 存储镜像的服务 Docker Hub、ghcr.io
Repository (仓库) 同一软件的镜像集合 nginx、mysql、mycompany/myapp
Tag (标签) 仓库内的版本标识 latest、1.25、alpine

一个完整的 Docker 镜像名称由 Registry 地址、用户名/组织名、仓库名和标签组成。了解其结构有助于我们更准确地定位镜像。基本格式如下:
[registry 地址/][用户名/]仓库名[:标签]
完整示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
registry.example.com/mycompany/myapp:v1.2.3
│ │ │ │
│ │ │ └── 标签
│ │ └── 仓库名
│ └── 用户名/组织名
└── Registry 地址

## Docker Hub 官方镜像(省略 registry 和用户名)

nginx:1.25
ubuntu:24.04

## Docker Hub 用户镜像

jwilder/nginx-proxy:latest

## 其他 Registry

ghcr.io/username/myapp:v1.0
gcr.io/google-containers/pause:3.10

公共 Registry

Docker Hub 是最大的公共 Registry,也是 Docker 的默认 Registry,有以下特点:

  1. 拥有大量官方镜像(nginx、mysql、redis)
  2. 免费账户可以创建公开仓库
  3. 付费账户支持私有仓库

除了 Docker Hub,还有以下几个常见的公共 Registry:

Registry 地址 说明
GitHub Container Registry ghcr.io GitHub 提供,与 GitHub Actions 集成好
Google Container Registry gcr.io Google Cloud 提供,Kubernetes 镜像常用
Quay.io quay.io Red Hat 提供
阿里云容器镜像服务 registry.cn-*.aliyuncs.com 国内访问快
腾讯云容器镜像服务 ccr.ccs.tencentyun.com 国内访问快

安装docker

如果是Windows端,开启WSL2后下载官方软件即可运行
如果是Linux端,尽管文章里给了一堆命令,但现在有Dokploy了.如果是部署在国外服务器上或者自己学习使用的话,使用Dokploy就没必要操心那么多了.

Dokploy

支持Dokploy的目前有以下服务器厂商:

  • Hostinger
  • AmericanCloud
  • Teramont
  • Hetzner
  • DigitalOcean
  • Vultr
  • Linode
  • Scaleway
  • Google Cloud
  • AWS
    而Dokploy目前可以在以下系统里部署:
  • Ubuntu 24.04 LTS
  • Ubuntu 23.10
  • Ubuntu 22.04 LTS
  • Ubuntu 20.04 LTS
  • Ubuntu 18.04 LTS
  • Debian 12
  • Debian 11
  • Debian 10
  • Fedora 40
  • Centos 9
  • Centos 8

换句话说,主流的Linux操作系统现在都支持Dokploy了

Dokploy is a stable, easy-to-use deployment solution designed to simplify the application management process. Think of Dokploy as your free self hostable alternative to platforms like Heroku, Vercel, and Netlify, leveraging the robustness of Docker and the flexibility of Traefik.

  • Dokploy本身就是为了简化在Linux服务器部署Docker而产生的

Dokploy utilizes Docker, so it is essential to have Docker installed on your server. If Docker is not already installed, Dokploy’s installation script will install it automatically.

  • 甚至都不用提前安装docker,易用性可见一斑

部署

前置要求

To ensure a smooth experience with Dokploy, your server should have at least 2GB of RAM and 30GB of disk space. This specification helps to handle the resources consumed by Docker during builds and prevents system freezes.

  • 官方推荐使用Hetzner的服务器来省钱

避免以下端口被占用:

  1. Port 80: HTTP traffic (used by Traefik)
  2. Port 443: HTTPS traffic (used by Traefik)
  3. Port 3000: Dokploy web interface

我们只需要运行以下命令便可以在服务器的3000端口访问dokploy界面:

1
curl -sSL https://dokploy.com/install.sh | sh

第一次进入dokploy界面时需要我们注册管理员账户,然后就可以在面板里部署自己的docker项目了,要进一步了解的话还是去看官方文档吧.

使用镜像

获取镜像

docker pull用于从镜像仓库获取镜像:

1
docker pull [选项] [Registry地址/]仓库名[:标签]

镜像名称的标准格式如下:

1
2
3
4
5
docker.io / library / ubuntu : 24.04
────┬──── ───┬─── ──┬─── ──┬──
│ │ │ │
Registry地址 用户名 仓库名 标签
(可省略) (可省略)
组成部分 说明 默认值
Registry 地址 镜像仓库服务的域名或 IP 地址 docker.io (Docker Hub)
用户名 镜像所属的用户、组织或命名空间 library (官方镜像默认路径)
仓库名 镜像的具体名称 必须指定
标签 (Tag) 镜像的版本标识或分类标签 latest

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
## 完整格式

$ docker pull docker.io/library/ubuntu:24.04

## 省略 Registry(默认 Docker Hub)

$ docker pull library/ubuntu:24.04

## 省略 library(官方镜像)

$ docker pull ubuntu:24.04

## 省略标签(默认 latest)

$ docker pull ubuntu

## 拉取第三方镜像

$ docker pull bitnami/redis:latest

## 从其他 Registry 拉取

$ docker pull ghcr.io/username/myapp:v1.0
  • 镜像是分层下载的,如果本地已经有相同的层(这可以通过ID来识别),那么就会跳过该层继续下载

docker pull常用参数

选项 说明 示例
–all-tags, -a 下载仓库中该镜像的所有版本标签 docker pull -a ubuntu
–platform 在多架构镜像中指定运行平台(如 arm64, amd64) docker pull --platform linux/arm64 nginx
–quiet, -q 静默模式,只输出镜像 ID,不显示拉取进度详情 docker pull -q nginx

管理镜像

docker image ls

基本用法

1
2
3
4
5
6
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
redis latest 5f515359c7f8 5 days ago 183MB
nginx latest 05a60462f8ba 5 days ago 181MB
ubuntu 24.04 329ed837d508 3 days ago 78MB
ubuntu noble 329ed837d508 3 days ago 78MB
字段 说明
REPOSITORY 镜像仓库名称
TAG 镜像的标签(通常代表版本号)
IMAGE ID 镜像的唯一标识符(取 SHA-256 哈希值的前 12 位)
CREATED 镜像在构建服务器上被创建的时间
SIZE 镜像解压后在本地磁盘占用的实际空间
  • 上面的 ubuntu:24.04 和 ubuntu:noble 拥有相同的 IMAGE ID——它们是同一个镜像的不同标签,只占用一份存储空间。

查找镜像

可以根据名字来找镜像:

1
2
3
4
5
6
7
## 列出所有 ubuntu 镜像

$ docker images ubuntu
REPOSITORY TAG IMAGE ID SIZE
ubuntu 24.04 329ed837d508 78MB
ubuntu noble 329ed837d508 78MB
ubuntu 22.04 a1b2c3d4e5f6 72MB

镜像删除

docker rmi/docker image rm

这两个命令等价,用于删除单个镜像:
使用ID删除

1
2
3
4
5
6
7
8
9
10
$ docker image ls
REPOSITORY TAG IMAGE ID SIZE
redis alpine 501ad78535f0 30MB
nginx latest e43d811ce2f4 142MB

## 只需输入足够区分的前几位

$ docker rmi 501
Untagged: redis:alpine
Deleted: sha256:501ad78535f0...

使用镜像名删除

1
2
3
$ docker rmi redis:alpine
Untagged: redis:alpine
Deleted: sha256:501ad78535f0...
  • Untagged:移除镜像标签
  • Deleted: 删除镜像的存储层
docker image prune
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
## 查看虚悬镜像

$ docker images -f dangling=true

$ docker image prune
# 不带参数,默认只删除悬空镜像

## 不提示确认

$ docker image prune -f

## 删除所有没有被容器使用的镜像

$ docker image prune -a

## 保留最近 24 小时的

$ docker image prune -a --filter "until=24h"
  • 虚悬镜像 (dangling):没有标签且未被容器引用的镜像,通常是旧版本被新版本覆盖后产生的

操作容器

启动容器

由于 Docker 容器非常轻量,实际使用中常常是随时删除和新建容器,而不是反复重启同一个容器。

基本语法

1
docker run [选项] 镜像 [命令] [参数...]

基本例子

1
2
$ docker run ubuntu:24.04 /bin/echo 'Hello world'
Hello world

基础选项

选项 说明 示例
-d 后台运行容器(detach) docker run -d nginx
-it 分配交互式终端 docker run -it ubuntu bash
--name 为容器指定自定义名称 docker run --name myapp nginx
--rm 容器退出后自动删除 docker run --rm ubuntu echo hi

端口映射

1
2
3
4
5
6
7
8
9
10
11
## 将容器的 80 端口映射到宿主机的 8080 端口

$ docker run -d -p 8080:80 nginx

## 随机映射端口

$ docker run -d -P nginx

## 只绑定到 localhost

$ docker run -d -p 127.0.0.1:8080:80 nginx

运行容器

当你在终端运行一个程序时,有两种模式:

前台运行:程序占用当前终端,输出直接显示,关闭终端程序就停止
后台运行:程序在后台执行,不占用终端,终端关闭也不影响程序
Docker 容器默认是 前台运行 的。使用 -d (detach) 参数可以让容器在后台运行

前台运行

1
2
3
4
5
$ docker run ubuntu:24.04 /bin/sh -c "while true; do echo hello world; sleep 1; done"
hello world
hello world
hello world
hello world

容器会把输出的结果 (STDOUT) 打印到宿主机上面。此时:

  1. 终端被占用,无法执行其他命令
  2. 按 Ctrl+C 会终止容器
  3. 关闭终端窗口,容器也会停止

后台运行

1
2
$ docker run -d ubuntu:24.04 /bin/sh -c "while true; do echo hello world; sleep 1; done"
77b2dc01fe0f3f1265df143181e7b9af5e05279a884f4776ee75350ea9d8017a

使用 -d 参数后:

  1. 容器在后台运行
  2. 返回容器的完整 ID
  3. 终端立即释放,可以继续执行其他命令
  4. 输出不会直接显示 (需要用 docker logs 查看)

终止容器

终止容器有三种方式:

方式 命令 说明
优雅停止 docker stop 先发 SIGTERM,超时后发 SIGKILL
强制停止 docker kill 直接发送 SIGKILL 信号
自动终止 - 容器主进程退出时自动停止

我们还可以在镜像被修改后重启容器

1
2
3
4
5
6
7
## 先停止再启动

$ docker restart 容器名

## 自定义停止超时

$ docker restart -t 30 容器名

删除容器

随着容器的创建和停止,系统中会积累大量的容器。

使用 docker rm 删除已停止的容器:

1
$ docker rm 容器名或ID
  • 该命令与docker container rm等效

使用 docker container prune 批量删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
## 方式一:使用 prune 命令(推荐)

$ docker container prune

WARNING! This will remove all stopped containers.
Are you sure you want to continue? [y/N] y
Deleted Containers:
abc123...
def456...
Total reclaimed space: 150MB

## 方式二:不提示确认

$ docker container prune -f

补充部分: 镜像的文件结构

非常离谱的是,这么详细的文档偏偏没有提到这一点: 镜像内部是怎么存放文件的?
自然,镜像是分层构建存储的,但是这些构建层显然要有个地方放吧.

镜像的默认工作目录是根目录,类似于Linux的根目录,当我们需要切换存储目录或者启动某个目录下的脚本时,可以显式指明,比如说以下的几个命令:

1
2
3
4
5
6
7
## 复制文件到指定目录

COPY package.json /app/

## 复制文件并重命名

COPY config.json /app/settings.json
  • 如此一来,我们成功的将本地文件复制到了app文件夹中.

因此,镜像不仅仅是一个iso,我们可以把它抽象成一个文件系统,存储层堆叠在不同的目录中,可以来回切换访问.

进阶部分

dockerfile编写

概览

Dockerfile 是一个文本文件,其内包含了一条条的 指令 (Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。

Dockerfile不是脚本,而是镜像的"设计图"。这个区别决定了你如何思考每条指令的作用:

  • 合并命令:应将 RUN apt-get update && apt-get install -y ... 写入同一个 RUN 指令中,因为它们是同一层的逻辑
  • 优化镜像大小:最后才清理缓存、删除临时文件,让这些"瘦身"操作在同一层完成

(补充)FROM: 基础镜像

很多时候我们都需要在官方镜像的基础上进行构建,这个时候我们可以这么写:

1
2
FROM node:20-alpine AS deps
# 在这个基础上进行构建

这个AS与python中的as一样,都是为导入的镜像重新设一个名字.

  • 事实上,如果你不写任何FROM,根本无法运行任何Linux命令

而FROM命令最厉害的地方在于,每一个FROM指令都会重新开辟一个新的文件系统,取代之前的所有内容.
因此,我们可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
# 第一阶段:编译环境(命名为 builder)
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp main.go # 物理产生了几百 MB 的编译器和缓存

# 第二阶段:运行环境(最小化镜像)
FROM alpine:latest
WORKDIR /root/
# 物理核心:通过 --from=builder 只从 builder 阶段拷贝最终的可执行二进制文件
COPY --from=builder /app/myapp .
CMD ["./myapp"]

这样既可以利用上一个阶段的构建内容,又不会将多余的内容打包进镜像

RUN: 执行命令

RUN 是 Dockerfile 中最常用的指令,主要用于在镜像构建阶段执行命令来修改镜像,有以下几个应用场景:

  • 安装依赖:RUN apt-get install nginx
  • 编译程序:RUN gcc -o app main.c
  • 下载文件:RUN curl -O https://example.com/file.tar.gz
  • 配置系统:RUN mkdir -p /app/data

理解 RUN 的核心是理解镜像分层:每一个 RUN 都会在当前层之上创建新的一层,这会影响镜像大小。因此,合理使用 RUN(特别是合并多个 RUN)是构建轻量级镜像的关键。

基本语法

有两种格式:

1
2
RUN <command>
RUN ["executable", "param1", "param2"]

shell格式

1
RUN apt-get update
  • 默认通过 /bin/sh -c 执行。
  • 可以使用环境变量、管道、重定向等 Shell 特性。

exec格式

1
RUN ["apt-get", "update"]
  • 直接调用可执行文件,不经过 Shell。
  • 无法使用 $VAR 环境变量替换 (除非显式调用 shell)

实战

由于每一个 RUN 指令都会新建一层镜像。为了减少镜像体积和层数,应使用 && 连接命令:

1
2
3
4
5
6
7
8
# 糟糕的写法(3层)
RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*
# 推荐写法
RUN apt-get update && \
apt-get install -y nginx && \
rm -rf /var/lib/apt/lists/*
  • 可以看到dockerfile将\用于换行,这与makefile的写法一致

COPY: 复制文件

COPY 是在构建镜像时,将构建上下文(Dockerfile 所在目录及其子目录)中的文件或目录复制到镜像内的指令。它是处理应用代码、配置文件最常用的方式,应用场景如下:

  • COPY . /app (应用源码)
  • COPY nginx.conf /etc/nginx/nginx.conf (配置文件)
  • COPY public /app/public(静态资源)

基本语法

1
2
COPY [选项] <源路径>... <目标路径>
COPY [选项] ["<源路径1>", "<源路径2>", ... "<目标路径>"]

复制文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## 复制文件到指定目录

COPY package.json /app/

## 复制文件并重命名

COPY config.json /app/settings.json

## 复制多个指定文件

COPY package.json package-lock.json /app/

## 使用通配符

COPY *.json /app/
COPY src/*.js /app/src/

复制目录

1
2
3
4
5
6
7
8
## 复制整个目录的内容(不是目录本身)

COPY src/ /app/src/

# 构建上下文: 镜像内:
# src/ /app/src/
# ├── index.js → ├── index.js
# └── utils.js └── utils.js

指定路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 绝对路径

COPY app.js /usr/src/app/

# 相对路径:基于 WORKDIR

WORKDIR /app
COPY package.json ./ # 复制到 /app/package.json
COPY src/ ./src/ # 复制到 /app/src/

# 如果目标目录不存在,Docker 会自动创建:

## /app/config/ 不存在也会自动创建
COPY settings.json /app/config/

dockerignore

排除不需要复制的文件,精简镜像体积

1
2
3
4
5
6
7
8
## .dockerignore

node_modules
.git
.env
*.log
Dockerfile
.dockerignore

实战

1
2
3
4
5
6
7
8
9
10
## ✅ 好:先复制依赖定义,再安装,最后复制代码

COPY package.json package-lock.json ./
RUN npm install
COPY . .

## ❌ 差:一次性复制所有文件,代码变更会导致重新 npm install

COPY . .
RUN npm install

详细解释一下,docker构建镜像是线性操作的,只有COPY,RUN,ADD三种命令会创建新的存储层,而每次构建时docker都会在本地存储缓存,如果下一次构建镜像时对应的命令没有变化,则会直接复用原来的缓存,不会重新构建;当docker发现COPY的文件内容有改动时,该行之后的所有命令被视为与原缓存不同,需要重新构建.

因此,如果直接写COPY . .的话,修改任何一个文件后构建镜像都要重新运行npm install;但如果把COPY . .放在后面,只会在package.json变化时重新构建.

ADD: 更高级的COPY

实践中的建议:除非你明确需要自动解压功能(比如官方基础镜像构建根文件系统),否则始终使用 COPY。原因很简单——显式优于隐式。你的 Dockerfile 在 6 个月后被接手维护时,清晰的意图会让团队少走很多弯路。

基本用法

1
2
ADD [选项] <源路径>... <目标路径>
ADD [选项] ["<源路径>", ... "<目标路径>"]

ADD 在 COPY 基础上增加了两个功能:

  1. 自动解压 tar 压缩包
  2. 支持从 URL 下载文件 (不推荐)

CMD: 容器启动命令(4/10)

在深入 CMD 的细节之前,我们需要理解一个关键问题:CMD 和 ENTRYPOINT 应该在什么时候使用?

这是 Dockerfile 使用中最常见的困惑之一。简单的答案是:

  1. CMD:定义容器的”默认命令”。如果用户在 docker run 时提供命令,CMD 会被覆盖
  2. ENTRYPOINT:定义容器的”入口脚本”。通常用于启动应用的某个特定部分

基本用法

CMD指令用于指定容器启动时默认执行的命令。它定义了容器的 “主进程”。

格式类型 语法示例 推荐程度 核心机制
Exec 格式 CMD ["executable", "param1", "param2"] 推荐 直接由内核执行,PID 为 1,可接收 SIGTERM 信号。
Shell 格式 CMD command param1 param2 ⚠️ 简单场景 通过 /bin/sh -c 调用,无法直接接收信号,环境变量会被解析。
参数格式 CMD ["param1", "param2"] 配合使用 仅作为 ENTRYPOINT 的默认参数传递。

exec 格式

1
2
3
CMD ["nginx", "-g", "daemon off;"]
CMD ["python", "app.py"]
CMD ["node", "server.js"]

shell格式

1
2
CMD echo "Hello World"
CMD nginx -g "daemon off;"

实际执行:会被包装为 sh -c

1
2
3
4
5
6
7
## 你写的

CMD echo $HOME

## 实际执行的

CMD ["sh", "-c", "echo $HOME"]
  • 换句话说shell写法实际上是使用了sh的exec简写格式.

CMD命令只能写一个

多个CMD只有最后一个生效.
因为CMD的PID为1,意思是在Linux内核中它作为根进程,是独一无二的.因此CMD一旦停止,容器就关闭了.

ENTRYPOINT: 入口点(4/11)

如果说 CMD 是"容器中的默认程序",那么 ENTRYPOINT 就是"把容器变成一个命令"。这个思维转变决定了你何时使用 ENTRYPOINT。

是什么,怎么用

ENTRYPOINT 指定容器启动时运行的入口程序。与 CMD 不同,ENTRYPOINT 定义的命令不会被 docker run 的参数覆盖,而是 接收这些参数。

基本语法

1
2
3
4
5
6
7
## exec 格式(推荐)

ENTRYPOINT ["nginx", "-g", "daemon off;"]

## shell 格式(不推荐)

ENTRYPOINT nginx -g "daemon off;"

ENV: 设置环境变量

  • 很好理解,就是设置了一个dockerfile中的变量而已.
1
2
3
4
5
6
7
## 格式一:单个变量

ENV <key> <value>

## 格式二:多个变量(推荐)

ENV <key1>=<value1> <key2>=<value2> ...

例子

1
2
3
4
5
6
7
ENV NODE_VERSION 20.10.0
ENV APP_ENV production

ENV NODE_VERSION=20.10.0 \
APP_ENV=production \
APP_NAME="My Application"
# 包含空格的值用双引号括起来

用法

使用 -e 或 --env 覆盖 Dockerfile 中定义的环境变量:

1
2
3
4
5
6
7
8
9
10
11
## 覆盖单个变量

$ docker run -e APP_ENV=development myimage

## 覆盖多个变量

$ docker run -e APP_ENV=development -e DEBUG=true myimage

## 从环境变量文件读取

$ docker run --env-file .env myimage

运行时传入密码:

1
2
3
4
5
6
7
## ❌ 错误:密码写入镜像

ENV DB_PASSWORD=secret123

## ✅ 正确:运行时传入

## docker run -e DB_PASSWORD=xxx myimage

使用docker compose的话就没必要考虑这么多了

ARG: 构建参数

ARG仅在构建时生效,用于传递版本号之类的信息,可以出现在FROM指令之前,也能在docker build阶段传入对应的参数.换句话说,我们可以更改构建初始镜像所用的版本号.

而ENV则会被打包进入镜像,在容器运行期间永久生效,也不能出现在FROM指令之前.
基本语法

1
ARG <参数名>[=<默认值>]

用法

1
2
3
4
5
6
7
8
ARG BASE_IMAGE=python:3.12-slim
FROM ${BASE_IMAGE}

## 可以构建不同基础镜像的版本

## docker build --build-arg BASE_IMAGE=python:3.14-alpine .

...

VOLUME: 定义匿名卷

是什么,怎么用

容器存储层应该保持无状态,任何运行时数据都应该存储在volume中。

1
2
3
4
5
6
7
# 定义单个volume
FROM mysql:8.0
VOLUME /var/lib/mysql

# 定义多个volume
FROM myapp
VOLUME ["/data", "/logs", "/config"]

volume的行为

自动创建匿名卷
如果运行时未指定挂载,Docker 会自动创建匿名卷:

1
2
3
4
$ docker run mysql:8.0
$ docker volume ls
DRIVER VOLUME NAME
local a1b2c3d4e5f6... # 自动创建的匿名卷

会被命名卷覆盖

1
2
3
## 使用命名卷替代匿名卷

$ docker run -v mysql_data:/var/lib/mysql mysql:8.0

VOLUME 之后对该目录的修改会被丢弃!

1
2
3
4
5
6
FROM ubuntu
VOLUME /data

## ❌ 这个文件不会出现在镜像中!

RUN echo "hello" > /data/test.txt

原因:在构建过程中,VOLUME 指令会为该目录创建一个临时的匿名卷。后续 RUN 指令对该目录的写入实际发生在这个临时卷中,而非镜像层。当该 RUN 指令结束后,临时卷被丢弃,因此写入的内容不会保存到最终镜像中。注意:这与容器运行时创建的匿名卷是不同的——运行时创建的卷会在容器生命周期内持续存在。

正确做法

1
2
3
4
5
6
7
8
9
FROM ubuntu

## ✅ 先写入文件

RUN mkdir -p /data && echo "hello" > /data/test.txt

## 再声明 VOLUME

VOLUME /data

在compose中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
db:
image: postgres:16
volumes:
# 命名卷(推荐)

- postgres_data:/var/lib/postgresql/data
# Bind Mount

- ./init.sql:/docker-entrypoint-initdb.d/init.sql

volumes:
postgres_data: # 声明命名卷

EXPOSE: 暴露端口(4/12)

是什么,怎么用

EXPOSE 声明容器运行时提供服务的端口。这是一个文档性质的声明,告诉使用者容器会监听哪些端口。

  • 换句话说只起一个约定作用,不通过-p的话不会起作用

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
## 声明单个端口

EXPOSE 80

## 声明多个端口

EXPOSE 80 443

## 声明 TCP 和 UDP 端口

EXPOSE 80/tcp
EXPOSE 53/udp

使用 docker run -P 时,Docker 会自动映射 EXPOSE 的端口到宿主机随机端口:

1
2
3
4
5
6
## Dockerfile
# EXPOSE 80

$ docker run -P nginx
$ docker port $(docker ps -q)
80/tcp -> 0.0.0.0:32768

实战

1
2
3
4
## Dockerfile

FROM nginx
EXPOSE 80 # 1. 声明:这个容器会在 80 端口提供服务
1
2
3
## 运行:需要 -p 才能从外部访问

$ docker run -p 8080:80 nginx # 2. 映射:宿主机 8080 → 容器 80

compose中的编写

1
2
3
4
5
6
7
services:
web:
build: .
ports:
- "8080:80" # 映射端口(类似 -p)
expose:
- "80" # 仅声明(类似 EXPOSE)

WORKDIR: 指定工作目录

WORKDIR 指定后续指令的工作目录。如果目录不存在,Docker 会自动创建。

基本用法

1
2
3
4
5
WORKDIR /app

RUN pwd # 输出 /app
RUN echo "hello" > world.txt # 创建 /app/world.txt
COPY . . # 复制到 /app/
1
2
3
4
5
6
7
# 相对路径

WORKDIR /a
WORKDIR b
WORKDIR c

RUN pwd # 输出 /a/b/c

实战

使用绝对命令

1
2
3
4
5
6
7
## ✅ 推荐:绝对路径,意图明确

WORKDIR /app

## ⚠️ 避免:相对路径可能造成混淆

WORKDIR app

USER: 指定当前用户

是什么,怎么用

USER 指令切换后续指令 (RUN、CMD、ENTRYPOINT) 的执行用户

1
2
USER <用户名>[:<用户组>]
USER <UID>[:<GID>]
  • 一般是用不上这个的

HEALTHCHECK: 健康检查

是什么,怎么用

HEALTHCHECK 指令告诉 Docker 如何判断容器状态是否正常。这是保障服务高可用的重要机制。

1
2
HEALTHCHECK [选项] CMD <命令>
HEALTHCHECK NONE

基本用法

1
2
3
4
5
FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -fs http://localhost/ || exit 1
选项 说明 默认值
--interval 两次检查的间隔 30s
--timeout 检查命令的超时时间 30s
--start-period 启动缓冲期 (期间失败不计入次数) 0s
--retries 连续失败多少次标记为 unhealthy 3

应用启动可能需要时间 (如 Java 应用)。设置 --start-period 可以防止在启动阶段因检查失败而误判

1
2
3
## 给应用 1 分钟启动时间

HEALTHCHECK --start-period=60s CMD curl -f http://localhost/ || exit 1

LABEL: 为镜像添加元数据

是什么,怎么用

LABEL 指令以键值对的形式给镜像添加元数据。这些数据不会影响镜像的功能,但可以帮助用户理解镜像,或被自动化工具使用。

  1. 版本管理:记录版本号、构建时间、Git Commit ID
  2. 联系信息:维护者邮箱、文档地址、支持渠道
  3. 自动化工具:CI/CD 工具可以读取标签触发操作
  4. 许可证信息:声明开源协议
1
LABEL <key>=<value> <key>=<value> ...
1
2
3
4
5
6
7
8
9
# 定义单个标签
LABEL version="1.0"
LABEL description="这是一个 Web 应用服务器"

# 定义多个标签
LABEL maintainer="user@example.com" \
version="1.2.0" \
description="My App Description" \
org.opencontainers.image.authors="Yeasy"

数据管理

这一章介绍如何在 Docker 内部以及容器之间管理数据,在容器中管理数据主要有以下几种方式:

  1. 数据卷
  2. 挂载主机目录
  3. tmpfs 挂载

数据卷

容器的存储层有一个关键问题:容器删除后,数据就没了。数据卷 (Volume) 解决了这个问题,它的生命周期独立于容器。

创建和查看数据卷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 创建数据卷
$ docker volume create my-vol

# 列出所有数据卷
$ docker volume ls
DRIVER VOLUME NAME
local my-vol
local postgres_data
local redis_data

# 查看数据卷详情
$ docker volume inspect my-vol
[
{
"CreatedAt": "2026-01-15T10:00:00Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/my-vol/_data",
"Name": "my-vol",
"Options": {},
"Scope": "local"
}
]

关键字段

  • Mountpoint:数据卷在宿主机上的实际存储位置
  • Driver:存储驱动 (默认 local,也可以用第三方驱动)

挂载数据卷

方式一:–mount:推荐

1
2
3
4
$ docker run -d \
--name web \
--mount source=my-vol,target=/usr/share/nginx/html \
nginx
参数 说明
source 数据卷名称 (不存在会自动创建)
target 容器内挂载路径
readonly 可选,只读挂载
方式二:-v:简写
1
2
3
4
$ docker run -d \
--name web \
-v my-vol:/usr/share/nginx/html \
nginx

提示:官方更推荐使用 --mount。除了语法格式可读性更好之外,最重要的行为差异发生在 绑定挂载 (Bind Mount) 时:如果挂载的宿主机源路径尚未存在,-v 会擅自将其自动创建为一个空目录;而 --mount 则会严格检查并直接报错。这能有效避免因路径拼写错误而在宿主机上留下垃圾目录(以及导致的容器访问空目录问题)。而对于本节的 数据卷 (Volume) 挂载而言,两者在目标指定的卷不存在时皆会自动创建卷,产生的结果是 完全一致 的。

实战

数据库持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
## 创建数据卷

$ docker volume create postgres_data

## 启动 PostgreSQL,数据存储在数据卷中

$ docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=secret \
-v postgres_data:/var/lib/postgresql/data \
postgres:16

## 即使删除容器,数据仍然保留

$ docker rm -f postgres

## 重新启动,数据还在

$ docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=secret \
-v postgres_data:/var/lib/postgresql/data \
postgres:16

多容器共享数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
## 创建共享数据卷

$ docker volume create shared-data

## 容器 A 写入数据

$ docker run -d --name writer \
-v shared-data:/data \
alpine sh -c "while true; do date >> /data/log.txt; sleep 5; done"

## 容器 B 读取数据

$ docker run --rm \
-v shared-data:/data \
alpine cat /data/log.txt

配置文件持久化

1
2
3
4
5
6
7
## 将 nginx 配置存储在数据卷中

$ docker run -d \
-v nginx-config:/etc/nginx/conf.d \
-v nginx-logs:/var/log/nginx \
-p 80:80 \
nginx

挂载主机目录

绑定挂载

Bind Mount (绑定挂载) 将 Docker daemon 所在主机 上的目录或文件直接挂载到容器中。容器可以读写这台主机上的文件系统。
Bind Mount vs Volume
| 特性 | Bind Mount (绑定挂载) | Volume (数据卷) |
| :------------- | :----------------------------------- | :------------------------------------------------- |
| 数据位置 | 宿主机任意路径 | Docker 管理的特定目录 (/var/lib/docker/volumes/) |
| 路径指定 | 必须是绝对路径 (如 /opt/app/data) | 卷名 (如 my-vol),隐式管理物理路径 |
| 可移植性 | 。依赖宿主机特定的文件目录结构 | 。不依赖物理路径,易于在不同环境迁移 |
| 性能 | 依赖宿主机文件系统原生性能 | 绕过 Storage Driver 层,具备原生 I/O 性能 |
| 适用场景 | 开发环境同步代码、挂载宿主机配置文件 | 生产环境数据库持久化、日志存储、多容器共享 |
| 备份与管理 | 手动定位宿主机路径进行备份 | 使用 docker volume 命令管理,备份需挂载容器操作 |
| 隔离性 | 宿主机进程可轻易修改,安全性较低 | 由 Docker 隔离,减少了被宿主机其他进程误删的风险 |

基本语法

方案 A:使用 --mount(推荐方式)

1
2
3
4
$ docker run -d \
--name web-bind \
--mount type=bind,source=/宿主机路径,target=/容器路径 \
nginx

方案 B:使用 -v(简写方式)

1
2
3
4
$ docker run -d \
--name web-v \
-v /宿主机路径:/容器路径 \
nginx

可以看到,这里的语法与之前的volume挂载基本相同

网络配置

配置DNS

Docker 容器的 DNS 配置有两种情况:

  1. 默认 Bridge 网络:继承宿主机的 DNS 配置 (/etc/resolv.conf)。
  2. 自定义网络(推荐):使用 Docker 嵌入式 DNS 服务器 (Embedded DNS),支持通过 容器名 进行服务发现。

使用自定义网络

1
2
3
4
5
6
7
8
9
10
11
12
13
## 1. 创建自定义网络

$ docker network create mynet

## 2. 启动容器 web 并加入网络

$ docker run -d --name web --network mynet nginx

## 3. 启动容器 client 并尝试 ping web

$ docker run -it --rm --network mynet alpine ping web
PING web (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.074 ms

端口映射

容器的网络访问规则如下:

  1. 容器之间:可以通过 IP 或容器名 (自定义网络) 互通。
  2. 宿主机访问容器:可以通过容器 IP 访问。
  3. 外部网络访问容器:❌ 默认无法直接访问。

为了让外部 (如你的浏览器、其他局域网机器) 访问容器内的服务,我们需要将容器的端口 映射 到宿主机的端口。

基本用法

1
2
3
## 将宿主机的 8080 端口映射到容器的 80 端口

$ docker run -d -p 8080:80 nginx:alpine
  • 此时访问 http://localhost:8080 即可看到 Nginx 页面。
    | 格式 | 含义 | 示例 |
    | :---------------------------- | :------------------------------------- | :-------------------------------------- |
    | ip:hostPort:containerPort | 绑定指定 IP 的特定端口 | -p 127.0.0.1:8080:80 (仅允许本机访问) |
    | ip::containerPort | 绑定指定 IP 的随机端口 | -p 127.0.0.1::80 |
    | hostPort:containerPort | 绑定所有网卡 IP (0.0.0.0) 的特定端口 | -p 8080:80 (最常用格式) |
    | containerPort | 绑定所有网卡 IP 的随机端口 | -p 80 |
随机映射

如果不关心宿主机使用哪个端口,可以使用随机映射。使用 -P (大写) 参数,Docker 会把 Dockerfile 中 EXPOSE 指令暴露的所有端口发布到宿主机的随机高位端口。具体落在哪个端口,取决于宿主机当前可用的临时端口范围。

1
2
3
4
5
6
docker run -d -P nginx

docker ps
CONTAINER ID PORTS
abc123456 0.0.0.0:49153->80/tcp
# 此时 Nginx 被映射到了宿主机的一个随机高位端口49153

实战

默认情况下,-p 8080:80 会监听 0.0.0.0:8080,这意味着任何人只要能连接你的宿主机 IP,就能访问该服务。如果不希望对外暴露 (例如数据库服务),应绑定到 127.0.0.1:

1
2
3
## 仅允许本机访问

$ docker run -d -p 127.0.0.1:3306:3306 mysql

网络隔离

不同网络之间默认隔离,容器只能与同一网络中的容器直接通信:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
## 创建两个网络

$ docker network create frontend
$ docker network create backend

## 容器 A 在 frontend

$ docker run -d --name web --network frontend nginx

## 容器 B 在 backend

$ docker run -d --name db --network backend postgres

## web 无法直接访问 db(不同网络)

$ docker exec web ping db
ping: db: Name or service not known

Docker Compose

Attention Is All You Need

Understanding The Linux Kernel(深入理解Linux内核)