OpenShift Image Mode 試玩

某天早上看到這篇文章:Debugging image mode with Red Hat OpenShift 4.20: A practical guide,一開始我以為是 OpenShift 除了 debug pod 之後有新的除錯方法,後來發現這個 Image Mode 蠻有意思的,可以取代 Machine Config 的方式針對 OpenShiftworker node 預先除錯,縮短 troubleshooting 時間。

實驗目標: 在不驚動任何實體 Worker 節點的狀況下,利用 MachineOSConfig(Image Mode On-Cluster Layering)安全地 Dry-Run 編譯出一個內建 tmuxhtop 的客製化 RHEL CoreOS 9 作業系統映像檔。


注意事項

Caution

本文為了驗證單一 Technology Preview 功能而啟用 TechPreviewNoUpgrade,其影響不是只有功能層級,是整個 cluster 的生命週期:此設定不可逆,且後續無法進行 minor version upgrade。
X: 4.20 -> 4.21
O: 4.20.23 -> 4.20.24

Image Mode 是什麼?

我們可以理解成客製化的 RHCOS 作業系統如下:

把 OpenShift 節點底層的 RHCOS 作業系統,當作 container image 一樣進行客製化、版本化與部署。

以往要做線上的 troubleshooting 都是開 debug pod 臨時針對節點操作。Image Mode 讓某些節點可以永久具備額外套件或功能,例如:

  • 安裝特定 RPM 套件,例如 rsyslog、chrony、iputils
  • 安裝 Security Agent、Inventory Agent、Forensic tool
  • 套用 Red Hat 提供的 hotfix
  • 加入第三方 driver 或特殊工具
  • 讓指定 MCP 的所有節點都以相同 OS image 開機

OpenShift Image Mode 是基於該版本的 RHCOS 建立一個 custom layered image,再由 Machine Config Operator(MCO) 將它部署到指定的 MachineConfigPool(MCP)節點。總而言之是為了解決手動修改造成的追蹤不易等潛在問題。


第一階段:初始化

為了 100% 確保叢集生產環境的安全,本次實驗只驗證 build 階段是否成功。所以建立一個帶有 0 台實體機器的自訂池子,主要是因為 MachineOSConfig 一定要指定 machineConfigPool

建立幽靈 node pool

因為我今天只是要測試 dry-run 的過程,並不是實際要替換掉現有的 worker node,所以
建立自訂的幽靈機器池 ghost-pool(機器數量為 0),並準備送入第一版 test-image-mode.yaml

image_mode_screenshot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: machineconfiguration.openshift.io/v1
kind: MachineConfigPool
metadata:
name: ghost-pool
spec:
machineConfigSelector:
matchLabels:
machineconfiguration.openshift.io/role: worker

# 維持不變,確保這個池子裡永遠是 0 台實體機器
nodeSelector:
matchLabels:
# 目前沒有符合 selector 的 node,因此目前為 0
node-role.kubernetes.io/ghost: ""

這邊是我準備的 test-image-mode.yaml,這個在 UI Console 上面沒有陳列,必須要輸入 https://<OpenShift-FQDN>/k8s/cluster/machineconfiguration.openshift.io~v1~MachineOSConfig 才看得到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: machineconfiguration.openshift.io/v1
kind: MachineOSConfig
metadata:
name: ghost-pool
spec:
machineConfigPool:
name: ghost-pool

imageBuilder:
imageBuilderType: Job

renderedImagePushSecret:
name: pull-secret

# 因為我的 lab 環境沒有內建的 image registry ,改用外部的 docker hub
renderedImagePushSpec: image-registry.openshift-image-registry.svc:5000/openshift/custom-rhcos:poc

containerFile:
- containerfileArch: NoArch # 代表這個設定通吃所有 CPU 架構
content: |-
FROM configs
RUN dnf install -y tmux htop
RUN echo "Welcome to OpenShift Image Mode!" > /etc/motd

第二階段:開始除錯

初次送出配置後,後台 Build Pod 彈出 Init:Error 狀態。

排除排他性防呆鎖

MCO 具備「一池一任務」的 Registry 車道防撞保護。換新配方前必須手動清除舊的 Build 紀錄:

1
oc delete machineosbuild --all

DNS 撞牆:no such host

任務在 VFS 磁碟複製中跑了十幾分鐘,卻敗在最後的 buildah push 階段。

日誌顯示沙盒隔離網路找不到 image-registry.openshift-image-registry.svc,加上 .cluster.local 全網域後依然被 CoreDNS 拒絕。

1
oc get image.config.openshift.io/cluster -o jsonpath='{.status.internalRegistryHostname}'

指令回傳完全的空白,看起來內建倉庫被拔除了

1
oc get configs.imageregistry.operator.openshift.io/cluster -o jsonpath='{.spec.managementState}'

結果: Removed

預設沒有內建 registry storage,自然沒有內部的 Image registry,得改外部的 registry。


第三階段:轉存 Docker Hub,成功

既然內網無路可走,轉向外網借道 Docker Hub。

建立外部專用通關金鑰

1
2
3
4
5
oc create secret docker-registry dockerhub-secret \
--docker-server=https://index.docker.io/v1/ \
--docker-username=【你的DockerHub帳號】 \
--docker-password=【你的Token或密碼】 \
-n openshift-machine-config-operator

最終 YAML

排除不在紅帽官方源的 htop(EPEL 源),改用官方 AppStream 的 gitjq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: machineconfiguration.openshift.io/v1
kind: MachineOSConfig
metadata:
name: ghost-pool
spec:
machineConfigPool:
name: ghost-pool
imageBuilder:
imageBuilderType: Job
renderedImagePushSecret:
name: dockerhub-secret
renderedImagePushSpec: docker.io/【DockerHub帳號】/custom-rhcos:poc
containerFile:
- containerfileArch: NoArch
content: |-
FROM configs
RUN dnf install -y git jq
RUN echo "Welcome to OpenShift Image Mode!" > /etc/motd

成功 build OS image

超渡舊紀錄後強行覆蓋套用,工廠順利完成 DNF 安裝並將客製化 OS 映像檔推播至 Docker Hub:

1
oc get machineosbuild
1
2
NAME                                           PREPARED   BUILDING   SUCCEEDED   INTERRUPTED   FAILED
ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a False False True False False

成功版本的 oc log 結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
oc logs -n openshift-machine-config-operator pod/build-ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a-6vjfg -c image-build --tail=100

Copying blob sha256:dc2cddc2c1f6a6c958458497675150ab90733cb228cd302d9484a9432eb2a823
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:dc2cddc2c1f6a6c958458497675150ab90733cb228cd302d9484a9432eb2a823: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:dc2cddc2c1f6a6c958458497675150ab90733cb228cd302d9484a9432eb2a823 (already present):"
Copying blob sha256:fff55d5b118e69c52d431b5d5ad8e6cd04552c9c6f31ed5f2cab0bf4abd077af
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:fff55d5b118e69c52d431b5d5ad8e6cd04552c9c6f31ed5f2cab0bf4abd077af: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:fff55d5b118e69c52d431b5d5ad8e6cd04552c9c6f31ed5f2cab0bf4abd077af (already present):"
Copying blob sha256:848b9270a4cb614899eaa2e381bc5b7d21279c79477c2112b70adcbb7172bc14
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:848b9270a4cb614899eaa2e381bc5b7d21279c79477c2112b70adcbb7172bc14: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:848b9270a4cb614899eaa2e381bc5b7d21279c79477c2112b70adcbb7172bc14 (already present):"
Copying blob sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:12787d84fa137cd5649a9005efe98ec9d05ea46245fdc50aecb7dd007f2035b1 (already present):"
Copying blob sha256:215bb759d988f713b8650f2ec84636d3689223af3570fb2e1e70027f8a430a25
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:215bb759d988f713b8650f2ec84636d3689223af3570fb2e1e70027f8a430a25: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:215bb759d988f713b8650f2ec84636d3689223af3570fb2e1e70027f8a430a25 (already present):"
Copying blob sha256:a543989d318c57b45f9d2c052fa4851f7bae1d331c1786d286f7295fc7c8e2c3
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:a543989d318c57b45f9d2c052fa4851f7bae1d331c1786d286f7295fc7c8e2c3: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:a543989d318c57b45f9d2c052fa4851f7bae1d331c1786d286f7295fc7c8e2c3 (already present):"
Copying blob sha256:9c6b1561152d40674986482ba90352bb321fba1fd4612c8cb26e8a65c5892068
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:9c6b1561152d40674986482ba90352bb321fba1fd4612c8cb26e8a65c5892068: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="Skipping blob sha256:9c6b1561152d40674986482ba90352bb321fba1fd4612c8cb26e8a65c5892068 (already present):"
Copying blob sha256:29341f18c3d200d1ba3a8bdedfd3790c2ee3f4ffeb0d4c1bcf88813efaa6a96a
time="2026-05-28T09:01:33Z" level=debug msg="Checking if we can reuse blob sha256:29341f18c3d200d1ba3a8bdedfd3790c2ee3f4ffeb0d4c1bcf88813efaa6a96a: general substitution = true, compression for MIME type \"application/vnd.oci.image.layer.v1.tar\" = true"
time="2026-05-28T09:01:33Z" level=debug msg="reading layer \"sha256:29341f18c3d200d1ba3a8bdedfd3790c2ee3f4ffeb0d4c1bcf88813efaa6a96a\""
time="2026-05-28T09:01:33Z" level=debug msg="No compression detected"
time="2026-05-28T09:01:33Z" level=debug msg="Using original blob without modification"
time="2026-05-28T09:01:38Z" level=debug msg="Start untar layer"
time="2026-05-28T09:01:39Z" level=debug msg="Untar time: 1.269012695s"
time="2026-05-28T09:01:39Z" level=debug msg="finished reading layer \"sha256:29341f18c3d200d1ba3a8bdedfd3790c2ee3f4ffeb0d4c1bcf88813efaa6a96a\""
Copying config sha256:3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0
time="2026-05-28T09:01:39Z" level=debug msg="No compression detected"
time="2026-05-28T09:01:39Z" level=debug msg="Compression change for blob sha256:3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0 (\"application/vnd.oci.image.config.v1+json\") not supported"
time="2026-05-28T09:01:39Z" level=debug msg="Using original blob without modification"
Writing manifest to image destination
time="2026-05-28T09:01:39Z" level=debug msg="setting image creation date to 2026-05-28 09:01:30.422776125 +0000 UTC"
time="2026-05-28T09:01:39Z" level=debug msg="created new image ID \"3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0\" with metadata \"{}\""
time="2026-05-28T09:01:39Z" level=debug msg="added name \"docker.io/hazel910159/custom-rhcos:ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a\" to image \"3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0\""
--> 3f370770df4d
time="2026-05-28T09:01:39Z" level=debug msg="parsed reference into \"[vfs@/home/build/.local/share/containers/storage+/var/tmp/storage-run-1000/containers]docker.io/hazel910159/custom-rhcos:ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a\""
Successfully tagged docker.io/hazel910159/custom-rhcos:ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a
3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0
time="2026-05-28T09:01:43Z" level=debug msg="printing final image id \"3f370770df4d9446e741f3e5695fce92dbd4c57702b09f0a1dd18f42ab54c7e0\""
time="2026-05-28T09:01:43Z" level=debug msg="shutting down the store"
+ buildah push --storage-driver vfs --authfile=/tmp/final-image-push-creds/config.json --digestfile=/tmp/done/digestfile --cert-dir /var/run/secrets/kubernetes.io/serviceaccount docker.io/hazel910159/custom-rhcos:ghost-pool-0d82e40212fe20bf27b9ae9858bcc55a
Getting image source signatures
Copying blob sha256:3a1265f127cd4df9ca7e05bf29ad06af47b49cff0215defce94c32eceee772bc
Copying blob sha256:c0a1ed51dc9cede536c633b9c1a37ed2e985a6b80c213d252b6b7820aae6a144
...
...
...
Copying blob sha256:29341f18c3d200d1ba3a8bdedfd3790c2ee3f4ffeb0d4c1bcf88813efaa6a96a

結果

項目 說明
交付產物 docker.io/【你的帳號】/custom-rhcos:poc,完整可開機 RHEL CoreOS 9 映像檔
安全驗證 全程在ghost-pool(0 台機器)中完成,零節點受到影響
解鎖技能 OpenShift 4.20 OS-as-Code(作業系統即程式碼)運維工法

Image Mode 主要限制

這邊整理了一下 官方文件陳列的一些限制,使用上需要多加注意。

限制 說明
On-cluster 不支援 multi-architecture compute machines 混合架構的 compute nodes 不能使用 on-cluster image mode。
每個 MCP 只能對應一個 MachineOSConfig 同一個 MachineConfigPool 不支援多個MachineOSConfig
擴充使用 layered image 的 MachineSet 時,新 Node 會 reboot 兩次 第一次以 base image 建立,第二次套用 custom layered image。
使用 custom layered image 的 Node 不支援 Node Disruption Policy 無法透過該政策避免相關 node disruption。
套用 image 時會造成 Node reboot MCO 會 drain、cordon 並重新啟動相關 nodes。
Registry 需要額外空間與清理管理 Build 出來的 custom layered images 會持續佔用 registry 空間。
使用 custom layered image 後,需要自行維護更新 Node pool 不會再由 OpenShift 自動更新 OS image;升級後需重新基於新 RHCOS image build。
Custom layered image 不宜落後 cluster 版本太久 若 node image 與 cluster version 差距過大,可能出現非預期行為。
必須使用 image digest,不可只用 tag 建立 custom layered image 時,base image 必須引用 OpenShift image digest。
部署前應先在非 production 環境測試 尤其是 hotfix、agent 或額外 RPM,套用後的相容性與問題由使用者承擔。

實際部署情境

整個部署階段可以分成三階段:

  1. image build 是否成功(ghost node = 0)
  2. image 部署以及啟動是否成功(ghost node = 1)
  3. 替換線上 node pool 節點

Image Mode Flow

Reference