使用K8s的一些經驗和體會
- 2021 年 1 月 18 日
- 筆記
- Kubernetes
使用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 可以極大地提高你的生產力。
記住,為了技術而技術是沒有意義的。