使用K8s的一些經驗和體會
坑
Java應用程式的奇怪案例
在微服務和容器化方面,工程師傾向於避免使用 Java,這主要是由於 Java 臭名昭著的記憶體管理。但是,現在情況發生了改變,過去幾年來 Java 的容器相容性得到了改善。畢竟,大量的系統(例如Apache Kafka和Elasticsearch)在 Java 上執行。
回顧 2017-18 年度,我們有一些應用程式在 Java 8 上執行。這些應用程式通常很難理解像 Docker 這樣的容器環境,並因堆記憶體問題和異常的垃圾回收趨勢而崩潰。我們瞭解到,這是由於 JVM 無法使用Linuxcgroup和namespace造成的,而它們是容器化技術的核心。
但是,從那時起,Oracle 一直在不斷提高 Java 在容器領域的相容性。甚至 Java 8 的後續補丁都引入了實驗性的 JVM標誌來解決這些,XX:+UnlockExperimentalVMOptions和XX:+UseCGroupMemoryLimitForHeap。
但是,儘管做了所有的這些改進,不可否認的是,Java 在記憶體佔用方面仍然聲譽不佳,與 Python 或 Go 等同行相比啟動速度慢。這主要是由 JVM 的記憶體管理和類載入器引起的。
現在,如果我們必須選擇 Java,請確保版本為 11 或更高。並且 Kubernetes 的記憶體限制要在 JVM 最大堆記憶體(-Xmx)的基礎上增加 1GB,以留有餘量。也就是說,如果 JVM 使用 8GB 的堆記憶體,則我們對該應用程式的 Kubernetes 資源限制為 9GB。
Kubernetes生命週期管理: 升級
Kubernetes 生命週期管理(例如升級或增強)非常繁瑣,尤其是如果已經在 裸金屬或虛擬機器 上構建了自己的叢集。對於升級,我們已經意識到,最簡單的方法是使用最新版本構建新叢集,並將工作負載從舊版本過渡到新版本。節點原地升級所做的努力和計劃是不值得的。
Kubernetes 具有多個活動元件,需要升級保持一致。從 Docker 到 Calico 或 Flannel 之類的 CNI 外掛,你需要仔細地將它們組合在一起才能正常工作。雖然像 Kubespray、Kubeone、Kops 和 Kubeaws 這樣的專案使它變得更容易,但它們都有缺點.
我們在 RHEL 虛擬機器上使用 Kubespray 構建了自己的叢集。Kubespray 非常棒,它具有用於構建、新增和刪除新節點、升級版本的 playbook,以及我們在生產環境中操作 Kubernetes 所需的幾乎所有內容。但是,用於升級的 playbook 附帶了免責宣告,以避免我們跳過子版本。因此,必須經過所有中間版本才能到達目標版本。
關鍵是,如果你打算使用 Kubernetes 或已經在使用 Kubernetes,請考慮生命週期活動以及解決這一問題的方案。構建和執行叢集相對容易一些,但是生命週期維護是一個全新的體驗,具有多個活動元件。
構建和部署
在準備重新設計整個構建和部署流水線之前, 我們的構建過程和部署必須經歷 Kubernetes 世界的完整轉型。不僅在 Jenkins 流水線中進行了大量的重構,而且還使用了諸如 Helm 之類的新工具,策劃了新的 git 流和構建、標籤化 docker 映象,以及版本化 helm 的部署 chart。
你需要一種策略來維護程式碼,以及 Kubernetes 部署檔案、Docker 檔案、Docker 映象、Helm chart,並設計一種方法將它們組合在一起。
經過幾次迭代,我們決定採用以下設計:
- 應用程式程式碼及其 helm chart 放在各自的 git 儲存庫中。這使我們可以分別對它們進行版本控制(語義版本控制)。
- 然後,我們將 chart 版本與應用程式版本關聯起來,並使用它來跟蹤釋出。例如,app-1.2.0使用charts-1.1.0進行部署。如果只更改 Helm 的 values 檔案,則只更改 chart 的補丁版本(例如,從1.1.0到1.1.1)。所有這些版本均由每個儲存庫中的RELEASE.txt中的發行說明規定。
- 對於我們未構建或修改程式碼的系統應用程式,例如 Apache Kafka 或 Redis ,工作方式有所不同。也就是說,我們沒有兩個 git 儲存庫,因為 Docker 標籤只是 Helm chart 版本控制的一部分。如果我們更改了 docker 標籤以進行升級,則會升級 chart 標籤的主要版本。
存活和就緒探針(雙刃劍)
Kubernetes 的存活探針和就緒探針是自動解決系統問題的出色功能。它們可以在發生故障時重啟容器,並將流量從不正常的例項進行轉移。但是,在某些故障情況下,這些探針可能會變成一把雙刃劍,並會影響應用程式的啟動和恢復,尤其是有狀態的應用程式,例如訊息平臺或資料庫。
我們的 Kafka 系統就是這個受害者。我們執行了一個3 Broker 3 Zookeeper有狀態副本集,該狀態集的ReplicationFactor為 3,minInSyncReplica為 2。當系統意外故障或崩潰導致 Kafka 啟動時,問題發生了。這導致它在啟動期間執行其他指令碼來修復損壞的索引,根據嚴重性,此過程可能需要 10 到 30 分鐘。由於增加了時間,存活探針將不斷失敗,從而向 Kafka 發出終止訊號以重新啟動。這阻止了 Kafka 修復索引並完全啟動。
唯一的解決方案是在存活探針設定中配置initialDelaySeconds,以在容器啟動後延遲探針評估。但是,問題在於很難對此加以評估。有些恢復甚至需要一個小時,因此我們需要提供足夠的空間來解決這一問題。但是,initialDelaySeconds越大,彈性的速度就越慢,因為在啟動失敗期間 Kubernetes 需要更長的時間來重啟容器。
因此,折中的方案是評估initialDelaySeconds欄位的值,以在 Kubernetes 中的彈性與應用程式在所有故障情況(磁碟故障、網路故障、系統崩潰等)下成功啟動所花費的時間之間取得更好的平衡 。
更新:如果你使用最新版本,Kubernetes 引入了第三種探針型別,稱為“啟動探針”,以解決此問題。從 1.16 版開始提供 alpha 版本,從 1.18 版開始提供 beta 版本。
啟動探針會禁用就緒和存活檢查,直到容器啟動為止,以確保應用程式的啟動不會中斷。
公開外部IP
我們瞭解到,使用靜態外部 IP 公開服務會對核心的連線跟蹤機制造成巨大代價。除非進行完整的計劃,否則它很輕易就破壞了擴充套件性。
我們的叢集執行在Calico for CNI上,在 Kubernetes 內部採用BGP作為路由協議,並與邊緣路由器對等。對於 Kubeproxy,我們使用IP Tables模式。我們在 Kubernetes 中託管著大量的服務,通過外部 IP 公開,每天處理數百萬個連線。由於來自軟體定義網路的所有 SNAT 和偽裝,Kubernetes 需要一種機制來跟蹤所有這些邏輯流。為此,它使用核心的Conntrack and netfilter工具來管理靜態 IP 的這些外部連線,然後將其轉換為內部服務 IP,然後轉換為 pod IP。所有這些都是通過conntrack表和 IP 表完成的。
但是conntrack表有其侷限性。一旦達到限制,你的 Kubernetes 叢集(如下所示的 OS 核心)將不再接受任何新連線。在 RHEL 上,可以通過這種方式進行檢查。
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_maxnet.netfilter.nf_conntrack_count = 167012
net.netfilter.nf_conntrack_max = 262144
解決此問題的一些方法是使用邊緣路由器對等多個節點,以使連線到靜態 IP 的傳入連線遍及整個叢集。因此,如果你的叢集中有大量的計算機,累積起來,你可以擁有一個巨大的conntrack表來處理大量的傳入連線。
回到 2017 年我們剛開始的時候,這一切就讓我們望而卻步,但最近,Calico 在 2019 年對此進行了詳細研究,標題為“為什麼 conntrack 不再是你的朋友”。
安全方面
對於大部分 Kubernetes 使用者來說,安全是無關緊要的,或者說沒那麼緊要,就算考慮到了,也只是敷衍一下,草草了事。實際上 Kubernetes 提供了非常多的選項可以大大提高應用的安全性,只要用好了這些選項,就可以將絕大部分的攻擊抵擋在門外。為了更容易上手,我將它們總結成了幾個最佳實踐配置,大家看完了就可以開幹了。當然,本文所述的最佳安全實踐僅限於 Pod 層面,也就是容器層面,於容器的生命週期相關,至於容器之外的安全配置(比如作業系統啦、k8s 元件啦),以後有機會再嘮。
為容器配置Security Context
大部分情況下容器不需要太多的許可權,我們可以通過 Security Context
限定容器的許可權和訪問控制,只需加上 SecurityContext 欄位:
apiVersion: v1
kind: Pod
metadata:
name: <Pod name>
spec:
containers:
- name: <container name>
image: <image>
securityContext:
禁用allowPrivilegeEscalation
allowPrivilegeEscalation=true 表示容器的任何子程式都可以獲得比父程式更多的許可權。最好將其設定為 false,以確保 RunAsUser 命令不能繞過其現有的許可權集。
apiVersion: v1
kind: Pod
metadata:
name: <Pod name>
spec:
containers:
- name: <container name>
image: <image>
securityContext:
allowPrivilegeEscalation: false
不要使用root使用者
為了防止來自容器內的提權攻擊,最好不要使用 root 使用者執行容器內的應用。UID 設定大一點,儘量大於 3000。
apiVersion: v1
kind: Pod
metadata:
name: <name>
spec:
securityContext:
runAsUser: <UID higher than 1000>
runAsGroup: <UID higher than 3000>
限制CPU和記憶體資源
requests和limits都加上
不比掛載Service Account Token
ServiceAccount 為 Pod 中執行的程式提供身份標識,怎麼標識呢?當然是通過 Token 啦,有了 Token,就防止假冒偽劣程式。如果你的應用不需要這個身份標識,可以不必掛載:
apiVersion: v1
kind: Pod
metadata:
name: <name>
spec:
automountServiceAccountToken: false
確保seccomp設定正確
對於 Linux 來說,使用者層一切資源相關操作都需要通過系統呼叫來完成,那麼只要對系統呼叫進行某種操作,使用者層的程式就翻不起什麼風浪,即使是惡意程式也就只能在自己程式記憶體空間那一分田地晃悠,程式一終止它也如風消散了。seccomp(secure computing mode)就是一種限制系統呼叫的安全機制,可以可以指定允許那些系統呼叫。
對於 Kubernetes 來說,大多數容器執行時都提供一組允許或不允許的預設系統呼叫。通過使用 runtime/default 註釋或將 Pod 或容器的安全上下文中的 seccomp 型別設定為 RuntimeDefault,可以輕鬆地在 Kubernetes 中應用預設值。
apiVersion: v1
kind: Pod
metadata:
name: <name>
annotations:
seccomp.security.alpha.kubernetes.io/pod: "runtime/default"
預設的seccomp 配置檔案應該為大多數工作負載提供足夠的許可權,如果你有更多的需求,可以自定義配置檔案.
限制容器的capabilities
容器依賴於傳統的Unix安全模型,通過控制資源所屬使用者和組的許可權,來達到對資源的許可權控制。以 root 身份執行的容器擁有的許可權遠遠超過其工作負載的要求,一旦發生洩露,攻擊者可以利用這些許可權進一步對網路進行攻擊。
預設情況下,使用 Docker 作為容器執行時,會啟用 NET_RAW
capability,這可能會被惡意攻擊者進行濫用。因此,建議至少定義一個PodSecurityPolicy
(PSP),以防止具有 NET_RAW 功能的容器啟動。
通過限制容器的 capabilities,可以確保受攻擊的容器無法為攻擊者提供橫向攻擊的有效路徑,從而縮小攻擊範圍。
apiVersion: v1
kind: Pod
metadata:
name: <name>
spec:
securityContext:
runAsNonRoot: true
runAsUser: <specific user>
capabilities:
drop:
-NET_RAW
-ALL
是否一定需要Kubernetes?
它是一個複雜的平臺,具有自己的一系列挑戰,尤其是在構建和維護環境方面的開銷。它將改變你的設計、思維、架構,並需要提高技能和擴大團隊規模以適應轉型。
但是,如果你在雲上並且能夠將 Kubernetes 作為一種“服務”使用,它可以減輕平臺維護帶來的大部分開銷,例如“如何擴充套件內部網路 CIDR?”或“如何升級我的 Kubernetes 版本?”
今天,我們意識到,你需要問自己的第一個問題是“你是否一定需要 Kubernetes?”。這可以幫助你評估所遇到的問題以及 Kubernetes 解決該問題的重要性。
Kubernetes 轉型並不便宜,為此支付的價格必須確實證明“你的”用例的必要性及其如何利用該平臺。如果可以,那麼 Kubernetes 可以極大地提高你的生產力。
記住,為了技術而技術是沒有意義的。