製作容器映象的最佳實踐

東風微鳴發表於2023-01-14

概述

這篇文章主要是我日常工作中的製作映象的實踐, 同時結合我學習到的關於映象製作的相關文章總結出來的. 包括通用的容器最佳實踐, java, nginx, python 容器最佳實踐. 最佳實踐的目的一方面保證映象是可複用的, 提升 DevOps 效率, 另一方面是為了提高安全性. 希望對各位有所幫助.

本文分為四部分內容, 分別是:

  1. 通用容器映象最佳實踐
  2. Java 容器映象最佳實踐
  3. NGINX 容器映象最佳實踐
  4. 以及 Python 容器最佳實踐

通用容器映象最佳實踐

使用 LABEL maintainer

LABEL maintainer 指令設定映象的作者姓名和郵箱欄位。示例如下:

LABEL maintainer="cuikaidong@foxmail.com"

複用映象

建議儘量使用 FROM 語句複用合適的上游映象。這可確保映象在更新時可以輕鬆從上游映象中獲取安全補丁,而不必直接更新依賴項。

此外,在 FROM 指令中使用標籤 tag(例如 alpine:3.13),使使用者能夠清楚地瞭解映象所基於的上游映象版本。

禁止使用 latest tag以確保映象不會受到 latest 上游映象版本的重大更改的影響。

保持標籤 TAGS 的相容性

給自己的映象打標籤時,注意保持向後相容性。例如,如果製作了一個名為example 的映象,並且它當前為 1.0 版,那麼可以提供一個 example:1 標籤。後續要更新映象時,只要它繼續與原始映象相容,就可以繼續標記新映象為 example:1,並且該 tag 的下游消費者將能夠在不中斷的情況下獲得更新。

如果後續釋出了不相容的更新,那麼應該切換到一個新 tag,例如 example:2。那麼下游消費者可以按照自身實際情況升級到新版本,而不會因為新的不相容映象而造成事故。但是任何使用 example:latest的下游消費者都會承擔引入不相容更改的風險, 所以這也是前面我強烈建議不要使用 latest tag 的原因.

避免多個程式

建議不要在一個容器內啟動多個服務,例如 nginx 和 後端 app。因為容器是輕量級的,可以很容易地透過 Docker Compose 或 Kubernetes 連結在一起。Kubernetes 或基於此的 TKE 容器平臺透過將相關映象排程到單個 pod 中,輕鬆地對它們進行集中管理。

在封裝指令碼中使用 EXEC 指令

許多映象會透過在啟動應用程式之前使用封裝指令碼進行一些設定。如果您的映象使用這樣的指令碼,那麼該指令碼最後應該使用 exec 啟動應用程式,以便用應用程式的程式替換該指令碼的程式。如果不使用 exec,那麼容器執行時傳送的訊號(比如 TERMSIGKILL)將轉到封裝指令碼,而不是應用程式的程式。這不是我們所期望的。

清除臨時檔案

應刪除在生成過程中建立的所有臨時檔案。這還包括使用 ADD 指令新增的任何檔案。例如,? 我們強烈建議您在執行apt-get install操作之後執行 rm -rf /var/lib/apt/lists/* 命令。

透過如下建立 RUN 語句,可以防止 apt-get 快取儲存在映象層中:

RUN apt-get update && apt-get install -y \
    curl \
    s3cmd=1.1.* \
 && rm -rf /var/lib/apt/lists/*

請注意,如果您改為:

RUN apt-get install curl -y
RUN apt-get install s3cmd -y && rm -rf /var/lib/apt/lists/*

那麼,第一個 apt-get 呼叫會在這一層 (image layer) 中留下額外的檔案,並且在稍後執行rm -rf ... 操作時,無法刪除這些檔案。額外的檔案在最終映象中不可見,但它們會佔用空間。

另外,在一條 RUN 語句中執行多個命令可以減少映象中的層數,從而縮短下載和安裝時間。

yum 的例子如下:

RUN yum -y install curl && yum -y install s3cmd && yum clean all -y

? 備註:

  1. RUNCOPYADD 步驟會建立映象層。
  2. 每個層包含與前一層的差異項。
  3. 映象層會增加最終映象的大小。

? 提示:

  1. 將相關命令(apt-get install)放入同一 RUN 步驟。
  2. 在同一 RUN 步驟中刪除建立的檔案。
  3. 避免使用 apt-get upgradeyum upgrade all ,因為它會把所有包升級到最新版本.

按正確的順序放置指令

容器構建過程中, 讀取 dockerfile 並從上到下執行指令。成功執行的每一條指令都會建立一個層,在下次構建此映象或另一個映象時可以重用該層。建議在 Dockerfile 的頂部放置很少更改的指令。這樣做可以確保同一映象的下一次構建速度非常快,因為上層更改的快取還在, 可以複用。

例如,如果正在處理一個 dockerfile,其中包含一個用於安裝正在迭代的檔案的 ADD 指令,以及一個用於 apt-get install 包的 RUN 指令,那麼最好將ADD命令放在最後:

FROM alpine:3.11
RUN apt-get -y install curl && rm -rf /var/lib/apt/lists/*
ADD app /app

這樣,每次編輯 app 並重新執行 docker build 時,系統都會為 apt-get 命令複用快取層,並且只為 ADD 操作生成新層。

如果反過來, dockerfile 如下:

FROM alpine:3.11
ADD app /app
RUN apt-get -y install curl && rm -rf /var/lib/apt/lists/*

那麼,每次更改 app 然後再次執行 docker build 時,ADD 操作都會使映象層的快取失效,因此必須重新執行 apt-get 操作。

標記重要埠

EXPOSE 指令使容器中的埠對主機系統和其他容器可用。雖然可以指定使用 docker run -p 呼叫公開埠,但在dockerfile 中使用 EXPOSE 指令可以透過顯式宣告應用程式需要執行的埠,使人和應用程式更容易使用您的映象:

  • 暴露的埠將顯示在 docker ps 下。
  • docker inspect 返回的映象的後設資料中也會顯示暴露的埠。
  • 當將一個容器連結到另一個容器時,會連結暴露的埠。

設定環境變數

?️ 使用 ENV 指令設定環境變數是很好的實踐。一個例子是設定專案的版本。這使得人們在不檢視 dockerfile 的情況下很容易找到版本。另一個例子是在公佈一條可以被另一個程式使用的路徑,比如 JAVA_HOME.

避免預設密碼

❗ 最好避免設定預設密碼。許多人會擴充套件基礎映象,但是忘記刪除或更改預設密碼。如果為生產中的使用者分配了一個眾所周知的密碼,這可能會導致安全問題。?️ 應該使用環境變數, secret 或其他 K8s 加密方案來配置密碼

如果確實選擇設定預設密碼,請確保在容器啟動時顯示適當的警告訊息。訊息應該通知使用者預設密碼的值,並說明如何更改,例如設定什麼環境變數。

禁用SSHD

❗ 禁止在映象中執行 sshd。可以使用 docker exec 命令訪問本地主機上執行的容器。或者,可以使用 kubectl exec 命令來訪問在 K8s 或 TKE 容器平臺上執行的容器。在映象中安裝和執行sshd 會遭受潛在攻擊, 需要額外的安全補丁修復。

將 VOLUMES(卷) 用於持久資料

映象應使用來儲存持久資料。這樣,Kubernetes 或 TKE 將網路儲存掛載到執行容器的節點,如果容器移動到新節點,則儲存將重新連線到該節點。透過將卷用於所有持久化儲存的需求,即使重新啟動或移動容器,也會保留持久化內容。如果映象將資料寫入容器內的任意位置,則可能資料會丟失。

此外,在 Dockerfile 中顯式定義卷使映象的消費者很容易理解在執行映象時必須定義哪些卷。

有關如何在 K8s 或 TKE 容器平臺中使用卷的更多資訊,請參閱 Kubernetes documentation.

使用非 root 使用者執行容器程式

預設情況下,Docker 用容器內部的 root 執行容器程式。這是一個不安全的做法,因為如果攻擊者設法突破容器,他們可以獲得對Docker 宿主機的 root 許可權。

❗ 注意:

如果容器中是 root,那麼逃逸出來就是主機上的 root。

使用多階段構建

利用多階段構建來建立一個用於構建工件的臨時映象,該工件將被複制到生產映象上。臨時構建映象將與與該映像關聯的原始檔案、資料夾和依賴項一起丟棄。

這會產生了一個精益,生產就緒的映象。

一個用例是使用非Alpine基礎映象來安裝需要編譯的依賴項。然後可以將 wheel 檔案複製到最終映象。

Python 示例如下:

FROM python:3.6 as base
COPY requirements.txt /
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt

FROM python:3.6-alpine
COPY --from=base /wheels /wheels
COPY --from=base requirements.txt .
RUN pip install --no-cache /wheels/* # flask, gunicorn, pycrypto
WORKDIR /app
COPY . /app

使用前大小: 705MB, 使用後大小: 103MB

❗ 禁止在容器中儲存機密資訊

禁止在容器中儲存機密資訊, 包括:

  • 敏感資訊
  • 資料庫憑據
  • ssh 金鑰
  • 使用者名稱和密碼
  • api 令牌等

以上資訊可以透過:

  • 環境變數 ENV 傳遞
  • 卷 VOLUME 掛載

避免將檔案放入 /tmp

對於一些應用程式(如: python 的 gunicorn ), 會將某些快取資訊或心跳檢測資訊寫入 /tmp 中, 這對 /tmp 的讀寫效能有較高要求, 如果 /tmp 掛載的是普通磁碟, 可能導致嚴重的效能問題.

在某些Linux發行版中,/tmp 透過 tmpfs 檔案系統儲存在記憶體中。但是,Docker容器預設情況下沒有為 /tmp 開啟 tmpfs

$ docker run --rm -it ubuntu:18.04 df
Filesystem       1K-blocks     Used Available Use% Mounted on
overlay           31263648 25656756   3995732  87% /
tmpfs                65536        0     65536   0% /dev
tmpfs              4026608        0   4026608   0% /sys/fs/cgroup
/dev/mapper/root  31263648 25656756   3995732  87% /etc/hosts
shm                  65536        0     65536   0% /dev/shm

如上所示,/tmp 正在使用標準的 Docker overlay 檔案系統:它由普通的塊裝置或計算機正在使用的硬碟驅動器支援。這可能導致效能問題 .

針對這類應用程式, 通用的解決方案是將其臨時檔案儲存在其他地方。特別是,如果你看上面你會看到 /dev/shm 使用 shm 檔案系統共享記憶體和記憶體檔案系統。所以你需要做的就是使用 /dev/shm 而不是 /tmp

使用 Alpine Linux基礎映象 (謹慎採納)

使用基於Alpine Linux 的映象,因為它只提供必要的包, 生成的映象更小。

收益有:

  1. 減少了主機成本,因為使用的磁碟空間更少
  2. 更快的構建、下載和執行時間
  3. 更安全(因為包和庫更少)
  4. 更快的部署

示例如下:

FROM python:3.6-alpine
WORKDIR /app
COPY requirements.txt /
RUN pip install -r /requirements.txt  # flask and gunicorn
COPY . /app

使用前大小: 702MB, 使用後大小: 102MB

❗ 注意:

謹慎使用 alpine, 我看到過使用 Alpine Linux 產生的一大堆問題,因為它建立在 musl libc 之上,而不是大多數 Linux 發行版使用的GNU libc(glibc)。問題有: 日期時間格式的錯誤, 由於堆疊較小導致的崩潰等等。

使用 .dockerignore 排除無關檔案

要排除與構建無關的檔案,請使用 .dockerignore 檔案。此檔案支援與 .gitignore 檔案類似的排除模式。具體請參閱 .dockerignore檔案

不要安裝不必要的包

為了降低複雜性,依賴性,檔案大小和構建時間,請避免安裝額外的或不必要的應用程式包。例如,不需要在資料庫映象中包含文字編輯器。

解耦應用程式

每個容器應該只有一個程式。將應用程式分離到多個容器中可以更容易地水平擴充套件和重用容器。例如,Web 應用程式堆疊 LNMP 可能包含三個獨立的容器,每個容器都有自己獨特的映像,以分離的方式管理 Web 伺服器, 應用程式,快取資料庫和資料庫。

將每個容器限制為一個程式是一個很好的經驗法則,但它不是一個硬性規則。例如,可以 使用 init 程式生成容器 ,另外某些程式可能會自行生成其他子程式 (如: nginx)。

根據自己的經驗進行判斷,儘可能保持容器簡潔和模組化。如果容器彼此依賴,則可以使用 容器網路 或 K8s Sidecar 來確保這些容器可以進行通訊。

對多行引數進行排序

建議透過按字母順序排序多行引數來方便後續的更改。這有助於避免重複包並使列表更容易更新。這也使 PR 更容易閱讀和審查。在反斜槓(\)之前新增空格也有幫助。

下面是來自一個示例openjdk影像

...
  apt-get update; \
  apt-get install -y --no-install-recommends \
    dirmngr \
    gnupg \
    wget \
  ; \
  rm -rf /var/lib/apt/lists/*; \
...

JAVA 容器映象最佳實踐

IDE外掛推薦

  • idea - 轉到“首選項”、“外掛”、“安裝JetBrains外掛…”,搜尋“Docker”並單擊“安裝”
  • Eclipse

? 備註:

Docker and IntelliJ IDEA

Docker and Eclipse

設定記憶體限制相關引數

? 備註:

指定 -Xmx=1g 將告訴 JVM 分配一個 1 GB 堆, 但是它並沒有告訴 JVM 將其整個記憶體使用量限制為 1 GB。除了對記憶體, 還會有 card tables、code cache 和各種其他堆外資料結構。用於指定總記憶體使用量的引數是 -XX:MaxRAM。請注意,使用 -XX:MaxRam=500m 時,堆將大約為 250 MB。

JVM 在歷史上查詢/proc以確定有多少可用記憶體,然後根據該值設定其堆大小。不幸的是,像 docker 這樣的容器在/proc中不提供特定於容器的資訊。2017年之後有一個補丁,提供了一個 -XX:+UseCGroupMemoryLimitForHeap命令列引數,它告訴 jvm 查詢 /sys/fs/cgroup/memory/memory.limit_in_bytes,以確定有多少可用記憶體。如果這個補丁在執行的 OpenJDK 版本中不可用,可以透過顯式設定 -XX:MaxRAM=n 來代替。

總結, 設定記憶體限制相關引數:

  1. Openjdk 8 的新版本, 新增: -XX:+UseCGroupMemoryLimitForHeap
  2. 如果沒有上邊的引數, 設定:-XX:MaxRAM=n
  3. 建議設定 JVM Heap 約為 memory limit 的 50% - 80%
  4. 建議設定 JVM MaxRAM 接近 K8s pod 的 memory limit

設定GC策略

OpenJDK8 中有一個補丁,它將使用 cgroup 可用的資訊來計算適當數量的並行 GC 執行緒。但是,如果這個補丁在您用的 OpenJDK 版本中不可用,假設您的容器宿主機有 8 個 CPU, 但是容器中 CPU limit 為 2 個 CPU, 那麼您最終可能會得到 8 個並行 GC 執行緒。解決方法是顯式指定並行GC執行緒的數量: -XX:ParallelGCThreads=2

如果您的容器中 cpu limit 設定為只有一個 CPU,強烈建議使用 -XX:+UseSerialGC 執行,來完全避免並行GC。

JAVA 啟動階段調優

JAVA 程式都有一個啟動階段,它需要大量的堆,之後可能會進入一個安靜的迴圈階段,在這個階段它就不需要太多的堆。

對於序列 GC 策略, 您可以透過配置使它更具侵略性, 如: -XX:MinHeapFreeRatio=20(當堆佔用率大於 80%,此值預設增大。)

XX:MaxHeapFreeRatio=40(堆佔用率小於60%時收縮)

對於並行 - parallel GC策略, 推薦如下配置:

-XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90

JAVA 容器全域性建議資源請求和資源限制

JAVA 程式都有一個啟動階段,啟動階段也會大量消耗 CPU, CPU 使用越多, 啟動階段越短.
下面是一個表,總結了不同CPU限制下的 spring boot 示例應用啟動時間(CPU 以 millicore 為單位):

  • 500m - 80 seconds
  • 1000m - 35 seconds
  • 1500m - 22 seconds
  • 2500m - 17 seconds
  • 3000m - 12 seconds

根據以上情況, K8s 或 TKE 容器平臺管理員可以考慮對 JAVA 容器做如下限制:

  • 使用CPU requests, 不設定 cpu limit
  • 使用 memory limit 且等於 memory request

示例如下:

resources:
  requests:
    memory: "1024Mi"
    cpu: "500m"
  limits:
    memory: "1024Mi"

使用 ExitOnOutOfMemoryError 而非 HeapDumpOnOutOfMemoryError (謹慎評估)

我們都知道, 在傳統的虛擬機器上部署的 Java 例項. 為了更好地分析問題, 一般都是要加上: -XX:+HeapDumpOnOutOfMemoryError這個引數的, 加這個引數後, 如果遇到記憶體溢位, 就會自動生成 HeapDump , 後面我們可以拿到這個 HeapDump 來更精確地分析問題.

但是, 容器技術的應用, 帶來了一些不同, 在使用容器平臺後, 我們更傾向於:

  1. 遇到故障快速失敗
  2. 遇到故障快速恢復
  3. 儘量做到使用者對故障"無感知"

所以, 針對 Java 應用容器, 我們也要最佳化以滿足這種需求, 以 OutOfMemoryError 故障為例:

  1. 遇到故障快速失敗, 即儘可能"快速退出, 快速終結"

-XX:+ExitOnOutOfMemoryError 就正好滿足這種需求:

傳遞此引數時,丟擲 OutOfMemoryError 時 JVM 將立即退出。 如果您想盡快終止異常應用程式,則可以傳遞此引數。

NGINX 容器映象最佳實踐

如果您直接在基礎硬體或虛擬機器上執行 NGINX,通常需要一個 NGINX 例項來使用所有可用的CPU。由於NGINX 是多程式模式,通常你會啟動多個 worker processes,每個工作程式都是不同的程式,以便利用所有CPU。

但是,在容器中執行時,如果將 worker_processes 設定為 auto, 會根據容器所在宿主機的 CPU 核數啟動相應程式數. 比如, 我之前在物理機上執行 NGINX 容器使用 auto 引數, 儘管 CPU limit 設定為2, 但是 NGINX 會啟動 64 (物理機 CPU 數) 個程式.

因此,?️建議根據 實際需求或 CPU limit 的設定配置 nginx.conf, 如下:

worker_processes  2;

Python 容器映象最佳實踐

?Warning:
隨著時間的遷移, 以及實踐的深入, 最佳實踐也在發生著變化, 以下部分內容已經不能作為 Python 容器映象的最佳實踐.
最新的 Python 容器映象最佳實踐可以參見這篇文章: https://EWhisper.cn/posts/25776/

示例如下:

# 基於官方基礎映象
FROM python:3.7-alpine

# 設定工作目錄
WORKDIR /app

# 設定環境變數
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0

# install psycopg2
RUN apk update \
    && apk add --virtual build-deps gcc musl-dev python3-dev \
    && apk add postgresql-dev \
    && pip install psycopg2 \
    && apk del build-deps

# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# copy project
COPY . .

# 切換到非 root 使用者
RUN adduser -D myuser
USER myuser

# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

△ 示例 Dockerfile

IDE外掛推薦

建議配置的環境變數

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
  1. PYTHONDONTWRITEBYTECODE: 防止 python 將 pyc 檔案寫入硬碟
  2. PYTHONUNBUFFERED: 防止 python 緩衝 stdout 和 stderr
  3. DEBUG: 方便根據環境型別的不同(測試/生產)調整是否開啟debug

安裝資料庫驅動包的方法

以 postgredb 的驅動 psycopg2 為例, 可能需要安裝額外的基礎元件:

# install psycopg2
RUN apk update \
    && apk add --virtual build-deps gcc musl-dev python3-dev \
    && apk add postgresql-dev \
    && pip install psycopg2 \
    && apk del build-deps

參考連結

2個問題

  1. 您是否有製作其他語言映象的最佳實踐呢?
  2. 您是否嘗試透過 GraalVM 製作 原生可執行 Java 映象? 體驗如何?

三人行, 必有我師; 知識共享, 天下為公. 本文由東風微鳴技術部落格 EWhisper.cn 編寫.

相關文章