
POV:手上那份 20 行的 docker compose,第一次要「翻譯」成 K8s,卻不知道 Pod、Deployment、Service 哪一個對應哪一行。
第一週最後一篇。Day 03 貼過 LINE bot 那份真的在跑的 compose(bot 加 cloudflared tunnel,20 行);今天直接把那 20 行翻成 K8s YAML,三個名詞自然會對上位置。
先講結論:三個模型各回答一個問題。Pod 回答「什麼東西要一起跑」,Deployment 回答「要維持幾份、掛了怎麼辦」,Service 回答「別人怎麼找到它」。 下面每節都照這個順序:回答問題、對應 compose、給 YAML、給指令。
它回答什麼問題。 哪幾個 container 要排在同一台機器、共用網路、當成一個單位。
對應 compose 哪幾行。 lineoa-course 和 cloudflared 這兩個 service:compose 裡是兩個獨立容器,靠 depends_on 管啟動順序;但 tunnel 只服務這隻 bot 的 webhook,離開 bot 就沒意義——這種關係在 K8s 裡就是同一個 Pod 裡的兩個 container,tunnel 當 bot 的 sidecar。
其他對應整理在文末表格;這裡多補一個 compose 沒有對應欄位的 allowPrivilegeEscalation: false,防止行程取得更高權限。
最小 YAML。 只放 containers 段,bot 的 volumes 先略過(config.toml 與 codex-home 的去向,最後一節講)。tunnel 的設定檔得先掛進去,不然 cloudflared 一起來就找不到 config.yml 退出;這裡只示範掛法,credentials 的 Secret 省略,照抄還跑不起來。
apiVersion: v1
kind: Pod
metadata:
name: lineoa-course
labels: { app: lineoa-course }
spec:
restartPolicy: Always
containers:
- name: bot
image: ghcr.io/openabdev/openab:0.9.0-beta.10-codex
ports:
- containerPort: 8080
envFrom:
- secretRef: { name: lineoa-course-env }
livenessProbe:
exec: { command: ["pgrep", "-x", "openab"] }
periodSeconds: 30
securityContext:
capabilities: { drop: ["ALL"] }
allowPrivilegeEscalation: false
- name: tunnel
image: cloudflare/cloudflared:latest
args: ["tunnel", "--config", "/etc/cloudflared/config.yml", "run"]
volumeMounts:
- name: tunnel-config
mountPath: /etc/cloudflared
readOnly: true
volumes:
- name: tunnel-config
configMap: { name: lineoa-course-tunnel } # credentials 另掛 Secret,此處省略
metadata.labels 那行現在看起來多餘,Service 那節會用到:Service 靠 label 找 Pod,沒有這行,後面的 selector 就選不到它。
容易寫錯的地方:restartPolicy 是 Pod 層級欄位,重啟卻是各個 container 自己來:tunnel 掛了只重啟 tunnel,不會連帶重啟 bot,反過來也一樣。「一起排程、共享網路」是真的,「一起重啟」不是。
一個指令看它。 kubectl get pod -o wide 看排在哪個節點、拿到什麼 IP;kubectl describe pod lineoa-course 看兩個 container 的重啟次數與 probe 結果。
它回答什麼問題。 上面那個 Pod 若整個消失,誰補一個新的?沒有人。單獨建的 Pod 沒了就沒了,跟直接 docker run 一個容器沒兩樣。(Deployment 取代上一節那個獨立 Pod,不是疊加;兩段 YAML 別一起套用,否則會留下一個 Deployment 不管、但同樣符合 selector 的獨立 Pod,Service 可能把流量分給兩邊。)
對應 compose 哪幾行。 還是 restart: unless-stopped,這次對應的是它的另一半語意。9/15 早上 dev-box 重開機,13 個 OpenAB 容器在 22 秒內自己回來,靠 Docker daemon 開機後照 restart policy 拉起容器。K8s 把這件事交給 controller:replicas: 1 之下,controller 不停比對「宣告要一個」和「實際有幾個」,差多少補多少,補不起來就一直試。
最小 YAML。 概念上是把 Pod 的 spec 塞進 template;下面這塊本身不能直接套用——篇幅所限只列了 bot,要真的取代前面的 Pod,得把 tunnel、probe、securityContext 從上一段整段搬進 containers 才算完整。
apiVersion: apps/v1
kind: Deployment
metadata:
name: lineoa-course
spec:
replicas: 1
selector:
matchLabels: { app: lineoa-course }
template:
metadata:
labels: { app: lineoa-course }
spec:
restartPolicy: Always
containers:
- name: bot
image: ghcr.io/openabdev/openab:0.9.0-beta.10-codex
ports:
- containerPort: 8080
# tunnel、livenessProbe、securityContext 照上一段 Pod 的寫法補上
replicas 這隻 bot 維持 1,不是不能開兩份,是我猜 codex 狀態放在具名 volume,兩份可能搶同一份——這只是假設,還沒實測過並行行為。
換版時 RollingUpdate 預設會短暫讓兩個 Pod 並存,replicas: 1 管的是穩態,兩者不衝突;真的不能同時碰狀態要改用 strategy.type: Recreate(還沒實測過)。
一個指令看它。 kubectl get deploy 看 READY 是不是 1/1;kubectl rollout status deploy/lineoa-course 看換版有沒有卡住。
它回答什麼問題。 Pod 會被重建,IP 常變;誰要連它,該連哪裡?
對應 compose 哪幾行。 ports: "127.0.0.1:8092:8080" 加上 tunnel,就是這隻 bot「怎麼被找到」的全部設定:容器內聽 8080,host 只綁 loopback 8092,外面的 LINE webhook 走 tunnel 進來。
另一個例子是 devstack:rag-engine:4001 是它自己的服務位址(compose 內部 DNS,同機同專案內有效)。K8s 的 Service 把這種名字升級成叢集範圍:固定名字加內部 IP,後面掛著 Ready 的 Pod(沒設 readinessProbe 就預設 Ready;設了才由它決定收不收流量),像 rag-engine.default.svc,換節點也解析得到。
最小 YAML。
apiVersion: v1
kind: Service
metadata:
name: lineoa-course
spec:
selector:
app: lineoa-course
ports:
- port: 8092
targetPort: 8080
老實說,這隻 bot 可能不需要 Service:tunnel 跟 bot 同 Pod,直接連 localhost:8080 就好,且是主動連出去,沒有 Pod 要找它。Service 真正派上用場的是 devstack 那種互相查找的關係,這隻 bot 要不要留,Day 13 再決定。
一個指令看它。 kubectl get svc 看名字和 ClusterIP;kubectl port-forward svc/lineoa-course 8092:8092 拉到自己電腦,效果跟 compose 綁 loopback 類似。
| compose 欄位 | K8s 物件/欄位 | 備註 |
|---|---|---|
restart: unless-stopped |
Pod spec.restartPolicy: Always + Deployment replicas |
不是完全對應:compose 手動 stop 後不會自動起來,Always 是容器退出就一直重啟;replicas 管的是 Pod 消失後補新的 |
healthcheck |
livenessProbe |
另有 readinessProbe 決定給不給流量 |
env_file |
Secret + envFrom.secretRef |
非機密的設定可改用 ConfigMap |
ports: "127.0.0.1:8092:8080" |
containerPort + Service port/targetPort |
沒有 loopback-only 對應;ClusterIP 預設只在叢集內可達 |
depends_on |
沒有直接對應 | compose 是「等容器起來再啟動」;同 Pod 的 container 同時啟動、沒有這種等待,順序得用 init container;跨 Pod 靠應用程式自己重試,readinessProbe 只決定給不給流量 |
volumes(config.toml:ro) |
ConfigMap 掛成檔案 | |
volumes(具名 codex-home) |
PersistentVolumeClaim | |
cap_drop: [ALL] |
securityContext.capabilities.drop: ["ALL"] |
寫在 container 層級 |
mem_limit,搬過來若也不寫,排程器不知道它要多少,Day 09 專門講。下週開始講資源:AI 工作負載跟一般 web app 差別很大;明天先把我的服務分成三種工作負載,再決定哪一種該用哪個 controller。
Pod 是「什麼東西要一起跑」,Deployment 是「要維持幾份」,Service 是「怎麼被找到」;compose 20 行大部分能對到這三個之一,對不到的(像 depends_on)就是 K8s 多出來要你決定的事。