1. 程式人生 > >ceph快照原理

ceph快照原理

http://www.sysnote.org/2016/02/28/ceph-rbd-snap/

前段時間一直在研究ceph的快照以及其所涉及的故障恢復邏輯,ceph在這部分的實現挺精妙的,雖然也是基於COW實現的,但是細細品來,其在快照的元資料管理以及父子關係的維護上有獨到之處,本文將詳細闡述快照的實現原理以及快照物件在恢復時如何至關重要的作用。

1.rbd快照的直觀理解

ceph的rbd卷可用做多次快照,每次做完快照後再對捲進行寫入時就會觸發COW操作,即先拷貝出原資料物件的資料出來生成快照物件,然後對原資料物件進行寫入。直觀圖解如下:


rbd-flow做快照的操作是很快的,因為只更新了卷的元資料,添加了一些快照資訊(包括該卷有哪些快照,快照id,如果這個卷是克隆出來的,那麼還包括parent資訊)。每個rbd卷在rados裡都有一個rbd_header

物件,這個rbd_header物件裡面沒資料,卷的元資料都是作為這個物件的屬性以omap方式記錄到leveldb裡(可以使用rados -p listomapvals rbd_header.來獲得快照相關的元資料資訊)。

當做完快照後,如果對原捲進行寫入的時候,就會先拷貝資料出來生成快照物件(在程式碼實現裡就是一個clone操作),拷貝的時候是整個物件的資料(有可能這個資料物件不是預設的4MB大小,那麼有多大拷多大),然後再寫入新資料。在ceph裡,卷的原物件叫做head物件,而卷作為快照後通過cow拷貝出來的快照物件稱為snap物件
因為ceph裡物件是寫時分配的,一個新建立的卷如果每一寫過資料,在rados裡是沒有生成資料物件的,只有當資料寫入時,才分配出資料物件來。

1)圖示中一開始卷就只寫了前8MB的資料,也就是隻生成了2個數據物件,當做完第一個快照後,更新原資料,再對obj1進行寫入前就會拷貝生成obj1-snap1;
2)接著做第二次快照,更新元資料,然後對obj1、obj2、obj3都進行寫入,對於obj1,為快照2生成obj1-snap2物件,但是對於obj2就有所不同,因為obj2是做了第二次快照後才要進行修改,也就是說第一次快照和第二次快照之間這個物件的資料沒有改變,因此只需要拷貝一次就行了,有兩種做法,一種是為較老的快照生成快照物件(這裡就是snap1),另外一種做法就是為最新的快照生成快照物件(即snap2)。兩種做法都可以,只要記錄了引用關係,第一種是snap2引用snap1,而第二種是snap1引用snap2,在ceph裡是採用第二種方式來實現的,因此拷貝出obj2-snap2。對於obj3,因為卷原來就沒有寫過這個物件,所以無需生成快照物件;
3)接著做第三次快照,更新元資料,然後對obj2、obj3進行寫入時,生成快照物件obj2-snap3、obj3-snap3;

2.快照關鍵資料結構

為了便於後續闡述,需要先介紹幾個重要的資料結構。
在rbd端,有一個snap相關的資料結構

struct SnapContext {
  snapid_t seq;            // 'time' stamp
  vector<snapid_t> snaps;  // existent snaps, in descending order
  ...............
}
其中seq表示最新的快照序號,而snaps儲存了這個rbd的所有快照序號。struct librados::IoCtxImpl {
  ......
  snapid_t snap_seq;
  ::SnapContext snapc;
  ....
}

在librados相關的結構裡記錄了快照資訊。


其中snap_seq的含義:如果是快照,這個snap_seq就是該snap的快照序號;如果不是快照,snap_seq就是CEPH_NOSNAP,在程式碼裡經常看到使用snap_id來判斷是否是CEPH_NOSNAP來判斷是卷的head物件,還是快照的snap物件。

在OSD端,也即是ceph的服務端,使用SnapSet來儲存。

struct SnapSet {
  snapid_t seq;
  bool head_exists;
  vector<snapid_t> snaps;    // descending
  vector<snapid_t> clones;   // ascending
  map<snapid_t, interval_set<uint64_t> > clone_overlap;  // overlap w/ next newest
  map<snapid_t, uint64_t> clone_size;
  ......
}


其中

  • seq:表示最新快照的序號;
  • head_exists:表示head物件是否存在;
  • snaps:儲存所有的快照序號;
  • clones:儲存在做完快照後,對原物件進行寫入時觸發cow進行clone的快照序號,注意並不是每個快照都需要clone物件,從前面的圖示中可以看出,只有做完快照後,對相應的物件進行寫入操作時才會clone去拷貝資料;
  • clone_overlap:為每個做過clone動作的快照,記錄在其clone資料物件後,原資料物件上未寫過的資料部分,是採用offset~len的方式進行記錄的,比如{2=[0~1646592,1650688~12288,1667072~577536]};
  • clone_size:表示每次clone的物件的大小,因為有的物件一開始並不是預設的物件大小,比如預設物件大小是4MB,但是一個物件一開始只寫了前2MB,所以在做完快照後進行新的寫入前,這個物件就是2MB大小;

下面以圖示來簡單描述快照後對原物件進行寫入時的處理:

為了直觀理解,clone_overlap採用[startoffset~endoffset]的形式來描述的,實際在程式碼裡實現的時候是採用[offset~len]的形式。

如圖示:一個4MB的head物件,在做完快照後第一次寫入觸發cow,在ceph裡就是CLONE操作,就會將這個head物件的資料(可能不是4MB,因為這個head物件沒有全部寫過一遍)讀取出來,拷貝到一個新生成的snap物件裡(這個就是snap1的快照物件),然後將新寫入的資料(比如256k~512k區間的就是新寫入的資料)寫到head物件上,並且會更新這個head物件上的clone_overlap[1],一開始這個clone_overlap[1]是整個物件的區間(比如[0~4M]),每次新寫入時,就從這個區間裡減去寫入的區間,比如減去寫入了[256k~512k]的資料,得到的clone_overlap[1]的區間就是[0~256k, 512k~4M]。

如果又做了一次快照snap2,然後又新寫入資料,比如[2M, 2.25M]區間的資料,同樣的,會先clone出snap2的快照物件,也是整個head物件都拷貝,然後寫入新的資料到head物件,並且更新clone_overlap[2]為[0~2M, 2.25M~4M]。後續如果繼續有新的寫入,仍然是將clone_overlap[2]裡減去新寫入的區間,最後如果這個4MB的物件在快照後都寫過一遍後,clone_overlap[2]就會變成空的區間[]。

3.克隆

有了快照後,自然就想從快照恢復出來一個新的卷裝置,在ceph裡就是克隆卷。
從快照克隆一個卷出來後,在使用這個新卷的時候,在librbd端open的時候就會層層構建出它的父子關係。
在進行I/O請求的處理時就會用到這個父子關係,克隆出的卷在沒寫入之前因為COW的關係,其資料都是引用的它的父卷和快照的物件。

在ceph的實現中,對於克隆卷的讀寫,都是先去找這個卷的物件,如果未找到,就再找parent的物件,這樣層層往上,直到找到物件為止。應該是先找自己的物件,找不到,再找其所基於的快照的物件,再找不到就找源卷物件(即它的parent),如果這個源卷物件也沒有並且它也是從另外一個快照克隆出來的,就會繼續往上找。而且這個過程可能涉及到多次librbd與rados的互動,都是通過網路,一旦快照克隆鏈比較長,效率就很低。而ceph提供克隆卷的flatten的功能,就是先將全部資料都拷貝一份,就可以將鏈斷掉,從而避免去快照或源捲上拿資料,但是flatten本身就是一個比較耗時的操作。
關於ceph librbd的快照也有過不少討論,王豪邁在《解析ceph:librbd的克隆問題》中提到過兩種改進方法。從這個克隆卷的I/O讀寫流程中可以發現,在OSD端是儲存了卷的快照及父子關係的,其實直接在OSD端應該就能夠直接找到要讀取的物件的,而不用經過多次librbd與rados的互動,當然這只是一個初步的想法,ceph本身已經很複雜了,需要考慮的情況也比較多。

下面結合兩個場景來簡單說明下。

場景1
1)一個卷寫了一部分資料(比如3個物件已寫過),然後做了快照snap1,接著對原卷的物件obj1繼續寫入,觸發cow,拷貝出snap1的快照物件obj1-snap1,然後對卷做快照snap2,接著寫入obj2和obj3,觸發cow,拷貝出snap2的快照物件obj2-snap2, obj3-snap2,然後從snap2克隆出一個卷clone;
2)讀克隆卷的資料時,比如讀obj1-clone,librbd傳送請求到rados,在osd端處理的時候沒有找到obj1-clone,返回ENONET給librbd,然後librbd接著去找克隆卷的parent,找到snap2,然後傳送請求去讀obj1-snap2,在osd端處理的時候根據這個快照id去判斷,如果這個快照id比物件上所持有的snapset裡的最新的快照id還要新(說明在做完快照2後並沒有對obj1進行寫入,這樣物件obj1的snapset就沒有更新),這時如果head物件存在(這裡就是obj1)則直接讀取head物件(即obj1)的資料;否則就直接返回ENOENT給librbd,librbd端就會構造一個零資料返回給client,這種情況說明源head物件在做快照2之前就不存在。


場景2
1)一個卷寫了一部分資料(比如3個物件已寫過),然後做了快照snap1,接著對原卷的物件obj1和obj2繼續寫入,觸發cow,拷貝出snap1的快照物件obj1-snap1和obj2-snap1,然後對卷做快照snap2,接著寫入obj2和obj3,觸發cow,拷貝出snap2的快照物件obj2-snap2, obj3-snap2,然後從snap1克隆出一個卷clone;
2)讀取clone卷的資料,比如obj3-clone的時候,發請求到rados,在osd端沒有找到該物件,返回ENONENT給librbd,librbd再請求其父物件,就是obj3-snap1,在osd端查詢obj3-snap1這個快照物件時,是從snapset的clones裡找的,clones裡是已經做過cow的快照的id,比如clones=[1,2,3,6,7],那麼如果找snapid=2就直接找到了,如果找snapid=4的,那麼就找到snapid=6的物件,也就是遍歷clones的時候要找到剛好大於等於指定snapid的那個id,這個邏輯是在find_object_context裡實現的。這裡obj3的clones裡是[2],根據snapid=1找的時候就找到了snapid=2的物件。
3)ceph的快照的cow邏輯裡的處理是老的快照引用新的快照物件,比如做了2次快照後,再寫入資料時觸發的cow生成的快照物件的snapid就是最新的id,也就是snapid=2,這樣快照1就引用快照2的物件。

從上面兩種場景中可以得知,對於一個克隆卷,如果其父卷不是從其他快照克隆出來的,那麼對這個克隆卷的讀操作時,librbd和osd最多經過兩次互動(克隆卷資料物件存在時就直接讀取,不存在時就去讀其parent的物件,還是讀不到就是構造零資料)。但是如果其父卷也是從快照克隆出來的話,就可能存在多於2次的互動。

4.有快照物件的資料恢復

在ceph的基於pglog的資料恢復邏輯裡都是與snap物件有所關聯的。恢復分為兩種:一種是恢復primary上的資料,一種是恢復replica的資料。

在進行恢復的時候,不管是要恢復replica上的還是primary上的都是由primary發起的,要恢復replica的,就是由primary push到replica上;要恢復primary自己的物件,就是從replica pull過來。這兩個過程都會由primay先計算出一個物件的data_subsetsclone_subsets,其中data_subsets就是需要通過網路傳輸過去的資料,而clone_subsets就是可以從本地拷貝的。
為了描述方便,後續的data_subsets和clone_subsets都是採用[startoffset~endoffset]的形式來描述的,實際在程式碼裡實現的時候是採用[offset~len]的形式

4.1恢復replica


恢復replica時是先恢復snap物件,然後再恢復其head物件。

1)對於snap物件

根據pglog構建出的missing列表的順序,按照快照物件的新舊,先恢復較老的快照的物件,最後是最新的快照的物件。

  • a)如果待恢復的快照物件就是第一次快照後產生的物件,說明它是replica掛掉之後做的快照產生的,這樣這個快照物件就需要全量恢復,即{data_subsets=[0, size],clone_subsets=[]},其中size表示當時做快照時,那個head物件的大小,一般head物件都寫過一遍後就是4MB;
  • b)如果待恢復的快照物件是第n次(n>=2)快照後產生的物件,那麼clone_subsets就是根據這個快照的父輩和子孫快照的clone_overlap計算的一個交集,而data_subsets就是用[0~size]減去這個快照父輩和子孫快照的clone_overlap的並集得到的區間(具體計算過程參考原始碼的calc_clone_subsets函式),比如圖示上對於snap2的物件計算得到的就是{data_subsets=[256k~512k], clone_subsets[1]=[0~256k, 512k~4M]};
  • c)然後對於一個物件的data_subsets部分,就需要通過網路從primary push過去寫到replica的本地,對於clone_subsets就會從replica本地對應的snap物件去clone_range,即在本地將snap1物件的部分資料拷貝給snap2物件;

2)對於head物件

遍歷其所有的快照物件,根據這些snap的clone_overlap計算得到的一個交集作為clone_subsets,而data_subsets就是用[0~size]減去這些clone_overlap的並集(具體計算過程參考原始碼的calc_head_subsets函式),比如圖示上對於head物件計算得到的就是{data_subsets=[2M~2.25M], clone_subsets[2]=[0~256k, 512k~4M], clone_subsets[1]=[0~256k, 512k~2M, 2.25M~4M]}。
然後data_subsets的部分,就從本地讀出來,push到replica,而clone_subsets的部分就是在replica端從自己本地的snap物件上讀取出來拷貝給head物件。

比較奇怪的是,為什麼會從多個快照去拷貝資料到head物件,只用最新的那個快照的clone_subset來拷貝到head物件應該就可以了,這個地方還有疑問
另外,在原來的處理邏輯裡是先把replica的head物件刪除,然後再結合data_subsets從primary通過網路傳輸過來的資料,加上clone_subsets從本地snap物件,這樣一起來重新構造出head物件,之所以這樣做,是因為data_subsets就是根據head物件的各個快照的clone_overlap算出來的,而做快照的時間點與replica掛掉的時間點不一樣,也無妨區分是否是replica掛掉的時候做的快照,如果只是用data_subsets那部分資料覆蓋寫到replica的head物件上(沒有先刪除replica的head物件),那麼replia恢復的head物件就跟primary上的head物件可能不一樣,造成資料不一致

4.2恢復primary


當primary掛掉又起來後進行恢復,先經過peering過程後構建出missing列表,然後由primary進行恢復。
恢復的時候是先恢復head物件,再恢復其snap物件。

1)對於head物件

data_subsets=[0, size],clone_subsets=[]
這裡size是head物件大小,一般如果一個物件都寫過一遍後就是4MB,也就是說head物件是需要將整個物件的資料通過網路從replica pull過來的。clone_subsets為空,表示沒有資料從該primary的本地快照物件拷貝。
這裡沒有像恢復replica那樣去計算data_subsets和clone_subsets,是因為primay掛掉後,可能又做了快照並寫入了新資料,這樣新產生的快照物件在primay上就沒有,而且primary上沒有最新的snapset(即裡面包含的clone_overlap不是最新的),因此就不能拿來計算,所以直接恢復整個head物件。

2)對於snap物件

與恢復replica一樣,根據pglog構建出的missing列表的順序,按照快照物件的新舊,先恢復較老的快照的物件,最後是最新的快照的物件。

  • a)如果待恢復的快照物件就是第一次快照後產生的物件,說明它是primary掛掉之後做的快照產生的,這樣這個快照物件就需要全量恢復,即{data_subsets=[0, size],clone_subsets=[]},其中表示當時做快照時,那個head物件的大小,一般head物件都寫過一遍後就是4MB;
  • b)如果待恢復的快照物件是第n次(n>=2)快照後產生的物件,那麼clone_subsets就是根據這個快照的父輩和子孫快照的clone_overlap計算的一個交集,而data_subsets就是用[0~size]減去這個快照父輩和子孫快照的clone_overlap的並集得到的區間(具體計算過程參考原始碼的calc_clone_subsets函式),比如圖示上對於snap2的物件計算得到的就是{data_subsets=[256k~512k], clone_subsets[1]=[0~256k, 512k~4M]};
  • c)然後對於一個物件的data_subsets部分,就需要通過網路從replica pull過來寫到本地,對於clone_subsets就會從本地對應的snap物件去clone_range,即在本地將snap1物件的部分資料拷貝給snap2物件;

從上面分析得出,恢復replica時是先恢復snap物件,再恢復head物件;而恢復primary時,卻是先恢復head物件,再恢復snap物件。這個為什麼沒有采用一樣的邏輯,比如都先恢復snap物件,再恢復head物件,這個地方沒有想明白。