Pearl 的个人小站

终身学习者


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

Crash Course Computer Science

发表于 2021-07-03 | 分类于 计算机 | | 阅读次数:

[TOC]

安利一个最近一个多月看完的YouTube Crash Course出品的《Computer Science》。

Crash Course涵盖了许多学科,包括计算机科学、天文学、哲学、文学、物理学、生物学、心理学、经济学等多个学科系列。听说其它系列也很赞,准备有时间陆续去看感兴趣的学科。

感谢Carrie Anne老师!

以下三个平台都可享用。

  1. B站:https://www.bilibili.com/video/av21376839/
  2. GitHub:https://github.com/1c7/Crash-Course-Computer-Science-Chinese
  3. YouTube:https://www.youtube.com/watch?v=tpIctyqH29Q&list=PLH2l6uzC4UEW0s7-KewFLBC1D0l6XRfye

从这个系列可以看到Computer Science的全景(almost)以及未来的蓝图。也很好地解释了抽象在计算机科学领域的重要性。

尤其前十集,收获很大,很多遗失和零碎的知识被串起来了。晶体管作为开关,依据布尔代数原理组成各种逻辑门电路,包括加法器、锁存器等,再用不同的逻辑门组成ALU/控制器/寄存器/内存等,再到指令集的设计,一层层精妙的抽象。庞大、精准、快速,真是不可思议,美妙绝伦。

正如授课者Carrie Anne小姐姐说的:

It’s hard to believe we’ve worked up from mere transistors and logic gates, all the way to computer vison, machine learning, robotics and beyond.
Hopefully you’ve developed a newfound appreciation for the incredible breadth of computing application and topic. My biggest hope is that these episodes have inspired you to learn more about how these subjects affect your life.

许倬云先生说,”我们要拿全人类曾经走过的路,都要算是我走过的路之一”。计算机科学也是如此,今天我们拥有和可以学习的知识,是站在了巨人的肩膀上。个人力量和能力微不足道,希望更多的人加入到to make the world a better place的队伍中来。

Computer science isn’t magic, but it sort of is. Those who know how to wield that tremendous power will be able to craft great things.No one really knows how this is going to shake out, but if history is any guide, it’ll probably be ok in the long run. Afterall, no one is advocating that 90% of people go back to farming and weaving textiles by hand.

虽然这个系列看似适合未接触过计算机科学的同学看,但我觉得作为一名程序员,也应该站在一个全局的角度,感受我们所学、正在或者将来从事的工作的现实意义。我们虽然走了很远,依然要记得当初为何要出发。

真希望所有的中学生都能把Crash Course系列的课程看一下,也许可以更早地明确自己的爱好,而不是随大流选择一个自己未来可能不感兴趣的专业。

最后附上令我最感动的一句话。

And when the sun is burned up and the Earth is space dust, maybe our technological children will be hard at work exploring every nook and cranny of the universe, hopefully in honor of their parents’ tradition to build knowledge, impove the state of the universe and to boldly go where no one has gone before!

浅析Golang的内存管理(二)

发表于 2021-06-28 | 分类于 计算机 | | 阅读次数:

[TOC]

学习Go的内存管理可以帮助我们编写更高性能的代码。

引言

在Go中由runtime来进行内存管理,通过内存分配器分配堆内存,垃圾处理器回收堆上不再使用的对象和内存空间。上一节讲了内存分配,这节讲垃圾回收。

现代编程语言中,垃圾收集有很多种算法,本文只讨论Go的垃圾收集器算法。

彻底理解Go runtime的垃圾回收是比较困难的,本文只是我个人学习过程中的总结,是站在我个人角度上进行的理解和梳理,深度不够。如果要更全面地学习Go的垃圾回收,推荐阅读本文最后列出的Reference和Go的源码。

垃圾回收

概述

虽然Go的GC(Garbage Collection)和用户goroutine可以并发执行,但是需要一段时间的STW(Stop the world)。当程序占用的内存达到一定阈值时,整个应用程序会暂停。垃圾收集器扫描已经分配的所有对象并回收不再使用的内存空间。

Go的GC的STW(Stop the world)影响程序性能是常听到的说法。因为一旦触发垃圾回收,在启动STW到停止STW的过程中,CPU不执行应用代码,全部用于执行GC代码,追求实时的应用无法接受长时间的STW。

垃圾收集器是Go的runtime改进最努力的部分,针对缩短STW时间做了很多迭代优化,目的都是为了提供程序实时性。

src/runtime/mgc.go的开头有这样一段注释。

The GC runs concurrently with mutator threads, is type accurate (aka precise), allows multiple GC thread to run in parallel. It is a concurrent mark and sweep that uses a write barrier. It is non-generational and non-compacting. Allocation is done using size segregated per P allocation areas to minimize fragmentation while eliminating locks in the common case.

Go的GC是并发标记清理、使用写屏障、非紧缩、非分代的。

并发标记和用户代码同时执行让程序处于不稳定状态。用户代码在标记过程中,可能会修改已经扫描标记过的区域,或在标记过程中分配新对象。

垃圾回收最大的问题是,究竟什么时候启动垃圾回收?过早会浪费CPU资源,影响用户程序的性能;太晚会导致内存堆积。所以垃圾回收核心需要解决的问题有两个:一是抑制堆内存增长;二是充分利用CPU资源。

标记-清除算法

早期的Go用的是标记清除(mark-sweep)算法。

GC流程

标记清除算法分成标记和清除两个阶段。

  1. 标记阶段:从根对象出发,遍历并标记所有可达的对象,作上标记。
  2. 清除阶段:清除未被标记的对象。

缺点

  1. 标记需要遍历整个heap。
  2. 清除会产生heap碎片。
  3. 标记前启动STW,清除后停止STW。在该过程中,应用程序都是暂停的,程序卡顿影响性能。

三色标记算法

为了缩短标记清除算法的STW时间,用三色标记算法优化。

三色标记算法将程序中的对象分为黑、灰、白三类。新创建的对象,默认都是白色。当完成全部扫描和标记后,剩余的非黑即白,黑色代表活跃对象,白色代表待回收对象,清理操作只需将白色对象的内存回收即可。

GC流程

  1. 从根节点遍历所有对象一次,标记为灰色,放入灰色标记表。
  2. 遍历灰色标记表,将可达的对象,标记为灰色,放入灰色标记表。已经遍历过的灰色对象,标记为黑色,放入黑色标记表。
  3. 重复上一步,直到灰色标记表为空。
  4. 回收最后剩下的白色对象。

缺点

整个GC过程,都需要STW。

如果不使用STW,GC中途创建或删除对象引用,下面两种情况一旦同时满足,就会导致对象被错误回收,这是致命的内存管理故障。

  1. 如果黑色对象引用白色对象。因为黑色对象不会再被重复扫描,白色对象以及下游的对象会被GC清除。
  2. 如果灰色对象引用白色对象,但是引用被清除。白色对象以及下游的对象会被GC清除。

如何在保证对象不丢失情况下,减少STW时间,提高GC效率?——只要破坏其中一个条件,这个问题就被解决。

强-弱三色不变式

提出强-弱三色不变式来破坏上面两种三色标记算法不能接受的情况。
只要满足下面两种不变式的任意一种,对象就不会被错误清理。

强三色不变式

不允许黑色对象引用白色对象。—— 破坏了上述三色标记算法的缺点1。

弱三色不变式

黑色对象可以引用白色对象,但是白色对象必须被灰色对象直接或间接(多级可达)引用。—— 破坏了上述三色标记算法的缺点2。

三色标记算法+屏障机制

何为屏障?我理解是一种hook机制,在GC过程中,某些条件(并发增加/修改/删除对象)满足的时候触发回调,以满足强三色不变式或弱三色不变式,从而保证三色标记算法的正确。

有两种屏障机制,来保证三色标记算法的正确。

  • 插入屏障机制:对象被引用时,触发的机制。
  • 删除屏障机制:对象被删除时,触发的机制。

插入屏障机制

当A对象新增引用B对象时,将B对象标记为灰色。——满足强三色不变式。

因为栈内存操作频繁,出于性能考虑,该策略只在堆内存的对象使用。即只在堆对象触发插入屏障机制。

对于栈内存的对象,当A对象新增引用B对象时,还是将B对象标记为白色。但是在GC回收白色对象之前,重新开启STW(防止插入),扫描一次栈空间。

所以缺点是:在GC结束时需要STW来重新扫描栈。

删除屏障机制

当白色对象被删除引用时,将它标记为灰色。——满足弱三色不变式。

在栈内存和堆内存均触发删除屏障机制。

这个机制的目的是,当白色对象被删除引用时,如果有黑色对象引用白色对象,白色对象不会被错误回收。但如果白色对象真的被删除引用,在下一轮才会被清理。

所以缺点是:回收精度低。一个对象即使被真的删除了,也只能到下一轮被清理。

混合写屏障机制

为了避免重新扫描栈,进一步减少STW时间,在插入屏障机制和删除屏障机制的基础上,结合了优点,规避了缺点,引入混合写屏障机制。混合写屏障机制在GC期间通过监视内存中对象的修改,重新标色,来保障标记和用户代码并发执行。

只有堆对象触发混合写屏障机制。

具体规则是:

  1. GC开始时,栈上从根节点开始扫描,将全部可达对象都标记为黑色。(避免GC结束时STW重新扫描栈,因为第一轮的可达对象始终是黑色的,而那些GC操作过程中由于并发操作导致的需要被清除的对象,在下一轮GC开始时不再被标记为黑色,所以在下一轮可以被清除。)
  2. GC期间,创建在栈上的新对象,标记为黑色。
  3. GC期间,被添加的对象,标记为灰色。(满足强三色不变式)
  4. GC期间,被删除的对象,标记为灰色。(满足弱三色不变式)

三色标记算法+混合写屏障机制是目前Go runtime使用的垃圾回收策略。

何时触发GC

GC的触发条件有两种方式:手动触发和系统触发。

  • 手动触发:通过应用程序调用runtime.GC()来触发检查调用GC。

  • 系统触发:runtime自行维护,有两个地方会定时检查和GC。

  1. 在分配内存时,在mallocgc函数里,会检查调用GC。
  2. 后台监控线程sysmon定时会检查调用GC。

手动触发

应用程序主动调用GC函数,会阻塞当前运行的应用代码,直到GC完成。

函数位于src\runtime\malloc.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func GC() {
// 获取GC的循环次数
n := atomic.Load(&work.cycles)
// 等待上一个循环的标记终止、标记和清除终止阶段完成
gcWaitOnMark(n)
// 触发新一轮的GC
gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
// 等待当前这轮的循环的标记终止、标记和清除终止阶段完成
gcWaitOnMark(n + 1)
// 等待清理全部待处理的内存管理单元
for atomic.Load(&work.cycles) == n+1 && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
// 主动让出P
Gosched()
}

for atomic.Load(&work.cycles) == n+1 && atomic.Load(&mheap_.sweepers) != 0 {
Gosched()
}

mp := acquirem()
cycle := atomic.Load(&work.cycles)
if cycle == n+1 || (gcphase == _GCmark && cycle == n+2) {
// 完成本轮垃圾收集清理后,将该阶段的堆内存状态快照发布出来(heap profile)
mProf_PostSweep()
}
releasem(mp)
}

可以看到,开始执行GC的是gcStart()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
func gcStart(trigger gcTrigger) {
...
// 检查是否满足垃圾收集条件
// 并清理已经被标记的内存单元
for trigger.test() && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
}
// 获取全局的startSema信号量(加锁)
semacquire(&work.startSema)
// 再次检查是否满足垃圾收集条件
if !trigger.test() {
semrelease(&work.startSema)
return
}
// 检查是不是手动调用了runtime.GC
work.userForced = trigger.kind == gcTriggerCycle

semacquire(&gcsema)
semacquire(&worldsema)
// 启动后台标记任务
gcBgMarkStartWorkers()
// 重置标记相关的状态
systemstack(gcResetMarkState)

// 对work结构体做初始化工作,设置垃圾收集需要的goroutine数量
work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
if work.stwprocs > ncpu {
work.stwprocs = ncpu
}
work.heap0 = atomic.Load64(&memstats.heap_live)
work.pauseNS = 0
work.mode = mode
// 记录开始时间
now := nanotime()
work.tSweepTerm = now
work.pauseStart = now
// 暂停程序STW
systemstack(stopTheWorldWithSema)
// 在并发标记前,确保清理结束
systemstack(func() {
finishsweep_m()
})
// 清理sched.sudogcache以及sync.Pools
clearpools()
// GC次数+1
work.cycles++
// 在开始GC之前清理控制器的状态,标记新一轮GC已开始
gcController.startCycle()
work.heapGoal = memstats.next_gc
// 设置全局变量中的GC状态为_GCmark,然后启用写屏障
setGCPhase(_GCmark)
// 初始化后台扫描需要的状态
gcBgMarkPrepare()
// 扫描栈上、全局变量等根对象并将它们加入队列
gcMarkRootPrepare()
// 标记所有tiny alloc内存块
gcMarkTinyAllocs()
// 启用mutator assists(协助线程)
atomic.Store(&gcBlackenEnabled, 1)
// 记录标记开始的时间
gcController.markStartTime = now
mp = acquirem()
// 启动程序,后台任务也会开始标记堆中的对象
systemstack(func() {
now = startTheWorldWithSema(trace.enabled)
// 记录停止了多久, 和标记阶段开始的时间
work.pauseNS += now - work.pauseStart
work.tMark = now
})
semrelease(&worldsema)
...
}

如图所示,显示了gcStart过程中状态变化,以及STW停顿的时间段,写屏障启用的时间段。

gcStart()函数有一个gcTrigger参数,这是GC的触发条件。

1
2
3
4
5
6
7
// A gcTrigger is a predicate for starting a GC cycle. Specifically,
// it is an exit condition for the _GCoff phase.
type gcTrigger struct {
kind gcTriggerKind
now int64 // gcTriggerTime: current time
n uint32 // gcTriggerCycle: cycle number to start
}

gcTrigger的kind有三种,gcTriggerHeap、gcTriggerTime、gcTriggerCycle。

  • gcTriggerHeap:当前分配的内存达到一定阈值时触发,这个阈值在每次GC过后都会根据堆内存的增长情况和CPU占用率来调整。
  • gcTriggerTime:自从上次GC后间隔时间达到runtime.forcegcperiod(120s,It normally doesn’t change.),将启动GC。通过sysmon监控线程调用runtime.forcegchelper检查。
  • gcTriggerCycle:如果当前没有开启垃圾收集,则启动GC。

系统触发

内存分配触发

从src\runtime\malloc.go的mallocgc函数可以看到,在为对象分配堆内存后,会检查GC的触发条件,如果满足条件,则开启gcStart。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { 
...
dataSize := size
// 获取mcache,用于处理微对象和小对象的分配
c := gomcache()
var x unsafe.Pointer
// 表示对象是否包含指针,true表示对象里没有指针
noscan := typ == nil || typ.ptrdata == 0
// maxSmallSize=32768 32k
if size <= maxSmallSize {
// maxTinySize= 16 bytes
if noscan && size < maxTinySize {
...
} else {
...
}
// 大于 32 Kb 的内存分配,通过 mheap 分配
} else {
...
}
...
// 在 GC 期间分配的新对象都会被标记成黑色
if gcphase != _GCoff {
gcmarknewobject(span, uintptr(x), size, scanSize)
}
...
// 检查GC触发条件
if shouldhelpgc {
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
gcStart(t)
}
}
return x
}

gcStart函数在上面已经分析过了。和手动调用的GC()中调用的gcStart函数是一样的。

在mallocgc中,通过调用gcTrigger.test()函数判断GC条件是否满足,满足则触发GC。上面已经提过gcTrigger的kind有三种,gcTriggerHeap、gcTriggerTime、gcTriggerCycle。只要满足其中一个kind就满足触发GC条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (t gcTrigger) test() bool {
if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
return false
}
switch t.kind {
case gcTriggerHeap:
// 当前分配的堆内存达到一定阈值(控制器计算的触发堆大小)
return memstats.heap_live >= memstats.gc_trigger
case gcTriggerTime:
if gcpercent < 0 {
return false
}
lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
// 自从上次GC后间隔时间已大于forcegcperiod
return lastgc != 0 && t.now-lastgc > forcegcperiod
case gcTriggerCycle:
// 要求启动新一轮的GC
return int32(t.n-work.cycles) > 0
}
return true
}

heap_live的值会在内存分配的时候进行计算。
gc_trigger的计算是通过runtime.gcSetTriggerRatio()函数。gcSetTriggerRatio函数会根据计算出来的triggerRatio来获取下次触发GC的堆大小是多少。triggerRatio是通过gcControllerState.endCycle()函数(triggerRatio每次GC后都会调整)。

监控线程sysmon触发

在runtime.main()函数中,执行init前,会启动sysmon监控线程,执行后台监控任务。

代码在src/runtime/proc.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
...
systemstack(func() {
// 创建监控线程,该线程独立于调度器,不需要跟p关联即可运行
newm(sysmon, nil, -1)
})
...
}

func sysmon() {
...
for {
// 检查是否满足GC条件(GC间隔已超过gcTriggerTime)
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
lock(&forcegc.lock)
forcegc.idle = 0
var list gList
// 将forcegc.g这个goroutine添加到全局队列里等待被调度
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
}
...
}

runtime在启动时,会在一个初始化函数init()里启用一个forcegchelper()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// start forcegc helper goroutine
func init() {
go forcegchelper()
}

// 大部分时候陷入休眠,会被sysmon在满足GC条件时唤醒
func forcegchelper() {
forcegc.g = getg() // 指定forcegc的goroutine
lockInit(&forcegc.lock, lockRankForcegc)
for {
lock(&forcegc.lock)
if forcegc.idle != 0 {
throw("forcegc: phase error")
}
// 将forcegc设置为空闲状态,并进入休眠
atomic.Store(&forcegc.idle, 1)
// 为了减少系统资源占用,主动让自己陷入休眠,等待唤醒
// 由sysmon监控线程根据条件来恢复这个gc goroutine
goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceEvGoBlock, 1)
if debug.gctrace > 0 {
println("GC forced")
}
// 当forcegc.g被唤醒时,开始从此处进行调度完全并发
gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
}
}

我是这么理解的,forcegc是一个全局变量,所以forcegchelper可以由sysmon监控线程。在sysmon监控中,如果GC满足条件,会设置forcegc.idle = 0,一旦forcegc.g被唤醒,forcegchelper就会执行gcStart。

调节GC参数

Go的GC算法是固定的,用户无法去配置采用什么算法。GC相关的配置参数只有GOGC,用来表示触发GC的条件。

src\runtime\mgc.go的开头有这样一段注释。

Next GC is after we’ve allocated an extra amount of memory proportional to the amount already in use. The proportion is controlled by GOGC environment variable(100 by default). If GOGC=100 and we’re using 4M, we’ll GC again when we get to 8M(this mark is tracked in next_gc variable). This keeps the GC cost in linear proportion to the allocation cost. Adjusting GOGC just changes the linear constant (and also the amount of extra memory used).

下次GC的时机通过环境变量GOGC来控制,默认是100,即增长100%的堆内存才会触发GC。如果当前使用了4M内存,那么下次GC将会在内存达到8M的时候。设置GOGC=off将完全禁用GC。

也可以通过src/runtime/debug中的func SetGCPercent(percent int) int函数设置,设置负数将完全禁用GC。

增大GOGC,虽然可以降低GC频率,但是会增加触发GC的堆大小,可能会导致OOM,需要根据实际情况调节。

如何观察GC

可以通过不同的方法来观察GC。

GODEBUG=gctrace=1

将GODEBUG设置为gctrace=1。两种方式:

  • export GODEBUG=gctrace=1
  • GODEBUG=gctrace=1 ./main
1
2
3
4
5
6
7
8
9
gc # @#s #%: #+#+# ms clock, #+#/#/#+# ms cpu, #->#-># MB, # MB goal, # P
where the fields are as follows:
gc # the GC number, incremented at each GC
@#s time in seconds since program start
#% percentage of time spent in GC since program start
#+...+# wall-clock/CPU times for the phases of the GC
#->#-># MB heap size at GC start, at GC end, and live heap
# MB goal goal heap size
# P number of processors used
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ GODEBUG=gctrace=1 ./main
...
// gc 3: 第三个gc周期
// @0.002s: 程序开始后的0.002s
// 3%: 该GC周期中的CPU使用率
// 0.031+0.059+0.002 ms clock: 标记开始时STW所花的时间 + 标记过程中并发标记所花的时间 + 标记终止时STW所花的时间。总和是wall clock(实际的时间)。
// 0.12+0.040/0.024/0+0.009 ms cpu: 标记开始时STW所花的时间 + 标记过程中标记辅助所花的时间/标记过程中并发标记所花的时间/标记过程中GC空闲的时间 + 标记终止时STW所花的时间。总和是cpu time。
// 4->4->0 MB: 标记开始时堆大小的实际值/标记结束时堆大小的实际值/标记结束时标记为存活的堆大小
// 5 MB goal: 标记结束时堆的大小的目标值
// 4 P: 使用的P的数量
gc 3 @0.002s 3%: 0.031+0.059+0.002 ms clock, 0.12+0.040/0.024/0+0.009 ms cpu, 4->4->0 MB, 5 MB goal, 4 P

// scvg: runtime向操作系统申请内存产生的垃圾回收,向操作系统归还多余的内存
// 向操作系统归还了0MB内存
scvg: 0 MB released
// inuse: 1 已经分配给用户代码、正在使用的总内存大小(MB)
// idle: 62 空闲以及等待归还给操作系统的总内存大小(MB)
// sys: 63 通知操作系统中保留的内存大小(MB)
// released: 58 已经归还给操作系统的内存大小(MB)
// consumed: 5 (MB) 已经从操作系统中申请的内存大小(MB)
scvg: inuse: 1, idle: 62, sys: 63, released: 58, consumed: 5 (MB)
...

go tool trace

从标准库导入runtime/trace,并添加几行模板代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main
import (
"os"
"runtime/trace"
)

func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
// Your program here
}

运行程序会在trace.out文件中写入事件数据。 然后运行go tool trace trace.out,将解析跟踪文件,该命令将启动服务器,并使用跟踪数据来响应可视化操作。

内存泄露的情况

C/C++这种没有原生GC的语言,如果程序员没有及时手动释放堆内存,可能会导致内存泄露最终OOM。Go虽然有GC,但是也可能发生内存泄露。Go程序的内存泄露是因为:预期的能很快被释放的内存由于附着在了长期存活的内存上或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。举例说明三种情况。

  • 情况1
1
2
3
4
5
6
7
8
var cache = map[interface{}]interface{}{}
func keepalloc1() {
for i := 0; i < 10000; i++ {
m := make([]byte, 1<<10)
// 全局变量cache被引用,没有得到迅速释放
cache[i] = m
}
}
  • 情况2
1
2
3
4
5
6
7
8
func keepalloc2() {
for i := 0; i < 100000; i++ {
// 不断产生新的goroutine,且不结束已经创建的goroutine并复用这部分内存
go func() {
select {}
}()
}
}
  • 情况3
1
2
3
4
5
6
7
8
var ch = make(chan struct{})
func keepalloc3() {
for i := 0; i < 100000; i++ {
// 没有接收方,goroutine会一直阻塞
// 该goroutine会被永久的休眠,整个goroutine及其执行栈都得不到释放
go func() { ch <- struct{}{} }()
}
}

通过go tool trace验证一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"os"
"runtime/trace"
)

func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
keepalloc1()
keepalloc2()
keepalloc3()
}

使用go tool trace trace.out命令得到下图。可以看到,运行过程中Heap持续增长,没有被回收,产生了内存泄漏。

Go的逃逸分析

Go的逃逸分析指的是编译器执行静态代码分析后,对内存管理进行的优化和简化,决定一个变量是分配到堆上还是栈上。通过逃逸分析,可以把不需要分配到堆上的变量分配到栈上,减轻分配堆内存的开销,同时也减少GC的压力,提高程序的性能。

How do I know whether a variable is allocated on the heap or the stack?
From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.
The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

从官方的回答,可以看出Go逃逸分析基本的原则是:

  • 如果一个函数返回的变量被外部引用,那么它就会发生逃逸,被分配到堆上。
  • 如果函数的局部变量非常大,也可能被分配到堆上。

Go的new函数分配的内存不一定在堆上。即使用new申请到的内存,如果在退出函数后没有用了,就会被分配到栈上;即使是一个普通的变量,但是逃逸分析发现在退出函数之后还有其他地方在引用,就被分配到堆上。

举个栗子。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func test() *int {
a := 1
return &a
}
func main() {
b := test()
fmt.Println(*b)
}
1
2
3
4
5
$ go build -gcflags '-m -l' demo.go
# command-line-arguments
./demo.go:6:2: moved to heap: a
./demo.go:11:13: ... argument does not escape
./demo.go:11:14: *b escapes to heap

通过查看逃逸分析结果看出:

  • test函数里的变量a逃逸了。因为test函数返回了a的地址。
  • main函数里的b也逃逸了。因为func Println(a ...interface{})参数为interface{}类型,编译期间不能确定其参数的类型,也会发生逃逸。

这个栗子只是逃逸分析里最简单的情况。

GC调优

调优思想

并非所有程序都需要关注GC,只有以下两种情况需要对GC进行性能调优。

  • 对停顿敏感:用户代码需要实时性,无法接受GC长时间STW。
  • 对CPU资源消耗敏感:对于频繁分配内存的应用,影响用户代码对CPU的利用率。

所以,针对这两点,除了降低GC频率(通过增大GOGC的值),GC调优的核心就是:

  • 控制:优化内存的申请速度
  • 减少:尽可能少申请内存,比如初始化至合适的大小,尽量使用引用传递
  • 复用:复用已申请的内存

一些优化

  1. 对于频繁分配内存的对象,可以使用sync.Pool进行内存复用,减少分配内存频次,从而降低GC频率。
  2. 控制内存分配的速度,限制goroutine的数量,从而提高赋值器对CPU的利用率。
  3. slice和map等结构提前分配足够的内存,降低扩容时多余的拷贝。
  4. 不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要GC标记清除,减少GC频率。
  5. 避免string与[]byte转换,两者发生转换的时候,底层数据结结构会进行复制,因此导致GC效率会变低(有优化的方法)。
  6. 少量使用+连接string。因为string是一个只读类型,Go不能直接修改string类似变量的内存空间,针对它的每一个操作都会创建一个新的string。如果是大量小文本拼接,用strings.Join;如果是大量大文本拼接,用bytes.Buffer。

GC优化点不只这些,等我研究一下有时间再细讲。

Reference

[1]. https://draveness.me/golang/
[2]. https://www.luozhiyun.com/archives/475
[3]. https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/pacing/
[4]. https://www.kancloud.cn/aceld/golang/1958308
[5]. https://pkg.go.dev/runtime?utm_source=godoc
[6]. https://www.bookstack.cn/read/qcrao-Go-Questions/GC-GC.md
[7]. https://golang.org/doc/faq#stack_or_heap

浅析Golang的内存管理(一)

发表于 2021-06-26 | 分类于 计算机 | | 阅读次数:

[TOC]

学习Go的内存管理可以帮助我们编写更高性能的代码。

引言

进程在内存中的存储空间,有两个大小随程序的运行而变化的区域:栈区(stack)和堆区(heap)。

  • 栈区(stack)
  1. 保存函数的局部变量、向被调用函数传递参数、返回函数的返回值、函数的返回地址。
  2. 进程的每个线程有独立的stack。
  3. 有大小限制(可修改),开发者需要控制递归深度等,防止栈溢出。
  4. 这部分内存由编译器进行管理。
  • 堆区(heap)
  1. 程序运行时动态分配的内存,保存全局变量、引用类型等。
  2. 进程的多个线程共享heap。
  3. 从堆上分配的内存用完后必须归还给堆,否则内存分配器可能会反复向操作系统申请扩展堆的大小,最后内存不足导致内存泄露。

所以,我们讨论内存管理,指的是堆内存管理。在C/C++中由开发者主动申请和释放(提供malloc、free等方法来管理内存),涉及用户态和内核态切换;在Go中由runtime来进行内存管理,通过内存分配器分配和垃圾处理器(Garbage collection,GC)回收,从而避免频繁地向操作系统申请、释放内存,有效地提升程序的性能。

内存管理的流程简单可以描述是:程序通过内存分配器申请内存,内存分配器负责从堆中初始化相应的内存区域,再被内存收集器回收。

如果堆上有足够的空间满足程序的内存申请,内存分配器可以完成内存申请无需内核参与,否则将通过操作系统调用(brk)进行扩展堆内存。

如果让我们设计内存管理,如何保证高效稳定?

  • 内存池:要减少用户态和内核态的频繁切换,就需要自己申请一块内存空间,将之分割成大小规则不同的内存块来供程序使用。
  • GC:动态地垃圾回收,销毁无用的对象,释放内存来保证内存使用过程中节约。
  • 锁:堆是被多线程共享的,一个办法是通过加锁保证同一时间只能有一个线程在申请;另一个办法是内存隔离,在Go中用的macache进行隔离。

内存分配

TCMalloc

内存池的设计直接决定是否能尽可能减少内存碎片。这个由调用底层哪种内存分配算法决定。
Go的内存管理基于TCMalloc,但又有些差异。在Go中,局部缓存并不是分配给进程或者线程,而是分配给P(Processor);Go的GC是stop the world,并不是对每个进程单独进行GC;Go对span的管理更有效率。

TCMalloc的核心思想是将内存分为多个级别,从而缩小锁的粒度。在TCMalloc内存管理内部分为两个部分:线程内存(thread memory)和页堆(page heap)。

线程内存

每一个内存页都被分为多个固定分配大小规格的空闲列表(free list)用于减少碎片化。每一个线程都可以获得一个用于无锁分配小对象的缓存,可以让并行程序分配小对象(<=32KB)非常高效。

页堆

TCMalloc管理的堆由一组page组成,一组连续的page被表示为span。当分配的对象大于32KB,将直接使用page进行内存分配。

当没有足够的空间分配小对象,则会到页堆获取内存。如果页堆没有足够的内存,则页堆会向操作系统申请更多的内存。

span

什么是span

Go与操作系统之间的内存申请和释放,以page为单位(8KB)。一个或多个连续的page组成一个span。span是Go内存管理的基本单位,是以page为单位的内存块。应用程序创建对象,就是通过找到对应规格的span来存储的。mspan是Go的runtime用于存储和管理对象的。

简单的说,mspan是一个包含页起始地址、页的span规格和页的数量的双端链表。

mspan结构体的定义在src/runtime/mheap.go。

1
2
3
4
5
6
7
8
9
10
type mspan struct {
next *mspan // 链表下一个span地址
prev *mspan // 链表前一个span地址
list *mSpanList // 链表地址

startAddr uintptr // 该span在arena区域的起始地址
npages uintptr // 该span占用arena区域page的数量
spanclass spanClass // span分类
...
}

怎么区分span

每个span通过span class标识属于哪种规格的span。Go的span规格一共有67种。

规格的定义在src/runtime/sizeclasses.go。

1
2
3
4
5
6
7
8
// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
...
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
  • class: span class,规格ID,表示该span可以存储的对象规格类型。
  • bytes/obj:表示能存储多大的对象(单位字节)。
  • bytes/span:每个span占用堆的大小(单位字节),即页数*页(npages*8KB)。
  • objects:每个span可存储的对象个数,即(bytes/span)/(bytes/obj)。
  • tail bytes:每个span产生的内存碎片,即(bytes/span)%(bytes/obj)。
  • max waste:最大浪费比例。计算公式是(bytes/obj-最小使用量)*objects/(bytes/span)*100。

通过span规格表,可以知道在创建对象的时候,选择哪一个span class的span去获取内存空间,尽可能节约地去使用内存空间。

内存分配器组件

对象存储在span中,但是如何将各种规格孤立的span串起来?由Go的内存分配器负责。内存分配器采用分级的机制,由3种组件组成:macache、mcentral、mheap。

mcache

我们知道,Go的强大并发能力依赖于GPM模型。Go runtime调度器会将goroutine绑定在P(processors)上。mcache就是绑定在GMP模型的P上的,每一个P都会有一个mcache与之绑定,用来给goroutine分配存储空间。

所以如果goroutine需要内存可以直接从mcache中获取。由于每个P都拥有各自的mcache,而且同一时间只有一个goroutine运行在逻辑处理器P上,所以从mcache分配内存无需持有锁。

mcache包含所有大小规格的mspan作为缓存。

对于每一种规格都有两个类型:

  • scan:包含指针的对象。
  • noscan:不包含指针的对象。

采用这种方法的好处之一就是进行gc时,noscan对象无需进一步扫描是否引用其他活跃的对象。

mcache结构体的定义在src/runtime/mcache.go。

1
2
3
4
5
6
7
type mcache struct { 
tiny uintptr // 申请小对象的起始地址
tinyoffset uintptr // 从起始地址tiny开始的偏移量
local_tinyallocs uintptr // tiny对象分配的数量
alloc [numSpanClasses]*mspan // 分配的mspan list,其中numSpanClasses=134,索引是spanClassId
...
}

关注下alloc [numSpanClasses]*mspan这行定义,因为span class一共有67种,为了满足指针对象和非指针对象,每种规格的span准备scan和noscan两种,因此alloc数组有134个*mspan,分别指向mspan双向链表。

Go对于[16B,32KB]的对象都会从alloc这个数组找,使用这部分的相应大小规格的span分配内存。

1
2
3
4
5
6
7
8
9
10
var sizeclass uint8
// 确定规则
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
size = uintptr(class_to_size[sizeclass])
spc := makeSpanClass(sizeclass, noscan)
span := c.alloc[spc] // 从alloc中通过span class查找span

对于更小的对象(<16B),称之为tiny对象,通过tiny和tinyoffset组合寻找位置分配内存空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
off := c.tinyoffset
// 根据不同大小内存对齐
if size&7 == 0 {
off = round(off, 8)
} else if size&3 == 0 {
off = round(off, 4)
} else if size&1 == 0 {
off = round(off, 2)
}
if off+size <= maxTinySize && c.tiny != 0 {
// tiny+偏移量
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.local_tinyallocs++
mp.mallocing = 0
releasem(mp)
return x
}
span := c.alloc[tinySpanClass] // 空间不足从alloc重新申请空间用于tiny对象分配

mcentral

mcache中的mspan是动态申请的。当mcache没有可用空间时,mcache会从mcentral的mspans列表获取一个新的所需规格的mspan。

mcentral收集所有给定规格大小的span,每个mcentral对象包含两个mspan列表:

  • empty mspanList:没有空闲对象或span已经被mcache缓存的span列表。
  • nonempty mspanList:有空闲对象的span列表。

mcentral结构体的定义在src/runtime/mcentral.go。

1
2
3
4
5
6
7
type mcentral struct {
lock mutex
spanclass spanClass // span class Id
nonempty mSpanList // 空闲的span列表
empty mSpanList // 已经被使用的span列表,未归还的会挂载到这里
nmalloc uint64 // 这个mcentral分配mspan的累积计数
}

由于mcentral是公共资源,会有多个mcache向它申请mspan,所以mcentral必须加锁。另外,由于在P上会处理大小不同的对象(因为绑定了不同的goroutine),mcache需要包含各种规格的span,但同一个mcentral只负责管理一种规格(span class)的mspan(mcentral也是用spanclass标记span规格)。

由于有各个规格的span的mcentral,当一个mcache从mcentral申请mspan时,只需在独立的mcentral中使用锁,所以其它任何mcache在同一时间申请不同大小规格的mspan将互不影响。

mheap

当mcentral的nonempty列表为空的时候,mcentral空间不足,会从mheap获取一系列页用于需要的大小规格的span。

mheap用于管理堆,只有一个全局变量。持有虚拟地址空间。
mheap存储了mcentral的数组,这个数组包含了各个的span的mcentral。

mheap结构体的定义在src/runtime/mheap.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
type mheap struct {
lock mutex
allspans []*mspan // 所有的spans
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // arenas数组集合,管理各个heapArena
allArenas []arenaIdx // 所有arena序号集合,可以根据arenaIdx算出对应arenas中的哪一个heapArena

// 各个规格的mcentral集合
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
...
}

每个Go程序启动的时候,会向操作系统申请一块虚拟内存空间,放在heapArena数组里,用于应用程序内存分配。

heapArena分为三个区域:

  • spans区域:存储page和span信息,比如一个span的起始地址是什么,有几个page,已使用了多大等。在堆外分配。主要用于GC。
  • bitmap区域:用于标记arena区域中哪些地址保存了对象,对象中哪些地址包含了指针,对象是否可回收等。在堆外分配。主要用于GC。
  • arena区域:heap区域,用于给程序分配内存,存储了所有在堆上初始化的对象。基本单位是page(8KB)。所有在堆上的内存申请都来自arena。

Go将大于32KB的对象定义为大对象,直接通过mheap分配。同时只被一个P申请,申请时需要加全局锁。大对象分配必须是page的整数倍。如果mheap内存不足,只能向操作系统申请。

内存分配规则

  • tiny对象(小于16B)内存分配:先向mcache的tiny对象分配器申请;如果不足,向mcache的tinySpanClass规格的span链表申请;如果不足,向mcentral申请对应规格mspan;如果不足,向mheap申请;如果不足,向操作系统申请。
  • 小对象(16B~32KB)内存分配:通过计算大小规格,先向mcache申请对应大小规格的mspan;如果不足,向mcentral申请对应规格mspan;如果不足,向mheap申请;如果不足,向操作系统申请。
  • 大对象(大于32KB)内存分配:直接向mheap申请;如果不足,向操作系统申请。

Go会在操作系统分配超大的页(称作arena)。分配一大批页会减少系统调用成本。

内存分配总结

总结下Go的内存分配思想。

  • 使用不同的内存结构为不同大小的对象,用不同的内存缓存级别来分配内存。
  • 将一个从操作系统接收的连续地址的块,切分成多级缓存,从而减少锁的使用。
  • 根据指定大小分配相应规格的内存,减少内存碎片,以提高内存分配的效率,也加快GC的速度。

Reference

[1]. https://medium.com/@ankur_anand/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed
[2]. https://draveness.me/golang/
[3]. http://goog-perftools.sourceforge.net/doc/tcmalloc.html

讲完Go的内存分配,下一节讲Go的垃圾回收。

浅析Golang的调度器(一)

发表于 2021-06-19 | 分类于 计算机 | | 阅读次数:

[TOC]

进程/线程/协程/Goroutine

进程(Process)

在说进程是什么之前,先考虑一下为什么需要进程?
早期批处理系统只能一次处理一个任务,多道程序设计的引入,内存中可以同时存放多个程序,在操作系统的管理下,可以并发(同一时间间隔)处理多个任务。所以需要引入进程,合理地隔离资源、运行环境,提升资源利用率。

所以,进程是操作系统进行资源分配和调度的基本单位,作为程序独立运行的载体保障程序正常执行,使得资源利用率大幅提升。

程序最初只是一个文本文件,被编译或解释成二进制,载入到内存后成为一个或多个正在运行的进程,它不仅需要告诉CPU应该执行什么二进制指令,还需要内存和各种操作系统资源才能运行。所以进程也可以理解为:加载到内存中的程序 + 程序运行所需的所有资源(操作系统分配和管理这些资源)。

进程作为运行中的程序在操作系统中的一个具象化的表现,在内存中的典型存储空间布局如图所示。每个进程都拥有如图的存储空间,独立不共享。所以从一个进程切换到另一个进程需要一些时间来保存和加载寄存器、内存映射和其他资源。

  • text: 代码区。程序代码编译后的能被CPU执行的机器指令。一旦加载后只读,大小不会再变化。
  • initialized data: 程序初始化的变量,全局变量和静态变量。一旦加载后只读,大小不会再变化。
  • uninitialized data(bss): 程序没有初始化的全局变量和静态变量,会被初始化为0或空指针。
  • stack: 函数调用栈。保存函数的局部变量、向被调用函数传递参数、返回函数的返回值、函数的返回地址。
  • heap: 堆。程序运行时动态分配的内存(例如调用malloc),大小随程序的运行而变化。从堆上分配的内存用完后必须归还给堆,否则内存分配器可能会反复向操作系统申请扩展堆的大小,最后内存不足导致内存泄露。

c/c++必须小心处理堆的分配和释放。但是go的runtime有垃圾处理器进行垃圾回收。
c/c++绝对不要返回函数局部变量的地址,因为同一地址的栈内存会被其它函数重用。但是go的编译器发现程序返回了某个局部变量的地址,编译器会把这个变量放到堆上去。

线程(Thread)

线程是进程中的执行单元。简单地说,线程是由内核负责调度且拥有自己私有的一组寄存器值和栈的执行流。

如图所示,例如一个程序要输出2次print,可以用一个执行流依次执行;也可以创建2个执行流,每个执行流执行一次print。如果有2个cpu,就可以实现单进程中利用2个执行流并行执行print。所以线程是操作系统独立调度的最小单位。操作系统对线程的调度可以简单地理解为内核对不同线程所使用的寄存器和栈的切换。

每个线程都有自己的stack,但是进程中的所有线程都将共享heap和其它资源。所以同一进程的线程之间的通信成本相对较低,但同时增加了解决临界资源的锁问题。

相比于进程,线程最大的好处是同一进程的线程之间切换上下文开销较小。

协程(Coroutine)

一些现代编程语言中,引入了协程的概念。为什么引入协程?

虽然多线程/多进程解决了阻塞带来的CPU浪费,可以并发执行多个任务。但是引入新的问题,一是进程/线程数量越多,CPU在执行程序和切换进程/线程中来回,切换成本越大;二是多线程需要解决同步和竞争(锁、资源竞争冲突等)问题;三是进程/线程都是高内存(虚拟内存)占用,进程的量级在GB,线程的量级在MB。

所以协程的产生是为了追求更好地利用CPU和内存。把线程一分为二:内核空间的线程+用户空间的协程。具体语言会做相应的绑定和调度策略,比如本文要讲的go语言的GMP模型的协程调度器。

所以,协程是用户态的概念。线程是由操作系统在内核态调度的,协程则是由应用程序在用户态调度的。

多个协程可以运行在一个线程中。相比于线程,有两个优势:

  • 一个协程只需要量级KB的栈内存,而线程的量级是MB。
  • 由于协程之间的切换发生在用户态,没有系统调用,切换效率远高于线程(我理解是在子程序之间来回切换)。

只有内核对线程的调度才能利用多核CPU让程序并行执行,所以一个线程中的多个协程是无法并行执行的。
协程非常适合用于并发执行IO密集型任务,但不适合计算密集任务。因为计算密集型任务需要连续执行指令,切换会损失CPU资源;IO密集型任务,任务阻塞在IO等待,切换不会损失CPU资源。

Goroutine

不能简单地将go语言中的goroutine理解为协程。因为多个goroutine在运行时创建多个线程来执行并发任务。goroutine在运行时可以被分派到其它线程执行。goroutine更像是线程和协程的结合,可以最大限度利用多核CPU。

相比于线程,goroutine的优势在于轻量,体现在两个方面:

  • goroutine的创建和切换在用户态就能完成,无需进入内核态。开销远小于需要进入内核态创建和切换的线程。
  • 线程的栈内存空间一旦创建和初始化完成后其大小就不能再变化,而且这个栈内存空间较大;而goroutine启动时默认栈大小只有2KB,而且可以由runtime自动伸缩,既没有栈溢出风险,也不会造成栈内存空间浪费。

线程的调度是抢占式的,由内核决定。但是goroutine的调度是用户态决定的,在go1.14之前是非抢占式的,在go1.14开始是抢占式的。

用一个example可以看出go1.14前后版本的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"runtime"
"time"
)

func main() {
runtime.GOMAXPROCS(1) // 限制CPU使用1核,否则无法区分结果
var a [10]int
for i := 0; i < 10; i++ {
go func(i int) {
for {
a[i]++
}
}(i)
}
time.Sleep(time.Second)
fmt.Println(a)
}

用go1.13编译器,该程序会死循环,永远无法退出。如图所示,可以看到CPU占用率持续在接近100%。
因为在go1.13中,goroutine调度器还是非抢占式的,除非遇到IO阻塞等情况,否则goroutine不会主动交出CPU的使用权。我们的示例是个计算密集型程序,main goroutine永远拿不到CPU的使用权,所以程序永远无法结束。

用go1.14编译器,可以得到类似[66722775 68857017 71320685 63615563 60855132 52515950 51764765 53227266 57977851 61729335]的输出。
因为在go1.14中,goroutine调度器是抢占式的,goroutine占用CPU的时间是被限制的,只要main函数中的goroutine拿到CPU的使用权,就能结束程序。

GMP模型

GM模型的问题

在2012年go1.0版本之前,goroutine调度器是GM模型。G代表goroutine,M(thread)代表线程。如图所示,goroutine维护在全局队列,M想要执行、放回G都必须访问全局队列。因为M有多个,访问临界资源全局队列需要加锁保证互斥/同步。

GM模型有几个缺点:

  1. 创建、销毁、调度G都需要M获取锁,锁竞争导致性能低下。
  2. 当G需要创建子协程G’,为了继续执行G,需要把G’交给M’执行,导致程序的局部性很差(因为G和G’相关的)。
  3. CPU在M之间的频繁切换导致较大的系统调用开销。

GMP模型的引入

针对GM模型的弊端,引入了GMP模型,它增加了P(processor)。M和P绑定,P中包含G的局部运行队列。

如图所示,可以看出,每个M都绑定了一个P,每个P都有一个私有的goroutine本地运行队列,M从本地和全局goroutine队列中获取goroutine并运行之。

  • 全局队列:存放等待运行的G。
  • P的本地队列:也是存放等待运行的G。不超过256个。G新建子协程G’时,G’优先加到本地队列。如果队列满,则把本地队列的一半G移动到全局队列。
  • P:所有P都在程序启动时创建,最多GOMAXPROCS(可配置) 个。
  • M:M和P绑定,M通过P间接从本地队列获取G,P队列为空时候,M也会尝试从全局队列拿一批G放到P的本地队列,或者从其他P的本地队列偷取G放到自己的本地队列。M运行G,G执行完成后,M会从P获取下一个G,不断重复,直到主进程退出。

P的数量由GOMAXPROCS决定,运行时候系统会根据这个数量创建P,这意味着同一时刻,只有GOMAXPROCS个goroutine在并行。
M的最大数量由runtime的 SetMaxThreads函数设置。当一个M阻塞,P会创建新的M或切到另一个空闲的M。所以P和M的数量没有绝对关系。

为什么是M:N

go的调度器是基于GMP模型的。先说结论,GMP模型中,线程和协程的绑定是M:N关系。M个goroutine运行在N个线程之上,内核负责对这N个线程进行调度,这N个线程又负责对这M个goroutine进行调度。

再说为什么是M:N关系?

  • 如果绑定是N:1关系,N个协程绑定在一个1个线程上。优点是可以在用户态快速切换协程。缺点是1个进程的所有协程都绑定在1个线程上,无法利用多核CPU。一旦某个协程阻塞,线程也会阻塞,其它协程都无法执行。

  • 如果绑定是1:1关系,1个协程绑定在1个线程上。可以利用多核,不存在N:1关系的缺点。但反而多了协程的创建和删除开销,且切换上下文慢。

  • 绑定是M:N关系,M个协程绑定在N个线程上。解决了上面两种模型的缺点,能利用多核,也能快速切换协程。但瓶颈在于协程调度器的优化和调度算法。

所以,即使某个工作线程遇到goroutine阻塞,该线程的其它goroutine也能被runtime调度,转移到其它工作线程执行。

GMP模型的数据结构

以下列举的结构体的定义位于Go源代码的src/runtime/runtime2.go。

g

线程对goroutine的调度和内核对线程的调度,其原理是类似的,都是通过保存和修改CPU寄存器的值来实现切换线程/goroutine。

所以,为了实现对goroutine的调度,goroutine调度器引入g结构体来保存CPU寄存器的值和goroutine的所有信息。g的每一个实例对象代表一个goroutine。当goroutine被调离CPU时,调度器负责把CPU寄存器值保存在g对象的成员变量中;当goroutine被调度在CPU运行时,调度器负责把g对象的成员变量保存的寄存器值恢复到CPU寄存器中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type g struct {
stack stack // 记录该goroutine使用的栈
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
......
m *m // 此goroutine正在被哪个工作线程执行
sched gobuf // 保存调度信息,主要是几个寄存器的值
......
schedlink guintptr // 指向全局运行队列中的下一个g,所有位于全局运行队列中的g形成一个链表
......
preempt bool // 抢占调度标志,如果需要抢占调度,设置preempt为true
......
}

schedt

可运行的g对象需要有存放在一个容器里,便于被工作线程调度并运行。调度器引入schedt结构体,用来保存g对象的运行队列(称之为goroutine全局运行队列),也用来保存调度器自身的状态信息。

每个go程序中,只有一个schedt的实例对象,被定义成一个共享的全局变量。这样每个线程都可以访问schedt对象,以及获取schedt的goroutine全局运行队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type schedt struct {
// accessed atomically. keep at top to ensure alignment on 32-bit systems.
goidgen uint64
lastpoll uint64
lock mutex
// 由空闲的工作线程组成链表
midle muintptr // idle m's waiting for work
// 空闲的工作线程的数量
nmidle int32 // number of idle m's waiting for work
nmidlelocked int32 // number of locked m's waiting for work
mnext int64 // number of m's that have been created and next M ID
// 最多只能创建maxmcount个工作线程
maxmcount int32 // maximum number of m's allowed (or die)
nmsys int32 // number of system m's not counted for deadlock
nmfreed int64 // cumulative number of freed m's
ngsys uint32 // number of system goroutines; updated atomically
// 由空闲的p结构体对象组成的链表
pidle puintptr // idle p's
// 空闲的p结构体对象的数量
npidle uint32
nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
// goroutine全局运行队列
runq gQueue
runqsize int32
......
// gFree是所有已经退出的goroutine对应的g结构体对象组成的链表
// 用于缓存g结构体对象,避免每次创建goroutine时都重新分配内存
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
......
}

p

因为全局运行队列是临界资源,每个工作线程都可读,访问它需要加锁,影响调度器性能。所以调度器为每个工作线程引入p结构体来保存一个私有的goroutine局部运行队列,工作线程优先用局部运行队列获取goroutine进行调度,提高工作线程的并行性。

所以,每个工作线程都会和一个p对象关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type p struct {
lock mutex
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
......

// 本地goroutine运行队列
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr // 使用数组实现的循环队列
runnext guintptr

// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
......
}

m

调度器用m结构体保存工作线程的状态,包括工作线程的栈起始位置、当前正在运行的goroutine、是否空闲等,还通过指针维持p对象的绑定关系。每个工作线程和一个m对象对应。

所以,通过m对象,可以找到它正在运行的goroutine,也可以间接通过p对象找到局部运行队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type m struct {
g0 *g // 用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈。执行用户goroutine代码时,使用用户goroutine自己的栈,调度时会发生栈的切换
tls [6]uintptr // 通过TLS实现m结构体对象与工作线程之间的绑定
mstartfn func()
curg *g // 指向工作线程正在运行的goroutine的g结构体对象

p puintptr // 记录与当前工作线程绑定的p结构体对象
nextp puintptr
oldp puintptr

// spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取goroutine
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note

park note // 没有goroutine需要运行时,工作线程睡眠在这个park成员上,其它线程通过这个park唤醒该工作线程
alllink *m // 记录所有工作线程的一个链表
schedlink muintptr

thread uintptr // 线程ID
freelink *m // on sched.freem
......
}

工作线程执行的代码是如何找到属于自己的m对象的呢?答案是通过线程本地存储。每个工作线程拥有各自私有的m对象,在不同的工作线程中,使用相同的全局变量名来访问不同的m对象。

在gcc中,在定义全局变量时增加__thread,这样该变量就变成线程私有变量了。

在goroutine调度器中,每个工作线程在被创建后,线程本地存储机制就为该线程实现一个指向m对象的私有全局变量。这个工作线程的代码就可以使用该全局变量访问自己的m对象以及和m对象关联的p对象和g对象。

stack

stack结构体用于记录goroutine使用的栈的起始和结束位置。

1
2
3
4
type stack struct {  
lo uintptr // 栈顶,指向内存低地址
hi uintptr // 栈底,指向内存高地址
}

gobuf

gobuf结构体用于保存goroutine的调度信息,包括CPU的几个寄存器的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
type gobuf struct {
sp uintptr // 保存rsp寄存器的值
pc uintptr // 保存rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个goroutine
ctxt unsafe.Pointer

// 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
// 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
ret sys.Uintreg
lr uintptr

bp uintptr // 保存rip寄存器的值
}

全局变量

一些重要的全局变量。

1
2
3
4
5
6
7
8
allgs     []*g // 保存所有的g
allm *m // 所有的m构成的一个链表,包括下面的m0
allp []*p // 保存所有的p,len(allp) == gomaxprocs
ncpu int32 // 系统中cpu核的数量,程序启动时由runtime代码初始化
gomaxprocs int32 // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改
sched schedt // 调度器结构体对象,记录了调度器的工作状态
m0 m // 代表进程的主线程
g0 g // m0的g0,也就是m0.g0 = &g0

这些全局变量会被初始化为0值(指针会被初始化为nil指针,切片初始化为nil切片,int被初始化为数字0,结构体的所有成员变量按其本类型初始化为其类型的0值)。所以程序刚启动时allgs,allm和allp都不包含任何g、m、p。

调度器

工作流程

goroutine调度器本质是按照一定的算法把大量的goroutine分配到少量的线程上利用CPU去运行,充分利用多核CPU并行。

工作流程简易地可以用下面伪代码描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for i := 0; i < N; i++ { // 创建N个线程执行schedule函数
create_os_thread(schedule) // 创建一个线程执行schedule函数
}

// 定义一个线程私有全局变量,它是一个指向m对象的指针
// 真实的调度器中,不光是schedule函数需要访问m,其它很多地方也需要访问m,所以将其定义成私有全局变量。
ThreadLocal self *m

// schedule函数实现调度逻辑
func schedule() {
// 创建和初始化m结构体对象,并赋值给私有全局变量self
self = initm()
for { // 调度循环
if (self.p.runqueue is empty) {
// 根据某种算法从全局运行队列中找出一个需要运行的goroutine
g := find_a_runnable_goroutine_from_global_runqueue()
} else {
// 根据某种算法从私有的局部运行队列中找出一个需要运行的goroutine
g := find_a_runnable_goroutine_from_local_runqueue()
}
run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值
}
}

设计策略

  1. 复用线程:避免频繁地创建、销毁线程。
  • work stealing:当某个M无可运行的G时,从其他M绑定的P偷取G,而不是销毁线程。
  • hand off:当某个M因为G进行系统调用阻塞时,M释放绑定的P,将P由其他空闲的M绑定并运行。
  1. 利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在CPU上并行。
  2. 抢占:不同于coroutine的非抢占式,从go1.14开始,goroutine的调度器是抢占式的,一个goroutine占用CPU的时间是被限制的。
  3. 全局G队列:依然有全局G队列。但是只有在M执行work stealing从其他P偷不到G时,才从全局G队列获取。

下一篇会简单分析下Golang调度器的源码。

Reference

[1].UNIX环境高级编程(第3版)https://book.douban.com/subject/25900403/
[2].https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/
[3].https://learnku.com/articles/41728
[4].https://mp.weixin.qq.com/mp/homepage?__biz=MzU1OTg5NDkzOA==&hid=1&sn=8fc2b63f53559bc0cee292ce629c4788&scene=1&devicetype=android-29&version=2800015d&lang=zh_CN&nettype=3gnet&ascene=7&session_us=gh_8b5b60477260&wx_header=1

《Kubernetes In Action》阅读笔记(二)

发表于 2021-06-16 | 分类于 计算机 | | 阅读次数:

[TOC]

本文是把《Kubernetes In Action》读薄的摘抄或转述,仅供参考。系统学习请阅读原书。
k8s的命令繁多,熟练使用它们提高工作效率和理解k8s设计思想同等重要,每章最后总结了该章涉及的命令。

7 ConfigMap 和 Secret :配置应用程序 195

7.1 配置容器化应用程序 195

因为pod的设计理念是无状态的,所以需要提供一种方式让pod中的应用可以读取到相应的配置文件。configMap就提供了一个集群级别的服务,让pod根据需求读取相应的配置(配置信息本质上是KV,不过可以以多种方式挂载)。

或者,pod的启动需要拉取registry的镜像,而registry可能会有授权认证,这时候就需要pod有认证信息,但是又不能直接将认证信息写进pod的配置(不然就所有的人都能看到了),所以最好能有一种等pod启动后再加载的相对安全的方式,这就是secret了。

使用configMap和secret的优点在于,配置可以动态的变化,而且容器完全不会感知到configMap和secret的存在,而是通过文件挂载等方式直接读取,不需要和configMap等直接交互。

7.2 向容器传递命令行参数 196

7.2.1 在 Docker 中定义命令与参数 196

容器中运行的完整指令由两部分组成:命令与参数。

Dockerfile中的两种指令分别定义命令与参数这两个部分:

  • ENTRYPOINT 定义容器启动时被调用的可执行程序。
  • CMD 指定传递给 ENTRYPOINT 的参数。

尽管可以直接使用CMD指令指定镜像运行时想要执行的命令,正确的做法依旧是借助ENTRYPOINT指令,仅仅用 CMD 指定所需的默认参数。镜像中定义的CMD可以被覆盖,而ENTRYPOINT无法被覆盖。

上述两条指令均支持以下两种形式:

  • shell 形式:如 ENTRYPOINT node app . js。
  • exec 形式:如 ENTRYPOINT ["node","app . js"]。

两者的区别在于指定的命令是否是在shell中被调用。
采用shell形式,PID 1是shell进程而非node进程,node进程于shell中启动。shell进程往往是多余的。所以通常可以直接采用exec形式的ENTRYPOINT指令。

7.2.2 在 Kubernetes 中覆盖命令和参数 199

1
2
3
4
5
6
kind: pod
spec:
containers:
- image: some/image
command: ["bin/command"]
args: ["arg1", "arg2", "arg3"]

Docker的ENTRYPOINT对应k8s的command,描述容器中运行的可执行文件。
Docker的CMD对应k8s的args,描述传给可执行文件的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1
kind: Pod
metadata:
name: fortune2s
spec:
containers:
- image: luksa/fortune:args
args: ["2"] # 该参数覆盖dockerfile中的CMD
name: html-generator
volumeMounts:
- name: html
mountPath: /var/htdocs
volumes:
- name: html
emptyDir: {}

少量参数值的设置可以使用数组表示。多参数值情况下可以采用如下标记:

1
2
3
4
args:
- foo # 字符串不需要引号标记
- bar
- "15" # 数值需要引号标记

7.3 为容器设置环境变量 200

7.3.1 在容器定义中指定环境变量 201

1
2
3
4
5
6
7
spec:
containers:
- image: luksa/fortune:env
env:
- name: INTERVAL # 在环境变量列表中添加一个新变量
value: "30"
name: html-generator

7.3.2 在环境变量值中引用其他环境变量 201

1
2
3
4
5
env:
- name: FIRST_VAR
value: "foo"
- name: SECOND_VAR
value: "$(FIRST_VAR)bar"

7.3.3 了解硬编码环境变量的不足之处 202

pod定义硬编码意味着需要有效区分生产和开发环境的pod定义。如果想在多个环境中能复用pod的定义,需要将配置从pod定义中解耦出来。——可以通过ConfigMap的资源对象完成解耦。

7.4 利用 ConfigMap 解耦配置 202

configMap的官方文档

7.4.1 ConfigMap 介绍 202

k8s允许将配置选项分离到单独的资源对象ConfigMap中,本质上就是一个键/值对映射。

7.4.2 创建 ConfigMap 203

configMap创建自多种选项:完整文件夹、单独文件、自定义键名的条目下的文件(替代文件名作键名)以及字面量。

多种形式创建 configMap:

7.4.3 给容器传递 ConfigMap 条目作为环境变量 206

1
2
3
4
5
6
7
8
9
spec:
containers:
- image: luksa/fortune:env
env:
- name: INTERVAL # 设置环境变量INTERVAL
valueFrom:
configMapKeyRef: # 用ConfigMap初始化,不设定固定值
name: fortune-config # 引用的ConfigMap名称
key: sleep-interval # 环境变量值被设置为ConfigMap下对应键的值

如果pod中引用了不存在的configMap会导致pod启动失败。如果稍后创建了该configMap后,pod就会自动启动。

7.4.4 一次性传递 ConfigMap 的所有条目作为环境变量 208

可以通过envFrom属性字段将所有条目暴露作为环境变量。

1
2
3
4
5
6
7
spec:
containers:
- image: some-image
envFrom:
prefix: CONFIG_ # 环境变量名为`CONFIG_{name}`
configMapRef:
name: my-config-map # configMap name

configMap里的名字必须符合环境变量格式才能被转为环境变量。k8s不会主动转换键名(例如不会将破折号转换为下画线)。如果configMap的某键名格式不正确,创建环境变量时会忽略对应的条目(忽略时不会发出事件通知)。

7.4.5 传递 ConfigMap 条目作为命令行参数 209

在字段pod.spec.containers.args中无法直接引用configMap的条目,但是可以利用configMap条目初始化某个环境变量,然后再在参数字段中引用该环境变量,形如 “$ENV_VAR”。

1
2
3
4
5
6
7
8
9
10
spec:
containers:
- image: luksa/fortune:args
env:
- name: INTERVAL
valueFrom:
configMapKeyRef:
name: fortune-config
key: sleep-interval
args: ["$(INTERVAL)"] # 在参数设置中引用环境变量

7.4.6 使用 configMap 卷将条目暴露为文件 210

configMap卷会将configMap中的每个条目均暴露成一个文件。key 为文件名,value 为文件内容。运行在容器中的进程可通过读取文件内容获得对应的条目值,所以可以用configMap来保存配置文件。

volume声明可以直接声明configMap,将configMap条目作为容器卷中的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spec:
containers:
- image: nginx:alpine
name: web-server
volumeMounts: # 通过volume挂载configMap volume
...
- name: config
mountPath: /etc/nginx/conf.d # 挂载configMap卷至这个位置
readOnly: true
...
volumes:
- name: config
configMap:
name: fortune-config # 卷定义引用fortune-config configMap
...

挂载任意一种卷时均可以使用subPath属性。可以选择挂载部分卷而不是挂载完整的卷。

1
2
3
4
5
6
7
spec:
containers:
- image: some/image
volumeMounts:
- name: myvolume
mountPath: /etc/someconfig.conf # 挂载至某一文件,而不是文件夹
subPath: myconfig.conf # 仅挂载指定的条目的myconfig.conf,并非完整的卷

configMap卷中所有文件的权限默认被设置为644,可以通过卷规则定义中的defaultMode属性改变默认权限。

1
2
3
4
5
volume:
- name: config
configMap:
name: fortune-config
defaultMode: "6600"

7.4.7 更新应用配置且不重启应用程序 216

将configMap暴露为卷可以达到配置热更新的效果,无须重新创建pod或者重启容器。

如果挂载的是容器中的单个文件而不是完整的卷,configMap更新之后对应的文件不会被更新。 热更新只能运用于挂载整个文件夹。

7.5 使用 Secret 给容器传递敏感数据 218

secret的官方文档

7.5.1 介绍 Secret 218

secret和configMap最大的区别在于,secrets用于保存敏感的数据。
secret的数据都不会被写入磁盘,而是挂载在内存盘中。

7.5.2 默认令牌 Secret 介绍 218

default-tokenSecret会被自动创建且对应的卷被自动挂载到每个pod上。

7.5.3 创建 Secret 220

与创建ConfigMap的过程类似。用kubectl create secret创建secret。

7.5.4 对比 ConfigMap 与 Secret 221

Secret条目的内容会被以Base64格式编码,而configMap直接以纯文本展示。采用Base64的原因在于,secret的条目可以涵盖二进制数据。

Secret的大小限于1MB。

k8s允许通过Secret的stringData字段设置条目的纯文本值。

1
2
3
4
5
6
7
kind: Secret
apiVersion: v1
stringData: # 可以被用来设置非二进制数据
foo: plain text # 可以看出值未被Base64编码
data:
https.cert: xxx
https.key: xxx

7.5.5 在 pod 中使用 Secret 222

挂载fortune-secret至pod。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
apiVersion: v1
kind: Pod
metadata:
name: fortune-https
spec:
containers:
- image: luksa/fortune:env
name: html-generator
env:
- name: INTERVAL
valueFrom:
configMapKeyRef:
name: fortune-config
key: sleep-interval
volumeMounts:
- name: html
mountPath: /var/htdocs
- image: nginx:alpine
name: web-server
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: certs
mountPath: /etc/nginx/certs/ # 配置Nginx从/etc/nginx/certs中读取证书和密钥文件,需将secret卷挂载于此
readOnly: true
ports:
- containerPort: 80
- containerPort: 443
volumes:
- name: html
emptyDir: {}
- name: config
configMap:
name: fortune-config
items:
- key: my-nginx-config.conf
path: https.conf
- name: certs
secret: # 这里引用fortune-https secret来定义secret卷
secretName: fortune-https

由于挂载时使用的是tmpfs,存储在Secret中的数据不会写入磁盘。

k8s允许通过环境变量暴露Secret,然而此特性的使用往往不是一个好主意。应用程序通常会在错误报告时转储环境变量,或者是启动时打印在应用日志中,无意中暴露了Secret信息。另外,子进程会继承父进程的所有环境变量,如果是通过第三方二进制程序启动应用,你并不知道它使用敏感数据做了什么。提示由于敏感数据可能在无意中被暴露,通过环境变量暴露Secret给容器之前请再三思考。为了确保安全性,请始终采用secret卷的方式暴露Secret。

7.6 本章的k8s命令 228

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

# 创建一个ConfigMap,指定字面量
$ kubectl create configmap fortune-config --from-literal=sleep-interval=25

# 创建一个ConfigMap,从文件内容创建
$ kubectl create configmap my-config --from-file=customkey=config-file.conf

# 创建一个ConfigMap,从文件夹创建
$ kubectl create configmap my-config --from-file=/path/to/dir

# 修改ConfigMap
$ kubectl edit configmap fortune-config

# 重启nginx
$ kubectl exec fortune-configmap-volume -c web-server -- nginx -s reload

########## Secret ##########

# 创建一个Secret
$ kubectl create secret generic fortune-https --from-file=https.key --from-file=https.cert --from-file=foo

# 列出容器的挂载点
$ kubectl exec fortune-https -c web-server -- mount | grep certs

8 从应用访问 pod 元数据以及其他资源 229

8.1 通过 Downward API 传递元数据 229

downward-api的官方文档

有时候,容器里的应用会需要获取到pod的metadata,或者node的一些信息,举几个例子来说:

  • 应用希望获取到podmetadata中设置的labels。
  • 应用需要调用主机上的一些端口(如daemonset),所以需要获取到主机的IP。

DownwardAPI允许我们通过环境变量或者文件(在downwardAPI卷中)的传递pod和node的元数据。

和configMap和secret类似,downwardAPI也是以volume的形式挂载。

8.1.1 了解可用的元数据 230

Downward API 可以给在pod 中运行的进程暴露pod的元数据。目前我们可以给容器传递以下数据:

  • pod 的名称
  • pod 的IP
  • pod 所在的命名空间
  • pod 运行节点的名称
  • pod 运行所归属的服务账户的名称
  • 每个容器请求的 CPU 和内存的使用量
  • 每个容器可以使用的 CPU 和内存的限制
  • pod 的标签
  • pod 的注解

8.1.2 通过环境变量暴露元数据 231

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spec:
containers:
- name: main
...
env:
- name: POD_NAME
valueFrom:
fieldRef: # 引用pod manifest中的元数据名称字段,而不是设定一个具体的值
fieldPath: metadata.name
- name: CONTAINER_CPU_REQUEST_MILLICORES
valueFrom:
resourceFieldRef: # 容器请求的CPU和内存使用量是引用resourceFieldRef字段而不是filedRef字段
resource: requests.cpu
divisor: 1m # 设定基数,从而生成每一部分的值
- name: CONTAINER_MEMORY_LIMIT_KIBIBYTES
valueFrom:
resourceFieldRef:
resource: limits.memory
divisor: 1Ki

获取资源用量的字段是resourceFieldRef。

对于暴露资源请求和使用限制的环境变量,我们会设定一个基数单位(divisor)。 实际的资源请求值和限制值除以这个基数单位,所得的结果通过环境变量暴露出去。

8.1.3 通过 downwardAPI 卷来传递元数据 234

如果更倾向于使用文件的方式而不是环境变量的方式暴露元数据,可以定义一个downwardAPI卷并挂载到容器中。

可以在pod运行时修改标签和注解。当标签和注解被修改后,k8s会更新存有相关信息的文件, 从而使pod可以获取最新的数据。这也解释了为什么不要通过环境变量的方式暴露标签和注解,在环境变量方式下,一旦标签和注解被修改,环境变量不会被动态更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: v1
kind: Pod
metadata:
name: downward # 通过downwardAPI卷来暴露这些标签和注解
labels:
foo: bar
annotations:
key1: value1
key2: |
multi
line
value
spec:
containers:
...
volumeMounts:
- name: downward
mountPath: /etc/downward # 在/etc/downward目录下挂载这个downward卷
volumes:
- name: downward # 通过将卷的名字设定为downward来定义一个downwardAPI卷
downwardAPI:
items:
- path: "podName" # pod的名称(来自manifest文件中的manifest.name字段)将被写入podName文件中
fieldRef:
fieldPath: metadata.name
- path: "podNamespace"
fieldRef:
fieldPath: metadata.namespace
- path: "labels"
fieldRef: # pod的标签将被保存到/etc/downward/labels文件中
fieldPath: metadata.labels
- path: "annotations" # pod的注解将被保存到/etc/downward/annotations文件中

8.2 与 Kubernetes API 服务器交互 237

downwardAPI的问题在于,只能暴露当前pod和当前节点的信息,如果我们需要获取集群中的其他信息,就需要去和APIServer交互了。

8.2.1 探究 Kubernetes REST API 238

在本地运行kubectl proxy,就会在127.0.0.1:8001开启一个代理端口。通过该代理就可以直接和APIServer交互(已经做好了所有的认证凭证等)。

8.2.2 从 pod 内部与 API 服务器进行交互 242

集群内的apiserver地址:https://kubernetes。

一个名为defalut-token-xyz的Secret被自动创建,并挂载到每个容器的/var/run/secrets/kubernetes.io/serviceaccount目录下,包含apiserver的ca和token。

8.2.3 通过 ambassador 容器简化与 API 服务器的交互 248

上述方法中,与APIServer交互需要手动处理ca和token。

有一个更简单的方式,在pod里启动一个sidecar容器,这个容器就负责运行kubectl proxy,然后主容器通过proxy端口和APIServer交互。

8.2.4 使用客户端库与 API 服务器交互 251

k8s API客户端库

  • golang client - https://github.com/kubernetes/client-go
  • python client - https://github.com/kubernetes-incubator/client-python

8.3 本章的k8s命令 253

1
2
3
4
5
6
7
########## downwardAPI ##########  

# 展示downwardAPI卷中的标签(path labels是定义的)
$ kubectl exec downward cat /etc/downward/labels

# 展示downwardAPI卷中的注解(path annotations是定义的)
$ kubectl exec downward cat /etc/downward/annotations

9 Deployment: 声明式地升级应用 255

deployment的官方文档

9.1 更新运行在 pod 内的应用程序 256

9.1.1 删除旧版本 pod,使用新版本 pod 替换 257

Deployment是用户需要关心的负责管理pod的最小单位,在deployment的配置文件里可以直接一次性地配置pod和replicaset。使用deployment不但可以简单的迅速启动容器,还提供了非常方便的滚动升级的方法。

9.1.2 先创建新 pod 再删除旧版本 pod 257

手动执行滚动升级的方法繁琐,不建议。

9.2 使用 ReplicationController 实现自动的滚动升级 259

9.2.1 运行第一个版本的应用 259

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: ReplicationController
metadata:
name: kubia-v1
spec:
replicas: 3 # 创建3个pod
template:
metadata:
name: kubia
labels:
app: kubia
spec:
containers:
- image: luksa/kubia:v1 # 使用ReplicationController来创建pod
name: nodejs
---
apiVersion: v1
kind: Service # service指向所有由ReplicationController创建的pod
metadata:
name: kubia
spec:
type: LoadBalancer
selector:
app: kubia
ports:
- port: 80
targetPort: 8080

9.2.2 使用 kubectl 来执行滚动式升级 261

如果使用同样的tag推送更新镜像,需要将容器的imagePullPolicy属性设置为Always。当然最好使用一个新的tag来更新镜像。

可以用kubectl rolling-update来滚动升级rc,不过因为有deployment,所以rc已经不再使用了。在升级过程中,service将请求同时切换到新旧版本的pod。

9.2.3 为什么 kubectl rolling-update 已经过时 265

  1. 如果kubectl这个客户端在执行升级时失去了网络连接,升级进程将会中断。pod和rc最终会处于中间状态。
  2. k8s的理念是通过不断地收敛达到期望的系统状态。直接使用期望副本数来伸缩pod而不是手动地删除一个pod或者添加一个pod。只要在pod定义中更改所期望的镜像tag,并让k8s用运行新镜像的pod替换旧的pod。正是这一点推动了Deployment的新资源的引入。目前k8s中部署应用程序首选这种方式。

9.3 使用 Deployment 声明式地升级应用 266

rc和rs已经能保证一组pod实例的正常云进行了,为什么要在rc或rs之上再引入另一个对象?
在升级应用程序时,需要引入一个额外的rc,并协调两个controller,使它们再根据彼此不断地修改,而不会造成干扰。所以需要Deployment资源来协调。

9.3.1 创建一个 Deployment 267

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1
kind: Deployment # 需要将原有kind从ReplicationController修改为Deployment
metadata:
name: kubia # Deployment的名称中不再需要包含版本号
spec:
replicas: 3
template:
metadata:
name: kubia
labels:
app: kubia
spec:
containers:
- image: luksa/kubia:v1
name: nodejs
selector:
matchLabels:
app: kubia

Deployment可以同时管理多个版本的pod,所以在命名时不需要指定应用的版本号。

通过kubectl create -f xxxx-deployment.yml --record创建一个Deployment。在创建时使用了--record选项,这个选项会记录历史版本号。

现在由Deployment创建的三个pod名称中均包含一个额外的数字。这个数字对应Deployment和ReplicaSet中的pod模板的哈希值。

ReplicaSet的名称中也包含了其pod模板的哈希值。之后的篇幅也会介绍,Deployment会创建多个ReplicaSet,用来对应和管理一个版本的pod模板。像这样使用pod模板的哈希值,可以让Deployment始终对给定版本的pod模板创建相同的(或使用已有的)ReplicaSet。

9.3.2 升级 Deployment 269

只需修改Deployment资源中定义的pod模板,Kubernetes 会自动将实际的系统状态收敛为资源中定义的状态。

如何达到新的系统状态的过程是由Deployment的升级策略决定的,默认策略是执行滚动更新(策略名为 RollingUpdate)。 另一种策略为Recreate,它会一次性删除所有旧版本的pod,然后创建新的pod。

升级过程是由运行在k8s上的一个控制器处理和完成的(而不再是手动执行kubectl rolling-update), 只有通过修改deployment内的pod信息才会触发升级。

如果Deployment中的pod模板引用了一个configMap(或 Secret),那么更改configMap资源本身将不会触发升级操作。 如果真的需要修改应用程序的配置并想触发更新的话,可以通过创建一个新的configMap并修改pod模板引用新的configMap。

与rc类型,所有新的pod现在由心的rs管理。但是不同的是,旧的rs会被保留,而旧的rc会在滚动升级过程后被删除。

9.3.3 回滚 Deployment 273

滚动升级成功后,老版本的 ReplicaSet 也不会被删掉,回滚操作可以回滚到任何一个历史版本。

旧版本的ReplicaSet过多会导致ReplicaSet列表过于混乱,可以通过指定Deployment的revisionHistoryLimit属性来限制历史版本数量。默认值是2,所以正常情况下在版本列表里只有当前版本和上一个版本(以及只保留了当前和上一个ReplicaSet),所有再早之前的ReplicaSet都会被删除。注意extensions/v1beta1版本的Deployment的revisionHistoryLimit没有值,在apps/v1beta2版本中,这个默认值是10。

9.3.4 控制滚动升级速率 276

在Deployment的滚动升级期间,有两个属性会决定一次替换多少个pod: maxSurge和maxUnavailable。默认都是25%。

1
2
3
4
5
6
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
type: RollingUpdate

9.3.5 暂停滚动升级 278

通过kubectl rollout pause/resume来手动控制滚动步骤。通过暂停滚动升级过程,只有一小部分客户端请求会切换到v4 pod,而大多数请求依然仍只会切换到v3 pod。 一旦确认新版本能够正常工作,就可以恢复滚动升级。

在滚动升级过程中,想要在一个确切的位置暂停滚动升级目前还无法做到,以后可能会有一种新的升级策略来自动完成上面的需求。但目前想要进行金丝雀发布的正确方式是, 使用两个不同的Deployment并同时调整它们对应的pod数量。

9.3.6 阻止出错版本的滚动升级 279

k8s可以设置多种就绪探针,来探测pod是否已经ready,可以接收svc的流量。

minReadySeconds属性指定新创建的pod至少要成功运行多久之后,才能将其视为可用。在pod可用之前,滚动升级的过程不会继续。

默认情况下,在10分钟内不能完成滚动升级的话,将被视为失败。如果运行kubectl describe deployment命令, 将会显示一条 ProgressDeadlineExceeded的记录。

如果只定义就绪探针没有正确设置minReadySeconds,一旦有一次就绪探针调用成功,便会认为新的pod已经处于可用状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: apps/v1beta1
kind: Deployment
metadata:
name: kubia
spec:
replicas: 3
minReadySeconds: 10 # 至少就绪运行10s才可用
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 确保升级过程中pod被挨个替换
type: RollingUpdate
template:
metadata:
name: kubia
labels:
app: kubia
spec:
containers:
- image: luksa/kubia:v3
name: nodejs
readinessProbe:
periodSeconds: 1 # 定义一个就绪探针并每隔1s执行一次
httpGet:
path: / # 就绪探针会执行发送HTTP GET请求到容器
port: 8080

9.4 本章的k8s命令 284

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
########## deployment ##########  

# 开始rc的滚动升级, --v 6 是用于提高日志级别
$ kubectl rolling-update kubia-v1 kubia-v2 --image=luksa/kubia:v2 --v 6

# 创建一个Deployment
$ kubectl create -f kubia-deployment-v1.yaml --record

# 列举Deployment
$ kubectl get deployment

# 查看Deployment详细信息
$ kubectl describe deployment

# 查看部署状态
$ kubectl rollout status deployment kubia

# 设置minReadySeconds属性
$ kubectl patch deployment kubia -p '{"spec": {"minReadySeconds": 10}}'

# 更改任何包含容器资源的镜像
$ kubectl set image deployment kubia nodejs=luksa/kubia:v2

# 观察整个升级过程
$ kubectl rollout status deployment kubia

# 回滚到上一版本
$ kubectl rollout undo deployment kubia

# 显示升级的版本
$ kubectl rollout history deployment kubia

# 回滚到一个特定的版本
$ kubectl rollout undo deployment kubia --to-revision=1

# 暂停滚动更新
$ kubectl rollout pause deployment kubia

# 恢复滚动升级
$ kubectl rollout resume deployment kubia

# 取消滚动升级
$ kubectl rollout undo deployment kubia

10 StatefulSet :部署有状态的多副本应用 285

statefulset的官方文档

10.1 复制有状态 pod 285

ReplicaSet通过一个pod模板创建多个pod副本。这些副本除了它们的名字和IP地址不同外,没有别的差异。

10.1.1 运行每个实例都有单独存储的多副本 286

一个比较取巧的做法是:所有pod共享同一数据卷,但是每个pod在数据卷中使用不同的数据目录。实现方法是让每个实例自动选择或创建一个别的实例还没有使用的数据目录。但是这种方法,需要实例之间互相协作,增加难度。

10.1.2 每个 pod 都提供稳定的标识 287

针对集群中的每个成员实例,都创建一个独立的service来提供稳定的网络地址。因为服务IP是固定的,可以在配置文件中指定集群成员对应的服务IP(而不是pod IP)。但这不是好的解决办法,因为pod无法知道它对应的service,不能在别的pod里通过服务IP自行注册。

10.2 了解 Statefulset 289

10.2.1 对比 Statefulset 和 ReplicaSet 289

与ReplicaSet不同的是,Statefulset创建的pod副本并不是完全一样的。每个pod都可以拥有一组独立的数据卷(持久化状态)而有所区别。

10.2.2 提供稳定的网络标识 290

一个Statefulset创建的每个pod都有一个从零开始的顺序索引,这个会体现在pod的名称和主机名上,同样还会体现在pod对应的固定存储上。这些pod的名称则是可预知的,因为它是由Statefulset的名称加该实例的顺序索引值组成的。

一个StatefulSet通常要求你创建一个用来记录每个pod网络标记的headless Service。通过这个Service,每个pod将拥有独立的DNS记录,这样集群里它的伙伴或者客户端可以通过主机名方便地找到它。比如说,一个属于default命名空间,名为foo的控制服务,它的一个pod名称为A-0,那么可以通过下面的完整域名来访问它a-0.foo.default.svc.cluster.local。而在ReplicaSet中这样是行不通的。

另外,也可以通过 DNS 服务,查找域名foo.default.svc.cluster.local对应的所有SRV记录, 获取一个Statefulset中所有pod的名称。

调度时,Statefulset使用标识完全一致的新的pod替换,ReplicaSet则是使用一个不相干的新的pod替换。缩容一个Statefulset将会最先删除最高索引值的实例。

10.2.3 为每个有状态实例提供稳定的专属存储 292

Statefulset在有实例不健康的情况下是不允许做缩容操作的。statefulset会为每一个pod绑定一个不同的固定的pvc,而且该pvc会在pod删除后依然保留,pvc只能手动删除。

因为缩容Statefulset时会保留持久卷声明,所以在随后的扩容操作中,新的pod实例会使用绑定在持久卷上的相同声明和其上的数据。当你因为误操作而缩容一个Statefulset后,可以做一次扩容来弥补自己的过失,新的pod实例会运行到与之前完全一致的状态(名字也是一样的)。

10.2.4 Statefulset 的保障 294

Statefulset拥有稳定的标记和独立的存储。通常来说,无状态的pod是可以替代的,有状态的pod则不行。k8s必须保证两个拥有相同标记和绑定相同持久卷声明的有状态的pod实例不能同时运行。一个Statefulset必须保证有状态的pod实例的at-most-one语义。一个Statefulset必须在准确确认一个pod不再运行后,才会去创建它的替换pod。

10.3 使用 Statefulset 295

10.3.1 创建应用和容器镜像 295

使用kubia应用作为基础镜像,让每个pod实例都能用来存储和接收一个数据项。

10.3.2 通过 Statefulset 部署应用 296

为了部署应用,需要创建两个(或三个)不同类型的对象:

  • 存储你数据文件的持久卷(当集群不支持持久卷的动态供应时,需要手动创建)
  • Statefulset必需的一个控制Service
  • Statefulset本身

在部署statefulSet前,需要先创建一个用于在有状态的pod之间提供网络标识的headless service。

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: kubia
spec:
clusterIP: None # Statefulset的控制service必须是headless模式
selector: # 所有标签为app=kubia的pod都属于这个service
app: kubia
ports:
- name: http
port: 80

第二个pod会在第一个pod运行并且处于就绪状态后创建。Statefulset这样的行为是因为:状态明确的集群应用对同时有两个集群成员启动引起的竞争情况是非常敏感的。所以依次启动每个成员是比较安全可靠的。

10.3.3 使用你的 pod 301

API服务器的一个很有用的功能就是通过代理直接连接到指定的pod。如果想请求当前的 kubia-0 pod,可以通过如下 URL:<apiServerHost>:<port>/api/v1/namespaces/default/pods/kubia-0/proxy/<path>。

通过 kubectl proxy 访问服务,都会经过两个代理的两次跳转。

statefulSet的pod在重启后也有可能会被调度到不同的节点上,但是会保留它的存储卷和主机名。

10.4 在 Statefulset 中发现伙伴节点 305

10.4.1 通过 DNS 实现伙伴间彼此发现 306

k8s通过一个headless service创建SRV记录来指向pod的主机名。创建svc的目的就是在dns中生成srv记录,这一记录会返回与该svc关联的所有的pods。

10.4.2 更新 Statefulset 308

可以使用kubectl edit statefulset来更新stateful的配置。

Statefulset更像ReplicaSet,而不是Deployment,所以在模板被修改后,它们不会重启更新。 需要手动删除这些副本,然后Statefulset会依据新的模板重新调度启动它们。不会主动删除旧pod,等手动删除后,才会自动创建新pod。

10.4.3 尝试集群数据存储 309

当一个客户端请求到达集群中的任意一个节点后,它会发现它的所有伙伴节点,然后通过它们收集数据,然后把收集到的所有数据返回给客户端。

10.5 了解 Statefulset 如何处理节点失效 310

10.5.1 模拟一个节点的网络断开 310

Statefulset要保证不会有两个拥有相同标记和存储的pod同时运行,当一个节点似乎失效时,Statefulset在明确知道一个pod不再运行之前,它不能或者不应该创建一个替换pod, 保证at-most-one。

只有当集群的管理者告诉它这些信息的时候,它才能明确知道。为了做到这一点,管理者需要删除这个pod,或者删除整个节点。

若该节点过段时间正常连通,并且重新汇报它上面的pod状态,那这个pod就会重新被标记为 Runing。 但如果这个pod的未知状态持续几分钟后(这个时间是可以配置的),这个pod就会自动从节点上驱逐。 不过所谓的”驱逐”也只是k8s会试图删除该 pod,但是由于节点不可达,所以节点状态会停留在Terminating。

10.5.2 手动删除 pod 312

除非确认节点不再运行或者不会再可以访问,否则不要强制删除有状态的pod。

10.6 本章的k8s命令 313

1
2
3
4
5
6
7
########## Statefulset ##########  

# 修改Statefulset
$ kubectl edit statefulset kubia

# 删除pod,发出警告信息
$ kubectl delete pod kubia-0 --force --grace-period 0

《Kubernetes In Action》阅读笔记(一)

发表于 2021-06-13 | 分类于 计算机 | | 阅读次数:

[TOC]

本文是把《Kubernetes In Action》读薄的摘抄或转述,仅供参考。系统学习请阅读原书。
k8s的命令繁多,熟练使用它们提高工作效率和理解k8s设计思想同等重要,每章最后总结了该章涉及的命令。

2019~2020年期间,在工作上使用过Kubernetes(k8s)+Istio治理微服务。我们的业务是做一个Iass+Paas平台,我们把计算、网络、存储、安全、数据库、负载均衡和监控等模块拆分成微服务,每个服务在一组相同的pod运行,每个pod中运行两个容器,业务容器和sidecar。Istio Ingress作为k8s的Ingress Controller,用于对外暴露服务,并且管理南北向流量。Istio的sidecar注入pod,用于管理集群内服务之间的东西向流量。

当时我只是在项目中应用了istio+k8s,没有系统学习过k8s,包括它的设计思想。最近因为工作再次接触k8s,于是挑选了《Kubernetes In Action》进行系统学习和温故而知新,这确实是一本不多见的涵盖广阔提纲挈领的好书。

微服务架构,它从管理上获取对服务的抽象,方便服务的管理和规划服务的边界。但它因为引入了很多新的机制,比如服务注册中心等,其实对硬件而言是一种牺牲。但舍弃一定的性能,换来的是服务的治理、团队协作开发的方便,这就是微服务架构的价值。

程序的本质从不同角度观察,会有不同的见解,就像光的波粒二象性。我的观点是不浪费硬件是对提升性能最大的帮助。会有听到微服务解决了高并发问题的说法,但微服务和高并发其实没有必然关系。而是因为通常微服务会使用分布式方式部署,硬件资源包括CPU、网络、磁盘等成倍增加,所以分布式对高并发问题有积极作用。

Istio,它其实不局限于微服务治理范畴,任何服务,只要服务间有访问,需要对服务间的流量进行管理、服务间认证等,都可以使用Istio来管理。

k8s不是一个专为Docker设计的容器编排系统。k8s的核心也不止是编排容器,只不过容器恰好是在不同集群节点上运行应用的最佳方式。k8s可以被看作集群的一个操作系统,提供服务发现、扩容、负载均衡、自恢复、leader选举等功能。

1 Kubernetes 介绍 1

微服务架构是替代以单个进程或几个进程运行在服务器上为部署方式的单体应用的一种方式,它将单体应用分解成若干个可独立运行组件。微服务的解耦性,确保它们可以被独立开发、部署、升级、伸缩。
如何部署、管理这些微服务,并充分利用宿主机的硬件资源,诞生了k8s。k8s可以理解为是一个数据中心操作系统(DCOS),他将人员分为开发人员和系统管理员,系统管理员负责处理和硬件、集群相关的事务,开发人员只需要提交自己的应用和描述。k8s会「自动」按照开发人员的描述,把应用启动起来,并暴露定义的端口。

在computer science领域,有一句话”All problems in computer science can be solved by another level of indirection”。k8s抽象了数据中心的硬件基础设置,对外暴露资源池API,开发人员不用关心底层的硬件设施。这种抽象和操作系统也有相似之处。

1.1 Kubernetes 系统的需求 2

1.1.1 从单体应用到微服务 2

对于单体应用,为了提升系统负载能力,有两种扩展方式。
垂直扩展:增加CPU、内存或其它系统资源。应用程序无需变化,但成本越来越高,无法无限扩展。
水平扩展:经常需要应用程序进行改动才可执行,可能会被某个模块无法水平扩展限制。

单体应用可被拆分成多个可独立部署、以独立进程运行的微服务,微服务之间以约定的API通信。

对于微服务架构,可以只扩容某些服务,因为扩容粒度细化,可以根据具体情况分配扩容资源。

如果有历史项目是单体应用,不得不水平扩容,而水平扩容受到某些模块的限制。可以把应用拆分成多个微服务,对能扩容的组件水平扩展,对不能扩容的组件垂直扩展。

看似一切很美好,微服务带来的弊端不容忽视。
当服务数量激增,如何处理服务间错综复杂的依赖关系,如何把正确的配置应用到每个服务,如何调试代码和定位异常调用,如何解决不同服务对于环境需求的差异,都是需要面对的。

1.1.2 为应用程序提供一个一致的环境 5

目标是让服务在开发和生产阶段可以运行在完全一样的环境下,有完全一样的操作系统、库、系统配置、网络环境等。各个服务之间独立互不影响。

1.1.3 迈向持续交付 :DevOps 和无运维 6

让应用开发者和系统管理员解耦,开发者可以自己参与配置和部署程序,但又无需关注硬件基础设施。而实际上系统管理员在幕后保证底层基础设施正常运转,但他们也无需关注运行的程序本身。
这正是k8s实现的功能。它对硬件资源进行抽象,对外暴露成一个平台,用于部署和运行应用程序。

1.2 介绍容器技术 7

k8s使用Linux容器技术来实现对应用的隔离。

1.2.1 什么是容器 7

虚拟机可以隔离不同的微服务环境是显然的,Linux容器技术也可以。容器和虚拟机相比开销小很多,容器里运行的进程实际上运行在宿主机上,但是和其它进程隔离,开销仅是容器消耗的资源。

虚拟机和容器中的应用进程对CPU的使用方式不同。每个虚拟机对应的Linux内核不一样,而不同容器对应的Linux内核一样(存在安全隐患)。如图所示。

如果多个进程运行在同一个操作系统上,是怎么利用容器是隔离它们的?有两个机制可用。

  1. Linux命名空间。可以在某个命名空间运行一个进程,进程只能看到这个命名空间下的资源。当然,会存在多种类型的命名空间,所以一个进程不单单只属于某一个命名空间,而属于每个类型的一个命名空间。

存在以下类型的命名空间:

  • Mount(mnt)
  • Process ID(pid)
  • Network(net)
  • Inter-process communicaion(ipd)
  • UTS
  • User ID(user)
  1. 内核的cgroups。限制进程能使用的资源量(CPU、内存、网络带宽等)不能超过被分配的量。

1.2.2 Docker 容器平台介绍 11

Docker是第一个使容器成为主流的容器平台。Docker本身不提供进程隔离,而是由Linux命名空间和cgroups之类的内核特性完成。

镜像层是只读的。容器运行时,一个新的可写层在镜像层之上被创建。 容器中进程写入位于底层的一个文件时,此文件的一个拷贝在顶层被创建,进程写的是此拷贝。

Docker可以借助于镜像在不同操作系统之间移植,但是内核由运行容器的宿主机决定。如果一个容器化的应用需要一个特定的内核版本,那它可能不能在每台机器上都工作。 如果一台机器上运行了一个不匹配的 Linux 内核版本,或者没有相同内核模块可用,那么此应用就不能在其上运行。所以容器镜像存在移植性的限制,在不同CPU架构上构建的镜像不能通用。例如在x86平台构建的镜像,不能在arm平台使用。

1.2.3 rkt——一个 Docker 的替代方案 14

开放容器计划OCI是围绕容器格式和运行时创建的开放工业标准。kubelet以CRI标准接口与OCI进行通信。rkt是另一个Linux容器引擎。

本书集中使用Docker作为k8s的容器,它是k8s最初唯一支持的容器类型,但k8s目前也支持rkt等其它容器类型。

1.3 Kubernetes 介绍 15

1.3.1 初衷 15

在海量服务器规模下,有效处理部署管理,并提高基础设施利用率。

1.3.2 深入浅出地了解 Kubernetes 15

k8s整个系统由一个主节点和若干个工作节点组成。开发者把一个应用列表提交到主节点,k8s会将它们部署到集群的工作节点。组件被部署在哪个节点对于开发者和系统管理员来说都不用关心 。开发者能指定一些应用必须一起运行,k8s将会在一个工作节点上部署它们。其他的将被分散部署到集群中,但是不管部署在哪儿,它们都能以相同的方式互相通信。

1.3.3 Kubernetes 集群架构 17

一个k8s集群由很多节点组成,分为两种类型:

  1. 主节点:它承载着k8s控制和管理整个集群系统的控制面板。控制面板的组件持有井控制集群状态,但是它们不运行应用,运行应用是由工作节点完成的。
  • API服务器:应用和其它控制面板组件都要和它通信。
  • Scheculer:调度应用(为应用的每个可部署组件分配一个工作节点)。
  • Controller Manager:执行集群级别的功能,如复制组件、持续跟踪工作节点、处理节点失败等。
  • etcd:一个可靠的分布式数据存储,它能持久化存储集群配置。
  1. 工作节点:它们运行用户实际部署的应用。
  • Docker、rkt或其它容器类型。
  • Kubelet:与API服务器通信,并管理它所在节点的容器。
  • kube-proxy:负责组件之间的负载均衡网络流量。

1.3.4 在 Kubernetes 中运行应用 18

在向k8提交描述符之后,它将把每个pod的指定副本数量调度到可用的工作节点上。 节点上的 Kubelets将告知Docker从镜像仓库中拉取 容器镜像井运行容器。

一旦应用程序运行起来,k8s就会不断地确认应用程序的部署状态始终与你提供的描述相匹配。

k8s采用声明式的控制流,所有的资源声明都保存在etcd,所有的组件都通过API Server来声明或监听资源。只要资源被声明,那么监听资源的控制器就会开始工作,确保让各个资源实例达到声明的状态。

1.3.5 使用 Kubernetes 的好处 20

  • 简化应用程序部署
  • 更好地利用硬件
  • 健康检查和自修复
  • 自动扩容
  • 敏捷交付

2 开始使用 Kubernetes 和 Docker 23

2.1 创建、运行及共享容器镜像 23

容器中的进程是运行在主机操作系统上的,但是该进程的ID在主机上和容器中不同。容器使用独立的PID Linux命令空间并且有着独立的系列号,完全独立于进程树。

正如拥有独立的进程树一 样,每个容器也拥有独立的文件系统。在容器内列出 根目录的内容,只会展示容器内的文件,包括镜像内的所有文件,再加上容器运行时创建的任何文件(类似日志文件)。

使用是比较简单的,本文不赘述了。

2.2 配置 Kubernetes 集群 34

主要讲如何创建k8s集群,讲了两个方法:用 Minikube 运行一个本地单节点 Kubernetes 集群;用 Google Kubernetes Engine 托管 Kubernetes 集群。以及为kubectl 配置别名和命令行补齐,方便命令输入。

使用是比较简单的,本文不赘述了。

2.3 在 Kubernetes 上运行第一个应用 40

2.3.1 部署 Node.js 应用 40

一个pod是一组紧密相关的容器,它们总是一起运行在同一个工作节点上,以及同一个Linux命名空间中。每个pod就像一个独立的逻辑机器,拥有自己的IP、主机名、进程等,运行一个独立的应用程序。应用程序可以是单个进程,运行在单个容器中,也可以是一个主应用进程或者其他支持进程,每个进程都在自己的容器中运行。一个pod的所有容器都运行在同一个逻辑机器上,而其它pod中的容器,即使运行在同 一个工作节点上,也会出现在不同的节点上 。

当运行kubectl命令时,它通过向API服务器发送一个REST HTTP请求,在集群中创建一个新的ReplicationController对象。然后,ReplicationController创建了一个新的pod,调度器将其调度到 一个工作节点上。Kubelet看到pod被调度到节点上,就告知Docker从镜像中心中拉取指定的镜像,因为本地没有该镜像。下载镜像后,Docker创建并运行容器。

2.3.2 访问 Web 应用 43

每个pod有自己的IP地址,但是这个地址是集群内部的,不能从集群外部访问。要让pod能够从外部访问,需要通过服务对象公开它,要创建一个LoadBalancer类型的服务。它将创建一个外部的负载均衡,外部可以通过负载均衡的公共IP访问pod。

2.3.3 系统的逻辑部分 45

k8s的基本构件是pod,但是没有直接创建和使用pod。通过运行kubectl run命令,创建了一个ReplicationController,它用于创建pod实例 。为了使该pod能够从集群外部访问,需要让 k8s将 该ReplicationController管理的所有pod由一个服务对外暴露。服务表示一组或多组提供相同服务的pod的静态地址。到达服务IP和端口的请求将被转发到属于该服务的一个容器的IP和端口。

2.3.4 水平伸缩应用 46

为了增加pod的副本数,需要改变ReplicationController期望的副本数。告诉k8s需要确保pod始终有三个实例在运行。成功后,请求会随机地切到不同的pod。

应用本身需要支持水平伸缩。

没有告诉k8s需要采取什么行动,也没有告诉k8s增加两个pod,只设置新的期望的实例数量并让 k8s决定需要采取哪些操作来实现期望的状态。这是k8s最基本的原则之一。不是告诉 k8s 应该执行什么操作,而是声明性地改变系统的期望状态,并让k8s检查当前的状态是否与期望的状态一致。在整个 k8s 世界中都是这样的——声明式设计。

2.3.5 查看应用运行在哪个节点上 49

不管调度到哪个节点,容器中运行的所有应用都具有相同类型的操作系统。每个pod都有自己的IP,并且可以与任何其他pod通信,不论其他pod是运行在同一 个节点上,还是运行在另一个节点上。每个pod都被分配到所需的计算资源,因此这些资源是由一个节点提供还是由另一个节点提供,并没有任何区别。

2.3.6 介绍 Kubernetes dashboard 50

k8s的图形化用户界面。列出部署在集群中的所有pod、ReplicationController、服务和其他部署在集群中的对象, 以及创建、修改和删除它们。

2.4 本章的k8s命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
########## 集群 ##########  

# 展示集群信息
$ kubectl cluster-info

# 获取dashboard的URL
$ kubectl cluster-info | grep dashboard

########## node ##########

# 列出集群节点
$ kubectl get nodes

########## pod ##########

# 列出所有pod
$ kubectl get pods
可以加上 -o wide 选项请求其它列

# 描述一个pod
$ kubectl describe pod kubia-hczji

########## service ##########

# 列出所有服务
$ kubectl get services(缩写svc)

########## ReplicationController ##########

# 创建一个ReplicationController
$ kubectl run kubia --image=luksa/kubia --port=8080 --generator=run/v1

# 改变ReplicationController期望的副本数
$ kubectl scale re kubia --replicas=3

# 列出ReplicationController
$ kubectl get replicationcontroller(缩写rc)

########## LoadBalancer ##########

# 创建LoadBalancer服务对象
$ kubectl expose rc kubia --type=LoadBalancer --name kubia-http

3 pod :运行于 Kubernetes 中的容器 53

pod是k8s中最重要的核心概念,而其他对象仅仅是在管理、 暴露pod或被pod使用。

3.1 介绍 pod 53

当一个 pod包含多个容器时,这些容器总是运行于同一个工作节点上。一个pod绝不会跨越多个工作节点。

3.1.1 为何需要 pod 54

容器被设计为每个容器只运行一个进程(除非进程本身产生子进程)。如果在单个容器中运行多个不相关的进程,那么保持所有进程运行、管理它们的日志等将会是我们的责任。例如,我们需要包含一种在进程崩溃时能够自动重启的机制。同时这些进程都将记录到相同的标准输出中, 而此时我们将很难确定每个进程分别记录了什么。
我们需要让每个进程运行于自己的容器中,而这就是Docker和k8s期望使用的方式。
pod是k8s调度的最小单位,一个 pod可以包含一个或多个容器。

3.1.2 了解 pod 55

由于不能将多个进程聚集在一个单独的容器中,我们需要另一种更高级的结构来将容器绑定在一 起,并将它们作为一个单元进行管理,这就是pod背后的根本原理。

k8s通过配置Docker来让一个pod内的所有容器共享相同的Linux命名空间,而不是每个容器都有自己的一组命名空间。

由于一个pod中的所有容器都在相同的network和UTS命名空间下运行,所以它们都共享相同的主机名和网络接口。 同一个pod中的容器共享相同的IP地址和端口空间。同样地,这些容器也都在相同的IPC命名空间下运行,因此能够通过IPC进行通信。在最新的k8s和Docker版本中,它们也能够共享相同的PID命名空间(但是该特征默认是未激活的)。

k8s集群的pod之间没有NAT网关,两个pod彼此之间发送网络数据包时,它们都会将对方的实际IP地址看作数据包中的源IP。

3.1.3 通过 pod 合理管理容器 56

当决定是将两个容器放入一个pod还是 两个单独的pod时,我们需要问自己以下问题:

  • 它们需要 一起运行还是可以在不同的主机上运行?
  • 它们代表的是一个整体还是相互独立的组件?
  • 它们必须一起进行扩缩容还是可以分别进行?

我们总是应该倾向于在单独的pod中运行容器,除非有特定的原因要求它们是同一pod的一部分。
比如常见的是sidecar容器,用于日志轮转器和收集器、数据处理器、通信适配器等。

在实际业务场景中,在pod中使用多个容器,sidecar是最常见的方式。其它情况,需要三思。

3.2 以 YAML 或 JSON 描述文件创建 pod 58

pod和其它k8s资源通常是通过向k8s REST API提供JSON或YAML描述文件来创建的。
全面的文档在Kubernetes API参考文档。

3.2.1 检查现有 pod 的 YAML 描述文件 59

pod定义由这几个部分组成:首先是YAML中使用的k8s API版本和YAML描述的资源类型;其次是几乎在所有k8s资源中都可以找到的三大重要部分:

  • metadata:包括名称、命名空间、标签和关于该容器的其他信息。
  • spec:包含pod内容的实际说明,例如pod的容器、卷和其他数据。
  • status:包含运行中的pod的当前信息,例如pod所处的条件、 每个容器的描述和状态,以及内部IP和其他基本信息。status只包含只读的运行时数据,在创建新的pod时,不需要提供status部分。

3.2.2 为 pod 创建一个简单的 YAML 描述文件 61

一个基本的pod描述文件非常简单。

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod
metadata:
name: kubia-manual
spec:
containers:
- image: luksa/kubia
name: kubia
ports:
- containerPort: 8080
protocol: TCP

3.2.3 使用 kubectl create 来创建 pod 63

kubectl create -f命令用于从YAML或JSON文件创建任何资源。创建后可以请求k8s获得完整的YAML和JSON格式的描述文件。

3.2.4 查看应用程序日志 64

当日志文件达到一定大小时,容器日志会自动轮替。kubectl logs命令仅显示最后一次轮替后的日志条目。

当一个pod被删除时,它的日志也会被删除。如果希望在pod删除之后仍然可以获取其日志,我们需要设置中心化的、集群范围的日志系统,将所有日志存储到中心存储中。

3.2.5 向 pod 发送请求 65

如果要在外部访问pod,除了前面提到的lb service,还可以借助端口转发(常用于开发中测试pod)。端口转发通过kubectl port-forward命令完成。

3.3 使用标签组织 pod 66

通过一次操作对属于某个组的所有pod进行操作,而不必单独为每个pod执行操作。
标签可以做到这一点。通过标签来组织pod和所有其他k8s对象。

3.3.1 介绍标签 66

标签是可以附加到资源的任意键值对。通过标签选择器可以选择具有确切标签的资源。
标签和资源是多对多关系。

比如常用的场景有,给每个pod标有两个标签。

  • app:它指定pod属于哪个应用、 组件或微服务。
  • rel:它显示在pod中运行的应用程序版本是stable、beta还是canary(用于金丝雀发布)。

3.3.2 创建 pod 时指定标签 67

包含creation_method=manual,env=prod两个标签。

1
2
3
4
5
6
7
...
metadata:
name: kubia-manual-v2
labels:
creation_method: manual
env: prod
...

3.3.3 修改现有 pod 的标签 68

标签可以在现有pod上进行添加和修改。

3.4 通过标签选择器列出 pod 子集 69

标签要与标签选择器结合,否则标签没有作用。

3.4.1 使用标签选择器列出 pod 69

标签选择器根据资源的以下条件来选择资源:

  • 包含(或不包含)使用特定键的标签。
  • 包含具有特定键和值的标签。
  • 包含具有特定键的标签,但其值与我们指定的不同。

3.4.2 在标签选择器中使用多个条件 71

在包含多个逗号分隔的清况下,可以在标签选择器中同时使用多个条件。 此时,资源需要全部匹配才算成功匹配了选择器。

3.5 使用标签和选择器来约束 pod 调度 71

在硬件基础设施不是同质的情况下,比如想将执行GPU密集型运算的pod调度到提供GPU加速的节点上,需要约束pod的调度。这可以通过节点标签和节点标签选择器完成。

3.5.1 使用标签分类工作节点 72

pod并不是唯一可以附加标签的k8s资源。标签可以附加到任何k8s对象上,包括节点。

3.5.2 将 pod 调度到特定节点 72

在spec部分添加了一个nodeSelector字段。当创建该pod时,调度器将只在包含标签gpu=true的节点中选择。

1
2
3
4
5
...
spec:
nodeSelector:
gpu: "true"
...

3.5.3 调度到一个特定节点 73

也可以将pod调度到某个确定的节点,由于每个节点都有一个唯一标签,其中键为kubernetes.io/hostname, 值为该节点的实际主机名, 因此也可以将pod调度到某个确定的节点。但如果节点处于离线状态,通过hostname标签将nodeSelector设置为特定节点可能会导致pod不可调度。所以绝不应该考虑单个节点,而是应该通过标签选择器考虑符合特定标准的逻辑节点组。

3.6 注解 pod 73

pod和其它对象还可以包含注解。注解也是键值对。但是注解不能像标签一样用于对对象分组。不存在注解选择器这样的东西。

3.6.1 查找对象的注解 74

注解可以包含相对更多的数据,标签则是应该比较简短的。

3.6.2 添加和修改注解 74

通过kubectl annotate命令添加和修改注解。

3.7 使用命名空间对资源进行分组 75

k8s中可供声明的类称为资源(Resource),包括 pod、rs、deployment 等。声明一个资源构成的实例都有名字,这些名字都归属于一个个的命名空间之中(namespace),互不影响。

3.7.1 了解对命名空间的需求 75

在使用多个namespace的前提下,可以将包含大量组件的复杂系统拆分为更小的不同组,这些不同组也可以用于在多租户环境中分配资源,将资源分配为生产、开发和QA环境。两个不同命名空间可以包含同名资源。

我们在业务上也这样使用过,为了减小硬件开销,开发和QA环境使用同一套k8s集群,使用不同的namespace区分。

3.7.2 发现其他命名空间及其 pod 75

命名空间除了为资源名称提供了一个作用域,也可用于仅允许某些用户访问某些特定资源,甚至限制单个用户可用的计算资源数量。

3.7.3 创建一个命名空间 76

k8s中的所有资源都是一个API对象。命名空间同理,所以创建namespace也可以用YAML文件描述,使用kubectl create -f xxx.yaml创建。

1
2
3
4
apiVersion: v1
kind: Namespace
metadata:
name: custom-namespace

也可以通过kubectl create namespace命令创建。

3.7.4 管理其他命名空间中的对象 77

在列出、描述、创建、修改、删除等操作中,需要给kubectl命令传递--namespace。否则kubectl在当前上下文中配置的默认命名空间执行操作。

当前上下文的命名空间可以通过kubectl config修改。要想快速切换到不同的命名空间,可以设置以下别名:alias kcd='kubectl config set-context $(kubectl config current-context) --namespace'。然后使用kcd some-namespace在命名空间之间进行切换。

3.7.5 命名空间提供的隔离 78

你需要首先创建命名空间,然后再创建资源。

k8s 包含三个预设的命名空间:

  • default
  • kube-public
  • kube-system

命名空间之间是否网络隔离依赖于k8s使用的NetworkPolicy的配置。

3.8 停止和移除 pod 78

3.8.1 按名称删除 pod 78

在删除pod的过程中,实际上我们指示k8s终止该pod中的所有容器。k8s会向进程发送SIGTERM信号并等待一定时间,使其正常关闭(所以为了确保进程能正常关闭,业务代码中需要处理SIGTERM信号)。如果没有及时关闭,k8s则通过发送SIGKILL终止该进程。

3.8.2 使用标签选择器删除 pod 79

可以使用标签一次删除所有指定标签的pod。

3.8.3 通过删除整个命名空间来删除 pod 80

删除整个命名空间,pod将也会自动删除。

3.8.4 删除命名空间中的所有 pod,但保留命名空间 80

要删除pod,还需要删除ReplicationController,否则会根据YAML描述文件自动创建新的pod。因为k8s是声明式设计。

3.8.5 删除命名空间中的(几乎)所有资源 80

--all删除所有内容并不是真的删除所有内容,一些资源例如secret会被保留下来,除非明确指定删除。

3.9 本章的k8s命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
########## 查看pod ########## 

# 查看已部署的pod的完整YAML
$ kubectl get pod kubia-zxzij -o yaml

# 查看API对象支持的属性和解释
$ kubectl explain pods
$ kubectl explain pods.spec

# 得到运行中的pod的完整定义
$ kubectl get pod kubia-manual -o yaml
$ kubectl get pod kubia-manual -o json

########## 创建资源 ##########

# 从文件(YAML或JSON)创建资源
$ kubectl create -f kubia-manual.yaml

########## 查看日志 ##########

# 查看pod日志(准确地说是容器的日志)
$ kubectl logs kubia-manual -c kubia

########## 端口转发 ##########

# 将本地端口8888转发到pod端口8080
$ kubectl port-forward kubia-manual 8888:8080

########## label ##########

# 列出pod,带上标签
$ kubectl get pod --show-labels

# 列出pod,仅展示指定标签
$ kubectl get pod -L creation_method,env

# 为pod添加标签
$ kubectl label pod kubia-manual creation_method=manual

# 为pod修改标签
$ kubectl label pod kubia-manual-v2 env=debug --overwrite

# 列出包含creation_method标签,值等于manual的pod
$ kubectl get pod -l creation_method=manual

# 列出包含env标签的pod
$ kubectl get pod -l env

# 列出没有env标签的pod
$ kubectl get pod -l '!env'

# 其它标签筛选条件
creation_method!=manual:选择带有creation_method标签,并且值不等于manual的pod。
env in (prod, devel):选择带有env标签且值为prod或devel的pod。
env notin (prod, devel):选择带有env标签,但其值不是prod或devel的pod。

# 给节点添加标签gpu=true
$ kubectl label node gke-kubia-85f6-node-orrx gpu=true

# 列出只包含标签gpu=true的节点
$ kubectl get nodes -l gpu=true

# 列出所有节点,展示gpu标签值附加列
$ kubectl get nodes -L gpu

########## annotations ##########

# 给pod添加注解,将注解mycompany.com/someannotation添加为值foo bar
$ kubectl annotate pod kubia-manual mycompany.com/someannotation="foo bar"

# 查看pod的注解
$ kubectl describe pod kubia-manual | grep annotations

########## namespace ##########

# 列出所有命名空间
$ kubectl get ns

# 列出属于命名空间kube-system的pod
$ kubectl get pod --namespace kube-system(--namespace的缩写是-n)

# 创建命名空间
$ kubectl create namespace custom-namespace

########## 删除资源 ##########

# 删除pod
$ kubectl delete pod kubia-gpu

# 删除指定标签的pod
$ kubectl delete pod -l create_method=manual

# 删除指定命名空间
$ kubectl delete ns custom-namespace

# 删除当前命名空间中的所有pod
$ kubectl delete pod --all

# 删除当前命名空间的所有资源(并不是真的删除所有内容)
$ kubectl delete all --all

4 副本机制和其他控制器 :部署托管的 pod 83

前三章比较基础,从这一章开始,事情变得有趣起来。

通过创建ReplicationControlle或Deployment这样的资源,由它们来创建并管理实际的pod。kubelet会保持该节点上的pod健康。

4.1 保持 pod 健康 84

4.1.1 介绍存活探针 84

k8s可以通过存活探针(liveness probe)检查容器是否还在与进行。可以为pod中的每个容器单独指定存活探针,如果探测失败,k8s将定期执行探针并重新启动容器。

k8s有三种探测容器的机制(在spec内定义livenessProbe):

  • HTTP GET 探针:是否能正确响应GET请求。
  • TCP 探针:是否能建立TCP连接。
  • exec 探针:在容器内执行指定命令并检查退出状态码。

4.1.2 创建基于 HTTP 的存活探针 85

1
2
3
4
5
6
7
8
9
10
...
spec:
containers:
- image: luksa/kubia-unhealthy
name: kubia
livenessProbe:
httpGet:
path: /
port: 8080
...

4.1.3 使用存活探针 86

通过kubectl describe查看为什么必须「重启」容器。不是真的重启,是创建一个新的容器。

Exit Code的值减去128是终止进程的信号编号。比如Exit Code是137,表示因为SIGKILL(9)信号被终止。

4.1.4 配置存活探针的附加属性 87

其它属性,包括delay、timeout、period等。

例如可用initialDelaySeconds自定义初始延迟。务必记得设置一个初始延迟来说明应用程序的启动时间。否则容器可能不断被重启。

1
2
3
4
5
6
7
...
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 15
...

4.1.5 创建有效的存活探针 88

一定要检查应用程序的内部,而没有外部因素的影响,比如不能调用在其它pod的数据库容器。并且保证存活探针轻量,也无需在探针中实现重式循环。

4.2 了解 ReplicationController 89

ReplicationController已经完全被ReplicaSet替代,阅读了一下但不再赘述。

4.3 使用 ReplicaSet 而不是 ReplicationController 104

通常不会直接创建ReplicaSet,而是通过在创建Deployment资源(在后面章节讲)时创建。

replicaset的官方文档

4.3.1 比较 ReplicaSet 和 ReplicationController 104

ReplicaSet的标签选择器的表达能力比ReplicationController更强。

4.3.2 定义 ReplicaSet 105

ReplicaSet不是v1 API的一部分,但属于apps API组的v1beta2版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: apps/v1beta2
kind: ReplicaSet
metadata:
name: kubia
spec:
replicas: 3
selector:
matchLabels:
app: kubia
template:
metadata:
labels:
app: kubia
spec:
containers:
- name: kubia
image: luksa/kubia

4.3.3 创建和检查 ReplicaSet 106

使用kubectl create命令根据YAML文件创建ReplicaSet。

4.3.4 使用 ReplicaSet 的更富表达力的标签选择器 106

rs和rc相比最大的改动就是支持更为强大复杂的标签选择器。

1
2
3
4
5
6
selector:
matchExpressions:
- key: app
- operator: In
values:
- kubia

可以在selector中使用matchExpressions,支持的operator有:

  • In:pod的label在指定labels之中。
  • NotIn:不在指定labels中。
  • Exists:指定的label key存在。
  • DoesNotExist:指定的label key不存在。

如果你指定了多个表达式,则所有这些表达式都必须为true才能使选择器与pod匹配。如果同时指定matchLabels和matchExpressions,则所有标签都必须匹配,并且所有表达式必须计算为true以使该pod与选择器匹配。

4.3.5 ReplicaSet 小结 107

删除ReplicaSet会删除所有的pod。

4.4 使用 DaemonSet 在每个节点上运行一个 pod 107

daemonset的官方文档

4.4.1 使用 DaemonSet 在每个节点上运行一个 pod 108

使用DaemonSet在每个节点上运行一个pod。一般用于运行一些基础组件,如kube-proxy、日志组件等。

DaemonSet没有期望的副本数的概念,它的工作是确保一个pod匹配它的选择器并在每个节点上运行。如果节点下线,DaemonSet不会在其它地方重新创建pod。但是当一个新节点加入到集群中,DaemonSet会立即部署一个新的pod实例。

4.4.2 使用 DaemonSet 只在特定的节点上运行 pod 109

通过pod模板中的nodeSelector属性指定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: apps/v1beta2
kind: DaemonSet
metadata:
name: ssd-monitor
spec:
selector:
matchLabels:
app: ssd-monitor
template:
metadata:
labels:
app: ssd-monitor
spec:
nodeSelector:
disk: ssd
containers:
- name: main
image: luksa/ssd-monitor

4.5 运行执行单个任务的 pod 112

前面提到的ReplicationController、ReplicaSet、DaemonSet都会持续运行任务,永远达不到完成态。k8s通过Job资源提供了可完成任务的支持,其进程正常终止后,不重新启动。

Job的官方文档

4.5.1 介绍 Job 资源 112

Job可以调度pod来运行一次性的任务,程序运行成功退出后,不重启容器。一旦任务完成,pod就被认为处于完成状态。

如果pod在被调度的节点上异常退出后,由Job管理的pod会一直被重新安排,直到成功完成任务。

4.5.2 定义 Job 资源 113

重启策略restartPolicy默认为Always。Job pod不能使用默认策略。需要明确将其设置为OnFailure或Never。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: batch/v1
kind: Job
metadata:
name: batch-job
spec:
template:
metadata:
labels:
app: batch-job
spec:
restartPolicy: OnFailure
containers:
- name: main
image: luksa/batch-job

4.5.3 看 Job 运行一个 pod 114

完成后的pod STATUS是Completed,并且不被删除。除非手动删除pod,或者删除创建它的Job。

4.5.4 在 Job 中运行多个 pod 实例 114

通过在Job配置中设置completions和parallelism属性,可以以并行或串行方式运行多个pod。

  • 顺序运行Job pod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: batch/v1
kind: Job
metadata:
name: multi-completion-batch-job
spec:
completions: 5
template:
metadata:
labels:
app: batch-job
spec:
restartPolicy: OnFailure
containers:
- name: main
image: luksa/batch-job
  • 并行运行Job pod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
apiVersion: batch/v1
kind: Job
metadata:
name: multi-completion-batch-job
spec:
completions: 5
parallelism: 2
template:
metadata:
labels:
app: batch-job
spec:
restartPolicy: OnFailure
containers:
- name: main
image: luksa/batch-job

通过kubectl scale命令更改parallelism属性,Job可以在运行过程中被缩放。

4.5.5 限制 Job pod 完成任务的时间 116

通过activeDeadlineSeconds属性,限制pod运行的时间。
通过spec.backoffLimit属性,配置Job在被标记为失败之前可以重试的次数。默认为6。

4.6 安排 Job 定期运行或在将来运行一次 116

k8s用CronJob资源设置cron任务。

CronJob的官方文档

4.6.1 创建一个 CronJob 116

CronJob通过jobTemplate模板创建资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: batch-job-every-fifteen-minutes
spec:
schedule: "0,15,30,45 * * * *"
jobTemplate:
spec:
template:
metadata:
labels:
app: periodic-batch-job
spec:
restartPolicy: OnFailure
containers:
- name: main
image: luksa/batch-job

4.6.2 了解计划任务的运行方式 118

在计划的时间内,CronJob资源会创建Job资源,然后Job创建pod。

可以通过指定CronJob规范中的startingDeadlineSeconds字段来指定截止时间。

CronJob总是为计划中配置的每个执行创建一个Job,但可能会同时创建两个Job,或者根本没有创建。为了解决第一个问题,你的任务应该是幂等的(多次而不是一次运行不会得到不希望的结果)。对于第二个问题,请确保下一个任务运行完成本应该由上一次的(错过的)运行完成的任何工作。

4.7 本章的k8s命令 118

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
########## 日志 ##########

# 获取前一个容器的日志
$ kubectl logs mypod --previous

########## ReplicationController ##########

# 编辑模板
$ kubectl edit rc kubia

# 对管理的pod数量伸缩,等于修改spec.replicas=3
$ kubectl scale rc kubia --replicas=3

# 删除rc使pod不受管理但保持运行
$ kubectl delete rc kubia --cascade=false

########## ReplicaSet ##########

# 查看ReplicaSet
$ kubectl get rs

# 描述ReplicaSet
$ kubectl describe rs

# 删除ReplicaSet
$ kubectl delete rs kubia

########## DaemonSet ##########

# 查看DaemonSet
$ kubectl get ds

########## node ##########

# 给节点添加标签
$ kubectl label node minikube disk=ssd

# 从节点删除标签
$ kubectl label node minikube disk=hdd --overwrite

########## Job ##########

# 查看Job
$ kubectl get jobs

# 查看所有pod包括已经完成的
$ kubectl get pod --show-all(缩写-a)

# 将Job并行度改成3
$ kubectl scale job multi-completion-batch-job --replicas 3

5 服务 :让客户端发现 pod 并与之通信 121

pod会在node间被调度,一组功能相同的pod需要对外提供一个稳定地址,而service就是pod对外的门户。

service的官方文档

5.1 介绍服务 122

service会通过selector绑定多个pod,service通过clusterIP对外接收请求,然后分配给绑定的pod。

5.1.1 创建服务 123

通过kubectl expose命令或者YAML文件描述创建service均可。

例如创建一个名叫kubia的service。它将在端口80接收请求并将连接路由到具有标签选择器app=kubia的pod的8080端口上。

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Service
metadata:
name: kubia
spec:
ports:
- port: 80 # 该服务的可用端口
targetPort: 8080 # 服务将连接转发到的容器端口
selector:
app: kubia # 具有app=kubia标签的pod都属于该服务

kubectl exec命令可以在一个存在的pod中运行命令。

如果希望特定客户端产生的所有请求每次都指向同一个 pod,可以设置服务的sessionAffinity属性为ClientIP。
k8s仅仅支持两种形式的会话亲和性服务: None 和 ClientIP。默认值None。

1
2
3
4
...
spec:
sessionAffinity: ClientIP
...

k8s服务不是在HTTP层工作,服务处理TCP/UDP包,并不关心包的内容,所以k8s不支持基于cookie(HTTP协议的一部分)的会话亲和性选项。

同一个服务可以暴露多个端口,但必须给每个端口指定名字。标签选择器应用于整个服务,不能对每个端口做单独的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: kubia
spec:
ports:
- name: http
port: 80
targetPort: 8080
- name: https
port: 443
targetPort: 8443
selector:
app: kubia

可以在pod中定义port的名称,这样可以在service中按名称引用这些端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kind: pod
spec:
container:
- name: kubia
ports:
- name: http
containerPort: 8080
- name: https
containerPort: 8443
---
kind: Service
spec:
ports:
- name: http
port: 80
targetPort: http
- name: https
port: 443
targetPort: https

5.1.2 服务发现 129

在服务后面的pod可能删除重建,它们的IP地址可能改变,数量也会增减,但是始终可以通过服务的单一不变的IP地址访问到这些pod。

  • 可以通过环境变量获取服务IP地址和端口号。
  • 可以通过DNS发现服务(推荐)。但是客户端必须知道服务的端口号。

service的DNS地址为<service_name>.<namespace>.svc.cluster.local。svc.cluster.local是在所有集群本地服务名称中使用的可配置集群域后缀,可以省略。

k8s有一个kube-dns的pod,作为集群的DNS服务。集群中的其它pod都被配置成使用其作为DNS服务器(通过修改每个容器的/etc/resolv.conf文件实现)。pod是否使用内部的DNS服务器根据spec.dnsPolicy决定。

创建service时会自动地创建DNS记录,DNS里会记录和service关联的所有pods的IP。这一特性非常的有用,比如如果你想要在prometheus里监听某个daemonset,那么就可以为这些daemonset配置一个svc,然后让prometheus通过dns_sd_configs(基于DNS的服务发现)去自动发现所有的daemonset pods。

5.2 连接集群外部的服务 132

5.2.1 介绍服务 endpoint 133

服务并不是和 pod 直接相连的。有一种资源介于两者之间——它就是Endpoint资源。
service创建endpoint,并且将流量导向 endpoint。

Pods expose themselves through endpoints to a service.

5.2.2 手动配置服务的 endpoint 133

尽管在spec服务中定义了pod选择器,但在重定向传入连接时不会直接使用它。相反,选择器用于构建IP和端口列表,然后存储在Endpoint资源中。当客户端连接到服务时,服务代理选择这些IP和端口对中的一个。

selector用于构建endpoint,svc直接从endpoint中选择一个地址来使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Service
metadata:
name: external-service # service的名称必须和endpoint的名字匹配
spec: # 因为不需要匹配到pods,所以无需定义selector
ports:
- port: 80
---
apiVersion: v1
kind: Endpoints
metadata:
name: external-service # endpoint的名称必须和service的名称匹配
subsets:
- addresses:
- ip: 11.11.11.11 # service将会将请求重定向的地址
- ip: 22.22.22.22
ports:
- port: 80 # endpoint的目标端口

5.2.3 为外部服务创建别名 135

要创建一个具有别名的外部服务的服务时,将创建service资源的type字段设置为ExternalName。

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Service
metadata:
name: external-service
spec:
type: ExternalName
externalName: api.somecompany.com # 实际服务的完全限定域名
ports:
- port: 80

5.3 将服务暴露给外部客户端 136

5.3.1 使用 NodePort 类型的服务 137

通过创建NodePort类型的服务,可以让k8s在其所有节点上保留一个端口(所有节点上都使用相同的端口号),并将传入的连接转发给作为服务部分的pod。

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: kubia-nodeport
spec:
type: NodePort
ports:
- port: 80
targetPort: 8080
nodePort: 30123
selector:
app: kubia

EXTERNAL-IP列显示nodes,表明服务可通过任何集群节点的IP地址访问。

5.3.2 通过负载均衡器将服务暴露出来 140

在EKS或GKE等云端使用k8s服务时,可以将服务的类型设置成LoadBalance,直接将服务绑定到云上的lb上。

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: kubia-loadbalancer
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 8080
selector:
app: kubia

EXTERNAL-IP列显示的是lb的IP,可以通过该IP访问服务。

5.3.3 了解外部连接的特性 142

可以通过将服务配置为仅将外部通信重定向到接收连接的节点上运行的pod来阻止此额外跳数。这是通过在服务的spec部分中设置externalTrafficPolicy字段。

1
2
spec:
externalTrafficPolicy: Local

5.4 通过 Ingress 暴露服务 143

除了NodePort和LoadBalance这两种向集群外部的客户端公开服务的方法,还有一种方法,创建Ingress资源。

每个LoadBalancer服务都需要自己的负载均衡器,以及独有的公有 IP 地址,而 Ingress 只需要一个公网IP就能为许多服务提供访问。

Ingress在HTTP层工作,可以提供服务不能实现的功能(service在TCP/UDP层工作)。比如基于cookie的会话亲和性(session affinity)等功能。

Ingress其实就是集群的网关,一般都会使用Nginx或HAProxy,通过绑定虚拟主机的形式暴露集群内的服务。

5.4.1 创建 Ingress 资源 145

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: kubia
spec:
rules:
- host: kubia.example.com # Ingress将域名kubia.example.com映射到你的服务
http:
paths:
- path: / # 将所有的请求发送到kubia-nodeport服务的80端口
backend:
serviceName: kubia-nodeport
servicePort: 80

定义了一个单一规则的Ingress,确保Ingress控制器收到的所有请求主机kubia.example.com的HTTP请求,将被发送到端口80上的kubia-nodeport服务。

5.4.2 通过 Ingress 访问服务 146

客户端通过Ingress控制器连接到其中一个pod的流程:

  • 客户端首先对kubia.example.com执行DNS查询,得到Ingress控制器的IP。
  • 客户端然后向Ingress控制器发送HTTP请求,并在HTTP header指定host(-H "Host: kubia.example.com")。
  • Ingress控制器从该头部确定客户端目标访问哪个service。
  • 通过与该service关联的endpoint对象查看pod IP。
  • 将客户端的请求转发给其中一个pod IP。

curl http://kubia.example.com(需要在/etc/hosts添加192.168.99.100 kubia.example.com)和curl http://192.168.99.100 -H "Host: kubia.example.com"均可用来通过Ingress访问服务。

5.4.3 通过相同的 Ingress 暴露多个服务 147

一个Ingress可以将多个主机和路径映射到多个服务。

  • 将不同的服务映射到相同虚拟主机的不同路径
1
2
3
4
5
6
7
8
9
10
11
12
13
...
- host: kubia.example.com
http:
paths:
- path: /kubia
backend:
serviceName: kubia # 对kubia.example.com/kubia的请求将会转发至kubia服务
servicePort: 80
- path: /bar
backend:
serviceName: bar # 对kubia.example.com/bar的请求将会转发至bar服务
servicePort: 80
...
  • 将不同的服务映射到不同的虚拟主机上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
- host: foo.example.com
http:
paths:
- path: /
backend:
serviceName: foo # 对foo.example.com的请求将会转发至foo服务
servicePort: 80
- host: bar.example.com
http:
paths:
- path: /
backend:
serviceName: bar # 对bar.example.com的请求将会转发至bar服务
servicePort: 80
...

5.4.4 配置 Ingress 处理 TLS 传输 149

将证书可私钥附加到Ingress控制器。
当客户端创建到Ingress控制器的TLS连接时,控制器将终止TLS连接。客户端和控制器之间的通信是加密的,而控制器和后端pod之间的通信则不是。运行在pod上的应用程序不需要支持TLS。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: kubia
spec:
tls: # 在这个属性下包含了所有的TLS的配置
- hosts:
- kubia.example.com # 将接收来自kubia.example.com主机的TLS连接
secretName: tls-secret # 从tls-secret中获得之前创建的私钥和证书
rules:
- host: kubia.example.com
http:
paths:
- path: /
backend:
serviceName: kubia-nodeport
servicePort: 80

5.5 pod 就绪后发出信号 150

5.5.1 介绍就绪探针 151

就绪探针(readinessProbe)会定期调用,并确定特定的 pod 是否接收客户端请求。当容器的准备就绪探测返回成功时,表示容器已准备好接收请求。

和存活探针一样,就绪探针也有三种类型:

  • Exec
  • HTTP GET
  • TCP

就绪探针与存活探针最重要的区别是,如果容器未通过准备检查,则不会被终止或重新启动,只是从服务中删除该pod,如果pod再次准备就绪,则重新添加pod到服务。

如果一个pod的就绪探测失败,则将该容器从端点对象中移除。连接到该服务的客户端不会被重定向到pod。这和pod与服务的标签选择器完全不匹配的效果相同。

5.5.2 向 pod 添加就绪探针 152

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
kind: ReplicationController
metadata:
name: kubia
spec:
replicas: 3
selector:
app: kubia
template:
metadata:
labels:
app: kubia
spec:
containers:
- name: kubia
image: luksa/kubia
ports:
- name: http
containerPort: 8080
readinessProbe: # pod中的每个容器都会有一个就绪探针
exec:
command:
- ls
- /var/ready

5.5.3 了解就绪探针的实际作用 154

应该通过删除pod或更改pod标签而不是手动更改探针来从服务中手动移除 pod。
应该始终定义一个就绪探针,即使它只是向基准URL发送HTTP请求一样简单。

5.6 使用 headless 服务来发现独立的 pod 155

headless service的官方文档

如果告诉k8s,不需要为服务提供集群IP,则DNS服务器将返回pod IP而不是单个服务IP。
将服务spec中的clusterIP字段设置为None会使服务成为headless服务,因为k8s不会为其分配集群IP,客户端可通过该IP将其连接到支持它的pod。
通常情况下,DNS查询svc会返回svc的clusterIP。而对于headless服务,DNS查询会返回一系列A记录,分别对应相应的pod的地址。

5.6.1 创建 headless 服务 156

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: kubia-headless
spec:
clusterIP: None # 这使得服务成为headless的
ports:
- port: 80
targetPort: 8080
selector:
app: kubia

5.6.2 通过 DNS 发现 pod 156

非headless服务返回的DNS是服务的集群IP。
headless服务返回的DNS是所有就绪的pod的IP。headless服务依然提供跨pod的负载均衡。

5.6.3 发现所有的 pod——包括未就绪的 pod 157

通过添加annotations,可以将所有匹配标签选择器的pod添加到服务中。

1
2
3
4
kind: Service
metadata:
annotations:
service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"

5.7 排除服务故障 158

  • 确保从集群内连接到服务的集群IP,而不是从外部。
  • 不要通过ping服务IP来判断服务是否可访问(服务的集群IP是虚拟IP,是无法ping通的)。
  • 如果已经定义了就绪探针,请确保它返回成功;否则该pod不会成为服务的一部分。
  • 要确认某个容器是服务的一部分,请使用kubectl get endpoint来检查相应的端点对象。
  • 如果尝试通过FQDN或其中一部分来访问服务(例如myservice.mynamespace.svc.cluster.local或myservice.mynamespace),但不起作用,请查看是否可以使用其集群IP而不是FQDN来访问服务。
  • 检查是否连接到服务公开的端口,而不是目标端口。
  • 尝试直接连接到pod IP以确认pod正在接收正确端口上的连接。
  • 如果甚至无法通过pod的IP访问应用,请确保应用不是仅绑定到本地主机。

5.8 本章的k8s命令 159

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
########## Service ##########

# 在一个运行的pod容器上执行curl,--代表kubectl命令的结束,之后是在pod内部需要执行的命令
$ kubectl exec kubia-7nog1 -- curl -s http://10.111.249.153

# 展示服务细节
$ kubectl describe svc kubia

########## Pod ##########

# 删除所有pod
$ kubectl delete pod --all

# 在pod容器上运行bash
$ kubectl exec -it kubia-3inly bash

# 查看Ingress控制器的pod
$ kubectl get pod --all-namespaces|grep ingress

# 不通过YAML文件进行pod,直接创建pod,不需要通过rc等资源来创建
$ kubectl run dnsutils --image=tutum/dnsutils --generator=run-pod/v1 --command -- sleep infinity

# 使用pod dnsutils执行DNS查找
$ kubectl exec dnsutils nslookup kubia-headless

########## Endpoint ##########

# 查看endpoint
$ kubectl get endpoint kubia

########## Node ##########

# 使用JSONPath获取所有节点的IP
$ kubectl get nodes -o jsonpath='{.items[*].status.addresses[?(@.type=="ExternalIP")].address}'

########## Ingress ##########

# 列出Ingress
$ kubectl get Ingress

# 更新Ingress资源
$ kubectl apply -f kubia-ingress-tls.yaml

########## Secret ##########

# 创建Secret
$ kubectl create secret tls tls-secret --cert=tls.cert --key=tls.key

6 卷 :将磁盘挂载到容器 161

volume的官方文档

pod中的每个容器都有自己的独立文件系统,因为文件系统来自容器镜像。
k8s通过在pod中定义卷,使得存储持久化,和pod共享生命周期,而不会随着容器的重启消失。

6.1 介绍卷 162

6.1.1 卷的应用示例 162

卷被绑定到pod的lifecycle中,只有在pod存在时才会存在,但取决于卷的类型,即使在pod和卷消失之后,卷的文件也可能保待原样,并可以挂载到新的卷中。

6.1.2 介绍可用的卷类型 164

  • emptyDir:用于存储临时数据的简单空目录。
  • hostPath:用于将目录从工作节点的文件系统挂载到 pod 中。
  • gitRepo:通过检出 Git 仓库的内容来初始化的卷。
  • nfs:挂载到 pod 中的 NFS 共享卷。
  • 云磁盘
    • gcePersistentDisk
    • awsElasticBlockStore
    • azureDisk
  • 网络存储
    • cinder
    • cephfs
    • iscsi
    • flocker
    • glusterfs
    • …
  • k8s 内部资源卷
    • configMap
    • secret
    • downwardAPI
  • persistentVolumeClaim:动态配置的持久存储

6.2 通过卷在容器之间共享数据 165

6.2.1 使用 emptyDir 卷 165

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: v1
kind: Pod
metadata:
name: fortune
spec:
containers:
- image: luksa/fortune
name: html-generator
volumeMounts:
- name: html # 名为html的卷挂载在html-generator容器的/var/htdocs中
mountPath: /var/htdocs
- image: nginx:alpine
name: web-server
volumeMounts:
- name: html # 名为html的卷挂载在web-server容器的/usr/share/nginx/html中
mountPath: /usr/share/nginx/html
readOnly: true # 设为只读
ports:
- containerPort: 80
protocol: TCP
volumes:
- name: html # 一个名为html的单独emptyDir卷,挂载在上面的两个容器中
emptyDir: {}

可以指定用于emptyDir的介质。

1
2
3
4
volumes:
- name: html
emptyDir:
medium: Memory # emptyDir的文件将会存储在内存中

6.2.2 使用 Git 仓库作为存储卷 168

将拉取的git repo作为文件系统,可以方便的读取到git的内容。
缺点是,在创建gitRepo卷后,它并不能和对应repo保持同步。
可以使用sider容器进行「git sync」。

如果想要将私有的Git repo克隆到容器中,则应该使用gitsync sidecar或类似的方法,而不是使用 gitRepo卷。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: v1
kind: Pod
metadata:
name: gitrepo-volume-pod
spec:
containers:
- image: nginx:alpine
name: web-server
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
ports:
- containerPort: 80
protocol: TCP
volumes:
- name: html
gitRepo: # gitRepo卷
repository: https://github.com/luksa/kubia-website-example.git
revision: master
directory: . # 将repo克隆到卷的根目录

6.3 访问工作节点文件系统上的文件 171

6.3.1 介绍 hostPath 卷 171

hostPath卷指向节点文件系统上的特定文件或目录。

6.3.2 检查使用 hostPath 卷的系统 pod 172

仅当需要在节点上读取或写入系统文件时才使用hostPath,不能用来持久化跨pod的数据。

6.4 使用持久化存储 173

6.4.1 使用 GCE 持久磁盘作为 pod 存储卷 174

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: v1
kind: Pod
metadata:
name: mongodb
spec:
volumes:
- name: mongodb-data
gcePersistentDisk: # 卷类型是GCE持久磁盘
pdName: mongodb
fsType: ext4 # 文件系统类型是EXT4
containers:
- image: mongo
name: mongodb
volumeMounts:
- name: mongodb-data
mountPath: /data/db # MongoDB数据存放的路径
ports:
- containerPort: 27017
protocol: TCP

6.4.2 通过底层持久化存储使用其他类型的卷 177

  • 使用AWS弹性块存储卷
1
2
3
4
5
6
7
8
...
spec:
volumes:
- name: mongodb-data
awsElasticBlockStore:
volumeID: my-volume
fsType: ext4
...
  • 使用NFS卷
1
2
3
4
5
6
7
8
...
spec:
volumes:
- name: mongodb-data
nfs:
server: 1.2.3.4
path: /some/path
...

6.5 从底层存储技术解耦 pod 179

persistent-volume的官方文档

将这种涉及基础设施类型的信息塞到一个pod设置中,意味着pod设置与特定的k8s集群有很大耦合度。这就不能在另一个pod中使用相同的设置了。所以使用这样的卷并不是在pod中附加持久化存储的最佳实践。

理想的情况是,在k8s上部署应用程序的开发人员不需要知道底层使用的是哪种存储技术,同理他们也不需要了解应该使用哪些类型的物理服务器来运行pod,与基础设施相关的交互是集群管理员独有的控制领域。

6.5.1 介绍持久卷和持久卷声明 179

系统管理员首先准备好磁盘资源,然后创建全局的持久卷(Persistent Volume)。

然后用户通过创建持久卷声明(PersistentVolumeClaim,简称PVC)清单,指定所需要的最低容量要求和访问模式,然后用户将持久卷声明清单提交给k8s的API服务器,k8s将找到可匹配的持久卷并将其绑定到持久卷声明。

持久卷声明可以当作pod中的一个卷来使用,其他用户不能使用相同的持久卷,除非先通过删除持久卷声明绑定来释放。

6.5.2 创建持久卷 180

创建持久卷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: PersistentVolume
metadata:
name: mongodb-pv
spec:
capacity: # 定义PersistentVolume的大小
storage: 1Gi
accessModes: # 可以被单个客户端挂载为读写模式或被多个客户端挂载为只读模式
- ReadWriteOnce
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain # 当声明被释放后,PersistentVolume将会被保留
gcePersistentDisk: # PersistentVolume指定支持之前创建的GCE持久磁盘
pdName: mongodb
fsType: ext4

在pod卷中引用GCE PD

1
2
3
4
5
6
spec:
volumes:
- name: mongodb-data
gcePersistentDisk:
pdName: mongodb
fsType: ext4

在创建持久卷时,管理员需要告诉k8s其对应的容量需求,以及它是否可以由单个节点或多个节点同时读取或写入。管理员还需要告诉k8s如何处理PersistentVolume(当持久卷声明的绑定被删除时)。最后,无疑也很重要的事情是,管理员需要指定持久卷支持的实际存储类型、位置和其他属性。

持久卷不属于任何命名空间,它跟节点一样是集群层面的资源。

6.5.3 通过创建持久卷声明来获取持久卷 182

创建持久卷声明

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongodb-pvc # 声明的名称,将声明当做pod的卷使用时需要用到
spec:
resources:
requests:
storage: 1Gi # 申请1GiB的存储空间
accessModes:
- ReadWriteOnce # 允许单个客户端访问(同时支持读取和写入操作)
storageClassName: "" # 将空字符串指定为存储类名可确保PVC绑定到预先配置的PV,而不是动态配置新的PV

当创建好持久卷声明,k8s就会找到适当的持久卷并将其绑定到声明,持久卷的容量必须足够大以满足声明的需求,并且卷的访问模式必须包含声明中指定的访问模式。

访问模式:

  • PWO: ReadWriteOnce,仅允许单个节点挂载读写;
  • ROX: ReadOnlyMany,允许多个节点挂载读;
  • RWX: ReadWriteMany,允许多个节点挂载读写。

accessModes设置的是同时使用卷的工作节点的数量,而非pod的数量。

6.5.4 在 pod 中使用持久卷声明 184

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Pod
metadata:
name: mongodb
spec:
containers:
- image: mongo
name: mongodb
volumeMounts:
- name: mongodb-data
mountPath: /data/db
ports:
- containerPort: 27017
protocol: TCP
volumes:
- name: mongodb-data
persistentVolumeClaim: # 在pod卷中通过名称引用持久卷声明
claimName: mongodb-pvc

6.5.5 了解使用持久卷和持久卷声明的好处 185

6.5.6 回收持久卷 186

通过将persistentVolumeReclaimPolicy设置为Retain从而通知到k8s,希望在创建持久卷后将其持久化,让k8s可以在持久卷从持久卷声明中释放后仍然能保留它的卷和数据内容。手动回收持久卷并使其恢复可用的唯一方法是删除和重新创建持久卷资源。

存在两种其他可行的回收策略:Recycle和Delete。第一种删除卷的内容并使卷可用于再次声明,通过这种方式,持久卷可以被不同的持久卷声明和pod反复使用。

6.6 持久卷的动态卷配置 187

storage-class的官方文档

6.6.1 通过 StorageClass 资源定义可用存储类型 188

1
2
3
4
5
6
7
8
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast
provisioner: kubernetes.io/gce-pd # 用于配置持久卷的卷插件
parameters:
type: pd-ssd
zone: europe-westl-b

6.6.2 请求持久卷声明中的存储类 188

StorageClass资源指定当久卷声明请求此StorageClass时应使用哪个置备程序来提供持久卷。StorageClass定义中定义的参数将传递给置备程序,并具体到每个供应器插件。

简单地说,管理员可以手动通过置备程序创建PV,或者直接创建对应的StorageClass,然后用户创建PVC时,会自动根据StorageClass的设置调用置备程序创建出可供使用的PV。

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongodb-pvc
spec:
storageClassName: fast # 该PVC请求自定义存储类
resources:
requests:
storage: 100Mi
accessModes:
- ReadWriteOnce

StorageClasses的好处在于,声明是通过名称引用它们的。因此,只要StorageClass名称在所有这些名称中相同,PVC定义便可跨不同集群移植。

6.6.3 不指定存储类的动态配置 190

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongodb-pvc2
spec:
resources:
requests:
storage: 100Mi
accessModes:
- ReadWriteOnce

6.7 本章的k8s命令 193

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
########## PersistentVolume ##########  

# 列出所有的PersistentVolume
$ kubectl get pv

########## PersistentVolumeClaim ##########

# 列出所有的PersistentVolumeClaim
$ kubectl get pvc

########## StorageClass ##########

# 列出所有的StorageClass
$ kubectl get sc

# 查看默认存储类
$ kubectl get sc standard -o yaml

深入理解Linux IO模型(一)

发表于 2021-06-12 | 分类于 计算机 | | 阅读次数:

[TOC]

Linux IO模型是后端工程师的必备技能。从以往的面试中看,部分后端开发人员对它的理解停留在调API的层面,我自己也理解欠缺。最近系统学习了一下,整理了此文。本文参考了一些文章,放在本文最后,大家可以直接去看这些文章,值得阅读。

  1. 本文为了描述方便,统一用读操作讲述,写操作同理。
  2. 本文为了撰写方便,统一将I/O写成了IO。
  3. 欢迎指正文中的错误。

操作系统预备知识

UNIX体系结构

UNIX操作系统的体系结构如图所示。

内核(kernel):控制计算机硬件资源,提供程序运行环境。
系统调用(system call):内核的函数接口。
公共函数库:构建在系统调用之上的函数接口。
shell:特殊的应用程序,为运行其他应用程序提供了一个接口。
应用程序:用户编写的程序。可使用公共函数库,也可直接调用系统调用。

系统调用

进一步介绍下系统调用(syscall)。
内核用于控制硬件资源,例如从磁盘上读写文件,需要控制硬盘这个硬件设备做IO操作。应用代码通过调用内核暴露出来的系统调用接口来使内核进行IO操作。
如图所示,库函数调用系统调用接口,应用程序可以调用系统调用和库函数。

例如用户常用的printf函数,可以调用它输出内容到显示器上,但是控制显示器的输出是内核。系统调用提供的是write函数,printf是库函数,它封装了write 这个系统调用接口。

用户空间和内核空间

对于32位CPU(表示CPU的寄存器长度为32位),指令集长度32位,数据总线宽度32位,地址总线宽度32位(因为受到寄存器长度的限制,再大也是浪费,无法把指令或数据从内存装载到寄存器或把寄存器的值写入内存)。因为地址总线宽度32位,所以最大寻址范围2^32,即对应 2^32*8bit=4GB 内存寻址空间。虽然内存的最大寻址容量只有4GB,但是每个进程的虚拟存储空间却都为4GB。

虚拟存储空间是什么?

  1. MMU(内存管理单元)通过段页式存储管理,负责物理地址和逻辑地址(虚拟地址)的转化。逻辑空间可以理解为内存空间和磁盘空间之间的抽象,为了解决容量问题。
  2. 程序的局部性原理。CPU访问内存时,无论是存取指令还是数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。程序运行时,无需全部装入内存,如果访问页不在内存,发出缺页中断,发起页面置换(页面置换有常用的几种算法,FIFO、LFU、LRU)。

操作系统怎么划分的虚拟存储空间?

  1. 程序在磁盘中,加载进内存后,才能变成进程运行起来。内存的第一个进程是kernel。
  2. kernel会注册一个GDT(Global Descriptor Table),把4GB虚拟内存划分成用户空间和内核空间。最高的1GB,从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,作为内核空间;较低的3GB,从虚拟地址0x00000000到0xBFFFFFFF),供各个应用进程使用,作为用户空间。

内核独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,用户进程不能直接操作内核。操作系统将4GB的虚拟存储空间划分为两部分,用户空间和内核空间。

CPU如何区分指令来自内核空间还是用户空间的?
指令存储在内存中,通过数据总线加载到CPU的指令寄存器,CPU解码执行。实际上CPU本身并不能区分是谁发出的指令,而是通过特权等级来区分。以X86架构来说,CPU指令集的特权等级分为Ring0~3,内核空间对应指令集Ring0,具有最高权限,可以访问所有资源;用户空间对应指令集Ring3,不能直接访问硬件设备。

如果用户空间存在特权指令,CPU如何区分这个指令来自用户进程从而禁止执行?
CPU有两种执行模式,用户模式和内核模式。用户模式受到限制,某些指令不能被执行,某些寄存器不能被访问,IO设备也不能被访问。内核模式则没有这些限制,可以执行所有的机器指令,可以读写所有的内存位置。
这个问题我理解可能不到位,抛砖引玉。

用户态和内核态

进程运行时会有用户态和内核态的区别。
如图所示,程序执行时,如果执行的是用户空间的应用代码,这些代码运行在用户态;当调用了系统调用后,内核空间的内核代码就会执行,内核中的这些代码运行在内核态。

进程阻塞

进程有五个状态,创建、就绪、执行、阻塞和终止。
就绪状态:当进程被分配到除CPU以外所有必要的资源(包括PCB、栈空间、堆空间等)后。只要获得CPU的使用权,就可以立即执行。
执行状态:进程获得CPU,在执行的状态。单CPU同一时刻只能有一个进程在执行状态。
阻塞状态:因为某种原因如IO未就绪,进程放弃CPU的使用权,进入阻塞状态。

进程模型之间的切换关系如图所示。

进程切换(进程调度)是指操作系统通过某种进程调度算法决定哪个就绪进程可以获得CPU的使用权。进一步说,内核以一定策略挂起当前正在利用CPU运行的进程,并保存进程的上下文运行信息,然后分配CPU给某个就绪状态的另一个进程执行。

可以看出,进程阻塞是进程的主动行为,只有处于获得CPU在执行状态的进程,才可能转变成阻塞状态。当进程进入阻塞状态,不占用CPU资源。所以,在执行IO请求后进入阻塞状态的进程,是不占用CPU资源的。

PCB(进程控制块)指的是什么?
PCB是进程常驻在内存中的通用数据结构,记录进程运行的全部信息,被用于操作系统调用时读取。记录包括进程的标识符、状态、优先级、程序计数器、内存指针、CPU的上下文数据、被进程占用的IO的状态信息、记账信息等。

中断

理解中断,对理解IO模型很重要。

任务事件

操作系统是事件驱动的,只有在有中断、陷阱或系统调用时才执行。如图所示。

为什么操作系统要采用事件驱动设计呢?

  1. 操作系统不能信任用户进程
  • 用户进程可能是错误的或恶意的
  • 用户进程崩溃不应影响操作系统
  1. 操作系统需要保证对所有用户进程公平性
  • 一个进程不能霸占CPU时间
  • 采用定时中断的方式

事件触发简化流程如图所示。

事件有中断和异常两类。中断由硬件或者程序触发,以引起操作系统的注意。异常由于非法操作而导致的。本文不讲异常,只讲中断。
中断又有硬件中断和软件中断之分:硬件中断由外部硬件设备触发;软件中断由应用进程触发。

每个中断都有一个编号,称为中断向量(interrupt vector),用于在中断描述符表IDT(也称为中断向量表)中进行索引,从而获得中断服务程序的指针,即中断处理程序的入口点。

为什么「中断向量」不叫「中断指针」?我猜可能是历史原因,知道的读者可以留言告诉我。

硬件中断

与CPU相连接的外部设备,如键盘、鼠标、网卡等,偶尔需要CPU提供服务,但是CPU无法预测它们何时发生。
如果在数据采集系统中,可以采用CPU定期轮询设备的方式,以确定它们是否需要提供服务。否则,轮询会浪费CPU资源。
所以引入了硬件中断的方式,每个连接的外部设备都可以向CPU发出信号,表示它们需要CPU提供服务。一般来说,CPU有2个引脚,INT用于中断,NMI用于不可屏蔽的关键的信号。

如图所示,是8259可编程中断控制器。可支持转发8个中断。当设备通过中断请求(IRQ)引发中断,CPU确认并查询8259以确定哪个设备产生中断。8259可以为每个IRQ线分配优先权,可以级联以支持更多的中断。

当硬件中断发生的时候,发生了什么?流程如图所示。

第1步,CPU完成当前指令后,立即响应硬件中断,先获取到中断向量。
第2步,切换到内核堆栈不是必须的,因为只有从用户模式转到内核模式,才需要进行堆栈切换。有可能中断来临时,CPU正在内核模式执行内核的指令。
第3步,保存程序状态,是为了保证当前正常执行的程序在中断服务完成后能恢复。
第4步到第7步,是中断处理程序执行流程。

典型的中断处理程序过程是:

  1. 保存CPU上下文
  2. 处理中断(如与IO设备通信)
  3. 调用内核调度程序
  4. 恢复CPU上下文并返回

如图所示,中断处理是有延时的,最小值受限于中断控制器,它的最大值受到操作系统的限制,如当内核和中断处理程序需要操作同一个全局变量,需要保证内核执行的是原子操作,中断处理程序需要等待原子操作完成,才能得到处理。

软件中断

下面讲软件中断,它是为何产生?
为了让CPU能尽快响应其它硬件中断,中断处理程序需要小型化,可以只是设置flag或放入工作队列,让非关键性的代码推迟执行。于是提出了Top and Bottom Half Technique。
Top half:做最小的工作并从中断处理程序中返回。如保存寄存器、取消对其他中断的屏蔽、恢复寄存器并返回到以前的上下文。
Bottom half :对Top half剩下的工作延迟处理。

硬件中断和软件中断的直观对比如图所示。硬件中断由一个设备(如PIC)向CPU的一个引脚发出信号产生;软件中断由正在执行的某条指令产生。

软件中断通过请求系统调用产生,如图所示。

一个write system call的例子,如图所示。

软件中断产生、处理和返回的流程,如图所示。

操作系统是如何区分系统调用的?答案是利用 System call number。直观上看,如图所示。

数据包的接收过程

网卡->内存

数据包如何进入内存,并被内核的网络模块开始处理的?流程如图所示。

  1. 数据包进入网卡(如果目的地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃)。
  2. 网卡将数据包通过DMA方式写入指定的内存地址(该地址由网卡驱动分配并初始化)。
  3. 网卡raise硬件中断IRQ,通知CPU,告诉有数据包到来。
  4. CPU查询中断向量表,得到中断服务程序的指针,这个中断服务程序会调用网卡驱动程序中的相应函数。
  5. 网卡驱动先禁用网卡的硬件中断,表示驱动程序已经知道内存中有网络数据,如果网卡下次再接收到数据包,直接DMA方式写内存就可以,不需要raise硬件中断通知CPU(这样避免CPU不停地被中断)。
  6. 网卡驱动程序raise软件中断,内核启动软件中断服务。目的是将硬件中断服务程序中耗时久的部分放到软中断函数慢慢处理。

内存->内核网络模块->内核网络协议栈

内核在软件中断服务中接收链路层帧,并逐层递交上层协议栈处理,处理流程如下。

  1. 内核中有专门的进程负责接收网卡驱动raise的软中断,然后该进程调用对应的软中断处理函数,读取之前网卡写到内存中的数据包。
  2. 网卡驱动程序知道如何处理内存中的数据包格式,它将数据包转换成内核网络模块能识别的格式。
  3. 内核网络模块将数据包合并(这样可以减小调用协议栈的次数),并将数据包放入CPU对应的接收队列(softnet_data.input_pkt_queue)中等待处理。
  4. CPU在软中断上下文中处理队列中的网络数据。
  5. 调用协议栈相应的函数,把数据包交给协议栈处理。
  6. 协议栈的处理过程(IP层->TCP/UDP层)不展开描述了。
  7. 用户空间的应用层通过调用socket接口接收数据。比如调用recvfrom函数阻塞等待数据到来,当socket fd收到通知后,recvfrom函数被唤醒,然后读取数据;或通过select/epoll等IO多路复用方式监听多个socket fd,只要其中有fd收到通知,进程主动调用recvfrom函数去读取数据。本文后面会展开描述。

CPU的接收队列input_pkt_queue是什么?
网络设备模块在初始化时,为每个CPU初始化结构体softnet_data,用于处理网络数据。input_pkt_queue是该结构体的一个成员变量,作为接收队列,在对其操作的时候,关闭当前CPU的中断。
如果接收队列input_pkt_queue不为空,将接收队列拼接到处理队列process_queue上。接收队列input_pkt_queue清空,继续处理添加到处理队列process_queue的数据包,并且在处理前就打开当前的CPU中断。

关于IO的认知

IO是什么

IO是指Input/Output,即输入和输出。
IO从广义上说,是数据流动的过程。
IO有内存IO、网络IO和磁盘IO等。

从计算机架构上讲,CPU和内存与其他外部设备之间的数据转移过程就是IO。
本文从用户进程的角度理解IO。用户进程要完成IO读写,需要对内核发起IO调用,内核执行IO任务,返回IO结果,即完成一次IO。内核为每个IO设备维护一个内核缓冲区。

不带缓冲的IO和带缓冲的IO

IO分为不带缓冲的IO和带缓冲的IO(标准IO)。

不带缓冲的IO:读和写都调用内核中的系统调用read和write,写入内核缓冲区。

带缓冲的IO:目的是减少调用系统调用read和write的次数。方法是在用户空间建立流缓冲区。例如用户多次调用fwrite将数据写入流缓冲区,等流缓冲区满的时候只调用一次系统调用write,写入内核缓冲区。标准IO库实现的就是对IO流的缓存管理。

需要注意,不管是哪种IO,内存和磁盘之间,总是会有内核缓冲区的,这是IO设备的缓冲区。

总结一下数据流向路径:
不带缓冲的IO: 数据—内核缓存区—磁盘
带缓冲的IO: 数据—流缓存区—内核缓存区—磁盘

不管是哪种IO,因为用户进程是运行在用户空间的,不能直接操作内核缓冲区的数据。所以数据在传输过程中,总是需要从内核缓冲区复制到用户进程空间(对于带缓冲的IO,就是需要在内核缓冲区到用户缓冲区之间复制)。这个复制的过程对CPU和内存的开销是比较大的。

文件描述符

文件描述符(fd)在形式上是一个非负整数。
内核用以标记一个特定进程正在访问的文件。
当内核打开一个现有的文件或创建一个新的文件时,内核返回一个文件描述符,用于后续的IO操作。

流

流是可以进行读写操作的内核对象。
比如文件、管道、套接字。
Linux一切皆文件,一切都是流。用户进程都是对这些流进行读写操作,实现数据交换。
用户进程用文件描述符fd实现对流的操作。
准确地说,流是带缓冲的IO(标准IO)才有的概念。
流有方向。对流的读写操作,可以理解为IO操作。如图所示。

如果流中没有数据,读取,就阻塞。进一步说,是用户缓冲区没有数据,无法读取数据。

如果流中数据已满,写入,就阻塞。进一步说,是用户缓冲区数据已满,无法写入数据。

IO操作

对于用户进程的一个读IO操作,包括以下阶段:
1.用户进程调用IO系统调用读数据。
2.内核先看下内核缓冲区是否有数据,如果没有数据,则从设备读取,先加载到内核缓冲区,再复制到用户进程缓冲区;如果有数据,直接复制到用户进程缓冲区(对于标准IO)。

直观的流程如图所示。

具体地说,对于一个网络IO输入操作,如果内核缓冲区无数据,包括以下阶段:

  1. 用户进程调用Socket API
  2. 等待网络数据到达网卡这个硬件设备
  3. 通过DMA,直接从网卡读取到内核缓冲区
  4. 内核把内核缓冲区的数据复制到用户空间

总结一下,一次完整的网络IO输入操作,是应用进程进行系统调用,内核从网卡读取数据写入内存,接着内核把数据从内存中复制到到用户空间的过程。
这个过程,有很多种IO模型可以处理,就引发了下文要讲的同步IO(包括阻塞IO、非阻塞IO、IO多路复用)和异步IO模型。

IO就绪

我们常说的fd就绪,也就是IO就绪,是IO可读或可写了,即应用程序调用的内核系统调用返回结果了,可以从内核缓冲区读取数据或者写入数据到用户进程缓冲区。

同步IO和异步IO

IO模型从大类上分,分为同步IO和异步IO。

同步IO(synchronous IO)

应用程序通过系统调用发送IO请求给内核后,必须等待IO返回后才继续执行后续代码。
同步IO有以下几种模型。

阻塞式IO(blocking IO)
  1. 应用进程调用系统调用recvfrom,应用进程进入阻塞状态。
  2. 内核准备好数据,写入内核缓冲区。
  3. 内核将数据从内核缓冲区复制到用户空间。
  4. 应用进程被唤醒,进入执行状态,处理拿到的数据。

R1和R2阶段的应用进程都是阻塞的。

非阻塞式IO(nonblocking IO)
  1. 应用进程轮询调用系统调用recvfrom(非阻塞方式),如果内核未准备好数据,返回错误EWOULDBLOCK。
  2. 内核准备好数据,写入内核缓冲区。
  3. 这次应用进程调用系统调用recvfrom,内核会返回非错误码数据,并将数据从内核缓冲区复制到用户空间。
  4. 应用进程被唤醒,进入执行状态,处理拿到的数据。

R1阶段的应用进程是非阻塞的,R2阶段的应用进程是阻塞的。

IO多路复用(IO multiplexing)
  1. 应用进程调用系统调用select(这里以select为例,还有poll、epoll等IO多路复用器),进程进入阻塞状态。(这里的阻塞不同于阻塞式IO只等待一个socket fd,而是同时等待多个socket fd)
  2. 内核将可读的socket fd数据准备好,写入内核缓冲区。
  3. 应用进程收到select的返回结果,知道存在任意数量可读的socket fd。对于这些socket fd,应用进程遍历socket fd(select和poll是遍历所有fd,epoll是只遍历可读的fd),分别调用系统调用recvfrom,内核将数据从内核缓冲区复制到用户空间。
  4. 应用进程被唤醒,进入执行状态,处理拿到的数据(多个socket fd的返回结果)。

R1和R2阶段的应用进程都是阻塞的。

信号驱动IO(signal-driven IO)
  1. 应用进程内建立信号捕获函数,和socket fd关联。
  2. 应用进程调用系统调用sigaction,收到调用返回后,接着应用进程去执行其它代码。
  3. 当内核准备好数据,发送SIGIO信号给应用进程。
  4. 应用进程回调信号捕获函数,调用系统调用recvfrom。
  5. 应用进程被唤醒,进入执行状态,处理拿到的数据。

R1阶段的应用进程是非阻塞的,R2阶段的应用进程是阻塞的。

异步IO(asynchronous IO)

相对于同步IO,异步IO在用户进程调用系统调用aio_read以后,无论内核缓冲区数据是否准备好,都立即返回,不会阻塞当前进程,转而处理其它代码。

  1. 应用进程调用系统调用aio_read,收到调用返回后,接着应用进程去执行其它代码。
  2. 内核准备好数据,并且将数据从内核缓冲区复制到用户空间。
  3. 内核发送aio_read中指定的信号给应用进程。
  4. 应用进程转而处理拿到的数据。

R1和R2阶段的应用进程都是非阻塞的。

为什么提出IO多路复用

以上在同步IO模型中已经介绍了IO多路复用。IO多路复用也是目前主流软件,如nginx、redis、kafka等使用的模型。它可以让一个线程在同一时刻监听多个socket fd。
技术的发展都是有迹可循,为了解决某种问题提出,那么IO多路复用是怎么产生的?

先介绍下阻塞等待和非阻塞忙轮询两种模型,分别对应上面同步IO中的阻塞式IO和非阻塞式IO。

阻塞等待

阻塞等待指的是被动地等待IO状态到来,即阻塞等待IO可读或可写。
阻塞等待的时候,CPU是空闲的,即不占用CPU的时间片(在上文已经说明原因)。
虽然不占用CPU时间片,但无法处理其它IO状态的到来(因为进程在阻塞状态等待IO可操作,无法进入就绪状态),即单个CPU无法并发处理多个IO请求。

优点:处理接收数据的时候,不浪费性能资源。
缺点:同一时刻,只能处理一个流的阻塞监听,即单个CPU不能并发处理多个IO请求。

虽然可以用阻塞+多线程/多进程的模型,实现多个CPU可以同一时刻监听多个IO状态。
但是开辟线程/进程浪费内存资源,而且切换线程/进程也浪费CPU。

非阻塞忙轮询

非阻塞忙轮询指的是主动地轮询IO状态,判断可读或可写。
非阻塞忙轮询判断IO状态的时候,CPU是忙碌的,即CPU时间片被占用。
注意:所以非阻塞忙轮询和异步是两个概念。

缺点:浪费CPU。

伪代码如下。CPU 大部分时间在做 while 和 for 判断处理,CPU 的利用率不高。

1
2
3
4
5
6
7
while true {
for i in 流[] {
if i has 数据 {
读 或者 其他处理
}
}
}

IO多路复用解决的问题

最基础的网络编程伪代码如下。

1
2
3
4
5
6
创建socketint s = socket(AF_INET, SOCK_STREAM, 0); // 得到socket fd
绑定bind(s, ...)
监听listen(s, ...)
接受客户端连接int c = accept(s, ...)
接收客户端数据recv(c, ...);
将数据打印出来printf(...)

先创建socket fd,依次调用bind、listen、accept,最后调用recv接收数据。recv是个阻塞方法,当程序运行到recv时,进程进入阻塞状态(不占用CPU资源),直到接收到数据,进程转到执行状态处理数据。进程阻塞在accept和recv。

但是有没有一种方式,既有阻塞等待不浪费CPU资源的优点,也能避免阻塞等待同一时刻只能处理一个流的问题,而是可以在同一时刻监听多个socket fd?即同时accept和recv多个socket fd。答案就是IO多路复用。

所以产生了select、poll、epoll等IO多路复用技术,目的是解决单线程同一时刻处理大量IO读写请求,并且不浪费CPU。

什么是 IO 多路复用

阻塞等待只能同一时刻只能监听一个IO状态。
为了解决大量 IO 请求读写的问题,提出了 IO 多路复用。
如果同一流程想同一时刻监听多个IO状态。要么非阻塞忙轮询;要么用 select/epoll等IO多路复用器,告诉用户态有哪些 IO 可读可写,一起处理。即 IO 多路复用可以实现单线程在同一时刻可以监听多个IO状态。IO多路复用在非忙轮询状态,不浪费CPU。

select 简介

先上伪代码。

1
2
3
4
5
6
7
8
9
10
while true {
select(流[]); // 阻塞。CPU 可以去做其他事。如果有流可读了,返回。

// 有消息抵达
for i in 流 [] { // 需要依次判断所有的流哪个可读
if i has 数据 { // 如果可读的流数量少,浪费性能
读 或者 其他处理
}
}
}

进一步的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
int s = socket(AF_INET, SOCK_STREAM, 0); 
bind(s, ...);
listen(s, ...)
int fds[] = 存放需要监听的socket

while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
// fds[i]的数据处理
}
}
}

select的流程是:

  1. 应用进程调用系统调用select(fds)。
  2. select阻塞直到有任意数量fd可读。
  3. 应用进程遍历fds,通过FD_ISSET判断哪些socket fd可读。
  4. 应用进程处理返回的数据。

select的缺点是:

  1. 应用进程每次select系统调用都需要应用空间复制整个fds列表到内核空间。
  2. 内核需要主动遍历n次,才能返回哪些fd可读可写,CPU浪费在了内核空间。
  3. 规定select的最多同时监听1024个socket fd。
  4. 应用进程被唤醒后,不知道那些socket fd可读,需要遍历所有fd。

epoll 简介

先上伪代码。

1
2
3
4
5
6
7
8
while true {
可处理的流[] = epoll_wait(epoll_fd); // 阻塞

// 有消息抵达
for i in 可处理的流[] {
读 或者 其他处理
}
}

进一步的代码。

1
2
3
4
5
6
7
8
9
10
11
12
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); // 将所有需要监听的socket添加到epfd中

while(1){
int n = epoll_wait(...)
for(接收到数据的socket fds){
// fds[i]的数据处理
}
}

epoll的流程是:

  1. 应用进程调用系统调用epoll_create,创建eventpoll对象,用于维护等待列表和就绪列表。
  2. 应用进程调用系统调用epoll_ctl,添加要监听的fd。
  3. 应用进程调用系统调用epoll_wait。
  4. epoll_wait阻塞直到有任意数量fd可读。
  5. 应用进程遍历就绪列表,得到数据。
  6. 应用进程处理返回的数据。

epoll的优点是:

  1. 可以同时监听大量的socket fd。能够处理大量的链接请求(系统可以打开的文件数目) 。
  2. 应用进程被唤醒后,只需要遍历可读的fd。

cat /proc/sys/fs/file-max 得到当前操作系统可以打开的最大文件描述符个数。

epoll详解

关于epoll更多细节,在《深入理解Linux IO模型(二)》讲述。

Reference

UNIX环境高级编程(第3版)https://book.douban.com/subject/25900403/
http://www.cse.iitm.ac.in/~chester/courses/15o_os/slides/5_Interrupts.pdf
https://blog.packagecloud.io/eng/2016/10/11/monitoring-tuning-linux-networking-stack-receiving-data-illustrated/
https://blog.packagecloud.io/eng/2016/06/22/monitoring-tuning-linux-networking-stack-receiving-data/
https://mp.weixin.qq.com/s/kWDKpgmcOQFjoBAK3LyPTg
https://juejin.cn/post/6892687008552976398#heading-26

深入理解Linux IO模型(二)

发表于 2021-06-05 | 分类于 计算机 | | 阅读次数:

[TOC]

接着前文《深入理解Linux IO模型(一)》,本文深入分析IO多路复用模型中的epoll。

  1. 本文为了描述方便,统一用读操作讲述,写操作同理。
  2. 本文为了撰写方便,统一将I/O写成了IO。
  3. 欢迎指正文中的错误。

为何使用epoll

Nginx在几十万并发连接下,是如何做到高效利用服务器资源的?答案是epoll。

设想一个场景:有100万用户同时与一个网络应用进程保持着TCP连接,而每一时刻只有几十或几百个TCP连接是活跃的(网络应用进程接收到TCP报文),那么在这个时刻,进程只需要处理这100万个连接中的这些活跃的连接即可。那么,内核和应用进程如何协同,才能高效地处理这种情况呢?

select的缺陷

应用进程是否在每次调用系统调用,询问内核有事件发生的TCP连接时,把这100万个连接告诉内核,由内核找出其中有事件发生的TCP连接呢?
这正是select的做法。

select有明显的缺陷,因为这100万个TCP连接中,大部分是没有事件发生的。如果应用程序每次收集事件时,都把这100万个socket fd传给内核。有以下问题:

  1. 导致用户空间到内核空间的大量复制。
  2. 内核需要遍历所有的socket fd,判断哪些有事件到来。
  3. 应用进程仍需要遍历所有的socket fd,来判断哪些socket fd可读。

所以select限制了同时监听的socket fd数量,最多只能同时处理1024个并发连接。

epoll的提出

epoll在内核中申请了一片内存空间。

epoll的做法是,把应用程序的一个select调用分成三部分:

  1. 调用epoll_create创建一个epoll对象
  2. 调用epoll_ctl向epoll对象添加这100万个socket fd
  3. 调用epoll_wait等待收集发生事件的连接

这样,只要在应用进程启动时,建立1个epoll对象(下文用epfd表示),并在TCP连接到来和断开的时候,对epfd添加和删除事件就可以了(也可以修改)。
应用进程调用epoll_wait时,不需要向内核空间传递这100万个连接,内核也不需要遍历全部的连接。所以epoll_wait很高效。

epoll的原理

当应用进程调用epoll_create时,内核会在内核空间创建一个独立的eventpoll结构体对象,用于维护使用epoll_ctl向其添加的事件。

数据结构

epoll的数据结构如图所示。eventpoll有两个核心的数据结构:

  1. 红黑树(rbr):维护通过epoll_ctl添加的事件。
  2. 就绪链表(rdllist):保存就绪的事件,当事件发生时,由内核的中断处理程序插入该就绪链表。

说明: 这张图来自于《深入理解Nginx》,网上很多博客用了这张图,但是这张图关于就绪链表的描述有个小错误。”红黑树中每个节点都是基于epitem结构中的rdllink成员”应该改成”就绪链表中每个节点都是基于epitem结构中的rdllink成员”。

1
2
3
4
5
6
7
8
9
10
// 这里只列出了成员rbr、rdllist,它们和epoll的使用密切相关
struct eventpoll {
...
// 红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,即这个epoll监控的事件
struct rb_root rbr;

// 双向链表rdllist保存着要通过epoll_wait返回给应用程序的满足条件的事件
struct list_head rdllist;
...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct epitem {
...
// 红黑树节点
struct rb_node rbn;
// 双向链表节点
struct list_head rdllink;
// 事件句柄等信息
struct epoll_filefd ffd;
// 指向其所属的eventpoll对象
struct eventpoll *ep;
// 期待的事件类型
struct epoll_event event;
...
}

实现原理

在epoll中,为每个事件都建立一个epitem结构体对象。
这些事件都会添加到rbr红黑树中(重复添加的事件可以通过红黑树高效地识别出来)。从红黑树中查找事件非常快。

所有添加到epoll对象中的事件都会与设备驱动程序(如网卡驱动程序)建立回调关系,当相应的事件发生时,会调用回调函数(中断处理程序)。这个回调函数在内核叫ep_poll_callback,回调函数会把就绪的事件写入rdllist双向链表中。

当应用程序调用epoll_wait检查是否有事件发生的连接时,内核只是检查eventpoll对象的rdllist双向链表是否有epitem元素而已。如果rdllist链表不为空,内核把这里的事件复制到用户空间,同时返回对应的事件数量。

高效原因

最后总结分析下epoll之所以可以处理百万级别的并发连接,而且效率很高的原因。

  1. 应用程序在调用系统调用epoll_create创建epoll时,内核为它开辟了一片内存空间。把listen fd存在里面,以及当每次来客户端请求,三次握手后建立的client fd都会存在内核态的这个内存空间。这样不同于select,避免了用户空间和内核空间之间重复传递fd的过程。
  2. 应用程序要知道哪些事件可读了,不同于在select中应用程序需要主动遍历所有fd,内核只是将就绪列表中的事件复制到用户空间的event数组中(应用程序提前申请好的内存),这样应用程序只需要遍历这些就绪的事件。
  3. 就绪事件是怎么放入就绪列表的?答案是epoll利用了事件驱动。当数据包进入网卡,网卡将数据包通过DMA方式写入内存。网卡raise硬件中断IRQ,通知CPU有数据包到来了。CPU查询中断向量表,得到中断服务程序的指针,这个中断服务程序会调用网卡驱动程序。这里的中断服务程序是事先注册的,所以也可以理解CPU根据中断号回调中断处理程序,从内存读取数据,得到事件的fd,写入就绪列表rdllist中。

这里用到了上一篇《深入理解Linux IO模型(一)》写到的硬中断,不熟悉的可以看一下。

从程序的本质上看,程序是否有更好的并发,是看少浪费了什么。
CPU、内存、硬盘、网络带宽的利用率决定程序是不是能应对更复杂的场景。
当遇到高并发问题,怀疑程序在服务器上运转不良好时,一定要回过头看硬件有没有被充分利用,有没有浪费硬件资源。
CPU在执行什么事情的指令,决定了它的利用率。
select需要CPU在内核模式下(CPU的一种执行模式)主动去遍历所有描述符,这样CPU浪费在了遍历上。而epoll靠的是硬件中断,利用中断把就绪fd写入就绪列表。更充分发挥了硬件,不浪费CPU。这是epoll高效的最大原因。

epoll的API

epoll提供给应用程序的系统调用API有三个。

  • 创建epoll:epoll_create系统调用
  • 控制epoll:epoll_ctl系统调用
  • 等待epoll:epoll_wait系统调用

创建epoll

创建epoll,指的是在内核空间创建一颗红黑树(平衡二叉树)的根节点root。它返回一个fd(下文用epfd指代),用来标识这个epoll对象。这个root根节点与epfd相对应。如图所示。

API如下:

1
2
3
4
5
6
/** 
* @param size 告诉epoll要处理的大致事件数量,而不是能处理的事件最大数量。
*
* @returns 返回一个epoll句柄(即一个文件描述符)
*/
int epoll_create(int size);

调用方法:

1
int epfd = epoll_create(1000);

控制epoll

控制epoll,指的是以下三种操作:

  • 注册新的事件到epoll
  • 修改已经注册的事件
  • 删除一个注册到epoll的事件

添加某事件的时候,事件被插到红黑树的某个节点,并且与相应的设备驱动程序建立回调关系。当事件发生后,内核中断处理程序调用这个回调函数,将事件添加到就绪链表。

API如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @param epfd 用epoll_create所创建的epoll实例
* @param op 表示对epoll监控描述符控制的动作
*
* EPOLL_CTL_ADD(添加新的事件到epoll中)
* EPOLL_CTL_MOD(修改已经注册到epoll中的事件)
* EPOLL_CTL_DEL(删除epoll中的事件)
*
* @param fd 待监测的连接fd
* @param event 告诉内核需要监听的事件(包括类型),指向epoll_event的指针
*
* @returns 成功返回0,失败返回-1, errno查看错误信息
*/
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event);

struct epoll_event {
__uint32_t events; // epoll 事件
epoll_data_t data; // 用户传递的数据
}

typedef union epoll_data {
void *ptr;
int fd; // 监听的事件fd
uint32_t u32;
uint64_t u64;
} epoll_data_t;

关于epoll_event,具体介绍下。上文介绍过epoll为每个事件创建epitem对象,在结构体epitem中有一个成员epoll_event。

epoll_event.events的取值包括:
EPOLLIN:表示对应的连接上有数据可以读出(TCP连接的远端主动关闭连接,也相当于可读事件,因为要处理发过来的FIN包)
EPOLLOUT:表示对应的连接上可以写入数据发送
EPOLLRDHUP:表示TCP连接的远端关闭或半关闭连接
EPOLLPR:表示对应的链接上有紧急数据需要读
EPOLLERR:表示对应的连接发生错误
EPOLLHUP:表示对应的连接被挂起
EPOLLET:表示将处罚方式设置为边缘触发(ET),系统默认为水平触发(LT)
EPOLLONESHOT:表示对这个事件只处理一次,下次需要处理时需要重新加入epoll

调用方法:

1
2
3
4
5
6
struct epoll_event new_event;

new_event.events = EPOLLIN | EPOLLOUT;
new_event.data.fd = 5;

epoll_ctl(epfd, EPOLL_CTL_ADD, 5, &new_event);

在用户空间创建一个IO事件,绑定到某个fd上,然后把该事件的fd添加到内核中的epoll红黑树中。当fd可读或可写时,触发的是epoll_event。如图所示。

等待epoll

收集在epoll监控的事件中已经发生的事件,内核会检查就绪链表中是否有存在添加过的事件,如果没有任何事件发生,最多等待timeout毫秒后返回(在timeout设置>0的情况下)。返回值表示当前发生的事件个数。

API如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
*
* @param epfd 用epoll_create所创建的epoll实例
* @param event 从内核得到的就绪的事件集合
* @param maxevents 本次可以返回的最大事件数目,通常与预分配的event数组大小相等
* 注意: 值不能大于创建epoll_create()时的size
* @param timeout 等待IO事件发生的超时时间
* -1: 永久阻塞
* 0: 如果就绪链表rdllist为空,立即返回,不会等待,即非阻塞
* >0: 指定最多等待的时间,单位毫秒
*
* @returns 成功: 有多少文件描述符就绪,时间到时返回0
* 失败: -1, errno 查看错误
*/
int epoll_wait(int epfd, struct epoll_event *event,
int maxevents, int timeout);

注意: epoll_event不能是空指针,内核只是负责把内核空间中就绪链表的数据复制到用户空间的event数组中,不会去帮忙分配内存,所以用户空间需要自己提前分配内存。

调用方法:

1
2
3
struct epoll_event my_event[1000];

int event_cnt = epoll_wait(epfd, my_event, 1000, -1);

如图所示。应用程序调用epoll_wait后,进入阻塞状态。当内核检测到new_event或event1绑定的fd可读了,内核把就绪的event事件拷贝到用户空间的my_event数组。应用程序只需要遍历my_event,取出对应的事件和fd,知道是可读了,然后堵塞调用recv(fd)读数据。

使用epoll API

一个简单的使用epoll API的编程架构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 创建epoll fd,最多可接收1000个事件
int epfd = epoll_crete(1000);

// 将listen_fd添加进epoll中
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &listen_event);

while (1) {
// 阻塞等待epoll中的事件fd触发
int active_cnt = epoll_wait(epfd, events, 1000, -1);

for (i = 0 ; i < active_cnt; i++) {
if (evnets[i].data.fd == listen_fd) {
// 表示新的客户端连接请求到来
// 调用accept进行三次握手,创建client_fd
// 并将client_fd加进epoll中
}
else if (events[i].events & EPOLLIN) {
// 表示不是新的客户端连接,可读客户端发来的数据
// client_fd就绪可读,对此fd进行读操作
}
else if (events[i].events & EPOLLOUT) {
// 表示不是新的客户端连接,可把数据回写客户端
// client_fd就绪可写,对此fd进行写操作
}
}
}

如图所示,是一个服务器使用epoll的常规流程。

触发方式

epoll有两种工作模式:水平触发和边缘触发。默认情况下,epoll采用水平触发模式。

水平触发(Level Triggered, LT)

如果应用程序阻塞在epoll_wait,当内核有事件发生的时候,内核把已经触发的事件队列复制到用户空间。如果应用程序本次没有完成读操作,下一次epoll_wait会再次返回该事件。
即只要一个事件对应的套接字缓冲区还有数据,就总能从epoll_wait中获取这个事件。

优点:事件不会丢掉,除非应用程序处理完毕。保证事件的完整性。
缺点:如果应用程序不处理这个事件,就导致内核每次都把该事件从内核空间拷贝到用户空间,系统调用消耗性能。

边缘触发(Edge Triggered, ET)

如果应用程序阻塞在epoll_wait,当内核有事件发生的时候,内核把已经触发的事件队列复制到用户空间。如果应用程序本次没有完成读操作,下一次epoll_wait不再会返回该事件。
即如果这次没有把这个事件对应的套接字缓冲区处理完,在这个套接字没有新的事件再次到来时,无法再次从epoll_wait调用中获取到这个事件。

优点:内核不会重复把该事件从内核空间拷贝到用户空间。保证性能。
缺点:如果应用程序没有处理完毕,事件会被丢掉。导致事件不完整。

两者对比

在水平触发模式下,开发基于epoll的应用要简单一些,不太容易出错。而在边缘触发模式下,当事件发生时,如果没有彻底地将缓冲区数据处理完,则会导致缓冲区中的用户请求得不到响应。

Reference

深入理解Nginx(第2版)https://book.douban.com/subject/26745255/
https://mp.weixin.qq.com/s/kWDKpgmcOQFjoBAK3LyPTg

Hello World

发表于 2019-10-21 | | 阅读次数:

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

Pearl

Pearl

爱自己是终身浪漫的开始

9 日志
1 分类
4 标签
© 2024 Pearl
由 Hexo 强力驱动
|
主题 — NexT.Gemini v5.1.4