歡迎光臨
每天分享高質量文章

gVisor是什麼?可以解決什麼問題?

傳統的Container由於隔離性差而不適合作為Sandbox運行不受信工作負載,VM可以提供很好隔離但卻額外消耗較多的記憶體。Google開源的gVisor為我們提供另外一種選擇:在犧牲掉一定性能的情況下,它只額外消耗非常少量的記憶體,卻可以提供了類似等級的隔離性。在本文里我們深入gVisor,最後瞭解一下我們增強gVisor以支持資源控制的方案。
gVisor簡介

gVisor是什麼
gVisor為在Container中運行不受信代碼提供了新的解決思路,gVisor是一個Sandbox方案和實現。
gVisor嘗試解決什麼問題
雖然Container上可以通過Namespace和Cgroup做資源的限制,但Container里的應用程式依然可以訪問很多系統資源。事實上跟沒有跑在Container里的應用程式一樣,Container里的應用程式可以直接通過Linux內核的系統呼叫陷入到內核。任何一個被允許(通過Seccomp過濾系統呼叫)的系統呼叫的缺陷都可以被惡意的應用程式利用。
主流的Sandbox基於VM虛擬機的方案,將潛在惡意的應用程式隔離在獨立的虛擬機中,例如Kata Linux,該專案與Docker和Kubernetes都有集成。基於VM的方案提高了很好的隔離,但相應額外消耗的記憶體會多一些。在有需要運行大量Container的場景下的額外資源消耗不能被忽略。
gVisor提供了另外一種Sandbox思路,gVisor非常輕量級,額外的記憶體消耗非常小,但同時提供了和VM方案相當隔離等級。該分享里介紹的基於Ptrace的gVisor,系統呼叫的性能比較差,應用程式的兼容性也差一些。gVisor可以和Docker很好的集成,但和Kubernetes的集成還處於實驗階段。在和Docker集成的時候,gVisor遵循了OCI(Open Containers Initiative)標準,所以可以作為Docker的一個Runtime執行。
gVisor如何工作

以非特權用戶運行的gVisor通過截獲應用程式的系統呼叫,將應用程式和內核之間完全隔離。gVisor沒有簡單的把應用程式發出的系統呼叫直接作用到內核,而是實現了大多數的系統呼叫,通過對系統呼叫模擬,讓應用程式間接的訪問到系統資源。gVisor模擬系統呼叫本身時對操作系統執行系統呼叫,通過使用Seccomp對這些系統呼叫做過濾。那麼gVisor是如何截獲應用程式的系統呼叫的呢?
gVisor截獲系統呼叫
gVisor存在兩種運行樣式,這次分享只介紹了基於Ptrace的gVisor。
為了理解gVisor如何攔截系統呼叫,需要先瞭解一下Ptrace:Ptrace是Linux提供的一個系統呼叫接口,通過Ptrace,可以在兩個行程之間建立Tracer和Tracee之間的關係。Tracer可以控制Tracee,例如當Tracee收到信號的時候主動進入stopped狀態,此時Tracer可以選擇是否對Tracee做一些操作(比如設置Tracee的暫存器背景關係或者記憶體中內容等),在操作執行後,Tracer可以選擇是否讓Tracee繼續執行。
Tracee除了可以在接受到信號時候進入stopped狀態外,也可以被Tracer告知在即將進入系統呼叫時或者即將離開系統呼叫時進入stopped狀態。具體說Ptrace可以通過PTRACE_SYSEMU控制Tracee在即將進去系統調動時stop。gVisor也正是通過該命令來截獲應用程式的系統呼叫。
Sentry是通過Ptrace來控制應用程式,那麼應用程式是如何變為Tracee的呢?
將應用程式變成Tracee的
下圖是gVisor控制應用程式的行程關係:

當gVisor以Docker的Runtime啟動的時候,可以看到類似的行程間關係:docker-containerd-shim是容器的啟動器;sentry是gVisor用於截獲系統呼叫模擬內核的程式,他也正是Tracer。Stub可以暫時不用理會,stub的子行程正是我們想要放到Sandbox里的應用程式。Sentry創建stub,隨後stub創建應用程式行程,sentry通過Ptrace attach到了stub和應用程式上。當應用程式在將要執行系統呼叫的時候會主動stop,此時也正是sentry攔截和模擬系統呼叫的點。
這跟用gdb除錯程式C/C++程式類似,通過命令列給gdb指定一個要除錯的標的程式的時候,該程式會以子行程的方式運行,gdb作為Tracer attach到應用程式上來對應用程式進行控制。類似gdb,sentry也以類似的方式啟動應用程式,只是sentry先啟動了一個stub,然後讓stub以它的子行程方式啟動了應用程式。
應用程式被創建並變為Tracee後,接下來就是sentry如何完成應用程式的啟動流程了。
啟動應用程式
應用程式被初始attach到sentry後,sentry負責啟動應用程式。
在操作系統啟動應程式場景里,應用程式的二進制檔案由操作系統加載,譬如分配虛擬記憶體空間用來存放二進制中的代碼段、資料段、共享庫或者初始化應用程式的棧空間。gVisor啟動應用程式的場景下,類似的過程由sentry完成:
為了瞭解sentry是如何初始化應用程式的虛擬記憶體空間,需要先瞭解一下上文提到的stub行程。
Stub行程的一個重要的作用是作為應用程式的初始模板,以該模板創建應用程式。事實上stub作為sentry的子行程,在啟動後會主動將虛擬記憶體地址空間里幾乎所有的memory region(通過查看/proc/${pid}/maps查看一個行程虛擬記憶體地址空間里的所有memory region)甚至將代碼段和資料段也unmmap掉了。只保留兩個memory region:
其中第一個region存放了最簡化的代碼,即上圖中第一個段,執行該代碼甚至不需要棧空間,所以連棧段也被unmap掉了。
這樣的空洞的虛擬內容地址空間正好可以作為一個模板虛擬記憶體地址空間。當stub以子行程的方式啟動應用程式後,應用程式的虛擬記憶體地址空間的layout與stub的一樣。應用程式在創建出來後會立即被sentry attach,此時正是sentry做應用程式初始化的過程:sentry初始設置應用程式的RIP(指令執行暫存器)的初始值為應用程式二進制中讀出來的應用程式入口地址,該地址一般位於應用程式虛擬地址空間的較低位置,並通過PTRACE_SYSEMU指示應用程式開始執行,直到遇到以下兩種事件的時候進入stop狀態:
  1. 將要執行一個系統呼叫

  2. 收到了來自內核或者行程發給它的信號

因為應用程式的初始執行位置在用戶態的虛擬記憶體地址里沒有對應的memory region,所以應用程式會收到來自內核發來的SIGSEGV信號(段錯誤)。這裡的場景非常類似通常的page fault,當一個應用程式試圖訪問的地址位於某個虛擬記憶體地址段內,但該段沒有對應物理記憶體頁的時候,操作系統會因此陷入page fault,在page fault的handler中為該虛擬記憶體地址段映射物理頁。
事實上,在sentry在啟動應用程式運行環境之前,已經應用程式“分配”了一個虛擬記憶體地址段(這個分配並不是使用mmap或者brk真正的在應用程式的虛擬地址空間中分配地址段,該分配是一個提前占位)。上面說到當應用程式執行指令地址上因為沒有實際分配虛擬記憶體地址段,所以收到來自內核的SIGSEGV,並且進入stopped狀態,此時sentry會通過mmap在該地址上真實分配一個虛擬地址記憶體段(類比操作系統為虛擬記憶體地址段上分配物理頁),並且因為mmap的源檔案是二進制檔案本身,所以當sentry在處理完SIGSEGV指示應用程式繼續執行的後,應用程式將實際執行到該二進制中的代碼。
至此應用程式就已經啟動起來了。接下來需要瞭解就是sentry如何控制應用程式的執行了。
應用程式的執行
應用程式被啟動起來後,在執行的過程中可能會陸續遇到新的SIGSEGV(譬如程式讀寫地址段,或者棧空間的擴展),或者執行系統呼叫。
在“應用程式如何被啟動”里實際上已經描述了SIGSEGV信號處理的一種場景,即只讀地址且有映射檔案的場景,其他的場景譬如匿名地址段或者棧空間的區別在於該地址段沒有mmap實際的檔案,而是mmap了一個sentry提前準備好的“空白”檔案中。
在“gVisor如何攔截系統呼叫”中描述了系統呼叫的攔截,當應用程式在進入系統呼叫之前會自動進入stopped狀態,此時sentry讀取應用程式的系統呼叫號以及系統呼叫入參,試圖模擬該系統呼叫。以檔案的讀sys_read為例,sys_read的作用是找到指定的檔案,打開並讀取檔案內容,並將記憶體寫入到應用程式系統呼叫引數指定的虛擬記憶體地址上。Sentry在接到這個的系統呼叫時,會將檔案讀取請求通過9p協議發給之前提到的gofer行程(sentry和gofer之間有建立socket pair傳輸9p協議),由gofer行程執行真正的檔案讀取且將讀到的內容通過9p協議傳回給sentry。sentry把讀取到的檔案內容寫入到應用程式的虛擬記憶體中(如果該地址沒有對應的虛擬記憶體地址段,則分配後再複製),隨後sentry將系統呼叫的實際模擬結果寫入到應用程式的暫存器中,然後讓應用程式繼續執行。恢復執行後的應用程式因為得到了系統呼叫的結果,所以在應用程式在分不清實際上系統呼叫是直接由操作系統執行了還是由sentry做的模擬的情況下,系統呼叫得到了滿足。
應用程式的訪問控制
“應用程式的執行”中對於檔案讀系統呼叫處理的描述實際上也描述了對應用程式檔案系統訪問的控制,實際上在“應用程式啟動”中為了省略的根檔案系統掛載的描述,在根檔案系統掛載的模擬中也涉及到了通過9p協議對檔案系統的訪問。檔案寫的處理也非常類似。
除了檔案讀寫外,還有很多其他的系統呼叫,譬如共享記憶體或其他IPC,鎖,創建執行緒或者行程,發送信號,socket,execv,epoll,eventfd,pid namespace等,gVisor都進行了模擬,涉及到了操作系統的方方面面。這裡僅僅介紹了socket相關的系統呼叫:
Sentry在用戶態實現了基本的TCP/IP協議棧,在啟動應用程式之前,gVisor會啟動一個臨時的start行程,在start行程會進入到docker創建的network namespace,start行程從該network namespace中獲取veth pair中屬於gVisor的一端的veth設備,創建AF_PACKET的socket系結到該veth設備上來接管該設備上的網絡流量(同時也將ip從該設備上去掉了),並將該socket傳遞給sentry行程。後續當sentry截獲了應用程式的socket系統呼叫後,最終通過該socket將網絡包實際從veth設備上發送出去;從該veth設備上接收到的網絡包在經過sentry網絡協議棧後遞交給應用程式的socket層。
gVisor當前缺少資源限制

gVisor具備沙盒的能力,但是缺少Cgroup提供的資源使用量的限制的功能。在官方的Roadmap中計劃提供Cgroup支持。但此時為了能夠使用gVisor運行工作負載,需要讓gVisor具備資源使用量限制的功能。
Docker通過Runtime支持資源使用量限制,gVisor則是Docker的另外一個Runtime名叫runsc。通過瞭解Docker原生的Runtime即runC,可以為gVisor中支持Cgroup提供思路。Docker通過OCI(Open Containers Initiative)spec跟Runtime之間進行交互,符合該標準的Runtime可以通過Docker的命令來啟動。OCI spec里規範了應用程式啟動資源使用量的限制的描述,Docker在啟動Runtime的時候,將OCI spec內容傳遞給Runtime,由Runtime負責給應用程式應用這些資源限制:
runC由docker shim啟動,首先會創建一個init行程,該行程最終會通過execv轉變為我們希望啟動的應用程式,在init行程執行execv之前,runC會為init行程創建Cgroup,將實際上init行程放入到該Cgroup中。此後init行程通過execv變為應用程式,應用程式以及後來由它創建的子行程也都會進入到該Cgroup中,從而達到資源限制的功能。
gVisor的啟動流程中類似也可以嵌入類似的邏輯:runsc啟動流程中首先會創建gofer和boot行程,在boot行程真正啟動應用程式之前,runsc為boot行程創建新的Cgroup,並將boot行程放入到該Cgroup中,後續boot行程以及被它Ptrace的應用程式就都會處於該Cgroup中,從而達到資源限制的效果。

Kubernetes實戰培訓

Kubernetes應用實戰培訓將於2018年10月12日在深圳開課,3天時間帶你系統學習Kubernetes本次培訓包括:容器基礎、Docker基礎、Docker進階、Kubernetes架構及部署、Kubernetes常用物件、Kubernetes網絡、儲存、服務發現、Kubernetes的調度和服務質量保證、監控和日誌、Helm、專案實踐等,點擊下方圖片查看詳情。

赞(0)

分享創造快樂