蓝色步行者

每个人都有自己的梦想


  • 首页

  • 归档

  • 标签

  • 分类

  • 搜索

gotraining之concurrency-goroutines

发表于 2019-08-24 | 分类于 Go

设计指南

concurrent-software-design

并发软件设计

并发意味着“无序”执行。取一组本来会按顺序执行的指令,然后找到一种方法,在不按顺序执行它们的情况下,仍然生成相同的结果。无序执行需要能够在复出复杂性成本的同时增加足够的性能收益。根据问题,无序执行可能是合适的,甚至是没有意义的。
并发不同于并行。并行意味着同时执行两条或多条指令。只有当至少有2个操作系统(OS)和硬件线程可用,并且至少有2个goroutine,每个goroutine在每个OS/硬件线程上独立执行指令时,才可能实现并行。
开发者和运行时都有责任管理应用程序的并发性。

设计理念

  • 应用程序必须完整地启动和关闭
    • 知道创建的每个goroutine如何以及何时终止
    • 创建的所有goroutine都应该在main返回之前终止
    • 应用程序应该能够按需关闭,甚至在负载下,以一种受控的方式关闭
      • 您希望停止接受新请求,并完成已有的请求(减载)
  • 识别和监视应用程序中可能存在的背压临界点
    • 当goroutine需要等待时,通道、互斥锁和原子函数会产生背压
    • 有一点背压是好的,这意味着有一个良好的平衡
    • 很多背压是不好的,这意味着事情是不平衡的
    • 背压不平衡会导致:
      • 软件内部和整个平台的故障
      • 您的应用程序要崩溃、内爆或冻结
    • 测量背压是测量应用程序健康状况的一种方法
  • 速率限制,以防止应用程序存在压倒性的背压
    • 每个系统都有一个断点,您必须知道它对于您的应用程序是什么
    • 一旦新请求过载,应用程序应该尽早拒绝它们
      • 不要做超过你一次合理工作量的工作
      • 当你处于临界质量时,把它往后推。创建自己的外部背压
    • 在合理和实用的情况下,使用外部系统进行速率限制
  • 使用超时来释放应用程序中的背压
    • 任何请求或任务都不允许长时间执行
    • 确定用户愿意等待多长时间
    • 高级调用应该告诉低级调用它们必须运行多长时间
    • 在顶层,用户应该决定他们愿意等待多久
    • 使用Context包
      • 用户等待的函数应该具有上下文Context
        • 这些函数应该select <-ctx.Done(),否则它们将无限期阻塞
      • 只有当您有充分的理由认为函数的执行有实际的时间限制时,才可以在Context中设置超时
      • 允许上游调用程序决定何时应该取消Context
      • 当用户放弃或显式中止调用时,取消Context
  • 应用架构师需要:
    • 当问题发生时,找出它们
    • 止血
    • 将系统恢复到正常状态

Scheduling In Go : Part I - OS Scheduler

Scheduling In Go : Part I - OS Scheduler
这部分主讲OS调度器。要正确地设计多线程软件,对OS和Go调度程序的工作原理有一个全面而有代表性的理解是很重要的。这篇文章重点介绍OS调度程序的高级机制和语义。

OS调度程序

程序是一系列需要依次执行的机器指令。为了实现这一点,操作系统使用线程的概念。线程的任务是解释并顺序执行分配给它的一组指令。继续执行,直到没有更多的指令供线程执行为止。
每个程序运行时都创建一个进程,每个进程都有一个初始线程。线程能够创建更多的线程。所有这些不同的线程都是独立运行的,并且调度决策是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程轮流运行一个单独的内核),也可以并行运行(每个线程同时运行在不同的内核上)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。
如果有线程可以执行,OS调度程序负责确保内核不是空闲的。它还必须创建一个假象,即所有可以执行的线程都在同时执行。在创建这个假象的过程中,调度程序需要运行优先级较高的线程,而不是优先级较低的线程。但是,具有较低优先级的线程不能缺少执行时间。调度程序还需要通过快速、明智的决策尽可能减少调度延迟。

执行指令

程序计数器(PC)有时称为指令指针(IP),它允许线程跟踪要执行的下一条指令。在大多数处理器中,PC指向下一条指令,而不是当前指令。

1
2
3
4
5
goroutine 1 [running]:
main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
stack_trace/example1/example1.go:13 +0x39 <- LOOK HERE
main.main()
stack_trace/example1/example1.go:8 +0x72 <- LOOK HERE

这些数字表示从各自函数顶部偏移的PC值。+0x39 PC偏移量表示如果程序没有panic,线程将在example函数中执行的下一条指令。如果返回到main函数,则main函数中的下一条指令是0+x72 PC偏移量。更重要的是,指针之前的指令表示正在执行什么指令。

线程状态

线程状态指示调度程序在线程中所扮演的角色。线程可以处于三种状态之一:等待、可运行或执行。

  • Waiting
    线程被停止并等待唤醒。可能是由于等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥)等原因。这些类型的延迟是性能差的根本原因。
  • Runnable
    线程需要内核时间以便能够执行分配给它的机器指令。如果有很多线程需要时间,那么线程必须等待更长时间才能获得时间。而且,随着更多的线程争夺时间,任何给定线程获得的单个时间量都会缩短。这种类型的调度延迟也可能导致性能下降。
  • Executing
    线程已被放置在一个核上,并且正在执行它的机器指令。

工作类型

线程可以做两种类型的工作:cpu绑定和io绑定。
CPU-Bound:CPU-Bound工作是不断进行计算的,不会导致线程处于Waiting状态。例子:计算Pi到第n位数字。
IO-Bound:这是导致线程进入Waiting状态的工作。这项工作包括请求通过网络访问资源或对操作系统进行系统调用。需要访问数据库的线程将是io绑定的。

上下文切换

在核上交换线程的物理行为称为上下文切换。当调度程序将Executing线程从核中取出并用Runnable线程替换它时,将发生上下文切换。从运行队列中选择的线程将进入Executing状态。被拉出的线程可以回到Runnable状态(如果它仍然有能力运行),或者进入Waiting状态(如果由于io绑定类型的请求而被替换)。
上下文切换被认为是昂贵的,因为它需要时间来切换线程上、下核。实际上,程序正在失去在上下文切换期间执行大量指令的能力。
如果有一个专注于io绑定工作的程序,那么上下文切换将是一个优势。一旦一个线程进入Waiting状态,另一个处于Runnable状态的线程将代替它。这使得核总是在工作。
如果程序专注于cpu绑定的工作,那么上下文切换将成为性能噩梦。由于线程总是有工作要做,上下文切换会阻止工作的进展。

少即是多

早期处理器只有一个核,因为只有一个处理器和一个核心,所以在任何给定时间只能执行一个线程。其思想是定义一个调度程序周期,并尝试在此期间执行所有可运行线程:将调度周期除以需要执行的线程数。
如果将调度程序周期定义为10ms,并且有两个线程,那么每个线程将得到5ms。如果有5个线程,每个线程将得到2ms。但是,当有100个线程时,给每个线程一个时间片10μs不起作用,因为会花大量的时间在上下文切换。
需要的是限制时间片的长度。在上一个场景中,如果最小时间片是2ms,并且有100个线程,那么调度程序周期需要增加到2000ms或2s。如果有1000个线程,现在您看到的调度程序周期是20s。在这个简单的例子中,如果每个线程都使用它的全时间片,那么所有线程运行一次需要20秒。
在制定调度决策时,调度程序需要考虑和处理更多的事情。开发者可以控制应用程序中使用的线程数。当有更多的线程需要考虑,并且执行io绑定的工作时,就会出现更多的混乱和不确定性行为。
少即是多的规则:处于可运行状态的线程越少,调度开销就越小,每个线程的时间也就越多。处于可运行状态的线程越多,意味着每个线程超时的时间就越少。这意味着随着时间的推移,完成的工作也会越来越少。

平衡

开发者需要在拥有的内核数量和获得应用程序最佳吞吐量所需的线程数量之间找到一个平衡。当涉及到管理这种平衡时,线程池是一个很好的答案,但在Go中不再需要这样做。
作为一名工程师,需要计算出需要多少线程池,以及给定线程池的最大线程数,以便最大限度地提高给定内核数量的吞吐量。
如果每个内核使用线程数低了,那么完成所有的工作将花费更长的时间。如果每个内核使用线程数高了,也会花费更长的时间,因为在上下文切换中有更多的延迟。对于所有不同的工作负载,可能不可能找到一个始终有效的神奇数字。当涉及到使用线程池来调优服务的性能时,要找到正确的一致配置可能会变得非常复杂。

缓存行

从主存访问数据的延迟成本非常高(大约100到300个时钟周期),以至于处理器和核都有本地缓存,以便将数据保存在需要数据的硬件线程附近。根据访问的缓存,从缓存访问数据的成本要低得多(大约3到40个时钟周期)。如今,性能的一个方面是如何有效地将数据输入处理器以减少这些数据访问延迟。编写改变状态的多线程应用程序需要考虑缓存系统的机制。

数据通过高速缓存行在处理器和主存之间交换。高速缓存行是主存和高速缓存系统之间交换的64字节内存块。每个核心都有它自己需要的高速缓存行的副本,这意味着硬件使用了值语义。这就是多线程应用程序中内存的变化会导致性能噩梦的原因。
当多个并行运行的线程正在访问相同的数据值,甚至是相邻的数据值时,它们将访问同一高速缓存行上的数据。在任何核心上运行的任何线程都将从相同的高速缓存行进行拷贝。

如果给定核上的一个线程对其高速缓存行的副本进行更改,必须将同一高速缓存行的所有其他副本标记为dirty。当线程尝试对脏缓存行进行读写访问时,需要主内存访问来获得缓存行的新副本。
如果32核处理器同时运行32个线程,并且在同一高速缓存行上访问和修改数据,因为处理器到处理器通信增加了延迟。应用程序将在内存中反复运行,性能将非常糟糕,而且很可能开发者无法理解其中的原因。
这被称为缓存一致性问题,同时也引入了错误共享等问题。在编写将改变共享状态的多线程应用程序时,需要考虑缓存系统。

调度决策场景

编写OS调度程序时,考虑这个场景:启动应用程序,创建主线程并在core 1上执行。当线程开始执行指令时,由于需要数据,将检索高速缓存行。线程现在决定为一些并发处理创建一个新线程。一旦线程创建并准备好运行,调度程序应该:

  • 上下文切换掉core 1的主线程?这样做可以提高性能,因为这个新线程很可能需要相同数据,且已被缓存。但是主线程没有得到它的全时间片。
  • 线程是否在主线程的时间片完成之前等待core 1可用?线程没有被运行,但当它启动时,获取数据需要的延迟将被消除。
  • 线程是否等待下一个可用核?如果是,所选核的高速缓存行将被刷新、检索和拷贝,从而导致延迟。然而,线程会启动得更快,主线程可以完成它的时间片。

在制定调度决策时,OS调度程序需要考虑这些问题。幸运的是,开发者不需要做。如果有一个空闲内核,它将被使用。因为我们希望线程在可以运行的时候运行。

总结

这部分提供了关于在编写多线程应用程序时必须考虑的线程和OS调度程序的一些见解。这些也是Go调度程序要考虑的事情。

Scheduling In Go : Part II - Go Scheduler

Scheduling In Go : Part II - Go Scheduler
这部分将从语义层面解释Go调度程序的工作原理,并重点介绍高级行为。通过好的模型来描述Go调度器的工作和行为,方便开发者做出更好的工程决策。

程序启动

Go程序启动时,会为主机上标识的每个虚拟内核提供一个逻辑处理器P。如果有一个处理器,处理器的每个物理内核都有多个硬件线程(超线程),那么每个硬件线程将作为一个虚拟内核呈现给Go程序。虚拟内核个数通过runtime.NumCPU()获取。
每个P分配一个OS线程M。M代表机器。这个线程M仍然由OS管理,OS仍然负责将线程放在核上执行。如果机器上有8个虚拟内核,这意味着在机器上运行一个Go程序时,有8个线程可用来执行工作,每个线程独立地附加到一个P。
每个Go程序都有一个初始goroutine G,这是Go程序的执行路径。goroutine本质上是一个协程Coroutine,用G替换字母C,就得到了goroutine。可以将goroutine视为应用程序级线程,区别在于操作系统线程是在核切换上下文,goroutine是在M切换上下文。
Go调度程序中有两个不同的运行队列:全局运行队列GRQ和本地运行队列LRQ。每个P给定一个LRQ,用于在P的上下文中管理被分配执行的goroutine。这些goroutine轮流在M中切换上下文。GRQ用于尚未尚未分配给P的goroutines。专门有一个process负责将goroutines从GRQ移动到LRQ。

协作式调度器

OS调度程序是一种抢占式调度程序。本质上,这意味着不能预测调度程序在任何给定的时间将要做什么。运行在操作系统之上的应用程序无法控制内核内部的调度,除非它们利用原子指令和互斥调用等同步原语。
Go调度程序是Go运行时的一部分,Go运行时构建到应用程序中。这意味着Go调度程序在内核之上的用户空间中运行。Go调度器的当前实现不是抢占式调度器,而是协作式调度器。作为协作调度程序意味着调度程序需要定义良好的用户空间事件,这些事件发生在代码中的安全点,以便做出调度决策。
Go协作调度程序的绝妙之处在于它看起来是抢占式的。开发者无法预测Go调度程序将要做什么,因为这个协作调度程序的决策取决于Go运行时。将Go调度程序视为抢占式调度程序是很重要的。

协程状态

goroutine具有Waiting、Runnable、Executing三个高级状态。这些状态指示了Go调度程序在任何给定goroutine中所扮演的角色。
Waiting:这意味着goroutine停止了,等待调度。可能是由于等待操作系统(系统调用)或同步调用(原子和互斥操作)等原因。这些类型的延迟是性能差的根本原因。
Runnable:这意味着goroutine需要一个M上的时间以执行指定的指令。如果有很多goroutine需要时间,那么goroutine就需要等待更长的时间才能得到时间。随着更多的goroutine争夺时间,任何给定goroutine获得的时间量都会缩短。这种类型的调度延迟也可能导致性能下降。
Executing:这意味着goroutine已被放置在M上,并正在执行它的指令。

上下文切换

Go调度程序要求定义良好的用户空间事件,这些事件发生在代码中的安全点,以便上下文切换。这些事件和安全点在函数调用中表现出来。函数调用对Go调度程序的健康状况至关重要。在Go 1.11或更低版本中,如果运行任何不执行函数调用的紧密循环,就会在调度程序和垃圾收集中造成延迟。在合理的时间范围内发生函数调用是非常重要的。
NOTE:有一个1.12的提议被接受,即在Go调度程序中应用非协作抢占技术,以允许抢占紧密循环。
Go程序中四类事件允许调度程序做出调度决策。这并不意味着这些事件总是发生,而是意味着调度程序得到了机会。

  • 关键词go的使用
  • 垃圾回收
  • 系统调用
  • 同步和编排

关键词go的使用

关键字go用于创建goroutine。一旦创建了一个新的goroutine,它就为调度程序提供了一个做出调度决策的机会。

垃圾回收

由于GC使用自己的goroutine集运行,所以这些goroutine需要在M上的运行时间。这将导致GC创建大量的调度混乱。然而,调度程序能智能感知goroutine正在做什么并做出明智的决策。例如,GC期间上下文切换,将接触堆的goroutine切换为不接触堆的goroutine。当GC运行时,会做出许多调度决策。

系统调用

当goroutine调用系统调用时,将阻塞M,有时候调度器能够把该goroutine从M切换下来,并且切换新的goroutine到相同的M。但是,有时候需要一个新的M来继续执行在P中排队的goroutine。

同步和编排

如果原子、互斥或通道操作调用会导致goroutine阻塞,则调度程序可以上下文切换一个新的goroutine来运行。一旦goroutine可以再次运行,它就可以重新排队,并最终切换回M。

异步系统调用

当运行的操作系统具有处理异步系统调用的能力时,可以使用网络轮询器来更有效地处理系统调用,这是通过在这些操作系统中使用kqueue(MacOS)、epoll(Linux)或iocp(Windows)来实现的
基于网络的系统调用可以由今天使用的许多操作系统进行异步处理。这就是网络轮询器的名称由来,因为它的主要用途是处理网络操作。通过使用网络轮询器进行网络系统调用,调度程序可以防止goroutine在进行这些系统调用时阻塞M。这有助于保持M可用,从而执行P的LRQ中的其他goroutine,而不需要创建新的M,有助于减少操作系统上的调度负载。
假设只调度LRQ,当goroutine-1希望进行网络系统调用时,将移动到网络轮询器,并处理异步网络系统调用。M可用,切换LRQ的goroutine-2到M。当网络轮询器完成异步网络系统调用,goroutine-1被移回LRQ。这里的好处是要执行网络系统调用,不需要额外的M。网络轮询器有一个OS线程用于处理有效的事件循环。

同步系统调用

当goroutine发出一个不能异步执行的系统调用时,无法使用网络轮询器,发出系统调用的goroutine将阻塞M。一个例子是基于文件的系统调用。如果使用CGO,可能在其他情况下调用C函数也会阻塞M。
NOTE:Windows OS具有异步执行基于文件的系统调用的功能。从技术上讲,当运行在Windows上时,可以使用网络轮询器。
假设只调度LRQ,调度程序识别出goroutine-1已导致M1阻塞后,将M1从P中分离出来,M1仍旧依附着阻塞的goroutine-1。然后,调度程序引入一个新的M2来为P服务。此时,可以从LRQ中选择goroutine-2并切换到M2。如果由于之前的切换而已经存在一个M,那么这种切换比创建一个新的M要快。goroutine-1完成了阻塞系统调用后,可以回到LRQ中,再次由P提供服务。M1将放在一边以便将来面对相同的情况下可以使用。

工作窃取

调度程序能够窃取工作,这在一些方面有助于保证调度效率。一旦M进入等待状态,操作系统将从核上切走M。这意味着即使有goroutine处于可运行状态,P也不能完成任何工作,直到M被上下文切换回核。工作窃取还有助于平衡所有P的goroutine,保证工作更好地分配,更有效地完成。
当P1的LRQ中所有goroutine都执行完,且P2的LRQ和全局GRQ都存在可运行的goroutine时,工作窃取规则如下:

1
2
3
4
5
6
7
8
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}

当P1从P2窃取goroutine时,会窃取一半的数量。工作窃取可以参考Go’s work-stealing scheduler。

实际例子

设想一个用C语言编写的多线程应用程序,其中程序管理两个OS线程,这两个线程相互传递消息,线程传递完消息后进入Waiting状态,而接受到消息的线程则由Waiting状态转入Runnable状态。在C语言的实现中,所有上下文切换和状态更改都需要执行时间,这限制了完成工作的速度。由于每个上下文切换潜在的延迟约为1000纳秒,而硬件每纳秒约执行12条指令,所以约12k条指令没能在上下文切换期间执行。由于这些线程也在不同的核之间跳跃,所以很可能由于缓存行丢失而导致额外延迟。
而当使用goroutine实现时,同样会发生上下文切换和状态更改。然而,使用线程和goroutine之间有一个主要的区别,在使用goroutine的情况下,所有的处理都使用相同的OS线程和内核。这意味着,从OS的角度来看,OS线程永远不会进入等待状态。因此,在使用线程时由于上下文切换而没能执行12k条指令的情况,在使用goroutine时不会出现。
本质上,Go已经将IO/阻塞工作转换为操作系统级别的CPU绑定工作,所有的上下文切换都发生在应用程序级。在Go中,同样的上下文切换将花费约200纳秒或约2.4k的指令。调度程序还有助于提高缓存行效率和NUMA。这就是为什么不需要拥有比虚拟内核更多的线程。在Go中,随着时间的推移可以完成更多的工作,因为Go调度程序尝试使用更少的线程,在每个线程上做更多的工作,这有助于减少操作系统和硬件上的负载。

总结

Go调度器在设计中考虑了操作系统和硬件工作的复杂性,将IO/阻塞工作转换为操作系统级别的CPU绑定工作,是在利用更多CPU容量方面取得的重大胜利,也是不需要比虚拟内核更多的操作系统线程的原因。可以合理地期望通过一个OS线程/每个虚拟内核来完成所有工作(CPU和IO/阻塞绑定)。这种做法对于网络应用程序和其他不需要系统调用(阻塞OS线程)的应用程序是可能的。
开发人员仍然需要了解应用程序在处理哪些类型的工作,不可能在创建无限数量的goroutine的同时还期望获得惊人的性能。少即是多,通过理解这些Go-scheduler语义,可以做出更好的工程决策。

Scheduling In Go : Part III - Concurrency

Scheduling In Go : Part III - Concurrency

并发

并发与并行的区别参考文章开头。注意,在没有并行的情况下利用并发有时会降低吞吐量。同样有趣的是,有时候在有并行的情况下利用并发并不能带来更大的性能收益。

工作负载

了解工作负载类型,有助于理清“无序”执行是否是有意义的。在考虑并发性时,需要了解CPU-Bound和IO-Bound这两种类型的工作负载。
对于CPU-Bound工作负载,需要在有并行的情况下利用并发。一个OS/硬件线程处理多个goroutine的话效率不高,因为作为工作负载的一部分,goroutine不会切换出/入等待状态。若goroutine数目比OS/硬件线程数多,会降低执行速度,因为OS/硬件线程切换goroutine需要一定的延迟成本。上下文切换为当前工作负载创建一个Stop The World事件:切换期间,工作负载不被执行。
对于IO-Bound工作负载,不需要并行的情况下可以利用并发。作为工作负载的一部分,goroutine会自然切换出/入等待状态,OS/硬件线程可以高效处理多个goroutine。若goroutine数目比OS/硬件线程数多,可以加快工作负载执行速度,因为在OS/硬件线程切换goroutine的延迟成本不会创建Stop the World事件。工作负载会自然停止,允许另一个goroutine有效地利用相同的OS/硬件线程,而不是让OS/硬件线程处于空闲状态。
对于每个OS/硬件线程,需要考虑多少goroutine能提供最佳吞吐量。goroutine太少,会有更多空闲时间。goroutine太多,会有更多的上下文切换延迟时间。本文不作研究。需要强调的是,检查代码以确定工作负载何时可以利用并发性、何时不能利用并发性以及是否需要并行性,是非常重要的。

案例:求和

https://play.golang.org/p/r9LdqUsEzEz
串行程序:

1
2
3
4
5
6
7
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}

问题:add函数是一个适合无序执行的工作负载吗?是的。整数集合可以分解为较小的列表,并且可以同时处理这些列表。 一旦将所有较小的列表相加,就可以将这组和加在一起以产生与顺序版本相同的答案。
问题:应该独立创建和处理多少个较小的列表以获得最佳吞吐量?要回答这个问题,需要知道工作负载类型。add函数正在执行CPU-BOUND工作负载,因为算法正在执行纯数学运算,并且它不会导致goroutine进入等待状态。 这意味着每个OS/硬件线程使用一个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
func addConcurrent(goroutines int, numbers []int) int {
var v int64
totalNumbers := len(numbers)
lastGoroutine := goroutines - 1
stride := totalNumbers / goroutines

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func(g int) {
start := g * stride
end := start + stride
if g == lastGoroutine {
end = totalNumbers
}

var lv int
for _, n := range numbers[start:end] {
lv += n
}

atomic.AddInt64(&v, int64(lv))
wg.Done()
}(g)
}

wg.Wait()

return int(v)
}

问题:并发版本比顺序版本更复杂,带来的性能如何?要回答这个问题,最好创建一个基准。以下基准测试使用了1000万个数字的集合,关闭了垃圾收集器。
测试程序:

1
2
3
4
5
6
7
8
9
10
11
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
add(numbers)
}
}

func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
addConcurrent(runtime.NumCPU(), numbers)
}
}

没有并行情况下使用并发,cpu指定为1,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。顺序版本优于并发版本,因为并发版本具有单个OS线程上的上下文切换和goroutine管理的开销。

1
2
3
4
5
6
7
8
9
10
11
12
10 Million Numbers using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential 1000 5720764 ns/op : ~10% Faster
BenchmarkConcurrent 1000 6387344 ns/op
BenchmarkSequentialAgain 1000 5614666 ns/op : ~13% Faster
BenchmarkConcurrentAgain 1000 6482612 ns/op

并行情况下使用并发,cpu指定为8,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。并发版本优于顺序版本,因为所有goroutine并行运行,八个goroutine同时执行工作。

1
2
3
4
5
6
7
8
9
10
11
12
10 Million Numbers using 8 goroutines with 8 cores
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential-8 1000 5910799 ns/op
BenchmarkConcurrent-8 2000 3362643 ns/op : ~43% Faster
BenchmarkSequentialAgain-8 1000 5933444 ns/op
BenchmarkConcurrentAgain-8 2000 3477253 ns/op : ~41% Faster

案例:排序

并非所有CPU-BOUND工作负载都适合并发。基本上,当分解工作或将所有结果组合起来的代价很高时,就不适合并发,如冒泡排序。https://play.golang.org/p/S0Us1wYBqG6
串行程序:

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 bubbleSort(numbers []int) {
n := len(numbers)
for i := 0; i < n; i++ {
if !sweep(numbers, i) {
return
}
}
}

func sweep(numbers []int, currentPass int) bool {
var idx int
idxNext := idx + 1
n := len(numbers)
var swap bool

for idxNext < (n - currentPass) {
a := numbers[idx]
b := numbers[idxNext]
if a > b {
numbers[idx] = b
numbers[idxNext] = a
swap = true
}
idx++
idxNext = idx + 1
}
return swap
}

问题:bubbleSort函数是适合于无序执行的工作负载吗?不是。整数的集合可以分解成更小的列表,这些列表可以同时排序。然而,在完成所有并发工作之后,没有有效的方法将较小的列表排序在一起。
并发程序:

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
func bubbleSortConcurrent(goroutines int, numbers []int) {
totalNumbers := len(numbers)
lastGoroutine := goroutines - 1
stride := totalNumbers / goroutines

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func(g int) {
start := g * stride
end := start + stride
if g == lastGoroutine {
end = totalNumbers
}

bubbleSort(numbers[start:end])
wg.Done()
}(g)
}

wg.Wait()

// Ugh, we have to sort the entire list again.
bubbleSort(numbers)
}

由于冒泡排序的本质是遍历列表,因此最后对bubbleSort的调用将抵消使用并发带来的任何潜在收益。对于冒泡排序,使用并发性不会提高性能。

案例:读取文件

文件读取是IO-BOUND工作负载。
串行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func read(doc string) ([]item, error) {
time.Sleep(time.Millisecond) // Simulate blocking disk read.
var d document
if err := xml.Unmarshal([]byte(file), &d); err != nil {
return nil, err
}
return d.Channel.Items, nil
}

func find(topic string, docs []string) int {
var found int
for _, doc := range docs {
items, err := read(doc)
if err != nil {
continue
}
for _, item := range items {
if strings.Contains(item.Description, topic) {
found++
}
}
}
return found
}

并发程序:

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
func findConcurrent(goroutines int, topic string, docs []string) int {
var found int64

ch := make(chan string, len(docs))
for _, doc := range docs {
ch <- doc
}
close(ch)

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func() {
var lFound int64
for doc := range ch {
items, err := read(doc)
if err != nil {
continue
}
for _, item := range items {
if strings.Contains(item.Description, topic) {
lFound++
}
}
}
atomic.AddInt64(&found, lFound)
wg.Done()
}()
}

wg.Wait()

return int(found)
}

实现该并发版本的目的是控制用于处理未知数量文档的goroutine的数量,选择了一种池模式,其中通道用于为goroutine池提供数据。
问题:并发版本比顺序版本更复杂,带来的性能如何?要回答这个问题,最好创建一个基准。以下基准测试使用了1000个文档集合,关闭了垃圾收集器。
测试程序:

1
2
3
4
5
6
7
8
9
10
11
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
find("test", docs)
}
}

func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
findConcurrent(runtime.NumCPU(), "test", docs)
}
}

没有并行情况下使用并发,cpu指定为1,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。并发版本优于顺序版本,因为所有goroutine都有效地共享一个OS/硬件线程。对于read调用上的每个goroutine,上下文切换允许在单个OS/硬件线程上执行更多的工作。

1
2
3
4
5
6
7
8
9
10
11
12
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential 3 1483458120 ns/op
BenchmarkConcurrent 20 188941855 ns/op : ~87% Faster
BenchmarkSequentialAgain 2 1502682536 ns/op
BenchmarkConcurrentAgain 20 184037843 ns/op : ~88% Faster

并行情况下使用并发,cpu指定为8,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。引入额外的OS/硬件线程并不能提供更好的性能。

1
2
3
4
5
6
7
8
9
10
11
12
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential-8 3 1490947198 ns/op
BenchmarkConcurrent-8 20 187382200 ns/op : ~88% Faster
BenchmarkSequentialAgain-8 3 1416126029 ns/op
BenchmarkConcurrentAgain-8 20 185965460 ns/op : ~87% Faster

结论

本文的目标是提供语义方面的指导,以确定工作负载是否适合使用并发性。通过不同类型的算法和工作负载的示例,以便察觉语义上的差异以及不同的工程决策。
可以看到,在使用IO-BOUND的工作负载时,并不需要并行性来获得性能的大幅提升。这与CPU-BOUND的工作是相反的。当涉及到像冒泡排序这样的算法时,使用并发会增加复杂性,而不会带来真正的性能优势。我们需要先确定工作负载是否适合并发,然后根据工作负载类型使用正确的语义。

探讨下kubernetes的证书体系

发表于 2019-06-16 | 分类于 Kubernetes

序言

一直以来,对kubernetes的身份验证、授权、准入控制似懂非懂。抄起键盘胡乱打,似懂非懂最可怕。所以,利用起周末时间,好好地学习一下。

pem证书和相关命令

pem是什么

PEM: Privacy Enhanced Mail的缩写,以文本的方式进行存储。

  • 以pem格式存储的证书结构
    1
    2
    3
    -----BEGIN CERTIFICATE-----
    Base64编码过的证书数据
    -----END CERTIFICATE-----

kubernetes使用的是.crt和.key后缀类型的证书和密钥。在生成证书的过程中,可以用cfssl生成.pem证书,然后直接命名为.crt。

证书查看命令

1
2
openssl x509 -in xxx.crt -text -noout
cfssl certinfo -cert xxx.crt

证书标准

kubernetes使用X.509数字证书标准。使用kubeadm部署集群的话,可以自动生成证书,但证书有效期只有一到两年。推荐使用kubeadm生成证书用于参考,然后按照kubernetes的证书标准自制证书。

kubeadm和证书类型

生成证书命令:

1
kubeadm init phase certs all --cert-dir=绝对路径

查看生成的证书:

kubeadm主要是生成了etcd和kubernetes本身的证书。使用kubeadm安装集群的过程中,如果我们按照kubernetes标准自制证书,且跳过kubeadm init phase certs步骤,是不是所有场景都会使用到自制证书了呢?
答案是否定的,至少/etc/kubernetes/admin.conf、kubelet.conf、controller-manager.conf、scheduler.conf里面仍会默认生成认证证书和密钥。这些配置主要与user accounts有关,因此我们还要针对这些场景,自制证书,同时对上述文件进行扩展,这是后话。
完整的证书:

可以看出,主要是根证书、证书、密钥。
下面对根证书和证书的内容进行抽象说明,主要参考SSL 数字证书的标准、编码以及文件扩展名。

根证书主要内容

使用openssl x509 -in xxx.crt -text -noout查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 0 (0x0) 说明:CA机构给该证书的唯一序列号,根证书为0
Signature Algorithm: sha256WithRSAEncryption 说明:签名算法为SHA-256
Issuer: CN=xxx 说明:证书颁发者的相关信息
Validity 说明:证书生效日期和失效日期
Not Before: xxx
Not After : xxx
Subject: CN=xxx 说明:证书持有者的相关信息
Subject Public Key Info: 说明:服务端公开的密钥
Public Key Algorithm: rsaEncryption 说明:RSA公钥
Public-Key: (2048 bit)
Modulus:
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Key Usage: critical
xxx, xxx, xxx
X509v3 Basic Constraints: critical
CA:TRUE
Signature Algorithm: sha256WithRSAEncryption 说明:数字签名
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:

证书主要内容

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
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 12345678901234567890 (0x1234567890abcdef) 说明:CA机构给该证书的唯一序列号
Signature Algorithm: sha256WithRSAEncryption 说明:签名算法为SHA-256
Issuer: CN=xxx 说明:证书颁发者的相关信息
Validity 说明:证书生效日期和失效日期
Not Before: xxx
Not After : xxx
Subject: CN=xxx 说明:证书持有者的相关信息
Subject Public Key Info: 说明:服务端公开的密钥
Public Key Algorithm: rsaEncryption 说明:RSA公钥
Public-Key: (2048 bit)
Modulus:
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Key Usage: critical
xxx
X509v3 Extended Key Usage:
xxx
X509v3 Subject Alternative Name:
DNS:xxx, IP Address:xxx 说明:支持的DNS和IP
Signature Algorithm: sha256WithRSAEncryption 说明:数字签名
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:

各个证书的区别

注:证书的O、CN字段用于提供RBAC所需的用户组和用户。
kubernetest证书制作标准参考PKI certificates and requirements。
另外,参考Key usage extensions and extended key usage,简单说明一下证书中用到的Key Usage和Extended Key Usage。

Key Usage 说明
Digital Signature 当公钥与数字签名机制一起使用时使用,以支持除不可否认、证书签名或CRL签名之外的其他安全服务。数字签名通常用于具有完整性的实体身份验证和数据源身份验证。
Key Encipherment 当证书与用于加密密钥的协议一起使用时使用。一个例子是S/MIME信封,其中使用证书中的公钥加密快速(对称)密钥。SSL协议还执行密钥加密。
Certificate Sign 当subject公钥用于验证证书上的签名时使用。此扩展只能在CA证书中使用。
Extended Key Usage 说明
TLS Web Client Authentication 数字签名和/或密钥协议
TLS Web Server Authentication 数字签名、密钥加密或密钥协议
  • etcd
    注:kubeadm默认生成的证书包含的信息
证书 颁发者信息 持有者信息
ca.crt CN=etcd-ca CN=etcd-ca
healthcheck-client.crt CN=etcd-ca O=system:masters, CN=kube-etcd-healthcheck-client
peer.crt CN=etcd-ca CN=<hostname>
server.crt CN=etcd-ca CN=<hostname>
证书 Key Usage Basic Constraints Extended Key Usage Subject Alternative Name
ca.crt Digital Signature, Key Encipherment, Certificate Sign CA:TRUE - -
healthcheck-client.crt Digital Signature, Key Encipherment - TLS Web Client Authentication -
peer.crt Digital Signature, Key Encipherment - TLS Web Server Authentication, TLS Web Client Authentication DNS:<hostname>, DNS:localhost, IP Address:<Host_IP>, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1
server.crt Digital Signature, Key Encipherment - TLS Web Server Authentication, TLS Web Client Authentication DNS:<hostname>, DNS:localhost, IP Address:<Host_IP>, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1
  • kubernetes
    注:kubeadm默认生成的证书包含的信息
证书 颁发者信息 持有者信息
ca.crt CN=kubernetes CN=kubernetes
apiserver.crt CN=kubernetes CN=kube-apiserver
apiserver-kubelet-client.crt CN=kubernetes O=system:masters, CN=kube-apiserver-kubelet-client
apiserver-etcd-client.crt CN=etcd-ca O=system:masters, CN=kube-apiserver-etcd-client
front-proxy-ca.crt CN=front-proxy-ca CN=front-proxy-ca
front-proxy-client.crt CN=front-proxy-ca CN=front-proxy-client
sa.pub - -
证书 Key Usage Basic Constraints Extended Key Usage Subject Alternative Name
ca.crt Digital Signature, Key Encipherment, Certificate Sign CA:TRUE - -
apiserver.crt Digital Signature, Key Encipherment - TLS Web Server Authentication DNS:<hostname>, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, IP Address:集群serviveIP, IP Address:<Host_IP>
apiserver-kubelet-client.crt Digital Signature, Key Encipherment - TLS Web Client Authentication -
apiserver-etcd-client.crt Digital Signature, Key Encipherment - TLS Web Client Authentication -
front-proxy-ca.crt Digital Signature, Key Encipherment, Certificate Sign CA:TRUE - -
front-proxy-client.crt Digital Signature, Key Encipherment - TLS Web Client Authentication -
sa.pub - - - -
  • user accounts
    注:自制证书包含的信息
证书 颁发者信息 持有者信息
admin.crt CN=kubernetes O=system:masters, CN=kubernetes-admin
kubelet.crt CN=kubernetes O=system:nodes, CN=system:node:<nodeName>
scheduler.crt CN=kubernetes CN=system:kube-scheduler
controller-manager.crt CN=kubernetes CN=system:kube-controller-manager
证书 Key Usage Basic Constraints Extended Key Usage Subject Alternative Name Subject Key Identifier Authority Key Identifier
admin.crt Digital Signature, Key Encipherment CA:FALSE TLS Web Client Authentication - xx:xx xx:xx
kubelet.crt Digital Signature, Key Encipherment CA:FALSE TLS Web Client Authentication, TLS Web Server Authentication DNS:localhost, IP Address:127.0.0.1, IP Address:<Host_IP> xx:xx xx:xx
scheduler.crt Digital Signature, Key Encipherment CA:FALSE TLS Web Client Authentication - xx:xx xx:xx
controller-manager.crt Digital Signature, Key Encipherment CA:FALSE TLS Web Client Authentication - xx:xx xx:xx

注意:kubelet的Key Usage需要同时包含TLS Web Client Authentication和TLS Web Server Authentication。具体原因后面介绍。

自制证书

参考PKI certificates and requirements、CFSSL as an external CA for non-ha kubeadm intialized clusters。

ectd

  • ca-config.json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    {
    "signing": {
    "profiles": {
    "server": {
    "expiry": "876000h",
    "usages": [
    "digital signature",
    "key encipherment",
    "server auth",
    "client auth"
    ]
    },
    "client": {
    "expiry": "876000h",
    "usages": [
    "digital signature",
    "key encipherment",
    "client auth"
    ]
    }
    }
    }
    }

ca

  • ca-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "etcd-ca",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -initca ca-csr.json | cfssljson -bare ca

healthcheck-client

  • healthcheck-client-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "kube-etcd-healthcheck-client",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=client healthcheck-client-csr.json | cfssljson -bare healthcheck-client

peer

  • peer-csr.json.sed

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "kube-etcd-peer",
    "hosts": ["localhost","127.0.0.1",<ETCD_NODE_IPS>],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=server peer-csr.json | cfssljson -bare peer

server

  • server-csr.json.sed

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "kube-etcd",
    "hosts": ["localhost","127.0.0.1",<ETCD_NODE_IPS>],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=server server-csr.json | cfssljson -bare server

kubernetes

  • ca-config.json
    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
    {
    "signing": {
    "default": {
    "expiry": "876000h"
    },
    "profiles": {
    "server": {
    "usages": [
    "digital signature",
    "key encipherment",
    "server auth",
    "client auth"
    ],
    "expiry": "876000h"
    },
    "client": {
    "usages": [
    "digital signature",
    "key encipherment",
    "client auth"
    ],
    "expiry": "876000h"
    }
    }
    }
    }

ca

  • ca-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    "CN": "kubernetes-ca",
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "ca": {
    "expiry": "876000h"
    }
    }
  • 命令

    1
    cfssl gencert -initca ca-csr.json | cfssljson -bare ca

apiserver

  • apiserver-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    {
    "CN": "kube-apiserver",
    "hosts": [
    "localhost",
    "127.0.0.1",
    <MASTER_HOST_IPS>,
    <MASTER_HOST_NAMES>,
    "<CLUSTER_SVC_IP>",
    "<VIP>",
    "kubernetes",
    "kubernetes.default",
    "kubernetes.default.svc",
    "kubernetes.default.svc.cluster",
    "kubernetes.default.svc.cluster.local"
    ],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=server apiserver-csr.json | cfssljson -bare apiserver

apiserver指定了-profile=server,但其应该不需要client auth,因为由apiserver主动向etcd、kubelet等发送请求时,会使用额外的证书(apiserver作为客户端),如apiserver-etcd-client.crt、apiserver-kubelet-client.crt。profile server包含client auth,是为了提供给kubelet用的。

apiserver-kubelet-client

  • apiserver-kubelet-client-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {
    "CN": "kube-apiserver-kubelet-client",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "names": [
    {
    "O": "system:masters"
    }
    ]
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=client apiserver-kubelet-client-csr.json | cfssljson -bare apiserver-kubelet-client

apiserver-etcd-client

  • apiserver-etcd-client-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {
    "CN": "kube-apiserver-etcd-client",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "names": [
    {
    "O": "system:masters"
    }
    ]
    }
  • 命令

    1
    cfssl gencert -ca=etcd/ca.pem -ca-key=etcd/ca-key.pem -config=etcd/ca-config.json -profile=client apiserver-etcd-client-csr.json | cfssljson -bare apiserver-etcd-client

front-proxy

  • front-proxy-ca-config.json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    "signing": {
    "default": {
    "expiry": "876000h"
    },
    "profiles": {
    "client": {
    "expiry": "876000h",
    "usages": [
    "digital signature",
    "key encipherment",
    "client auth"
    ]
    }
    }
    }
    }

front-proxy-ca

  • front-proxy-ca-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    "CN": "kubernetes-front-proxy-ca",
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "ca": {
    "expiry": "876000h"
    }
    }
  • 命令

    1
    cfssl gencert -initca front-proxy-ca-csr.json | cfssljson -bare front-proxy-ca

front-proxy-client

  • front-proxy-client-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "front-proxy-client",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=front-proxy-ca.pem -ca-key=front-proxy-ca-key.pem -config=front-proxy-ca-config.json -profile=client front-proxy-client-csr.json | cfssljson -bare front-proxy-client

service account

  • 命令
    1
    2
    openssl genrsa -out sa.key 2048
    openssl rsa -in sa.key -pubout -out sa.pub

user accounts

admin

  • admin-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {
    "CN": "kubernetes-admin",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "names": [
    {
    "O": "system:masters"
    }
    ]
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=client admin-csr.json | cfssljson -bare admin

kubelet

  • kubelet-csr.json.json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    "CN": "system:node:<nodeName>",
    "hosts": [
    "127.0.0.1",
    "localhost",
    "HOST_IP"
    ],
    "key": {
    "algo": "rsa",
    "size": 2048
    },
    "names": [
    {
    "O": "system:nodes"
    }
    ]
    }

<nodeName>必须精确匹配由kubelet向apiserver注册时提供的节点名的值。
hosts字段不能为空,否则apiserver向kubelet发请求时kubelet无法验证ip的有效性。

  • 命令
    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=server kubelet-csr.json | cfssljson -bare kubelet

注意:kubelet指定的profile需要包含client auth和server auth。具体原因后面介绍。

scheduler

  • scheduler-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "system:kube-scheduler",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=client scheduler-csr.json | cfssljson -bare scheduler

controller-manager

  • controller-manager-csr.json

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "CN": "system:kube-controller-manager",
    "hosts": [],
    "key": {
    "algo": "rsa",
    "size": 2048
    }
    }
  • 命令

    1
    cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=client controller-manager-csr.json | cfssljson -bare controller-manager

格式转换

.pem和-key.pem直接转为.crt和.key文件。

1
2
3
4
5
for f in *-key.pem; do mv -v "$f" "${f%-key.pem}.key"; done
for f in *.pem; do mv -v "$f" "${f%.pem}.crt"; done
cd etcd
for f in *-key.pem; do mv -v "$f" "${f%-key.pem}.key"; done
for f in *.pem; do mv -v "$f" "${f%.pem}.crt"; done

配置user accounts证书

为user accounts配置上述自定义证书,参考Configure certificates for user accounts。

1
2
3
4
KUBECONFIG=<filename> kubectl config set-cluster default-cluster --server=https://<host ip>:6443 --certificate-authority <path-to-kubernetes-ca> --embed-certs
KUBECONFIG=<filename> kubectl config set-credentials <credential-name> --client-key <path-to-key>.pem --client-certificate <path-to-cert>.pem --embed-certs
KUBECONFIG=<filename> kubectl config set-context default-system --cluster default-cluster --user <credential-name>
KUBECONFIG=<filename> kubectl config use-context default-system

<filename>分别为

  • /etc/kubernetes/admin.conf
  • /etc/kubernetes/kubelet.conf
  • /etc/kubernetes/controller-manager.conf
  • /etc/kubernetes/scheduler.conf

kubernetes认证场景

通过上述证书内容和csr.json,可知kubernetes内部常用的加解密算法为非对称加密算法RSA。接下来具体分析一些场景下的认证过程。首先介绍一下https双向认证过程。

https双向认证

参考图解SSL/TLS认证流程。

kubectl

kubectl与apiserver通信,以控制和获取集群资源。这是一个双向认证过程。kubectl要验证apiserver的证书有效性,apiserver也要验证kubectl证书有效性。

默认证书

kubectl使用~/.kube/config配置文件,该文件拷贝自/etc/kubernetes/admin.conf。
需要关注的字段主要是:

  • certificate-authority-data
    用于验证apiserver发过来的证书。
  • client-certificate-data
    kubectl发送的证书,apiserver会对其进行验证。
  • client-key-data
    kubectl使用的密钥。

将内容进行base64 -d和openssl x509 -in xxx.crt -text -noout即可查看证书内容。可以发现,默认情况下的证书和密钥是kubeadm生成的,有效期只有一年。参考配置user accounts证书对其进行自定义。

通信过程

抓包研究下kubectl和apiserver的通信握手过程。apiserver监听端口为6443。((tcp[12] & 0xf0) >> 2)表示TCP头的大小。TLS包的第一个字节定义内容类型,值22(十六进制中的0x16)被定义为“握手”内容。

1
tcpdump -ni any "tcp port 6443 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w test.sap

执行kubectl get svc,从而捕抓到kubectl和apiserver之间的握手包。
用wireshark查看test.sap。

可以发现,kubectl首先向apiserver发送了client hello报文,apiserver响应hello报文之后,发送自己的证书,并发送客户端证书请求。kubectl收到证书请求后,发送自己的证书,并验证apiserver证书的有效性。
具体的证书内容可以通过wireshark查看。

apiserver使用了--client-ca-file字段指定了用于认证客户端证书的根证书。kubectl则是通过--certificate-authority-data验证apiserver的证书。简单修改下certificate-authority-data的内容,测试一下。

1
2
# kubectl get pods
Unable to connect to the server: x509: certificate signed by unknown authority

查看抓包情况:

可以发现,apiserver的证书并没有通过kubectl的验证。

tls+rbac

参考使用kubectl访问Kubernetes集群时的身份验证和授权,apiserver负责集群访问权限控制,访问权限控制由身份验证(authentication)、授权(authorization)和准入控制(admission control)三步骤按序进行。身份验证支持的方法包括:client证书验证(https双向验证)、basic auth、普通token以及jwt token(用于serviceaccount)。授权支持的方式包括Node、RBAC、Webhook。

身份信息

kubectl的身份信息在client-certificate的数据中。从~/.kube/config中获取证书的持有者信息:

1
Subject: O=system:masters, CN=kubernetes-admin

证书的O、CN字段用于提供RBAC所需的用户组和用户。起作用的主要是group = system:masters。
参考User-facing Roles,可知kubernetes默认创建了cluster-admin的ClusterRole,并且绑定了group = system:masters。

1
2
kubectl get clusterrolebinding/cluster-admin -o yaml
kubectl get clusterrole/cluster-admin -o yaml

可以发现,apiGroups、resources、verbs等都被授予了全部权限。

注:经过试验,修改了该场景下的CN后(即重新自定义证书),kubectl可以正常工作,因此在该场景下,CN应该没有发挥什么作用。

kubelet

kubelet.conf

kubelet.conf用于指明如何与apiserver进行通信。
需要关注的字段主要是:

  • certificate-authority-data
    用于验证apiserver发过来的证书。
  • client-certificate-data
    kubelet发送的证书,apiserver会对其进行验证。
  • client-key-data
    kubelet使用的密钥。

默认情况下,通过kubeadm init phase kubeconfig kubelet --config=kubeadm.yaml生成的kubelet.conf将会包含有效期为一年的证书,默认使用了/etc/kubernetes/pki/ca.crt。在配置user accounts证书一节,通过参数指定可以修改证书。
如果kubelet.conf的certificate-authority-data根证书内容有误,kubelet将会启动失败,出现类似以下错误日志:

1
Unable to register node "xxx.xxx.xxx.xxx" with API server: Post https://xxx.xxx.xxx.xxx:6443/api/v1/nodes: x509: certificate signed by unknown authority

apiserver则将出现类似以下错误日志:

1
http: TLS handshake error from xxx.xxx.xxx.xxx:48718: remote error: tls: bad certificate

如果kubelet.conf的client-certificate-data证书或client-key-data密钥内容有误,kubelet将会启动失败,出现类似以下错误日志:

1
2
Unable to load TLS configuration from existing bootstrap client config: tls: failed to find any PEM data in certificate input
Unable to load TLS configuration from existing bootstrap client config: tls: failed to find any PEM data in key input

这种场景下是无法使用kubectl log命令的:

1
Error from server: Get https://xxx.xxx.xxx.xxx:10250/containerLogs/kube-system/kube-apiserver-xxx.xxx.xxx.xxx/kube-apiserver?follow=true: dial tcp xxx.xxx.xxx.xxx:10250: connect: connection refused

参考apiserver-kubelet,可知通过kubectl查看log需要由apiserver连接到kubelet。

client-certificate-data和client-key-data也可以直接指向文件路径:

1
2
3
4
5
users:
- name: default-auth
user:
client-certificate: /var/lib/kubelet/pki/kubelet-client-current.pem
client-key: /var/lib/kubelet/pki/kubelet-client-current.pem

参考Kubernetes TLS bootstrapping那点事,可知kubelet-client-current.pem是一个软连接文件,当kubelet配置了--feature-gates=RotateKubeletClientCertificate=true选项后,会在证书总有效期的70%~90%的时间内发起续期请求,请求被批准后会生成一个/var/lib/kubelet/pki/kubelet-client-时间戳.pem。kubelet-client-current.pem文件则始终软连接到最新的真实证书文件,除首次启动外,kubelet一直会使用这个证书同apiserver通讯。参考kubelet,可知RotateKubeletClientCertificate参数默认为true。
查看kubelet-client-current.pem文件:

1
2
3
4
5
6
-----BEGIN CERTIFICATE-----
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-----END CERTIFICATE-----
-----BEGIN EC PRIVATE KEY-----
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-----END EC PRIVATE KEY-----

使用openssl x509 -in ./cert -text -noout查看证书内容,可以发现公钥加密算法为id-ecPublicKey,而不是rsaEncryption。

kubelet.conf vs kubelet-client-current.pem

既然kubelet.conf和kubelet-client-current.pem都用作kubelet和apiserver通信的证书,那两者之间的关系是什么?
基于kubelet启动的过程中需要向apiserver进行节点注册,以该场景为例进行抓握手包:

1
tcpdump -ni any "tcp port 6443 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w test.sap

然后重启kubelet:

1
systemctl restart kubelet

查看握手包的Certificate的serialNumber,对比kubelet.conf和kubelet-client-current.pem证书中的serialNumber,发现默认情况下,kubelet是以kubelet-client-current.pem的内容与apiserver通信的。
由此,猜测kubelet.conf类似于备胎的作用,当不进行client证书轮转更新时,使用kubelet使用kubelet.conf与apiserver进行通信。以下进行实验证实:

  • 修改/var/lib/kubelet/kubeadm-flags.env,添加--feature-gates=RotateKubeletClientCertificate=false --rotate-certificates=false
  • systemctl restart kubelet

再次抓包,可以发现握手包的Certificate的serialNumber等于kubelet.conf证书中的serialNumber,猜测成立。

证书问题

实验过程中,实际上是出现了一些奇怪的问题。如果开启RotateKubeletClientCertificate的话,每次重启kubelet,若kubelet-client-current.pem不存在,则会自动创建新的证书文件(通过certificatesigningrequests请求),并将kubelet-client-current.pem指向它。

kubelet-client-current.pem内容错误

实验过程:

  • systemctl stop kubelet
  • rm /var/lib/kubelet/pki/kubelet-client-current.pem
  • systemctl restart kubelet
  • journalctl -xeu kubelet

发现kubelet无法注册节点,apiserver出现以下错误日志:

1
Unable to authenticate the request due to an error: [x509: certificate signed by unknown authority, x509: certificate specifies an incompatible key usage]

查看kubelet-client-current.pem的证书内容,发现kubelet-client-current.pem的证书内容为kubelet.conf的证书内容。比对之前的证书内容,发现两者的差异是:

  • kubelet-client-current.pem:X509v3 Extended Key Usage为TLS Web Server Authentication
  • 之前的有效证书:X509v3 Extended Key Usage为TLS Web Client Authentication

猜测kubernetes在生成kubelet-client-current.pem的时候,若kubelet.conf的证书Key Usage包含Client Authentication,则轮转生成全新的证书内容(采用公钥加密算法为id-ecPublicKey);否则kubelet-client-current.pem直接引用kubelet.conf的证书内容。该猜测经过实验已证实。
在这个场景中,我们生成kubelet.crt的时候,指定了-profile=server,且该profile不包含client auth的usages;由该证书所生成的kubelet.conf、由该kubelet.conf所生成的kubelet-client-current.pem,并不满足认证要求。

查看log时apiserver和kubelet的验证

借助kubelet-client-current.pem证书出错(即只包含server auth)这个场景,验证下kubectl log的执行过程:

1
kubectl log kube-apiserver-xxx.xxx.xxx.xxx -n kube-system -f

输出以下日志:

1
Error from server (InternalError): Internal error occurred: Authorization error (user=kube-apiserver-kubelet-client, verb=get, resource=nodes, subresource=proxy)

抓取kubectl<->apiserver和apiserver<->kubelet的握手包:

1
2
tcpdump -ni any "tcp port 6443 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w 6443.sap
tcpdump -ni any "tcp port 10250 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w 10250.sap

继续执行kubectl log,查看握手包,发现:

  • kubectl使用~/.kube/config(O=system:masters, CN=kubernetes-admin)的证书与apiserver通信,apiserver使用/etc/kubernetes/pki/apiserver.crt与kubectl通信(CN=kube-apiserver)。
  • apiserver使用/etc/kubernetes/pki/apiserver-kubelet-client.crt与kubelet通信,kubelet使用/etc/kubernetes/pki/kubelet.crt与apiserver通信。

考虑到引用kubelet.crt的地方,是/var/lib/kubelet/config.yaml中的tlsCertFile和tlsPrivateKeyFile,这两个字段是由kubeadm.yaml指定的:

1
2
3
4
5
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
address: "HOST_IP"
tlsCertFile: KUBELET_CERT
tlsPrivateKeyFile: KUBELET_KEY

若不指定,将生成自签名证书/var/lib/kubelet/pki/kubelet.crt和私钥/var/lib/kubelet/pki/kubelet.key。此处我们指定为自制证书路径/etc/kubernetes/pki/kubelet.crt和/etc/kubernetes/pki/kubelet.key。将其修改为临时生成的证书路径,然后执行kubectl log,抓取10250端口的握手包,可以发现kubelet使用临时证书与apiserver通信。

在上一节中,kubelet向apiserver发请求注册节点,kubelet作为客户端,因此其证书需要包含client auth。在本节中,apiserver向kubelet发请求查看日志,kubelet作为服务端,其证书不但需要包含server auth,还需要包含host字段,用于验证apiserver所访问的ip。
若kubelet.crt不包含hosts字段,则apiserver可能报错:

1
Error from server: Get https://xxx.xxx.xxx.xxx:10250/containerLogs/kube-system/kube-apiserver-xxx.xxx.xxx.xxx/kube-apiserver?follow=true: x509: cannot validate certificate for xxx.xxx.xxx.xxx because it doesn't contain any IP SANs

若kubelet.crt不包含server auth,则apiserver可能报错:

1
Error from server: Get https://xxx.xxx.xxx.xxx:10250/containerLogs/kube-system/kube-apiserver-xxx.xxx.xxx.xxx/kube-apiserver?follow=true: x509: certificate specifies an incompatible key usage

因此,需要更新kubelet.crt,保证其同时包含client auth(确保正确生成kubelet-client-current.pem)和server auth(用于tlsCertFile),同时证书hosts字段包含节点ip。

身份信息

假设kubelet.crt同时包含client auth和server auth。获取kubelet证书的持有者信息:

1
Subject: O=system:nodes, CN=system:node:<nodeName>

修改CN=test,生成证书,并修改kubelet.conf,可以发现以下现象:
kubelet发出的证书请求被拒绝,无法向apiserver查阅资源:

1
2
3
Failed while requesting a signed certificate from the master: cannot create certificate signing request: certificatesigningrequests.certificates.k8s.io is forbidden: User "test" cannot create resource "certificatesigningrequests" in API group "certificates.k8s.io" at the cluster scope

Failed to list *v1.Node: nodes "xxx.xxx.xxx.xxx" is forbidden: User "test" cannot list resource "nodes" in API group "" at the cluster scope

kubectl log出现认证错误:

1
Error from server (InternalError): Internal error occurred: Authorization error (user=kube-apiserver-kubelet-client, verb=get, resource=nodes, subresource=proxy)

此时,kubelet-client-current.pem没有自动轮转,而是采用了kubelet.conf的内容。

修改O=test,生成证书,并修改kubelet.conf,可以发现以下现象:
kubelet发出的证书请求被拒绝,无法向apiserver查阅资源:

1
2
3
Failed while requesting a signed certificate from the master: cannot create certificate signing request: certificatesigningrequests.certificates.k8s.io is forbidden: User "system:nodes:xxx.xxx.xxx.xxx" cannot create resource "certificatesigningrequests" in API group "certificates.k8s.io" at the cluster scope

Failed to list *v1.Node: nodes "xxx.xxx.xxx.xxx" is forbidden: User "system:nodes:xxx.xxx.xxx.xxx" cannot list resource "nodes" in API group "" at the cluster scope

kubectl log出现认证错误:

1
Error from server (InternalError): Internal error occurred: Authorization error (user=kube-apiserver-kubelet-client, verb=get, resource=nodes, subresource=proxy)

此时,kubelet-client-current.pem没有自动轮转,而是采用了kubelet.conf的内容。

由此可以发现,kubelet的证书需要同时满足CN和O的定义形式。具体可以参考Using Node Authorization和Using RBAC Authorization的说明。

kube-controller-manager

相关的启动参数:

1
2
3
4
5
6
7
8
9
10
--authentication-kubeconfig=/etc/kubernetes/controller-manager.conf,
--authorization-kubeconfig=/etc/kubernetes/controller-manager.conf,
--client-ca-file=/etc/kubernetes/pki/ca.crt,
--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt,
--cluster-signing-key-file=/etc/kubernetes/pki/ca.key,
--kubeconfig=/etc/kubernetes/controller-manager.conf,
--requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt,
--root-ca-file=/etc/kubernetes/pki/ca.crt,
--service-account-private-key-file=/etc/kubernetes/pki/sa.key,
--use-service-account-credentials=true

controller-manager.conf用于与apiserver通信,其中根证书部分用于验证apiserver证书,证书密钥部分用于发送给apiserver。可以修改controller-manager.conf并通过重启docker进行验证。
若根证书无效,则kube-controller-manager日志会出现以下日志:

1
error retrieving resource lock kube-system/kube-controller-manager: Get https://xxx.xxx.xxx.xxx:6443/api/v1/namespaces/kube-system/endpoints/kube-controller-manager?timeout=10s: x509: certificate signed by unknown authority

至于controller-manager与apiserver的通信,可以通过抓包查看,并重启docker:

1
tcpdump -ni any "src host xxx.xxx.xxx.xxx and dst host xxx.xxx.xxx.xxx and tcp port 6443 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w 6443.sap

关于service-account,参考管理Service Accounts,通过--service-account-private-key-file参数项传入一个服务账户私钥文件至Token管理器,私钥用于为生成的服务账户token签名。同样地,通过--service-account-key-file参数将对应的公钥传入kube-apiserver,公钥用于认证过程中的token校验。
我们可以通过创建serviceaccount的方式,并查看对应生成的secret,然后在jwt.io上查看token内容,以及进行签名验证。

access-token vs cert

实验场景,获取controller-manager metrics:

1
2
curl https://xxx.xxx.xxx.xxx:10257/metrics --cert /etc/kubernetes/pki/controller-manager.crt --key /etc/kubernetes/pki/controller-manager.key -k
curl https://xxx.xxx.xxx.xxx:10257/metrics --header "Authorization: Bearer $TOKEN" -k

得出以下结论:

  • 服务端下发的是自签名的根证书和证书
  • 客户端证书需要包含client auth
  • 以controller-manager.crt为证书,User system:kube-controller-manager需要与已存在的system:auth-delegator ClusterRole进行绑定,不然会报以下错误:Internal Server Error: "/metrics": subjectaccessreviews.authorization.k8s.io is forbidden: User "system:kube-controller-manager" cannot create resource "subjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope
  • 以controller-manager.crt为证书,还需要为User system:kube-controller-manager赋予metrics的访问权限
  • 以apiserver-kubelet-client.crt为证书,不需要绑定system:auth-delegator ClusterRole,因为证书指定了O=system:masters,具备admin权限
  • 以apiserver-etcd-client.crt为证书,Unauthorized,因为签发的根证书是etcd根证书
  • 以secret admin-user为token的话,User system:kube-controller-manager需要与已存在的system:auth-delegator ClusterRole进行绑定
  • 以secret default为token的话,User system:kube-controller-manager需要与已存在的system:auth-delegator ClusterRole进行绑定,同时需要为ServiceAccount default赋予metrics的访问权限

以上实验参考Securing Controller Manager and Scheduler Metrics,主要的配置内容如下:
User system:kube-controller-manager与已存在的system:auth-delegator ClusterRole进行绑定

1
2
3
4
5
6
7
8
9
10
11
12
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: system:kube-controller-manager:auth-delegate
subjects:
- kind: User
name: system:kube-controller-manager
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: system:auth-delegator
apiGroup: rbac.authorization.k8s.io

secret default:default(<namespace>:<service-account>)授权/metrics nonResourceURLs

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
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secure-metrics-scrape
rules:
- apiGroups:
- ""
resources:
- nodes/metrics
verbs:
- get
- nonResourceURLs:
- /metrics
verbs:
- get

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: metrics-endpoint
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: secure-metrics-scrape
subjects:
- kind: ServiceAccount
name: default
namespace: default

获取default:default token

1
TOKEN=$(kubectl describe secret $(kubectl get secrets -n default | grep ^default | cut -f1 -d ' ') | grep -E '^token' | cut -f2 -d':' | tr -d " ")

为User system:kube-controller-manager赋予metrics的访问权限

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
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secure-metrics-scrape
rules:
- apiGroups:
- ""
resources:
- nodes/metrics
verbs:
- get
- nonResourceURLs:
- /metrics
verbs:
- get

---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: metrics-endpoint
subjects:
- kind: User
name: system:kube-controller-manager
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: secure-metrics-scrape

front-proxy-client

front-proxy-client的相关配置主要是用于aggregated apiserver的。
apiserver关于front-proxy-client的相关参数:

1
2
3
4
5
6
7
--proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.crt
--proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client.key
--requestheader-allowed-names=front-proxy-client
--requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
--requestheader-extra-headers-prefix=X-Remote-Extra-
--requestheader-group-headers=X-Remote-Group
--requestheader-username-headers=X-Remote-User

aggregated apiserver

aggregated apiserver即聚合apiserver,用于解耦apiserver,集成用户自定义apiserver。这些apiserver可以跟core apiserver无缝衔接,使用kubectl也可以管理它们。可参考Kubernetes API Aggregation Setup。

使用kubectl访问kubernetes API

参考APIServiceSpec,apiservice的service字段表明:service是对该api server的服务引用,必须在端口443上通信。如果服务为空,这意味着api groupversion的处理将在此服务器上本地处理。调用将简单地委托给要执行的常规处理程序链。
查询所有api:

1
kubectl get apiservice

查询指定api:

1
kubectl get apiservice v1beta1.events.k8s.io -o yaml

访问api:

1
kubectl get --raw=/apis/events.k8s.io/v1beta1

apiservice的注册参考Register APIService objects。

client-ca-file vs requestheader-client-ca-file

参考CA Reusage and Conflicts。

  • client-ca-file
    当请求到达apiserver时,如果开启client-ca-file选项,apiserver将检查请求的证书。如果证书是由client-ca-file所指定的ca证书签发的,请求将被视为合法的。user将取自CN=字段,group将取自O=字段。
  • requestheader-client-ca-file
    当请求到达apiserver时,如果开启requestheader-client-ca-file选项,apiserver将检查请求的证书。如果证书是由requestheader-client-ca-file所指定的ca证书签发的,请求将被视为潜在的合法请求。apiserver将检查证书的CN=是否在requestheader-allowed-names列表中,如果在,允许该请求,否则拒绝。

如果同时指定两个选项,apiserver先检查requestheader-client-ca-file根证书,再检查client-ca-file根证书。常规的客户端请求匹配client-ca-file,聚合请求匹配requestheader-client-ca-file。若两者指定了相同的根证书,则可能会引发问题:通常情况下,client request证书能通过client-ca-file验证,在client-ca-file和requestheader-client-ca-file指定了相同的根证书的情况下,client request证书将匹配requestheader-client-ca-file,但CN=通常不匹配requestheader-allowed-names,导致无法通过apiserver进行身份验证。(常规的客户端请求证书由于不匹配requestheader-client-ca-file,所以会尝试匹配client-ca-file)

认证流程

参考Authentication Flow,大致流程如下:

  • Kubernetes apiserver:对请求用户进行身份验证,并授权他们对请求的API路径的权限。
  • Kubernetes apiserver:将请求代理到扩展apiserver
  • 扩展apiserver:验证来自Kubernetes apiserver的请求
    当我们配置了apiserver关于front-proxy-client的相关参数,kubernetes将会在kube-system命名空间下创建extension-apiserver-authenticationconfigmap,扩展apiservers可以通过该configmap来验证请求。
    这需要为扩展apiservers绑定适当的角色,默认的角色是kube-system命名空间下的extension-apiserver-authentication-reader。
  • 扩展apiserver:授权来自原始用户的请求
    扩展apiserver通过向Kubernetes apiserver发送一个标准的SubjectAccessReview请求来验证用户/组是否被授权执行给定的请求。
    这需要为扩展apiservers绑定适当的角色,Kubernetes包含一个默认的ClusterRolesystem:authdelegate-delegate,它可以被授予扩展apiserver的服务帐户。
  • 扩展apiserver:执行

apiserver认证源码实现

在以实际例子验证上述认证流程之前,这里讲解一下apiserver,已解答实际验证过程中的一些问题。

指定http处理链

创建apiserver配置信息,配置信息中指定默认的http处理链。
代码片段:

1
2
3
4
5
6
7
8
9
10
11
//func buildGenericConfig(s *options.ServerRunOptions, proxyTransport *http.Transport)
genericConfig = genericapiserver.NewConfig(legacyscheme.Codecs)

//func NewConfig(codecs serializer.CodecFactory)
return &Config{
BuildHandlerChainFunc: DefaultBuildHandlerChain,
...
}

//func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config)
handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences)

参考:genericapiserver.NewConfig、DefaultBuildHandlerChain、genericapifilters.WithAuthentication

构建身份验证器

上述请求处理链绑定genericapifilters.WithAuthentication,然后传递c.Authentication.Authenticator身份验证器。这个身份验证器是一个union authenticator,包含了不同的认证场景下的不同的处理方法。
代码片段:

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
//func buildGenericConfig(s *options.ServerRunOptions, proxyTransport *http.Transport)
genericConfig.Authentication.Authenticator, genericConfig.OpenAPIConfig.SecurityDefinitions, err = BuildAuthenticator(s, clientgoExternalClient, versionedInformers)

//func BuildAuthenticator(s *options.ServerRunOptions, extclient clientgoclientset.Interface, versionedInformer clientgoinformers.SharedInformerFactory)
authenticatorConfig := s.Authentication.ToAuthenticationConfig()
...
return authenticatorConfig.New()

//func (config Config) New()
if config.RequestHeaderConfig != nil {
requestHeaderAuthenticator, err := headerrequest.NewSecure(
config.RequestHeaderConfig.ClientCA,
config.RequestHeaderConfig.AllowedClientNames,
config.RequestHeaderConfig.UsernameHeaders,
config.RequestHeaderConfig.GroupHeaders,
config.RequestHeaderConfig.ExtraHeaderPrefixes,
)
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, authenticator.WrapAudienceAgnosticRequest(config.APIAudiences, requestHeaderAuthenticator))
}
...
if len(config.ClientCAFile) > 0 {
certAuth, err := newAuthenticatorFromClientCAFile(config.ClientCAFile)
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, certAuth)
}
...
authenticator := union.New(authenticators...)

参考:genericConfig.Authentication.Authenticator、BuildAuthenticator、authenticatorConfig.New、headerrequest.NewSecure、NewSecure、union.New

验证过程

请求处理链绑定genericapifilters.WithAuthentication,然后传递c.Authentication.Authenticator身份验证器。实际调用Authenticator的AuthenticateRequest方法,而Authenticator是我们的union。

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
//func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler
handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences)

//func WithAuthentication(handler http.Handler, auth authenticator.Request, failed http.Handler, apiAuds authenticator.Audiences) http.Handler
resp, ok, err := auth.AuthenticateRequest(req)
if err != nil || !ok {
if err != nil {
klog.Errorf("Unable to authenticate the request due to an error: %v", err)
}
failed.ServeHTTP(w, req)
return
}

//func (authHandler *unionAuthRequestHandler) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error)
for _, currAuthRequestHandler := range authHandler.Handlers {
resp, ok, err := currAuthRequestHandler.AuthenticateRequest(req)
if err != nil {
if authHandler.FailOnError {
return resp, ok, err
}
errlist = append(errlist, err)
continue
}

if ok {
return resp, ok, err
}
}

return nil, false, utilerrors.NewAggregate(errlist)

每接收一个请求时,则遍历union authenticator的处理方法,一旦通过验证就跳出循环。union.AuthenticateRequest调用每个handler的AuthenticateRequest,按照func (config Config) New()的指定顺序,分别是Front proxy、Basic auth、Cert、Token auth、Service account token、Bootstrap token、OIDC、Webhook…
这里我们关注一下Front proxy的处理流程,首先验证证书的合法性,然后判断CN是否在allowedCommonNames里面,最后返回到上层验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//func (a *Verifier) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error)
if _, err := req.TLS.PeerCertificates[0].Verify(optsCopy); err != nil {
return nil, false, err
}
if err := a.verifySubject(req.TLS.PeerCertificates[0].Subject); err != nil {
return nil, false, err
}
return a.auth.AuthenticateRequest(req)

//func (a *Verifier) verifySubject(subject pkix.Name) error
if len(a.allowedCommonNames) == 0 {
return nil
}
if a.allowedCommonNames.Has(subject.CommonName) {
return nil
}
return fmt.Errorf("x509: subject with cn=%s is not in the allowed list", subject.CommonName)

上层验证实际上是尝试从头部信息中获取name、group、extra信息:

1
2
3
4
5
6
7
//func (a *requestHeaderAuthRequestHandler) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error)
name := headerValue(req.Header, a.nameHeaders)
if len(name) == 0 {
return nil, false, nil
}
groups := allHeaderValues(req.Header, a.groupHeaders)
extra := newExtra(req.Header, a.extraHeaderPrefixes)

可以发现,如果CN不在allowedCommonNames里,则会报错not in the allowed list,返回nil, false, err,由于err不为空,union.AuthenticateRequest将继续使用下一个handler进行验证,并将错误信息登记输出。
如果通过了front-proxy的证书认证,但是头部信息中没有包含name的话,验证也是失败的,返回nil, false, nil,由于err为空,union.AuthenticateRequest将继续使用下一个handler进行验证,但没有将错误信息登记输出。
参考:genericapifilters.WithAuthentication、WithAuthentication、union.AuthenticateRequest、Verifier.AuthenticateRequest、requestHeaderAuthRequestHandler.AuthenticateRequest

验证认证流程

案例

这里采取的案例是prometheus-operator,它将会创建一个apiservice。

1
2
3
4
kubectl get apiservice

===output
v1beta1.metrics.k8s.io monitoring/prometheus-adapter True 18d

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kubectl get apiservice v1beta1.metrics.k8s.io -o yaml

===output
apiVersion: apiregistration.k8s.io/v1
kind: APIService
spec:
group: metrics.k8s.io
groupPriorityMinimum: 100
insecureSkipTLSVerify: true
service:
name: prometheus-adapter
namespace: monitoring
version: v1beta1
versionPriority: 100

开启aggregator路由请求

尝试以下操作,默认出错:

1
2
3
4
5
6
7
8
9
kubectl api-resources
error: unable to retrieve the complete list of server APIs: metrics.k8s.io/v1beta1: Unauthorized

kubectl get --raw "/apis/metrics.k8s.io/v1beta1"
error: You must be logged in to the server (Unauthorized)

kubectl get <API_RESOURCE_NAME>.<API_VERSION>.<API_GROUP>
kubectl get pods.v1beta1.metrics.k8s.io
error: the server doesn't have a resource type "pods

参考CA Reusage and Conflicts,这是因为apiserver默认没有开启--enable-aggregator-routing=true,该参数的作用是打开到endpoints IP的aggregator路由请求,替换cluster IP。将其添加到/etc/kubernetes/manifests/kube-apiserver.yaml(kubeadm安装的集群),重启kubelet,apiserver可能就地重启,所以可以通过docker stop/rm apiserver容器强制创建新服务。

通过curl方式验证

上述kubectl get pods.v1beta1.metrics.k8s.io方式实际上是发送了以下请求:

1
2
//kubectl get pods.v1beta1.metrics.k8s.io --v=9
curl -k -v -XGET -H "Accept: application/json;as=Table;v=v1beta1;g=meta.k8s.io, application/json" -H "User-Agent: kubectl/v1.13.0 (linux/amd64) kubernetes/ddf47ac" 'https://xxx.xxx.xxx.xxx:6443/apis/metrics.k8s.io/v1beta1/namespaces/default/pods?limit=500'

这个请求实际上是使用了~/.kube/config(admin.conf)的证书,过的是client-ca-file验证,而不是requestheader-client-ca-file。
以下验证requestheader-client-ca-file的方式:
问题1:证书签发错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
curl -k -XGET 'https://xxx.xxx.xxx.xxx:6443/apis/metrics.k8s.io/v1beta1/namespaces/default/pods?limit=500' --cert /etc/kubernetes/pki/front-proxy-client.crt --key /etc/kubernetes/pki/front-proxy-client.key

===output
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {

},
"status": "Failure",
"message": "Unauthorized",
"reason": "Unauthorized",
"code": 401
}
===apiserver log
Unable to authenticate the request due to an error: [x509: subject with cn=kube-front-client is not in the allowed list, x509: certificate signed by unknown authority]

/etc/kubernetes/pki/front-proxy-client.crt签发时使用了错误的CN。需要重新签发证书或将kube-front-client设置到--requestheader-allowed-names。

问题2:没有指定username
重新签发证书后继续上述步骤,依然是Unauthorized,但是apiserver日志只输出:

1
Unable to authenticate the request due to an error: x509: certificate signed by unknown authority

根据上一节的内容,我们知道这是由于请求头部没有带上username,front proxy认证失败,继续检查认证时由于front-proxy-client.crt不是由apiserver指定的client-ca-file所签发,因此报错。
执行以下命令:

1
curl -k -XGET -H "X-Remote-User:front-proxy-client" 'https://xxx.xxx.xxx.xxx:6443/apis/metrics.k8s.io/v1beta1/namespaces/default/pods?limit=500' --cert /etc/kubernetes/pki/front-proxy-client.crt --key /etc/kubernetes/pki/front-proxy-client.key

问题3:没有权限
上述步骤将产生权限不足的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {

},
"status": "Failure",
"message": "pods.metrics.k8s.io is forbidden: User \"front-proxy-client\" cannot list resource \"pods\" in API group \"metrics.k8s.io\" in the namespace \"default\"",
"reason": "Forbidden",
"details": {
"group": "metrics.k8s.io",
"kind": "pods"
},
"code": 403
}

使用以下文件进行授权:

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
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secure-metrics-scrape
rules:
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- "*"

---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: metrics-endpoint
subjects:
- kind: User
name: front-proxy-client
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: secure-metrics-scrape

再次执行上述命令,将成功获得输出。

抓包验证:
进入prometheus-adapter容器,查看设备,设备号为33:

1
cat /sys/class/net/eth0/iflink

进入容器所在宿主机,查看设备对,假设为calixxxxx:

1
2
3
ip link |grep ^33
或
for i in /sys/class/net/*/ifindex; do grep -l 33 $i; done

docker stop/rm强制删除apiserver,然后重新curl(避免apiserver使用已有连接,从而捕捉不到握手包),抓握手包:

1
tcpdump -i calixxxxx "(tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)" -w 6443.sap

由于启用了calico和ipvs,查看握手包,可以发现实际是tunnel向prometheus-adapter pod通信。通信双方client是发送了front-proxy-client证书,而server端则是发送临时签发的证书。

开启caBundle:
若apiservice开启caBundle,添加caBundle之后发送curl指令:

1
2
3
4
curl:Error: 'x509: certificate is valid for localhost, localhost, not prometheus-adapter.monitoring.svc'

prometheus-adapter的pod日志,其中xxx.xxx.xxx.xxx是IPIP Tunnel的ip:
http: TLS handshake error from xxx.xxx.xxx.xxx:40812: remote error: tls: bad certificate

证书或配置及其作用

证书或配置 目前已知作用
*.conf相关
~/.kube/config(admin.conf) 与apiserver通信时kubectl使用的证书(apiserver使用apiserver.crt)
kubelet.conf key usage不包含client auth、O或CN不符合规定时,会导致kubelet-client-current.pem直接采用其证书内容(在kubelet-client-current.pem不存在的情况下)
controller-manager.conf 与apiserver通信时kube-controller-manager使用的证书(apiserver使用apiserver.crt)
scheduler.conf -
kubelet相关
/var/lib/kubelet/pki/kubelet-client-current.pem kubelet向apiserver注册节点、获取资源信息等操作时kubelet使用的证书(apiserver使用apiserver.crt)
kubelet.crt 由kubectl log触发的apiserver与kubelet通信时,kubelet使用的证书(tlsCertFile字段指定)
apiserver-kubelet-client.crt 由kubectl log触发的apiserver与kubelet通信时,apiserver使用的证书
apiserver相关
apiserver.crt kubectl与apiserver通信时apiserver使用的证书;kubelet向apiserver注册节点、获取资源信息等操作时apiserver使用的证书;kube-controller-manager与apiserver通信时apiserver使用的证书
apiserver-etcd-client.crt -
front-proxy相关
front-proxy-client.crt 用于扩展apiserver的认证
service account相关
sa.pub apiserver用于认证过程中的token校验
etcd相关
healthcheck-client.crt -
peer.crt -
server.crt -

access token

通过上文可知,kube-controller-manager通过--service-account-private-key-file参数项传入一个服务账户私钥文件至Token管理器,私钥用于为生成的服务账户token签名。同样地,通过--service-account-key-file参数将对应的公钥传入kube-apiserver,公钥用于认证过程中的token校验。
参考Accessing the API from a Pod,可知每个pod里面都放置了token文件夹和ca证书,用于在需要时与apiserver安全通信。
从Pod中定位和验证apiserver的推荐方式是通过kubernetes.default.svc这个DNS名称,该名称将会解析为服务IP,然后服务 IP将会路由到apiserver。
向apiserver进行身份验证的推荐方法是使用ServiceAccount凭证。ServiceAccount凭证放置在pod中每个容器的文件系统中,位于/var/run/secrets/kubernetes.io/serviceaccount/token,该凭证即为对应命名空间下默认创建的default-token。/var/run/secrets/kubernetes.io/serviceaccount/ca.crt,用于验证apiserver的服务证书。默认命名空间将被放置在/var/run/secrets/kubernetes.io/serviceaccount/namespace文件中。

借助git去除敏感信息助力代码开源

发表于 2019-06-12 | 分类于 Git

fork私有项目

删除敏感配置

创建全新分支,不保留任何历史,但是包含当前分支所有文件
git checkout –orphan new

git add -A

删除不必要的文件:
git rm xxx -f

git commit -m “init”

git branch -a

git branch -D master

git branch -a

git branch -m master

git push -f origin master

删除多余分支

git branch -D -r origin/mr

git push origin :mr

至此,项目可以开源了

私有项目更新合并

创建新mr分支

git branch -D -r origin/mr

git push origin :mr

git checkout –orphan mr

git add -A

删除不必要的文件:
git rm xxx -f

git commit -m “你的注释”

git push origin mr

提交合并

开源项目使用合并请求,合并私有项目mr分支

Hello World

发表于 2019-06-12

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

dm-writeboost读写

发表于 2018-03-02 | 分类于 Ceph

题外话

近期抽空了解了一下ceph客户端缓存的问题,以期获取断电容灾的解决方案。ceph使用rbd的方式如下(参考ceph的jewel新支持的rbd-nbd):

再结合ceph用户邮件相关讨论,如Local ssd cache for ceph on each compute node和Ceph and EnhanceIO cache,可知可以从三个维度了解:
1、krbd
2、librbd
3、缓存插件

krbd:
krbd的内核代码阅读入口为以4.14.12版本为例,ceph相关代码逻辑在include/linux/ceph,Sage Weil在内核中扩展的rbd模块是以驱动模式实现的,具体的I/O数据由内核负责,rbd模块获取读写请求之后再与ceph集群通信。krbd模块重点在于与集群通信功能,因此,想要在krbd维度上考虑缓存的问题,相当于要修改内核关于bio的实现,目前来说是不可能的。

librbd:
librbd+nbd的模式似乎可以替代krbd,同时librbd也是虚拟机在使用的,因此从librbd上考虑缓存实现方式比较实际。这里还有一篇推荐文章Ceph 性能优化 之 带掉电保护的Rbd Cache。这篇文章里的主要逻辑是用读写文件的方式来取代内存缓存,我对这篇文章的疑虑在以下地方:
1)、没有讲明断电重启后的数据恢复逻辑,因此也没有讲明应当保存数据结构中具体的哪些字段。
2)、没有结合ssd的特性来读写。
3)、文章通过默认设定I/O大小和周期性统计应用I/O平均大小来避免性能问题,这种做法是否可以规避掉。

缓存插件:
基于目前的了解,缓存插件应该是通过使用ssd缓存数据来加快块设备落盘的,也就是说,使用缓存插件的目的是“加速”而不是“断电保护”。其次,是否所有缓存插件都具备数据一致性检查,也是需要读取相关源码才能真切了解到。

推荐的块设备文章深入浅出Flashcache(一)、深入浅出Flashcache(二)、深入浅出Flashcache(三)、blktrace 深度了解linux系统的IO运作、IO模式调查利器blkiomon介绍、Linux下Fio和Blktrace模拟块设备的访问模式

基于上述背景,我首先阅读了rbd-nbd的相关代码rbd-nbd.cc,找到librbd的读写入口,了解了ceph客户端缓存数据的代码逻辑。接着,我阅读了缓存插件dm-writeboost的相关代码dm-writeboost,以期了解以下问题:
1)、如何读写ssd
2)、如何控制IO
3)、如何将随机写转换为顺序写

dm-writeboost

dm-writeboost采用驱动开发模式,通过定义IO处理规则来过滤IO请求。dm-writeboost使用了Device Mapper的相关知识,这有一篇推荐文章Linux 内核中的 Device Mapper 机制。

知识点

1、dm-writeboost.h中定义的Overall-Superblock-Segment即为落盘ssd的最终数据,segment_header、metablock等等是在内存中组织的逻辑数据,这些逻辑数据的索引信息将填充在segment_header_device中,而具体缓存在rambuffer中的数据则将写入到Segment的data中,最后落盘。其中,Segment中的segment_header_device (512B) + metablock_device * nr_caches_inseg占据4K大小,一个Segment为512K。
2、Segment数据代表了ssd的数据,下刷到ssd是以Segment为单位的,Segment会记录ssd的扇区位置。Segment中包含多个metablock,metablock记录了hdd的扇区位置。Segment的metablock是无序的、随机的。
3、读缓存单元数据缓存了hdd的数据,使用了红黑树组织,根据bio指向的hdd扇区位置寻位。读缓存单元存在的意义是将只存在hdd而未存在ssd的数据缓存到ssd,以便加速后续读写。
4、所有Segment的所有metablock都关联到哈希表中,哈希表的大小等于nr_caches等于metablock总数,哈希表存在的意义是组织存储在ssd上的数据,并使用了hdd的扇区数进行索引,根据hdd扇区数查找哈希表上的metablock,存在则代表数据存在于ssd上(或即将写入),由metablock又可以定位到对应的Segment。根据bio来寻位哈希表的规则是:bio扇区对应第几个4K/哈希表大小的余数,找到对应的head,并从head的list中根据扇区位置找到对应的metablock。
5、I/O大小为4K范围内,通过dm接口设定。
6、如何将随机写转变成顺序写?
在dm-writeboost里,I/O大小为4K,随机写单元为一个metablock。dm-writeboost采取的策略是将随机写包含在大块里,然后对大块进行顺序写。具体的实现是:dm-writeboost在内存中按顺序维护Segment数组,然后从其中获取下一个空闲Segment,将metablock包含在其中,当Segment满了以后,下刷到ssd中,然后获取下一个空闲的Segment。因此,需要一定的机制来检验Segment是否下刷成功以及有无I/O请求情况,dm-writeboost采用了计数加减来完成这个目标。
7、由内存写入ssd时,称为flush,写入时受到缓存区rambuf_pool控制,共8个rambuf,每一个对应缓存一个Segment的数据,因此分配新的rambuf时,需要先等待[当前id - 8]之前的rabbuf下刷到ssd。由ssd写入hdd时,称为writeback,写入时受到Segment数组控制,共nr_segments个。writeback批处理上限默认为32个Segment,受nr_max_batched_writeback参数控制,因此分配新Segment时,需要先等待[当前id - nr_segments]之前的段都writeback完;实际的writeback实现是将每个段的metablock作为一个io处理,若metablock数据不满4K,还将分成扇区处理。writeback写入磁盘的数据将使用红黑树进行组织。
8、断电重启由ssd恢复内存组织数据时最多只需要恢复nr_segments段,由最老的段开始恢复,在nr_segments个段范围内,最老的段为已记录max段的下一个(若该段没有数据,则表明之前数据未写满nr_segments个)。恢复过程中一旦出现检验码验证失败,直接丢弃后面数据。恢复过程中,若出现metablock已被hash表索引,则将数据写入到hdd(若新数据不满4k或旧数据不满4k,且旧数据为脏数据未下刷hdd,则需要合并,这种情况下prepare_overwrite的实现是先wait_for_flushing等待旧数据所在段被flush;容易得知,该段断电前一定是被成功下刷过此时我们才能成功读取,因此wait_for_flushing不会出现问题。况且此时还没启动flush进程呢)。
9、系统启动的后台进程有:

1
2
3
4
5
writeback_daemon:用于将segment_header_array的数据下刷到hdd
flush_daemon:用于将rambuf_pool的数据下刷到ssd
writeback_modulator:用于监控io情况,设置allow_writeback
sb_record_updater:用于更新超级块,记录last_writeback_segment_id
data_synchronizer:用于定期唤醒flush进程,并等待当前段flush完成(可能是为了防止长时间未有io的情况)

10、为了防止数据破坏,使用了两种锁metux和spin-lock。
其中,metux用于:唤醒flush进程和分配新段(flush_current_buffer)、读缓存单元写入到内存以下刷到ssd的过程(inject_read_cache)、重新设置读缓存单元(reinit_read_cache_cells)、写入数据(do_process_write)、读取数据时查找cache和读缓存单元(process_read)。spin-lock用于:读取和设置metablock的dirtiness信息。

读请求

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
根据扇区位置查找cache(即对应ssd的哈希表数据,但索引项是hdd的扇区数)
判断是否保留读缓存单元(若原本无读缓存单元,则新增且状态为保留)

无缓存cache
保留读缓存单元
读取hdd到bio中
状态改为PBD_WILL_CACHE
将bio数据复制到读缓存单元中
读缓存工作排队
超过阈值则取消连续读缓存单元
将读缓存单元注入为写请求,将所有读缓存单元的数据复制到当前缓存段中,下刷到ssd
不保留读缓存单元(已有读缓存单元/bio请求数据不满4k)
请求重映射到hdd(修改bio指向的设备和扇区)

有缓存cache(数据在SSD中或当前内存缓存段中)
对应数据在内存中
根据bio指示的位置和扇区数读取hdd数据到buf中,将buf复制到bio中
若cache数据指明为脏数据(未下刷到hdd),在内存中缓存段上获取对应数据,复写到bio中
对应数据不在内存中(在ssd中)
等待found_seg下刷到ssd,保证后续ssd数据读取正确
若cache metablock块数据不满4k(此时已下刷到ssd)
读取hdd到buf中,并复制到bio中
若cache数据指明为脏数据(未下刷到hdd),按扇区读取ssd脏数据,复写到bio中
若cache metablock块数据满4k(此时已下刷到ssd)
状态改为PBD_READ_SEG
将读请求重新映射到ssd

其中,判断是否保留读缓存单元的逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bio请求不满4k,返回不保留
根据bio扇区在红黑树中查找读缓存单元,若找到,返回不保留
读缓存单元游标减一,从读缓存单元数组中获取一个新单元
设置新单元指向的扇区
新单元加入到红黑树中
bio状态改为PBD_WILL_CACHE
根据需要取消新单元
若当前读取扇区为上次读取扇区last_sector的下一扇区,seqcount++
否则,seqcount=1
顺序读次数seqcount大于阈值
当前已经超过阈值
取消新单元
当前未超过阈值
设置当前已超过阈值
将当前读缓存单元游标之后的seqcount个单元取消
设置当前读取扇区last_sector

写请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
初始化写I/O数据结构,将bio数据复制到写I/O
根据扇区位置查找cache(即对应ssd的哈希表数据,但索引项是hdd的扇区数)

有缓存cache(数据在SSD中或当前内存缓存段中)
对应数据在内存中
写入位置指向找到的metablock,跳到do_write
对应数据不在内存中
若bio所带数据不满4k或cache数据不满4k,且cache为脏数据
读ssd数据,合并到写I/O中
若cache数据不脏或bio所带数据满4k
忽略ssd数据
将找到的metablock标记为干净
无缓存cache
取消读缓存单元
若当前缓存段已满
下刷到ssd,获取新缓存段
从当前缓存段中获取空metablock作为写入位置

do_write:
将写I/O数据复制到rambuffer中(rambuffer缓存了当前应下刷到ssd的段实际数据,是缓存池中的一个单元)
将新的metablock标记为脏
将新的metablock加入到哈希表中(所以哈希表找到记录时,数据可能正在内存中,也可能已经下刷到ssd中)
(等待下次读写触发段刷新)

docker-v0.1.0源码分析

发表于 2017-09-20 | 分类于 Docker

/sbin/init

检查docker可执行文件的绝对路径是否在/sbin/init目录下已经存在
如果在,则设置docker容器启动之前的环境:

设置环境变量

设置网络

根据参数中的网关IP添加网关路由

设置uid/gid

根据参数中的username或者uid,调用系统调用设置uid和gid,切换用户

exec执行docker程序

如果不存在则根据参入的命令行参数选择启动docker deamon还是执行docker cli的命令调用

-d daemon

主要是创建一个server对象, 然后通过这个server创建tcp服务端,创建server实质就是创建runtime对象,runtime对象中封装了所有docker daemon运行时所需要的所有的信息,在创建runtime时,首先会在 /var/lib/docker目录下创建对应的文件:containers,graph文件夹,然后创建对应的镜像tag存储对象,通过名为lxcbr0的卡的网络创建网络管理,最后创建dockerhub的认证对象AuthConfig。

创建runtime

只支持amd64,在/var/lib/docker创建runtime

containers

创建目录,权限0700

graph

创建目录,权限0700

repositories

创建或读入文件
关键数据结构:

1
2
3
4
5
6
7
type TagStore struct {
path string
graph *Graph
Repositories map[string]Repository
}

type Repository map[string]string

如果存在/var/lib/docker/repositories文件(json格式),则读入文件内容到TagStore中,否则将TagStore内容写入到/var/lib/docker/repositories文件,权限为0600。

从lxcbr0网桥接口创建网络管理器

通过指定名为lxcbr0的网络接口来实现网络管理,一个网络管理的实例包括:网桥名字,ip网络,ip分配器,端口分配器,端口映射器。实例化一个网络管理时,要将这些属性全部赋值,ip分配器是一个ip地址的channel,里面的ip地址是通过lxcbr0接口的ip 和对应的网关mask计算得到的子网ip。端口分配器是一个存放了指定范围49153~65535个int的channel,端口映射器是设置和清除iptable的方法集合。

根据网络接口获取IPv4地址(net.IPNet)
根据IPv4地址创建IP分配器(利用了net.IPNet),通过网卡lxcbr0的第一个ip和网关mask得到这个网卡下的所有子网ip,并且封装成一个ip分配器

1
2
3
根据IPv4计算网络地址范围(首IP-末IP),取首IP
根据掩码计算可用hosts个数
阻塞写入所有可用IP到分配器IP队列(排除network IP、broadcast IP、gateway IP)

创建port分配器,端口范围地址为[49153,65535]

1
阻塞写入所有port到分配器port队列

创建PortMapper,端口映射器通过设置iptables规则来处理将外部端口映射到容器。它跟踪所有映射,并能够随意取消映射。

1
2
3
4
5
6
7
8
9
删除docker规则链
清空nat表docker规则链的PREROUTING规则
清空nat表docker规则链的OUTPUT规则
清空nat表docker规则链
删除nat表docker规则链
创建docker规则链
创建nat表docker规则链
创建nat表docker规则链的PREROUTING规则
创建nat表docker规则链的OUTPUT规则

读取认证文件

仓库地址为 https://registry.docker.io ,文件路径为/var/lib/docker/.dockercfg,包括两个字段:auth = basic auth值、email = email值

创建runtime数据结构

1
2
3
4
5
6
7
root:           /var/lib/docker
repository: /var/lib/docker/containers
containers: container/list(list.New())双向链表
networkManager: NetworkManager结构
graph: Graph结构
repositories: TagStore结构
authConfig: AuthConfig结构

恢复容器

读取/var/lib/docker/containers目录,实际就是所有之前运行过的容器的目录,目录名为对应容器的id,对于每一个文件夹:

1
2
3
4
5
6
7
8
9
10
11
12
每个文件夹都有一个config.json文件,将其读入到Container数据结构中,实例化一个container对象,包括容器id
检查config.json的id和所加载的容器id是否一样,以此判断容器信息是否被更改过
将加载的容器注册到runtime中的容器list链表中
检查runtime的容器链表是否已包含当前容器id,存在则提示错误
检查容器id是否为空 如果为空则说明容器是错误的,则退出返回错误
指定container的runtime为当前runtime
创建container的互斥锁和条件变量
创建container的stdout和stderr结构,均为container/list双向链表
创建container的stdin和stdinPipe结构,分别为io.ReadCloser和io.WriteCloser类型,对应为io.Pipe的管道文件描述符
在容器目录下创建stdout日志文件,权限为0600,格式为"{id}-stdout.log",将其write接口添加到stdout双向链表中
在容器目录下创建stderr日志文件,权限为0600,格式为"{id}-stderr.log",将其write接口添加到stderr双向链表中
将容器数据结构添加到runtime的容器链表的最后

创建tcp服务端

在127.0.0.1:4242上启动tcp监听,接受tcp的请求,为每个请求开启一个单独的协程处理请求,如果有请求到来则进行处理。处理过程为:获取请求中的参数然后调用call,call根据参数是否有值来执行不同方法,如果没有参数,则执行runtime的help方法;如果有参数,进行参数的处理,处理逻辑:获取第二个参数,作为docker后的命令,然后获取命令之后的所有参数,整条命令进行日志打印输出,之后再通过cmd命令和反射技术找到对应的cmd所对应的方法,最后将参数传入方法,执行cmd对应的方法,结果返回connect中。
connect在此作为io.Writer类型参数,命令结果将写入到其中。

关于lxcbr0

安装lxc之后,就会有lxcbr0网络接口。
几个参考链接:
github lxc
lxc官网
LXC-Linux Containers介绍
Linux 容器的建立和简单管理
Enable LXC neworking in Debian Jessie, Fedora 21 and others

lxc安装

Centos7在Base repo上没有可用的lxc,因此需要先安装epel仓库。

1
2
yum -y install epel-release
yum install lxc lxc-templates lxc-extras dnsmasq bridge-utils iptables-services

利用brctl工具来创建网桥lxcbr0,利用dnsmasq工具提供dhcp服务。

1
vim /etc/systemd/system/lxc-net.service

添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Description=Bridge interface for LXC Containers

[Service]
Type=oneshot

# Bring up bridge interface
ExecStart=/sbin/brctl addbr lxcbr0
ExecStart=/sbin/ip address add 10.0.3.1/24 dev lxcbr0
ExecStart=/sbin/ip link set lxcbr0 up

RemainAfterExit=yes

# Bring bridge interface down
ExecStop=/sbin/ip link set lxcbr0 down
ExecStop=/sbin/brctl delbr lxcbr0

1
vim /etc/systemd/system/lxc-dhcp.service

添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Requires=lxc-net.service

[Service]
ExecStart=/sbin/dnsmasq \
--dhcp-leasefile=/var/run/lxc-dnsmasq.leases \
--user=nobody \
--group=nobody \
--keep-in-foreground \
--listen-address=10.0.3.1 \
--except-interface=lo \
--bind-interfaces \
--dhcp-range=10.0.3.2,10.0.3.254

[Install]
WantedBy=default.target

1
2
3
4
systemctl enable lxc-net.service
systemctl enable lxc-dhcp.service
systemctl start lxc-net.service
systemctl start lxc-dhcp service

查看是否存在逻辑(虚拟)网桥接口

1
brctl show

如果已经存在可以用以下命令删除

1
brctl delbr +网桥名

command

1
2
3
4
5
6
7
8
9
10
11
12
13
TODO...

tcp call
conn
stdout
stdin

local call
new docker server
解析参数
方法名命名格式
通过反射查找方法
调用方法

github page博客+hexo next主题+gitment评论

发表于 2017-08-27 | 分类于 工具

github page安装

github创建仓库,git项目格式
https://github.com/jianzzz/jianzzz.github.com.git

安装hexo(内网有自行开发的npm工具的话则使用这些npm工具)

1
npm install -g hexo-cli

初始化

1
2
3
4
5
mkdir your_blog_dir
cd your_blog_dir
hexo init
(内网环境到npm install时网络出错的话,改用这些npm工具)
(your-npm install)

修改_config.yml

  • 网站设置

    1
    2
    3
    4
    5
    6
    7
    # Site
    title: 蓝色步行者
    subtitle: 每个人都有自己的梦想
    description: 如果我后退的话,我曾重视的誓言和约定就会全部消失,然后,再也不会回来这个地方了
    author: zoro
    language: zh-Hans
    timezone: Asia/Shanghai
  • 版本库设置

    1
    2
    3
    4
    5
    # Deployment
    deploy:
    type: git
    repo: git@github.com:jianzzz/jianzzz.github.com.git
    branch: master

本地发布

1
hexo server

内网环境git添加代理

1
2
git config --global http.proxy http://your-proxy-domain:port
git config --global https.proxy http://your-proxy-domain:port

下载部署器,部署到网上

1
2
npm install hexo-deployer-git --save
hexo deploy

访问
jianzzz.github.io

hexo next主题

更换主题
参考 http://theme-next.iissnan.com/getting-started.html ,https://hexo.io/zh-cn/docs/asset-folders.html
theme: hexo-theme-next-5.1.2
主题配置文件:next-5.1.2/_config.yml
站点配置文件:_config.yml

hexo next配置分离

参考https://github.com/iissnan/hexo-theme-next/issues/328
建立source/_data/next.yml,将主题配置文件所有修改都填写到source/_data/next.yml
缺陷:在主题的 _config.yml 里面打开的选项,无法在 source/_data/next.yml 中关闭。每次修改 _next.yml 需要重启 hexo server 才能生效。

gitment评论

获得github的授权

OAuth application注册接入

上图中圈画出来的是回调地址,填写博客的域名。

next主题下添加Gitment

  • github创建评论用的仓库
    https://github.com/jianzzz/blog-comment

  • 主题配置文件themes/next/_config.yml或source/_data/next.yml添加

    1
    2
    3
    4
    5
    6
    7
    8
    gitment:
    enable: true
    github_id: jianzzz
    repo: blog-comment
    client_id: xxx
    client_secret: xxxxxx
    browser_js_path: /js/gitment.browser.js
    lazy: true

browser_js_path字段后面讲
lazy为true,文章底部评论是收起状态

  • 懒加载按钮显示的文字设置

    1
    2
    3
    4
    5
    6
    7
    8
    主题languages/en.yml添加
    gitmentbutton: Show comments from Gitment
    主题languages/zh-Hans.yml添加
    gitmentbutton: 显示 Gitment 评论
    主题languages/zh-hk.yml添加
    gitmentbutton: 顯示 Gitment 評論
    主题languages/zh-tw.yml添加
    gitmentbutton: 顯示 Gitment 評論
  • 懒加载按钮div及事件配置
    主题layout/_partials/comments.swig文件添加

    1
    2
    3
    4
    5
    6
    7
    8
    {% elseif theme.gitment.enable %}
    {% if theme.gitment.lazy %}
    <div onclick="ShowGitment()" id="gitment-display-button">{{ __('gitmentbutton') }}</div>
    <div id="gitment-container" style="display:none"></div>
    {% else %}
    <div id="gitment-container"></div>
    {% endif %}
    {% endif %}
  • 页面评论框生成代码配置
    主题layout/_third-party/comments/目录添加文件gitment.swig

    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
    {% if theme.gitment.enable %}
    {% set owner = theme.gitment.github_id %}
    {% set repo = theme.gitment.repo %}
    {% set cid = theme.gitment.client_id %}
    {% set cs = theme.gitment.client_secret %}
    {% set browserJsPath = theme.gitment.browser_js_path %}
    <link rel="stylesheet" href="https://imsun.github.io/gitment/style/default.css">
    <script src="{{browserJsPath}}"></script>
    {% if not theme.gitment.lazy %}
    <script type="text/javascript">
    var gitment = new Gitment({
    id: document.location.href,
    owner: '{{owner}}',
    repo: '{{repo}}',
    oauth: {
    client_id: '{{cid}}',
    client_secret: '{{cs}}',
    }});
    gitment.render('gitment-container');
    </script>
    {% else %}
    <script type="text/javascript">
    function ShowGitment(){
    document.getElementById("gitment-display-button").style.display = "none";
    document.getElementById("gitment-container").style.display = "block";
    var gitment = new Gitment({
    id: document.location.href,
    owner: '{{owner}}',
    repo: '{{repo}}',
    oauth: {
    client_id: '{{cid}}',
    client_secret: '{{cs}}',
    }});
    gitment.render('gitment-container');
    }
    </script>
    {% endif %}
    {% endif %}

注意最上面的变量读取需要和配置文件定义的gitment变量名保持一致。

  • 引入gitment.swig
    主题layout/_third-party/comments/index.swig文件引入gitment.swig文件

    1
    {% include 'gitment.swig' %}
  • 设置CSS样式
    主题source/css/_common/components/third-party/目录添加gitment.styl文件,设置button的样式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #gitment-display-button{
    display: inline-block;
    padding: 0 15px;
    color: #0a9caf;
    cursor: pointer;
    font-size: 14px;
    border: 1px solid #0a9caf;
    border-radius: 4px;
    }
    #gitment-display-button:hover{
    color: #fff;
    background: #0a9caf;
    }
  • 引入css样式
    主题source/css/_common/components/third-party/third-party.styl文件引入CSS样式

    1
    @import "gitment";

可能遇到的问题

  • 配置文件_config.yml或next.yml格式错误,上述所添加文件的一些缩进问题
  • API rate limit exceeded for xxxxxx ……
    解决方法:

    1
    2
    hexo clean
    hexo generate
  • 评论时多次login后会重复出现Initialize Comments
    参考https://github.com/imsun/gitment/issues/12

原因:
首先,页面会根据issue label值去获取issue,如果为空则会出现Initialize Comments。点击Initialize Comments时会创建一个issue,并且将issue label设为gitment和文章地址。反复login后,文章地址可能会携带上code查询字段,其值也不唯一。因此导致获取issue时指定的label值并不一定存在,最终导致重复出现Initialize Comments,issue也因此混乱。

解决方法:
参考上面所添加的主题layout/_third-party/comments/gitment.swig文件,可知评论所依赖的js文件是https://imsun.github.io/gitment/dist/gitment.browser.js ,我们需要修改issue创建和获取这两个过程的label值,将其修改为window.document.title即可;然后将主题layout/_third-party/comments/gitment.swig文件中对gitment.browser.js的引用改为本地引用/js/gitment.browser.js,配置到next.yml的browser_js_path字段中,并将gitment.browser.js文件放置于根目录public/js下。

聊聊Harbor请求registry组件API的处理过程

发表于 2017-08-26 | 分类于 Docker

Harbor是什么

Harbor是由VMware中国研发团队负责开发的开源企业级Registry,可帮助用户迅速搭建企业级的Registry服务。Harbor在docker distribution的基础上增加了一些安全、访问控制、管理的功能以满足企业对于镜像仓库的需求。一些关于Harbor的介绍:
用Harbor实现容器镜像仓库的管理和运维
Harbor:开源企业级容器Registry架构简介
用Swagger调用Harbor Registry的REST API
基于Harbor和CephFS搭建高可用Private Registry
解决登录Harbor Registry时鉴权失败的问题
Harbor和reigstry组件之间的验证采用了docker registry v2的token方式,token的service服务集成在Harbor中。相关token验证出现于docker login、请求registry api等情况下。以下的讨论中,我们主要是通过请求Harbor API,触发Harbor请求registry API,以此来研究Harbor请求registry组件API的处理过程。因此,我们需要先解决调用Harbor API的身份验证问题,再进一步研究token验证原理。
注意:以下的讨论均以Harbor tags v1.1.2为准,查看源代码的时候请注意切换tags。

调用Harbor API的身份验证

Harbor的API可以使用Swagger查看,具体请查看Harbor的github文档。当我们调用Harbor的API时,Harbor会从request中获取用户信息。以下以https://your-harbor-domain/api/repositories/repo_name/tags为例进行说明,该API是获取名为repo_name的镜像的所有tag。镜像的tag信息是没有存放在数据库中的,我们向Harbor服务发起该请求,Harbor需要向registry组件发送API请求。
该API会被src/ui/api/repository.go的GetTags()函数所处理,当名为repo_name的镜像所属的项目为私有项目时,会检查用户的身份:

1
2
3
4
5
6
if project.Public == 0 {
userID := ra.ValidateUser()
if !checkProjectPermission(userID, project.ProjectID) {
ra.CustomAbort(http.StatusForbidden, "")
}
}

ValidateUser()函数定义于src/common/api/base.go,其调用GetUserIDForRequest()函数,最终从request中读取basic auth信息:

1
2
func (b *BaseAPI) GetUserIDForRequest() (int, bool, bool) {
username, password, ok := b.Ctx.Request.BasicAuth()

查看go源码,go/src/net/http/request.go:

1
2
3
4
5
6
7
func (r *Request) BasicAuth() (username, password string, ok bool) {
auth := r.Header.Get("Authorization")
if auth == "" {
return
}
return parseBasicAuth(auth)
}

因此,可以得知Harbor支持http的basic auth方式。要在发送请求的时候添加HTTP Basic Authentication认证信息到请求中,有两种方法:
一是在请求头中添加Authorization:
Authorization: "Basic 用户名和密码的base64加密字符串"
二是在url中添加用户名和密码:
http://userName:password@your-api-url
从上面的代码片段可以看到,第二种方式并不适用Harbor,这也是postman使用第二种方式访问API不成功的原因。

token验证原理

为了更好地研究Harbor在token验证做了哪些工作,我们先看一下docker login的过程,以下内容参考自从源码看Docker Registry v2中的Token认证实现机制

docker login的过程

1、docker client接受用户的输入命令,将命令转化为调用engine-api的RegistryLogin方法;
2、在api的RegistryLogin方法中通过http调用registry中的auth方法;
3、在auth方法中由于是v2版本的,所以会调用loginV2方法;
4、在loginV2方法中会进行调用/v2/接口,该接口会对请求进行认证。此时的请求中并没有包含token信息,认证失败,会返回401错误,同时会在header中返回去哪里认证的服务器的地址;
5、registry client收到该返回结果后,便会去返回的认证服务器那里进去认证,向认证服务器发送的request的header中包含有加密的用户名和密码;
6、认证服务器从header中获取到加密的用户名和密码,这个时候就可以结合实际的认证系统进行认证;
7、认证成功后,需要生成一个token,并返回;
8、registry client会拿着返回的token再次尝试向registry server发生请求,这次由于带有token,请求验证成功,返回状态码为200;
9、docker client端接受到返回的状态码为200,说明操作成功,控制台会出现“Login Succeeded”。

Harbor所使用的是原生的docker registry v2,当我们通过Harbor的api来间接访问registry v2 API时,需要先通过Harbor的basic auth验证,之后Harbor会构造一个新请求来访问registry v2 API。Harbor构造新请求访问registry v2 API过程中涉及到的验证类似于docker login,但又有所不同,下面我们将通过追踪源码实现来阐述这一过程。

测试用例

url:https://your-harbor-domain/api/repositories/repo_name/tags
说明:获取名为repo_name的镜像的所有tag。镜像的tag信息是没有存放在数据库中的,我们向Harbor服务发起该请求,Harbor需要向registry组件发送API请求:your-registry-host:port/v2/library/alpine/tags/list。

Harbor实现概述

Harbor的实现流程大概是:向registry组件发送your-registry-host:port/v2/请求,registry组件配置了token验证,因此会返回401错误,并将token service相关信息附带在header中返回。Harbor解析出token service相关信息之后,将其与token验证的相关Authorizer数据结构一起封装到AuthorizerStore中,AuthorizerStore会被封装到Transport中,Transport最终作为http.Client的transport成员实例。transport是golang http请求的承载者,可参考Go 标准库剖析 1(transport http 请求的承载者)。自定义实现的transport需要实现RoundTrip方法,Harbor封装的Transport实现了该接口,因此向registry组件发送http请求时,会调用到自定义的RoundTrip方法,进而先调用Authorizer来生成满足条件的token,追加到request header中,然后才是真正地向registry组件发送请求。
接下来我们将分为几部分进行讲述:Harbor对数据结构的封装、Harbor向registry发送ping请求、Harbor生成token。

Harbor对数据结构的封装

以下是请求https://your-harbor-domain/api/repositories/repo_name/tags的函数调用过程:
先创建repository client,然后通过repository client访问registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//src/ui/api/repository.go
func (ra *RepositoryAPI) GetTags() {
client, err := ra.initRepositoryClient(repoName)
tags, err := listTag(client)

//src/ui/api/repository.go
func (ra *RepositoryAPI) initRepositoryClient(repoName string) (r *registry.Repository, err error) {
username, password, ok := ra.Ctx.Request.BasicAuth()
if ok {
return newRepositoryClient(endpoint, !verify, username, password,
repoName, "repository", repoName, "pull", "push", "*")
}

//src/ui/api/repository.go
func listTag(client *registry.Repository) ([]string, error) {
ts, err := client.ListTag()

创建repository client的过程包括创建TokenAuthorizer、AuthorizerStore、RepositoryClient:

1
2
3
4
5
6
//src/ui/api/repository.go
func newRepositoryClient(endpoint string, insecure bool, username, password, repository, scopeType, scopeName string, scopeActions ...string) (*registry.Repository, error) {
credential := auth.NewBasicAuthCredential(username, password)
authorizer := auth.NewStandardTokenAuthorizer(credential, insecure, config.InternalTokenServiceEndpoint(), scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
client, err := registry.NewRepositoryWithModifiers(repository, endpoint, insecure, store)

创建的TokenAuthorizer数据结构包括:标准go http transport、basic auth数据结构、token service服务url、scope、token生成包装函数。其中,scope是生成token所需要的数据结构之一,scope的type为”repository”,name为url的镜像名,actions为pull/push/*。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//src/common/utils/registry/auth/tokenauthorizer.go
func NewStandardTokenAuthorizer(credential Credential, insecure bool, tokenServiceEndpoint string, scopeType, scopeName string, scopeActions ...string) Authorizer {
authorizer := &standardTokenAuthorizer{
client: &http.Client{
Transport: registry.GetHTTPTransport(insecure),
Timeout: 30 * time.Second,
},
credential: credential,
tokenServiceEndpoint: tokenServiceEndpoint,
}
authorizer.scope = &scope{
Type: scopeType,
Name: scopeName,
Actions: scopeActions,
}
authorizer.tg = authorizer.generateToken

TokenAuthorizer将被封装到AuthorizerStore中,此外,AuthorizerStore数据结构还包括:ping registry组件的url结构(由your-registry-host:port/v2/构建)、pingyour-registry-host:port/v2/之后解析出来的challenges参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//src/common/utils/registry/auth/authorizer.go
func NewAuthorizerStore(endpoint string, insecure bool, authorizers ...Authorizer) (*AuthorizerStore, error) {
client := &http.Client{
Transport: registry.GetHTTPTransport(insecure),
Timeout: 30 * time.Second,
}

pingURL := buildPingURL(endpoint)
resp, err := client.Get(pingURL)

challenges := ParseChallengeFromResponse(resp)
ping, err := url.Parse(pingURL)
return &AuthorizerStore{
authorizers: authorizers,
ping: ping,
challenges: challenges,
}, nil

AuthorizerStore将被封装到自定义的transport中,该transport实现了标准go http transport定义的接口,并将作为http.Client的transport成员实例。http.Client又被封装到Repository数据结构中,Repository实例被当成RepositoryClient返回:

1
2
3
4
5
6
7
8
//src/common/utils/registry/repository.go
func NewRepositoryWithModifiers(name, endpoint string, insecure bool, modifiers ...Modifier) (*Repository, error) {
transport := NewTransport(GetHTTPTransport(insecure), modifiers...)
return NewRepository(name, endpoint, &http.Client{
Transport: transport,
// for transferring large image, OS will handle i/o timeout
// Timeout: 30 * time.Second,
})

以上就是Harbor封装数据结构以实现访问registry前先构造token的过程,当调用client.ListTag()时,将会调用到自定义的transport,从而调用TokenAuthorizer的相关方法。

Harbor向registry发送ping请求

上述NewAuthorizerStore函数的过程会向registry组件发送your-registry-host:port/v2/请求,该接口会对请求进行认证,此时的请求中并没有包含token信息,认证失败,会返回401错误。
NewAuthorizerStore函数调用ParseChallengeFromResponse函数对response header进行解析,其中,challenge是docker registry v2定义的:

1
2
3
//src/common/utils/registry/auth/challenge.go
func ParseChallengeFromResponse(resp *http.Response) []challenge.Challenge {
challenges := challenge.ResponseChallenges(resp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//https://github.com/docker/distribution/blob/master/registry/client/auth/challenge/authchallenge.go
func ResponseChallenges(resp *http.Response) []Challenge {
if resp.StatusCode == http.StatusUnauthorized {
// Parse the WWW-Authenticate Header and store the challenges
// on this endpoint object.
return parseAuthHeader(resp.Header)
}

func parseAuthHeader(header http.Header) []Challenge {
challenges := []Challenge{}
for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] {
v, p := parseValueAndParams(h)
if v != "" {
challenges = append(challenges, Challenge{Scheme: v, Parameters: p})
}
}
return challenges
}

使用postman请求your-registry-host:port/v2/,查看response header:

可知,registry会告知客户端token service的url地址,以及对应的service名。

Harbor生成token

Harbor通过封装数据结构,自定义transport,实现了在请求registry API前构造token的过程。下面将详细阐述其实现过程。

1
2
3
func (r *Repository) ListTag() ([]string, error) {
req, err := http.NewRequest("GET", buildTagListURL(r.Endpoint.String(), r.Name), nil)
resp, err := r.client.Do(req)

client就是http.Client实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//https://github.com/golang/go/blob/release-branch.go1.7/src/net/http/client.go
func (c *Client) Do(req *Request) (*Response, error) {
method := valueOrDefault(req.Method, "GET")
if method == "GET" || method == "HEAD" {
return c.doFollowingRedirects(req, shouldRedirectGet)
}

func (c *Client) doFollowingRedirects(req *Request, shouldRedirect func(int) bool) (*Response, error) {
if resp, err = c.send(req, deadline); err != nil {

func (c *Client) send(req *Request, deadline time.Time) (*Response, error) {
resp, err := send(req, c.transport(), deadline)

func send(ireq *Request, rt RoundTripper, deadline time.Time) (*Response, error) {
resp, err := rt.RoundTrip(req)

可以看出,http.Client.transport()方法获取的实例是RoundTripper接口类型,该接口定义如下:

1
2
3
4
//https://github.com/golang/go/blob/release-branch.go1.7/src/net/http/client.go
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}

Harbor自定义的transport正因为实现了该接口,所以在执行上述Do方法时可以执行自定义的RoundTrip函数:

1
2
3
4
5
6
7
8
//src/common/utils/registry/transport.go
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
for _, modifier := range t.modifiers {
if err := modifier.Modify(req); err != nil {
return nil, err
}
}
resp, err := t.transport.RoundTrip(req)

modifier即上面提到的AuthorizerStore,Harbor调用了Modify方法之后才会调用标准go http transport的RoundTrip方法来发送请求。我们看一下Modify方法的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//src/common/utils/registry/auth/authorizer.go
func (a *AuthorizerStore) Modify(req *http.Request) error {
//only handle the requests sent to registry
v2Index := strings.Index(req.URL.Path, "/v2/")
if v2Index == -1 {
return nil
}

for _, challenge := range a.challenges {
for _, authorizer := range a.authorizers {
if authorizer.Scheme() == challenge.Scheme {
if err := authorizer.Authorize(req, challenge.Parameters); err != nil {
return err
}
}
}
}

可以看出,封装到AuthorizerStore的TokenAuthorizer将被调用,我们看一下Authorize方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//src/common/utils/registry/auth/tokenauthorizer.go
func (t *tokenAuthorizer) Authorize(req *http.Request, params map[string]string) error {
var scopes []*scope
var token string

if t.scope != nil {
scopes = append(scopes, t.scope)
}

scopeStrs := []string{}
for _, scope := range scopes {
scopeStrs = append(scopeStrs, scope.string())
}
to, expiresIn, _, err := t.tg(params["realm"], params["service"], scopeStrs)
if err != nil {
return err
}
token = to

req.Header.Add(http.CanonicalHeaderKey("Authorization"), fmt.Sprintf("Bearer %s", token))

Authorize方法所做的工作就是处理一下scope,然后调用tg函数(其实是个函数指针)生成token,并将生成的token写入到request header中。还记得上面提到的scope吗,它将被转换为字符串后作为参数:

1
2
3
//src/common/utils/registry/auth/tokenauthorizer.go
func (s *scope) string() string {
return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ","))

上面的方法中,tg是一个函数指针,其指向在封装TokenAuthorizer的时候就被指定了,指向了tokenAuthorizer.generateToken函数,很明显,该函数就是token生成的具体实现,我们查看一下:

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
//src/common/utils/registry/auth/tokenauthorizer.go
func (u *usernameTokenAuthorizer) generateToken(realm, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) {
token, expiresIn, issuedAt, err = token_util.RegistryTokenForUI(u.username, service, scopes)

//src/ui/service/token/authutils.go
func RegistryTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) {
return genTokenForUI(username, service, scopes, registryFilterMap)

//src/ui/service/token/authutils.go
func genTokenForUI(username string, service string, scopes []string, filters map[string]accessFilter) (string, int, *time.Time, error) {
isAdmin, err := dao.IsAdminRole(username)
if err != nil {
return "", 0, nil, err
}
access := GetResourceActions(scopes)
err = filterAccess(access, u, filters)
if err != nil {
return "", 0, nil, err
}
return MakeRawToken(username, service, access)

//src/ui/service/token/authutils.go
func MakeRawToken(username, service string, access []*token.ResourceActions) (token string, expiresIn int, issuedAt *time.Time, err error) {
pk, err := libtrust.LoadKeyFile(privateKey)
expiration, err := config.TokenExpiration()
tk, expiresIn, issuedAt, err := makeTokenCore(issuer, username, service, expiration, access, pk)
rs := fmt.Sprintf("%s.%s", tk.Raw, base64UrlEncode(tk.Signature))
return rs, expiresIn, issuedAt, nil

上述genTokenForUI会对用户身份进行检查,只有是管理员角色才能生成token,此外还有其他的请求过滤检查,这里没有贴出代码。makeTokenCore函数是生成token的关键函数:

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
//src/ui/service/token/authutils.go
func makeTokenCore(issuer, subject, audience string, expiration int,
access []*token.ResourceActions, signingKey libtrust.PrivateKey) (t *token.Token, expiresIn int, issuedAt *time.Time, err error) {

joseHeader := &token.Header{
Type: "JWT",
SigningAlg: "RS256",
KeyID: signingKey.KeyID(),
}

jwtID, err := randString(16)

now := time.Now().UTC()
issuedAt = &now
expiresIn = expiration * 60

claimSet := &token.ClaimSet{
Issuer: issuer,
Subject: subject,
Audience: audience,
Expiration: now.Add(time.Duration(expiration) * time.Minute).Unix(),
NotBefore: now.Unix(),
IssuedAt: now.Unix(),
JWTID: jwtID,
Access: access,
}

var joseHeaderBytes, claimSetBytes []byte

if joseHeaderBytes, err = json.Marshal(joseHeader); err != nil {
return nil, 0, nil, fmt.Errorf("unable to marshal jose header: %s", err)
}
if claimSetBytes, err = json.Marshal(claimSet); err != nil {
return nil, 0, nil, fmt.Errorf("unable to marshal claim set: %s", err)
}

encodedJoseHeader := base64UrlEncode(joseHeaderBytes)
encodedClaimSet := base64UrlEncode(claimSetBytes)
payload := fmt.Sprintf("%s.%s", encodedJoseHeader, encodedClaimSet)

var signatureBytes []byte
if signatureBytes, _, err = signingKey.Sign(strings.NewReader(payload), crypto.SHA256); err != nil {
return nil, 0, nil, fmt.Errorf("unable to sign jwt payload: %s", err)
}

signature := base64UrlEncode(signatureBytes)
tokenString := fmt.Sprintf("%s.%s", payload, signature)
t, err = token.NewToken(tokenString)
return
}

嗯,是的,其实Harbor也没有生成token的算法过程,docker registry定义了token的格式,并提供了配置认证服务器的地址。因此只要按照定义的格式来准备数据,就可以调用registry的相关函数来生成token:

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
//https://github.com/docker/distribution/blob/master/registry/auth/token/token.go
func NewToken(rawToken string) (*Token, error) {
parts := strings.Split(rawToken, TokenSeparator)
if len(parts) != 3 {
return nil, ErrMalformedToken
}

var (
rawHeader, rawClaims = parts[0], parts[1]
headerJSON, claimsJSON []byte
err error
)

if headerJSON, err = joseBase64UrlDecode(rawHeader); err != nil {
return nil, ErrMalformedToken
}

if claimsJSON, err = joseBase64UrlDecode(rawClaims); err != nil {
return nil, ErrMalformedToken
}

token := new(Token)
token.Header = new(Header)
token.Claims = new(ClaimSet)

token.Raw = strings.Join(parts[:2], TokenSeparator)
if token.Signature, err = joseBase64UrlDecode(parts[2]); err != nil {
return nil, ErrMalformedToken
}

if err = json.Unmarshal(headerJSON, token.Header); err != nil {
return nil, ErrMalformedToken
}

if err = json.Unmarshal(claimsJSON, token.Claims); err != nil {
return nil, ErrMalformedToken
}

return token, nil
}

从代码中,我们可以发现,token是通过JWT(JSON Web Token)来实现的,主要部分是joseHeader和claimSet。joseHeader和claimSet结构体数据转换成字符串之后,进行连合,构成payload字符串,payload再由私钥进行加密签名,得到签名字符串。最后,payload和签名字符串进行连合,传递给token.NewToken函数来生成token。
在Harbor中,joseHeader描述了token的类型以及使用的hash算法,除此之外还加入了KeyID这一认证相关的信息,KeyID是采用github.com/docker/libtrust根据公钥生成。
ClaimSet包含了进行认证的必要信息,具体包括:

1
2
3
4
5
6
Issuer:代表了发起请求的实体,是一个大小写敏感的字符串。Harbor中该值为"harbor-token-issuer"。
Subject:代表的是JWT的主题,该字段要求在上下文中或者是全局唯一,可以放入用户帐号;Harbor中该值为用户帐号。
Audience:代表的是JWT希望的接受者;Harbor中该值为registry配置的service名,即"harbor-registry"。
Expiration Time:代表的是token的过期时间;Harbor中该值默认为30分钟。
NotBefore:代表的是JWT不能早于该时间来处理;
IssuedAt:代表的是JWT的签发时间。

docker registry在此基础上还增加2个字段:

1
2
– JWTID: 这是一个基于base64加密的随机长度的字符串;
– Access:代表了访问和操作权限,Harbor中该值根据scope字符串转换为对应数据结构得到。

有了这些信息,就可以生成满足要求的token,进而附加到request header中,成功请求registey v2 API。token service会使用私钥进行签名,registry则会使用公钥进行验证,因此,Harbor的ui和registry需要使用配对的公私钥。
注:token认证实现可以参考从源码看Docker Registry v2中的Token认证实现机制。

总结

以上就是Harbor请求registry组件API大概的处理过程。由此可以看出,在Harbor请求registry API的过程中,ping请求your-registry-host:port/v2/所得到的service url其实在这个过程中并没有起到作用,但这个service url在docker login的时候则会起到作用。因此,我们最好是对比docker login的过程来理解API请求的过程,方能理解得更深刻。
最后的一点提醒,以上源代码皆是代码片段,详情请查看github Harbor和registry V2源码。以Harbor tags v1.1.2为准,查看源代码的时候请注意切换tags,谨记。

实验室项目环境迁移

发表于 2017-08-26 | 分类于 工具

docker服务

部署docker环境

1、安装docker服务
2、拉取相关镜像(push/pull/save/load)

安装mysql

1、centos7安装mariadb
2、导出和导入数据库文件

部署beego环境

安装golang

(注意先查看安装的版本,ubuntu下直接命令安装可能会安装低版本)
centos下安装:

1
sudo yum install golang

ubuntu推荐源码安装
查看下载页面:
https://golang.org/dl/

1
sudo tar -xzf go1.7.4.linux-amd64.tar.gz -C /usr/local

添加/usr/local/go/bin到PATH
在/etc/profile (for a system-wide installation)或$HOME/.profile:

1
export PATH=$PATH:/usr/local/go/bin

设置当前用户环境变量:$HOME/.profile

1
2
3
export GOPATH=$HOME/golang
export GOBIN=$HOME/golang/bin
export PATH=$PATH:$GOBIN

执行source $HOME/.profile
测试:go env

安装git

安装

1
sudo yum install git

配置

1
2
git config --global user.name "Keith"
git config --global user.email "Keith_me_please@163.com"

生成密钥(~/.ssh/)

1
ssh-keygen -t rsa -C “Keith_me_please@163.com”

安装beego和bee

1
2
go get github.com/astaxie/beego
go get github.com/beego/bee

部署项目

beego项目godep管理,推送
拉取项目:

1
git clone https://git.coding.net/codingJian/docker.git

saas项目

saas项目所在主机需要安装expect、memcached。

安装memcached server

ubuntu源安装:

1
sudo apt-get install memcached

使用memcached:

1
memcached -d -m 128 -p 11211 -u root -c 1024

-d表示后台运行,-m指定内存大小,-p指定监听端口,-u指定假设运行用户身份,-c指定最大并发连接。

使用golang memcache client

1
go get github.com/bradfitz/gomemcache/memcache

使用方法参考 https://godoc.org/github.com/bradfitz/gomemcache/memcache

beego使用godep管理依赖

发表于 2017-08-26 | 分类于 工具

安装

1
go get github.com/tools/godep

出错

1
package golang.org/x/tools/go/vcs: unrecognized import path "golang.org/x/tools/go/vcs"

解决:虽然出错但是某些文件已经下载下来,如src/github.com/tools/godep/vendor/golang.org/x/tools/go/vcs文件夹,创建src/golang.org/x/tools/go/vcs文件夹,并将src/github.com/tools/godep/vendor/golang.org/x/tools/go/vcs文件夹,并将其内容复制到src/golang.org/x/tools/go/vcs文件夹,重新$ go get github.com/tools/godep。

保存依赖:旧版本

进入beego项目,执行godep save -r ./...。
因为依赖的项目和项目本身都应该是个git repository,所以应该根据提示将依赖项目处理为git repository。

1
2
3
git init
git add *
git commit -m "init"

godep save -r ./...命令完成以下工作:
首先,godep会扫描当前目录下所属包中的import语句导入的所有外部依赖库(系统库忽略),取得外部依赖库的路径和当前对应的版本(即commit id)保存到Godeps/Godeps.json里,格式是JSON。
其次,godep会将每个依赖库都从$GOPATH目录拷贝到Godeps/_workspace下,注意拷贝的内容不包含代码管理信息比如.git目录,拷贝后的目录结构与$GOPATH完全相同。
并且beego项目内对外部依赖库的import路径都会被修改。

成功执行的话会在项目目录下生成Godeps目录。将beego项目重新提交。

1
每次新增一个原文件时,你都应该再次执行 godep save -r ./... 以确保你应用的相关依赖都被重写与记录。

保存依赖:新版本

进入beego项目,执行godep save ./...。
在GO15VENDOREXPERIMENT=1时,将使用vendor目录(而不是Godeps目录)存放copy的第三方包,并在godep go build时不再rewrite GOPATH就可以实现利用vendor下第三方包的构建。

升级godep到最新版本

如果要用到go 1.5 vendor,那么godep要升级(go get -u github.com/tools/godep;go build github.com/tools/godep)到当前的最新版本“commit d8799f112f6c8dfe1e56142831bc3bb5c8796a0e”。最新版本兼容老版本的功能,同时提供对go 1.5 vendor支持,两者之间转换的开关就是环境变量:GO15VENDOREXPERIMENT。

当GO15VENDOREXPERIMENT没有被set时,godep沿用以前的方式;当GO15VENDOREXPERIMENT = 1时,godep将用vendor替代Godeps目录以存放第三方包,同时go save将无法使用-r命令行选项(-r选项用于重写源码中的import path):
如果使用godep save -r
将出现godep: flag -r is incompatible with the vendoring experiment

注意:go get github.com/tools/godep之后最好使用一个新项目验证保存依赖情况,验证godep是否是最新版本。

其他问题

加入A机器上的项目使用godep保存依赖,并移植到B机器,之后B机器的项目引用了新的第三方库,如果继续使用godep save ./…保存依赖的话,可能会出现一些错误。这种情况下,首先执行godep restore,再执行godep save ./…。

迁移:待检验正确性

由于godep前后的两种工作模式并不兼容,因此大量使用godep的repo,如果想使用Go 1.5 vendor,那么在升级到Go 1.5之后需要做一些迁移工作。godep没有提供自动的迁移工具,目前只能手动迁移,godep github主页上给出了手动迁移的命令步骤:

1
2
unset GO15VENDOREXPERIMENT
godep restore

如果之前使用了godep save -r,那么下面这行命令将自动undo rewritten import。

1
godep save ./…

1
2
3
rm -rf Godeps
export GO15VENDOREXPERIMENT=1
godep save ./…

Godeps/_workspace/src的文件将”moved”到vendor/。

12…7
zoro

zoro

如果我后退的话,我曾重视的誓言和约定就会全部消失,然后,再也不会回来这个地方了

65 日志
12 分类
18 标签
© 2020 zoro
由 Hexo 强力驱动
|
主题 — NexT.Gemini