Chapter 15

Docker、K8s、CI/CD 與雲端

從容器原理(namespace、cgroup、overlay2)一路講到 Kubernetes 物件、探針與資源、部署策略、GitOps、CI/CD pipeline、AWS/GCP 服務選型、VPC 與 IAM,最後補上職缺明列的 Git 進階操作。Appier 的服務是高 QPS、低延遲、大量批次運算的組合,「怎麼把服務穩定地跑起來、擴起來、便宜地跑」正是後端工程師日常,也是 senior 面試會直接問 AWS 與 K8s 的原因。

預估閱讀 75 分鐘

容器 vs VM 與 Docker 底層原理 高頻 進階

面試最常見的開場題是「容器跟虛擬機差在哪?」。只回答「容器比較輕」是不夠的,面試官要聽到的是隔離發生在哪一層:VM 透過 hypervisor 虛擬化硬體,每台 VM 有自己的 kernel;容器則是同一個 host kernel 上的一群被隔離的 process,隔離靠的是 Linux kernel 的 namespaces,資源限制靠 cgroups,檔案系統靠 union filesystem(overlay2)。

flowchart TB subgraph VM["虛擬機架構"] direction TB HW1[Hardware] --> HV[Hypervisor KVM/Xen] HV --> G1["Guest OS + Kernel A"] HV --> G2["Guest OS + Kernel B"] G1 --> A1[App 1] G2 --> A2[App 2] end subgraph CT["容器架構"] direction TB HW2[Hardware] --> K[Host Linux Kernel] K --> RT["Container Runtime containerd/runc"] RT --> C1["Container 1 namespaces+cgroups"] RT --> C2["Container 2 namespaces+cgroups"] end
面向容器(Container)虛擬機(VM)
隔離層級Process 層級,共用 host kernel硬體層級,每台有獨立 kernel
啟動時間通常秒級以下(本質上是 fork/exec 一個 process)數十秒到分鐘(要開機)
映像大小MB 等級(只含 user space)GB 等級(含整個 OS)
安全邊界較弱:kernel 漏洞可能造成 container escape較強:需要突破 hypervisor
密度一台機器可跑數十到上百個受記憶體與 guest OS overhead 限制
跨 OSLinux container 需要 Linux kernel(Mac/Windows 上的 Docker 其實跑在一台 Linux VM 裡)可跑任意 OS
典型用途微服務、CI、批次任務、可重現的開發環境多租戶強隔離、需要不同 kernel、傳統應用
實戰提示

兩者不互斥:雲端上的 K8s node 本身就是 VM(EC2/GCE),容器跑在 VM 之上。需要更強隔離又想保有容器體驗時,可用 gVisor(user-space kernel)或 Kata Containers / Firecracker(microVM,AWS Lambda 與 Fargate 背後的技術)。

Namespaces:讓 process「看不到」別人

Namespace 決定一個 process 能「看見」哪些系統資源。docker run 時 runtime(runc)會呼叫 clone() / unshare() 建立一組新的 namespace:

PID
容器內有自己的 PID 編號,第一個 process 是 PID 1。從 host 看它只是一般 process,有另一個 PID。
NET
獨立的網路介面、路由表、iptables、port 空間。Docker 預設用 veth pair 接到 host 的 docker0 bridge,再用 NAT 對外。K8s 中同一個 Pod 的容器共用 NET namespace,所以能用 localhost 互通。
MNT
獨立的 mount table,容器看到的 / 是 image 組成的 rootfs(透過 pivot_root)。
UTS
獨立的 hostname / domain name。
IPC
獨立的 System V IPC、POSIX message queue、shared memory。
USER
UID/GID 映射:容器內的 root(UID 0)可以映射到 host 上的非特權 UID(rootless container 的基礎)。
CGROUP
讓容器看到的 cgroup 樹以自己為根。
# 不用 Docker,自己體驗 namespace:開一個有獨立 PID 與 mount namespace 的 shell
sudo unshare --pid --fork --mount-proc bash
ps aux        # 只看得到 bash 與 ps,bash 是 PID 1

# 觀察一個容器 process 的 namespace
docker run -d --name demo nginx:alpine
PID=$(docker inspect -f '{{.State.Pid}}' demo)
sudo ls -l /proc/$PID/ns          # 每個 namespace 是一個 inode
sudo nsenter -t $PID -n ip addr   # 進入該容器的 network namespace 執行 host 上的 ip 指令

cgroups:限制「能用多少」

Control groups 負責資源的限制、計量與優先權。現代發行版多半使用 cgroup v2(單一統一階層)。常見控制器:

  • cpu:cpu.max 設定 quota/period(例如 50000 100000 代表每 100ms 最多用 50ms,相當於 0.5 核);cpu.weight 決定競爭時的相對權重。K8s 的 CPU limit 就是轉成 CFS quota,CPU request 轉成 weight(shares)。
  • memory:memory.max 是硬上限,超過時 cgroup 內觸發 OOM killer,容器被殺(exit code 137)。
  • pids:限制 process 數,防 fork bomb。
  • io:限制區塊裝置讀寫頻寬 / IOPS。
docker run -d --name limited --cpus=0.5 --memory=256m --pids-limit=100 python:3.12-slim sleep 3600
# cgroup v2 下查看(路徑依 cgroup driver 不同而異)
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max      # 50000 100000
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max   # 268435456
docker stats limited --no-stream
常見陷阱

容器內的 /proc/cpuinfo、/proc/meminfo 顯示的是整台 host 的資源。舊版 JVM、Python 的 os.cpu_count()、Go 的 runtime.NumCPU() 都會看到 host 核心數,導致開出過多 worker / thread,然後被 CPU quota 狠狠 throttle。Gunicorn worker 數、Go 的 GOMAXPROCS 要依 cgroup limit 設定(Go 可用 go.uber.org/automaxprocs;Go 1.25 起 runtime 已會參考 cgroup CPU limit);Python 3.13 新增 os.process_cpu_count() 但它看的是 CPU affinity 而非 cgroup quota,最穩是用環境變數明確指定。

Union FS / overlay2 與 image layer

Docker image 是一疊唯讀的 layer,每個 Dockerfile 中會改變檔案系統的指令(RUN、COPY、ADD)產生一層。overlay2 把這些層疊起來:

  • lowerdir:image 的各個唯讀 layer。
  • upperdir:容器專屬的可寫層。
  • merged:容器實際看到的合併視圖。

讀檔時由上往下找;修改 lower 層的檔案會觸發 copy-up(copy-on-write):整個檔案先複製到 upperdir 再改。刪除 lower 層的檔案則是在 upperdir 建立 whiteout 標記。這帶來幾個重要推論:

  1. 在後面的 layer 刪檔不會讓 image 變小。RUN apt-get install ... 之後再 RUN rm -rf /var/lib/apt/lists/*,檔案仍存在前一層裡。清理必須在同一個 RUN 內完成,或用 multi-stage build。
  2. 多個容器共用同一個 image 時,lower layers 只存一份,啟動快且省磁碟。
  3. 容器內寫大量資料到 rootfs 很慢且容器刪除就消失,資料要寫到 volume(bind mount / named volume / K8s PV)。
  4. 寫入密集的 DB 不要放在 overlay 的可寫層上。

Build cache 的運作規則

BuildKit 依序檢查每個指令:指令字串相同、且(對 COPY/ADD 而言)來源檔案的內容 checksum 相同,就重用快取。某一層快取失效,其後所有層都要重建。所以 Dockerfile 的順序原則是:越少變動的放越前面——先裝系統套件、再複製依賴描述檔(pyproject.toml、uv.lock、go.mod)並安裝依賴、最後才複製原始碼。這樣改一行程式碼只需重建最後一兩層。

面試官想聽到什麼

「容器本質上是 host 上的 process,用 namespaces 做視野隔離、cgroups 做資源限制、overlay2 做分層檔案系統,所以啟動快、密度高,但共用 kernel、隔離較弱。」能再補一句「所以容器內看到的 CPU 數不等於 limit、在後面 layer 刪檔不會讓 image 變小」就展現出實戰經驗。追問可能是:「Docker daemon、containerd、runc 的關係?」——Docker CLI 呼叫 dockerd,dockerd 透過 containerd 管理容器生命週期,containerd 再呼叫符合 OCI runtime spec 的 runc 實際建立 namespace/cgroup 並 exec process。K8s 透過 CRI 直接和 containerd 或 CRI-O 對話,不需要 dockerd(dockershim 已在 1.24 移除),但 Docker build 出來的 image 是 OCI 格式,照樣能跑。

Dockerfile 最佳實踐與 Production 級 Python 範例 高頻

寫 Dockerfile 的目標有五個:小(拉取快、攻擊面小)、快(build cache 有效)、安全(non-root、無 secret、無多餘工具)、可重現(鎖版本)、行為正確(信號處理、log 輸出到 stdout)。

實踐為什麼怎麼做
Multi-stage build編譯工具、build 依賴不進最終 imageFROM ... AS builder 建置,COPY --from=builder 只拿產物
小型 base image減少大小與 CVE 數量Python 用 python:3.12-slim(Debian slim);Go 靜態編譯可用 gcr.io/distroless/static 或 scratch。Alpine 用 musl libc,Python 的許多 wheel(numpy 等)需重新編譯或行為不同,要小心
Layer 順序最大化 cache 命中依賴描述檔先複製並安裝,原始碼最後複製
.dockerignore縮小 build context、避免把 .git、.env、.venv 送進 image類似 .gitignore 的語法
Non-root user容器被入侵時降低影響(容器內 root 預設對應 host root,除非啟用 user namespace)useradd 建立使用者並 USER app;K8s 再加 runAsNonRoot: true
不把 secret 放進 image任何拿到 image 的人都能 docker history 或解開 layer 看到build 期用 RUN --mount=type=secret;runtime 用環境變數 / K8s Secret / Secrets Manager 注入
Exec form CMD讓應用程式直接成為 PID 1 收到 SIGTERMCMD ["python", "-m", "app"] 而非 CMD python -m app
鎖定版本可重現 buildbase image 用明確 tag(甚至 digest @sha256:...),依賴用 lock file
單一職責一個容器一個主要 process,方便擴展與觀測sidecar 需求交給 K8s Pod 的多容器
Log 到 stdout/stderr交給 runtime / log agent 收集Python 設 PYTHONUNBUFFERED=1 避免 log 被 buffer 住

PID 1、信號處理與 tini

這是很多人踩過、面試也很愛問的細節。docker stop 或 K8s 刪除 Pod 時,runtime 會送 SIGTERM 給容器的 PID 1,等待寬限期(Docker 預設 10 秒,K8s 預設 terminationGracePeriodSeconds: 30)後送 SIGKILL。問題有三:

  1. Shell form 吃掉信號:CMD python app.py 實際執行的是 /bin/sh -c "python app.py",PID 1 是 sh,而 sh 不會把 SIGTERM 轉發給子 process,應用永遠收不到,只能等 SIGKILL 被硬殺——進行中的請求全斷、連線沒關、buffer 沒 flush。
  2. PID 1 的特殊信號語意:Linux kernel 對 namespace 內的 PID 1 不套用預設的信號處理動作。一般 process 收到 SIGTERM 沒裝 handler 時預設會終止,但 PID 1 若沒有明確註冊 handler,SIGTERM 會被忽略。Python 預設沒有 SIGTERM handler(只有 SIGINT 變成 KeyboardInterrupt)。
  3. Zombie 回收:PID 1 負責 wait() 被收養的孤兒 process。若應用會 fork 子 process(例如呼叫 subprocess 但沒 wait),zombie 會累積。

解法:使用 exec form;在應用中註冊 SIGTERM handler 做 graceful shutdown;並用輕量 init(tini、dumb-init)當 PID 1,它會轉發信號並回收 zombie。Docker 可直接 docker run --init;K8s 沒有對應選項,所以在 image 裡裝 tini 最保險。若一定要用 shell script 當 entrypoint,最後一行要用 exec "$@" 讓應用取代 shell。

# app/main.py —— 在應用層處理 SIGTERM(以 asyncio 服務為例)
import asyncio
import signal

async def serve(stop: asyncio.Event) -> None:
    # 假裝是 HTTP server / consumer 主迴圈
    while not stop.is_set():
        await asyncio.sleep(0.5)
    print("draining in-flight work, closing DB pool ...", flush=True)
    await asyncio.sleep(1)  # 等進行中的請求完成、flush buffer、commit offset
    print("bye", flush=True)

async def main() -> None:
    stop = asyncio.Event()
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(sig, stop.set)
    await serve(stop)

if __name__ == "__main__":
    asyncio.run(main())

實務上用 Uvicorn / Gunicorn 時,它們本身已處理 SIGTERM(停止接新連線、等待進行中請求完成),你只需確保它們真的是收到信號的那個 process。

Production 級 Python(uv)Dockerfile

以下以 FastAPI + Uvicorn、依賴由 uv 管理(pyproject.toml + uv.lock)為例。重點:builder 與 runtime 用同一個 Python base,讓 venv 可直接複製;依賴層與程式碼層分離;cache mount 加速;non-root;tini;healthcheck。

# syntax=docker/dockerfile:1.7
ARG PYTHON_VERSION=3.12

########## Stage 1: builder ##########
FROM python:${PYTHON_VERSION}-slim AS builder

# 從官方 image 取得固定版本的 uv 執行檔
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0 \
    UV_PROJECT_ENVIRONMENT=/app/.venv

WORKDIR /app

# 1) 只用 lock 檔安裝「依賴」——程式碼變動不會讓這層失效
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev

# 2) 再複製原始碼並安裝專案本身
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

########## Stage 2: runtime ##########
FROM python:${PYTHON_VERSION}-slim AS runtime

# tini 當 PID 1:轉發信號 + 回收 zombie;同一個 RUN 內清掉 apt list
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/* \
 && groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --no-create-home app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app
COPY --from=builder --chown=app:app /app /app

USER 10001
EXPOSE 8000

# Docker 本機用;K8s 會忽略 HEALTHCHECK,改用 probes
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status == 200 else 1)"]

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--timeout-graceful-shutdown", "20"]
# .dockerignore
.git
.github
.venv
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.env
*.env
tests/
docs/
Dockerfile*
docker-compose*.yml
# 建置與檢查
docker build -t events-api:$(git rev-parse --short HEAD) .
docker image ls events-api                 # 看大小
docker history events-api:<tag>          # 看每層大小,找出肥大的層
docker run --rm -p 8000:8000 events-api:<tag>

# build 期需要私有套件庫 token:用 secret mount,不留在任何 layer
docker build --secret id=pip_token,env=PIP_TOKEN -t events-api .
#   Dockerfile 內:RUN --mount=type=secret,id=pip_token \
#       PIP_TOKEN=$(cat /run/secrets/pip_token) uv sync --frozen
常見陷阱

(1)ARG/ENV 傳 secret 會留在 image metadata,docker history 看得到。(2)COPY . . 放在安裝依賴之前,每次改程式碼都重裝所有依賴。(3)用 latest tag 部署,無法追溯與回滾,且 K8s 預設 imagePullPolicy 對 :latest 是 Always。(4)在容器中用 --reload 或 dev server 上 production。(5)沒設 PYTHONUNBUFFERED,crash 前的最後幾行 log 消失。

補充:Go 服務的極小 image

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/bidder ./cmd/bidder

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/bidder /bidder
USER nonroot:nonroot
ENTRYPOINT ["/bidder"]

靜態編譯的 Go binary 搭配 distroless,image 通常只有十幾 MB,沒有 shell 也沒有套件管理器,攻擊面極小(代價是不能 kubectl exec 進去 debug,可用 kubectl debug 的 ephemeral container 補救)。

面試官想聽到什麼

被問「你怎麼優化 Docker image?」時,依序講:multi-stage 分離 build 與 runtime、slim/distroless base、依賴層在前程式碼在後以利 cache、同一 RUN 內清理、.dockerignore、non-root、secret 不進 image、exec form + tini 處理信號。能用 docker history / dive 找出肥大的 layer,是加分的實作細節。

Docker Compose:本機開發與整合測試環境 基礎

Compose 用一份 YAML 描述多個服務、網路與 volume,適合本機開發、整合測試與 CI。它不是 production orchestrator(沒有跨主機排程、自動 reschedule、rolling update),production 交給 K8s / ECS。以下是 app + PostgreSQL + Redis 的範例,重點在健康檢查與啟動順序。

# compose.yaml
services:
  api:
    build:
      context: .
      target: runtime
    image: events-api:dev
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:app@postgres:5432/events
      REDIS_URL: redis://redis:6379/0
      LOG_LEVEL: debug
    depends_on:
      postgres:
        condition: service_healthy   # 等 DB 真的可連線才啟動,而非只是容器啟動
      redis:
        condition: service_healthy
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

  migrate:
    image: events-api:dev
    command: ["alembic", "upgrade", "head"]
    environment:
      DATABASE_URL: postgresql://app:app@postgres:5432/events
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app        # 只限本機開發
      POSTGRES_DB: events
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d events"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
  redisdata:
docker compose up -d --build        # 建置並背景啟動
docker compose ps                   # 看狀態與 health
docker compose logs -f api          # 追 log
docker compose exec postgres psql -U app -d events
docker compose run --rm api pytest -q   # 在相同網路中跑整合測試
docker compose down -v              # 停止並刪除 volume(資料會清掉)
  • Compose 會為專案建立預設 network,服務之間用服務名稱當 hostname(postgres、redis),由 Docker 內建 DNS 解析。
  • depends_on 只控制啟動順序;沒有 condition: service_healthy 時不保證依賴「可用」。即使有,應用本身仍要有連線重試,因為 production 中依賴隨時可能短暫不可用。
  • 容器內連 localhost 指的是容器自己,不是 host——這是新手最常見的連線錯誤。

Kubernetes 架構與 Reconcile Loop 高頻 進階

Kubernetes 是一個宣告式(declarative)的容器編排系統:你告訴它「我要 5 個這個版本的 Pod」(desired state),一群 controller 不斷比對實際狀態(actual state)並採取行動讓兩者一致。理解這個「控制迴圈」比背元件名稱重要得多。

flowchart LR subgraph CP["Control Plane"] API["kube-apiserver"] ETCD[("etcd")] SCH["kube-scheduler"] CM["kube-controller-manager"] CCM["cloud-controller-manager"] API --- ETCD SCH -->|watch / bind| API CM -->|watch / update| API CCM -->|LB, routes, nodes| API end subgraph N1["Worker Node"] KL["kubelet"] KP["kube-proxy"] CR["containerd / CRI-O"] P1["Pod"] P2["Pod"] KL --> CR --> P1 CR --> P2 end U["kubectl / ArgoCD / CI"] --> API KL -->|watch pods, report status| API KP -->|watch Services, EndpointSlices| API

Control plane 元件

kube-apiserver
整個叢集的唯一入口,提供 REST API;負責認證(authn)、授權(RBAC)、admission control(mutating/validating webhook,例如自動注入 sidecar、強制 policy),並且是唯一直接讀寫 etcd 的元件。無狀態,可水平擴展。所有元件都透過 watch API server 取得變更通知,而不是彼此直接溝通。
etcd
分散式強一致 key-value store,使用 Raft 共識演算法,儲存所有叢集狀態。通常部署 3 或 5 個成員(奇數,容忍 (n-1)/2 個故障)。etcd 掛了叢集就無法變更(既有 Pod 仍會繼續跑)。必須定期備份(etcdctl snapshot save)。
kube-scheduler
watch 尚未指派 node 的 Pod,經過 filtering(資源 requests 是否放得下、nodeSelector/affinity、taints/tolerations、PVC 所在 zone)與 scoring(資源平衡、spread、image locality 等)選出 node,然後寫回 spec.nodeName(binding)。注意排程只看 requests,不看實際使用量。
kube-controller-manager
打包了許多 controller:Deployment、ReplicaSet、StatefulSet、Job、Node(偵測 node 失聯並驅逐 Pod)、EndpointSlice、ServiceAccount、Namespace 等。每個 controller 都是一個 reconcile loop。
cloud-controller-manager
與雲端 API 整合:建立 type: LoadBalancer 的雲端 LB、設定路由、管理 node 生命週期。

Node 元件

kubelet
每個 node 上的 agent。watch 指派到本 node 的 Pod,透過 CRI 呼叫 container runtime 建立容器、掛載 volume、執行 probes、回報 Pod 與 node 狀態、在資源壓力時驅逐(evict)Pod。
kube-proxy
watch Service 與 EndpointSlice,在 node 上寫 iptables 或 IPVS 規則,把送往 Service ClusterIP 的封包 DNAT 到後端 Pod IP。它不是真的 proxy 流量(早期 userspace 模式才是)。Cilium 等 eBPF CNI 可完全取代 kube-proxy。
Container runtime
實作 CRI 的 containerd 或 CRI-O,底層呼叫 runc。
CNI plugin
(不是核心元件但必備)負責 Pod 網路:每個 Pod 一個 IP,所有 Pod 不經 NAT 可互通。常見:Calico、Cilium、AWS VPC CNI(Pod 直接拿 VPC IP)、GKE 的 Dataplane V2。

從 kubectl apply 到容器跑起來

sequenceDiagram participant U as kubectl participant A as API Server participant E as etcd participant D as Deployment Controller participant R as ReplicaSet Controller participant S as Scheduler participant K as kubelet U->>A: apply Deployment (replicas=3) A->>E: 寫入 Deployment D->>A: watch 到新 Deployment,建立 ReplicaSet R->>A: watch 到 ReplicaSet,建立 3 個 Pod (nodeName 空) S->>A: watch 到未排程 Pod,filter+score 後 bind 到 node K->>A: watch 到本 node 的 Pod K->>K: CRI 拉 image、建容器、跑 probes K->>A: 回報 Pod Running / Ready Note over A: EndpointSlice controller 把 Ready Pod 加入 Service 後端

Reconcile loop 的本質

# 概念性虛擬碼:所有 controller 都長這樣(level-triggered,而非 edge-triggered)
def reconcile(key):
    desired = api.get(key)                 # 例如 ReplicaSet.spec.replicas = 5
    actual = api.list_pods(selector=desired.selector, owner=key)
    diff = desired.replicas - len([p for p in actual if not p.terminating])
    if diff > 0:
        for _ in range(diff):
            api.create_pod(desired.template)
    elif diff < 0:
        for pod in pick_pods_to_delete(actual, -diff):   # 優先刪 未就緒/新的/同 node 擁擠的
            api.delete_pod(pod)
    api.update_status(key, ready=count_ready(actual))

while True:
    key = workqueue.get()          # 由 informer 的 watch 事件 + 定期 resync 放入
    try:
        reconcile(key)
    except Exception:
        workqueue.add_rate_limited(key)   # 失敗就指數退避重試
    finally:
        workqueue.done(key)

重點特性:

  • Level-triggered:controller 看的是「現在狀態與期望狀態的差」,而不是「發生了什麼事件」。所以漏掉事件、controller 重啟都沒關係,下次 reconcile 會自動修正——這是 K8s 自癒(self-healing)的根源。
  • 冪等:reconcile 執行多次結果相同。
  • 樂觀並行控制:每個物件有 resourceVersion,更新時版本不符會得到 409 Conflict,controller 重新讀取再試。
  • Operator 模式就是自己寫 CRD + controller,把維運知識(例如資料庫的備份、failover)編碼成 reconcile loop。
面試官想聽到什麼

「K8s 是宣告式的,API server 是唯一入口且唯一碰 etcd 的元件,其他元件都 watch API server,各自跑 reconcile loop 讓 actual state 收斂到 desired state。」常見追問:「一個 node 突然斷電會發生什麼?」——kubelet 停止送 heartbeat(Lease 物件),node controller 在 grace period 後把 node 標為 NotReady 並加上 taint;Pod 依 tolerationSeconds(預設 300 秒)後被驅逐,ReplicaSet controller 發現 Ready 副本不足,建立新 Pod 由 scheduler 排到其他 node。StatefulSet 的 Pod 則不會自動在別處重建同名 Pod,直到確認舊的已終止(避免兩個同身分 Pod 同時寫入)。

核心物件與 YAML 範例 高頻

先看一張總表,再逐一看 YAML。記住物件之間的擁有關係:Deployment 管 ReplicaSet,ReplicaSet 管 Pod;Service 透過 label selector 找到 Pod(不是擁有)。

物件用途典型場景
Pod最小部署單位,一或多個共用網路/volume 的容器幾乎不直接建立,交給 controller
ReplicaSet維持 N 個相同 Pod由 Deployment 自動管理
Deployment無狀態服務,支援 rolling update 與 rollbackAPI server、bidder、worker
StatefulSet穩定的網路身分(pod-0、pod-1)、穩定的儲存(每 Pod 一個 PVC)、有序部署Kafka、ZooKeeper、Cassandra、自建 DB
DaemonSet每個(或符合條件的)node 跑一個log agent(Fluent Bit)、node exporter、CNI
Job / CronJob跑到完成的批次任務 / 定時任務報表產生、資料回填、模型訓練
Service穩定的虛擬 IP + DNS,負載平衡到一組 Pod服務發現
Ingress / Gateway APIL7 HTTP 路由(host/path)、TLS 終結對外 API 入口
ConfigMap / Secret設定與機密,與 image 解耦環境變數、設定檔
PV / PVC / StorageClass持久化儲存的抽象與動態佈建DB 資料目錄
Namespace邏輯隔離、RBAC 與 quota 的範圍依團隊或環境切分
HPA依指標自動調整副本數依 CPU 或 QPS 擴縮
PDB限制自願中斷時同時不可用的 Pod 數node 升級、drain 時保住可用性

Deployment(含 probes、resources、安全設定)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: events-api
  namespace: tracking
  labels: { app: events-api }
spec:
  replicas: 4
  revisionHistoryLimit: 5
  selector:
    matchLabels: { app: events-api }       # 建立後不可變更
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%          # 更新時最多多出幾個 Pod
      maxUnavailable: 0      # 更新時不允許可用數低於 replicas
  template:
    metadata:
      labels: { app: events-api, version: "1.8.2" }
    spec:
      serviceAccountName: events-api
      terminationGracePeriodSeconds: 40
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        seccompProfile: { type: RuntimeDefault }
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector: { matchLabels: { app: events-api } }
      containers:
        - name: api
          image: registry.example.com/tracking/events-api:1.8.2-3f9c2ab
          ports:
            - { name: http, containerPort: 8000 }
          env:
            - name: REDIS_URL
              valueFrom: { configMapKeyRef: { name: events-api-config, key: redis_url } }
            - name: DATABASE_PASSWORD
              valueFrom: { secretKeyRef: { name: events-api-secret, key: db_password } }
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { memory: "512Mi" }          # 常見做法:設 memory limit,CPU 不設 limit
          startupProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 2
            failureThreshold: 30                   # 最多給 60 秒啟動
          readinessProbe:
            httpGet: { path: /readyz, port: http }
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          lifecycle:
            preStop:
              exec: { command: ["sleep", "10"] }   # 等 endpoint 移除傳播完(見優雅關閉段落)
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: {}

Service、Ingress 與 Gateway API

Service type行為何時用
ClusterIP(預設)叢集內部虛擬 IP + DNS svc.ns.svc.cluster.local內部服務互相呼叫
Headless(clusterIP: None)不配 VIP,DNS 直接回所有 Pod IP;StatefulSet 搭配時每個 Pod 有 pod-0.svc 這種穩定 DNSStatefulSet、client-side LB(gRPC)
NodePort在每個 node 開一個 port(預設範圍 30000–32767)轉到 Service測試或自建外部 LB
LoadBalancerNodePort + 由 cloud-controller-manager 建立雲端 L4 LB(AWS NLB / GCP Network LB)直接對外暴露 TCP/UDP 服務
ExternalNameDNS CNAME 到外部名稱把外部服務納入叢集命名
apiVersion: v1
kind: Service
metadata: { name: events-api, namespace: tracking }
spec:
  type: ClusterIP
  selector: { app: events-api }
  ports:
    - { name: http, port: 80, targetPort: http }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: events-api
  namespace: tracking
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "1m"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [track.example.com]
      secretName: track-tls
  rules:
    - host: track.example.com
      http:
        paths:
          - path: /v1/events
            pathType: Prefix
            backend: { service: { name: events-api, port: { name: http } } }
---
# Gateway API:Ingress 的後繼者,角色分離(平台團隊管 Gateway,應用團隊管 Route),原生支援權重分流
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: events-api, namespace: tracking }
spec:
  parentRefs: [{ name: public-gateway, namespace: infra }]
  hostnames: [track.example.com]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /v1/events } }]
      backendRefs:
        - { name: events-api, port: 80, weight: 90 }
        - { name: events-api-canary, port: 80, weight: 10 }

Ingress 只是規則,需要 Ingress Controller(ingress-nginx、Traefik、AWS Load Balancer Controller 會建立 ALB、GKE 會建立 Google Cloud HTTP(S) LB)才會生效。Ingress 的進階功能(rewrite、canary、timeout)都要靠各家 annotation,不可攜;Gateway API 把這些標準化成欄位,是新專案較推薦的方向。

ConfigMap 與 Secret

apiVersion: v1
kind: ConfigMap
metadata: { name: events-api-config, namespace: tracking }
data:
  redis_url: redis://redis-master.tracking:6379/0
  app.yaml: |
    batch_size: 500
    flush_interval_ms: 200
---
apiVersion: v1
kind: Secret
metadata: { name: events-api-secret, namespace: tracking }
type: Opaque
stringData:                 # 寫入時是明文,API server 會存成 base64 的 data
  db_password: change-me
常見陷阱

Secret 只是 base64 編碼,不是加密。要真的安全需要:啟用 etcd encryption at rest(KMS provider)、用 RBAC 限制誰能 get secret、不要把 Secret YAML commit 進 Git(改用 Sealed Secrets、SOPS,或 External Secrets Operator 從 AWS Secrets Manager / GCP Secret Manager 同步)。另外,以環境變數注入的 ConfigMap/Secret 更新後不會自動反映到執行中的 Pod,需要重啟(常見手法是在 Pod template 加上設定內容的 hash annotation,觸發 rolling update);以 volume 掛載的則會在一段延遲後更新檔案(subPath 掛載除外)。

PV、PVC、StorageClass 與 StatefulSet

  • PersistentVolume(PV):叢集中的一塊實際儲存(EBS volume、GCE PD、NFS)。
  • PersistentVolumeClaim(PVC):應用對儲存的「請求」(要 50Gi、ReadWriteOnce)。
  • StorageClass:描述如何動態佈建 PV(provisioner、磁碟類型 gp3/pd-ssd、reclaimPolicy Delete/Retain、volumeBindingMode: WaitForFirstConsumer 讓磁碟建在 Pod 被排程到的 zone)。
  • Access modes:ReadWriteOnce(單一 node 讀寫,EBS 屬此類)、ReadOnlyMany、ReadWriteMany(需 NFS/EFS/Filestore 類)、ReadWriteOncePod。
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: gp3 }
provisioner: ebs.csi.aws.com
parameters: { type: gp3 }
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: redis, namespace: tracking }
spec:
  serviceName: redis-headless          # 需搭配 headless Service
  replicas: 3
  selector: { matchLabels: { app: redis } }
  template:
    metadata: { labels: { app: redis } }
    spec:
      containers:
        - name: redis
          image: redis:7.2
          ports: [{ containerPort: 6379 }]
          volumeMounts: [{ name: data, mountPath: /data }]
  volumeClaimTemplates:                # 每個 Pod 一個 PVC:data-redis-0, data-redis-1 ...
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: gp3
        resources: { requests: { storage: 20Gi } }

DaemonSet、Job、CronJob

apiVersion: batch/v1
kind: CronJob
metadata: { name: hourly-report, namespace: analytics }
spec:
  schedule: "5 * * * *"               # 每小時第 5 分
  timeZone: "Asia/Taipei"
  concurrencyPolicy: Forbid           # 上一次還沒跑完就跳過,避免重疊
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 3                 # 失敗重試次數
      activeDeadlineSeconds: 3000     # 超時就整個 Job 失敗
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: Never        # Job 只能是 Never 或 OnFailure
          containers:
            - name: report
              image: registry.example.com/analytics/report:2.3.0
              args: ["--window", "1h"]
              resources:
                requests: { cpu: "1", memory: "2Gi" }
                limits:   { memory: "2Gi" }

CronJob 不保證「恰好一次」:controller 故障可能錯過或重複觸發,所以任務本身要冪等(例如以時間窗為 key upsert 結果)。DaemonSet 則常配合 tolerations 讓它也能跑在有 taint 的 node 上(例如 GPU node、spot node)。

HPA、PDB、Namespace 與 ResourceQuota

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: events-api, namespace: tracking }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: events-api }
  minReplicas: 4
  maxReplicas: 60
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }   # 以 requests 為分母
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]  # 每 30 秒最多翻倍
    scaleDown:
      stabilizationWindowSeconds: 300                              # 縮容觀察 5 分鐘,避免抖動
      policies: [{ type: Percent, value: 10, periodSeconds: 60 }]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: events-api, namespace: tracking }
spec:
  minAvailable: 75%          # 或 maxUnavailable: 1
  selector: { matchLabels: { app: events-api } }
---
apiVersion: v1
kind: ResourceQuota
metadata: { name: team-quota, namespace: tracking }
spec:
  hard:
    requests.cpu: "100"
    requests.memory: 200Gi
    limits.memory: 300Gi
    pods: "500"
  • HPA 的核心公式:desiredReplicas = ceil(currentReplicas × currentMetric / targetMetric),預設有 10% 容忍區間避免小幅波動觸發。CPU 使用率是「實際使用 / requests」,所以沒設 requests 的 Pod,CPU-based HPA 無法運作。資源指標來自 metrics-server;自訂指標(QPS、queue lag)需要 Prometheus Adapter 或 KEDA。
  • HPA 管 Pod 數;Pod 放不下時要靠 Cluster Autoscaler 或 Karpenter(AWS)加 node。VPA 則調整單一 Pod 的 requests,不要和同指標的 HPA 同時作用在 CPU/memory 上。
  • PDB 只保護自願中斷(kubectl drain、node 升級、Cluster Autoscaler 縮容),對 node 當機這類非自願中斷無效。設定 minAvailable 等於 replicas 會讓 drain 永遠卡住,是常見事故。
  • Namespace 是 RBAC、ResourceQuota、LimitRange、NetworkPolicy 的作用範圍,但不是安全隔離邊界:預設不同 namespace 的 Pod 網路是通的,需要 NetworkPolicy 才能限制。

Probes、Resources、QoS 與 OOMKilled 高頻 深入

三種 probe 的正確用法

Probe失敗時的後果應該檢查什麼不應該檢查什麼
startupProbe超過 failureThreshold × periodSeconds 仍失敗就重啟容器;成功前 liveness/readiness 不執行應用是否完成啟動(載入模型、暖快取)—
readinessProbe從 Service 的 EndpointSlice 移除,不再接流量;不重啟「我現在能處理請求嗎」:初始化完成、未過載、必要依賴可用(視情況)不要做很重的檢查(每幾秒跑一次)
livenessProbe重啟容器「我的 process 是否卡死、無法自行恢復」:event loop 是否還有反應、是否 deadlock外部依賴(DB、Redis、下游 API)

Probe 預設值:periodSeconds: 10、timeoutSeconds: 1、failureThreshold: 3、successThreshold: 1。支援 httpGet(2xx/3xx 視為成功)、tcpSocket、exec、grpc。

常見陷阱:probe 誤用的連鎖災難

(1)Liveness 檢查 DB:DB 短暫變慢 → 所有 Pod liveness 失敗 → 全部同時重啟 → 重啟時建立大量新連線把 DB 壓得更慘 → 雪崩。依賴故障應讓 readiness 失敗(或乾脆降級回應),而不是重啟自己。(2)沒有 startupProbe、liveness 的 initialDelay 太短:載入 ML 模型要 90 秒,liveness 30 秒就開始失敗 → 永遠啟動不了 → CrashLoopBackOff。(3)Liveness 與 readiness 用同一個 endpoint 且門檻相同:過載時 readiness 失敗是好事(卸載流量),但 liveness 也一起失敗就變成重啟,容量更少。(4)timeoutSeconds 用預設 1 秒:GC pause 或 CPU throttling 時 probe 逾時,誤判為死亡。(5)Readiness 檢查共同依賴:所有 Pod 共用的 Redis 掛了 → 所有 Pod not ready → Service 沒有任何 endpoint,使用者拿到 503,而原本可以回降級結果。

# 健康檢查端點的合理分工(FastAPI 示意)
import asyncio, time
from fastapi import FastAPI, Response

app = FastAPI()
state = {"ready": False, "draining": False, "last_tick": time.monotonic()}

@app.on_event("startup")
async def startup() -> None:
    # 載入模型、建立連線池、暖快取……完成後才 ready
    await asyncio.sleep(2)
    state["ready"] = True
    asyncio.create_task(heartbeat())

async def heartbeat() -> None:
    while True:                         # event loop 若被阻塞,tick 就不會更新
        state["last_tick"] = time.monotonic()
        await asyncio.sleep(1)

@app.get("/healthz")                    # liveness:只檢查自己是否卡死
async def healthz(resp: Response):
    if time.monotonic() - state["last_tick"] > 10:
        resp.status_code = 500
    return {"ok": resp.status_code != 500}

@app.get("/readyz")                     # readiness:能不能接流量
async def readyz(resp: Response):
    if not state["ready"] or state["draining"]:
        resp.status_code = 503
    return {"ready": resp.status_code != 503}

Requests vs Limits

requests
排程依據與保證量。scheduler 只把 Pod 放到「剩餘可分配 requests」足夠的 node。CPU request 轉成 cgroup 的 CPU weight,在競爭時保證按比例分到 CPU。
limits
硬上限。CPU limit 轉成 CFS quota(每 100ms period 能用多少 CPU 時間),超過就被 throttle(暫停到下個 period);memory limit 超過就被 OOMKilled(exit code 137)。

CPU 是可壓縮資源(超用只會變慢),memory 是不可壓縮資源(超用只能殺掉)。這導出一個業界常見(但有爭議)的建議:memory 設 limit 且通常等於 request;CPU 設 request、不設 limit 或設寬鬆的 limit,避免不必要的 throttling。

CPU throttling:延遲殺手

假設 limit 為 1 核(每 100ms 可用 100ms CPU 時間),而服務有 4 個執行緒同時處理一個突發請求,每個需要 30ms CPU:前 25ms 內 4 執行緒就用光 100ms 配額,剩下 75ms 全部被暫停。結果平均 CPU 使用率看起來只有 40%,但 p99 延遲多了 75ms——對 RTB 這種 100ms 預算的服務是致命的。觀察指標:cgroup 的 cpu.stat 中的 nr_throttled、throttled_usec,或 Prometheus 的 container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total。

QoS class 與驅逐順序

QoS class條件node 記憶體壓力時
GuaranteedPod 內每個容器 CPU 與 memory 都有設 requests 且 requests == limits最後被驅逐;oom_score_adj 最低
Burstable至少一個容器有設 requests 或 limits,但不符合 Guaranteed依「使用量超出 request 的程度」排序驅逐
BestEffort所有容器都沒設 requests/limits最先被驅逐

注意兩種不同的「被殺」:容器超過自己的 memory limit → cgroup OOM killer 殺掉容器,Pod 狀態顯示 OOMKilled,容器原地重啟;node 整體記憶體不足 → kubelet 依 QoS 與使用量驅逐(evict)Pod,Pod 狀態為 Evicted,由 controller 在別處重建。

# 診斷 OOMKilled
kubectl get pod events-api-7d9c-abcde -o jsonpath='{.status.containerStatuses[0].lastState}'
#  {"terminated":{"exitCode":137,"reason":"OOMKilled",...}}
kubectl describe pod events-api-7d9c-abcde | grep -A5 "Last State"
kubectl top pod -n tracking --containers            # 需 metrics-server
# 判斷是 limit 太低還是 memory leak:看記憶體曲線是「穩定在高位」還是「持續爬升」
面試官想聽到什麼

能清楚區分「requests 給 scheduler、limits 給 kernel」、「CPU 超用被 throttle、memory 超用被 OOMKill」、「三種 QoS 與驅逐順序」,並講出 probe 誤用的真實後果(liveness 檢查依賴造成連鎖重啟)。追問「Python 服務 memory 一直漲最後 OOMKilled,你怎麼查?」——先確認是 leak 還是 limit 不足(看曲線)、用 tracemalloc 比對快照或 memray 找配置來源、注意 glibc malloc arena 造成的碎片(可調 MALLOC_ARENA_MAX)、無上限的 in-process cache、Gunicorn 可用 max_requests 定期回收 worker 作為止血。

Appier 情境

以廣告平台常見做法為例:bidder 這類延遲敏感服務通常給 Guaranteed 或至少「CPU request 充足、不設 CPU limit」,並把 Go 的 GOMAXPROCS / Python worker 數對齊 CPU request,避免 throttling 吃掉 RTB 的時間預算;而離線報表、特徵計算這類批次 job 則可以是 Burstable,放在可被搶占的 spot node 上。

部署策略與 K8s 優雅關閉 高頻 深入

Rolling update、Blue-Green、Canary

策略做法優點缺點 / 風險適用
Recreate先砍光舊版再起新版簡單,不會新舊並存有停機時間不能新舊並存的服務(schema 不相容、單例 job)
Rolling update逐批以新 Pod 取代舊 Pod(maxSurge/maxUnavailable)K8s 內建、零停機、資源成本低新舊版本並存一段時間;回滾也要一批批換;出問題時已有部分流量受害大多數無狀態服務的預設
Blue-Green完整建一套新版(green),驗證後把流量一次切過去(改 Service selector 或 LB),舊版(blue)保留以便瞬間切回切換與回滾都是瞬間的;可在正式環境對 green 做煙霧測試短時間雙倍資源;DB schema 需兩版都相容;一次全切風險集中需要快速回滾、能負擔雙倍資源
Canary先導 1%→5%→25%→100% 的流量到新版,每階段比對錯誤率、延遲、業務指標,自動決定前進或回滾爆炸半徑最小、用真實流量驗證需要流量分配能力(Ingress/Gateway 權重、service mesh)與好的可觀測性;流程較長高流量、高風險變更,例如 bidder 出價邏輯
Feature flag / Dark launch程式碼已部署但功能由開關控制,依使用者或比例開啟「部署」與「釋出」解耦,關閉不需重新部署flag 債務、組合測試複雜產品功能、A/B test
kubectl set image deploy/events-api api=registry.example.com/tracking/events-api:1.8.3-a1b2c3d -n tracking
kubectl rollout status deploy/events-api -n tracking --timeout=5m   # CI 中用來判斷成敗
kubectl rollout history deploy/events-api -n tracking
kubectl rollout undo deploy/events-api -n tracking                 # 回到上一個 ReplicaSet
kubectl rollout undo deploy/events-api -n tracking --to-revision=7
kubectl rollout pause / resume deploy/events-api -n tracking       # 手動做簡易分批

原生 Deployment 只能做 rolling update;要自動化 canary / blue-green 並根據指標分析決定前進或回滾,常用 Argo Rollouts 或 Flagger(搭配 Prometheus 查詢錯誤率、延遲)。

常見陷阱:資料庫 schema 與滾動更新

任何新舊版本並存的策略(rolling、canary、blue-green 回滾)都要求新舊程式碼能同時對同一份 schema 運作。做法是 expand / contract(parallel change):先部署「加欄位、雙寫」的相容版本 → 回填資料 → 切換讀取 → 確認沒有舊版後再移除舊欄位。永遠不要在同一次部署中「改名欄位 + 改程式」。

K8s 中的優雅關閉時序

這是深入題的常客:「為什麼我 rolling update 時會出現少量 502/503?」答案在於 Pod 終止與 endpoint 移除是平行發生的,沒有先後保證。

sequenceDiagram participant A as API Server participant K as kubelet participant C as Container participant EP as EndpointSlice Controller participant P as kube-proxy / Ingress A->>A: Pod 標記 deletionTimestamp (Terminating) par 路徑一:停止容器 A->>K: watch 到 Pod 終止 K->>C: 執行 preStop hook (例如 sleep 10) K->>C: preStop 結束後送 SIGTERM C->>C: 停止接新請求、處理完進行中請求、關連線 and 路徑二:移除流量 A->>EP: watch 到 Pod 終止 EP->>A: 從 EndpointSlice 移除此 Pod A->>P: 各 node 的 kube-proxy、Ingress controller 更新規則 (有傳播延遲) end Note over K,C: 從刪除開始計時 terminationGracePeriodSeconds (預設 30s),超時送 SIGKILL

如果沒有 preStop,容器可能在 kube-proxy / Ingress controller / 雲端 LB 還沒更新前就收到 SIGTERM 並關閉 listener,那幾百毫秒到幾秒內仍被送來的請求就會失敗。正確做法:

  1. preStop 加短暫 sleep(5–15 秒,視 LB 傳播時間而定),讓流量先停。distroless image 沒有 sleep 指令時,可用較新版本 K8s 原生的 preStop: { sleep: { seconds: 10 } },或在應用內實作「收到 SIGTERM 後先繼續服務 N 秒」。
  2. 應用處理 SIGTERM:停止接受新連線、讓 readiness 回 503、等進行中的請求完成、flush buffer、commit Kafka offset、關閉 DB 連線池,然後退出。
  3. terminationGracePeriodSeconds > preStop 時間 + 最長請求處理時間。注意 preStop 的時間是算在寬限期內的。
  4. 長連線(WebSocket、gRPC streaming、HTTP keep-alive):主動通知 client 重連(HTTP/2 GOAWAY、回 Connection: close),否則 client 會一直黏在要關掉的 Pod 上。
  5. Queue consumer:停止拉新訊息、處理完手上的、commit offset,未完成的交還 broker(visibility timeout / rebalance)。
spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: api
      lifecycle:
        preStop:
          exec: { command: ["sh", "-c", "sleep 10"] }
      # 應用收到 SIGTERM 後最多花 30 秒 drain:10 (preStop) + 30 < 45
面試官想聽到什麼

「刪 Pod 時,kubelet 跑 preStop + 送 SIGTERM,與 endpoint controller 把 Pod 從 Service 移除是同時進行的,所以需要 preStop sleep 讓 LB 先停止送流量,再由應用處理 SIGTERM 做 drain;整段要在 terminationGracePeriodSeconds 內完成,否則被 SIGKILL。」再補上 Dockerfile 的 exec form/tini 讓 SIGTERM 真的送得到,就是完整答案。

K8s 常見排錯 高頻 進階

排錯的通用順序:看狀態 → 看事件 → 看 log → 進去看 → 看資源。

kubectl get pods -n tracking -o wide                 # STATUS、RESTARTS、所在 node、IP
kubectl describe pod <pod> -n tracking               # 最重要:Events 區塊、Last State、Conditions
kubectl get events -n tracking --sort-by=.lastTimestamp | tail -30
kubectl logs <pod> -n tracking -c api --tail=200     # 多容器要指定 -c
kubectl logs <pod> -n tracking --previous           # 上一次 crash 的容器 log(CrashLoop 必用)
kubectl logs -l app=events-api -n tracking --since=10m --max-log-requests=20
kubectl exec -it <pod> -n tracking -- sh            # 進容器(需要 image 有 shell)
kubectl debug -it <pod> -n tracking --image=nicolaka/netshoot --target=api   # ephemeral debug 容器
kubectl top pod -n tracking --containers; kubectl top node
kubectl port-forward svc/events-api 8000:80 -n tracking                # 本機直連除錯
kubectl get endpointslices -n tracking -l kubernetes.io/service-name=events-api   # Service 有沒有後端
kubectl auth can-i get secrets -n tracking --as=system:serviceaccount:tracking:events-api
症狀常見原因怎麼查 / 怎麼修
CrashLoopBackOff容器啟動後一直退出:程式錯誤、缺設定或環境變數、連不上依賴且直接 exit、liveness 太嚴格被殺、OOMKilled、command 寫錯logs --previous 看最後的錯誤;describe 看 Exit Code(1 程式錯誤、137 SIGKILL/OOM、143 SIGTERM、127 指令不存在)。重啟間隔指數退避(10s、20s、40s…上限 5 分鐘)
ImagePullBackOff / ErrImagePullimage 名稱或 tag 打錯、私有 registry 沒有 imagePullSecrets 或 node IAM 權限、registry rate limit(Docker Hub)、架構不符(arm64 vs amd64)describe 的 Events 會寫明原因;在 node 上試拉;用 image digest 確認存在
Pending沒有 node 放得下(requests 太大)、nodeSelector/affinity 無符合、taint 沒 toleration、PVC 未綁定(zone 不符、StorageClass 錯)、ResourceQuota 超過describe 看 FailedScheduling 訊息,例如 0/12 nodes are available: 12 Insufficient cpu;確認 Cluster Autoscaler 是否在擴 node
Running 但 0/1 Readyreadiness probe 失敗:路徑/port 錯、依賴未就緒、啟動慢describe 看 probe 失敗訊息;exec 進去 curl 自己
CreateContainerConfigError引用的 ConfigMap / Secret 或其 key 不存在檢查名稱與 namespace
OOMKilled超過 memory limit見上一節;調高 limit 或修 leak
Evictednode 記憶體或磁碟壓力(常見:log 或 emptyDir 塞爆磁碟)describe node 看 Conditions 與 Allocated resources
Terminating 卡住finalizer 未完成、node 失聯查 metadata.finalizers;最後手段 --grace-period=0 --force(要知道風險)
Service 連不到selector 與 Pod label 不符、targetPort 錯、Pod 不 Ready、NetworkPolicy 擋住、DNS 問題查 EndpointSlice 是否有 IP;在 debug 容器內 nslookup、curl
flowchart TD S["Pod 有問題"] --> Q1{"STATUS?"} Q1 -->|Pending| P1["describe: FailedScheduling?"] P1 --> P2["資源不足 / affinity / taint / PVC / quota"] Q1 -->|ImagePullBackOff| I1["tag、registry 權限、rate limit、架構"] Q1 -->|CrashLoopBackOff| C1["logs --previous + Exit Code"] C1 --> C2{"Exit Code"} C2 -->|137 OOMKilled| C3["調整 memory 或修 leak"] C2 -->|1 / 2| C4["程式錯誤、設定缺漏"] C2 -->|137 非 OOM| C5["liveness 太嚴格被殺?"] Q1 -->|"Running 0/1"| R1["readiness 失敗: 路徑、port、依賴"] Q1 -->|"Running 1/1 但不通"| N1["EndpointSlice、selector、NetworkPolicy、DNS"]
實戰提示

面試若問「線上服務延遲突然升高,你在 K8s 上怎麼查?」可依序講:看 dashboard(RED 指標:rate/errors/duration)定位是哪個服務、是否與部署時間重疊(rollout history)→ kubectl top 與 throttling 指標看是否資源不足 → HPA 是否已到 max、node 是否 Pending 擴容 → 依賴(DB、Redis)延遲 → 分散式追蹤找慢的 span → 必要時 rollout undo 先止血再查根因。

Helm、Kustomize 與 GitOps(ArgoCD) 進階

直接維護多套 YAML(dev/staging/prod、多個區域)很快會失控,需要「範本化」或「疊加」工具,再透過 GitOps 讓叢集狀態與 Git 自動同步。

Helm:套件管理 + 範本

Chart = Go template 的 YAML + values.yaml。helm install/upgrade 產生 release 並記錄版本,可 helm rollback。適合發佈給別人用的套件(Prometheus、ingress-nginx、Redis 都有官方 chart)與參數很多的情境。缺點:template 語法難讀、除錯靠 helm template 渲染後看。

Kustomize:無範本的疊加

base/ 放共同 YAML,overlays/prod/ 用 patch 修改(replicas、image tag、resources)。內建於 kubectl apply -k。純 YAML、好 review,適合自家服務的多環境差異。缺點:沒有條件與迴圈,複雜參數化不易。

# Helm:templates/deployment.yaml 片段
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "events-api.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      containers:
        - name: api
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
---
# values-prod.yaml
replicaCount: 8
image:
  repository: registry.example.com/tracking/events-api
  tag: 1.8.3-a1b2c3d
resources:
  requests: { cpu: "1", memory: 1Gi }
  limits: { memory: 1Gi }
helm upgrade --install events-api ./charts/events-api -n tracking -f values-prod.yaml --atomic --timeout 5m
helm template events-api ./charts/events-api -f values-prod.yaml | less   # 先渲染出來看
helm history events-api -n tracking; helm rollback events-api 12 -n tracking
# Kustomize:overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: tracking
resources:
  - ../../base
images:
  - name: registry.example.com/tracking/events-api
    newTag: 1.8.3-a1b2c3d            # CI 只需要改這一行
replicas:
  - name: events-api
    count: 8
patches:
  - path: resources-patch.yaml
configMapGenerator:
  - name: events-api-config
    behavior: merge
    literals: [LOG_LEVEL=info]       # 產生帶 hash 後綴的名稱,內容變更會自動觸發 rolling update

GitOps 與 ArgoCD 原理

GitOps 的四個原則(OpenGitOps):宣告式、版本化且不可變(Git 是唯一事實來源)、自動拉取、持續 reconcile。關鍵在「pull 模型」:不是 CI 拿著叢集憑證 kubectl apply(push),而是叢集內的 agent(ArgoCD、Flux)去 Git 拉取並套用。

flowchart LR Dev["開發者 PR"] --> AppRepo["App repo"] AppRepo -->|"CI: test, build, scan"| Reg[("Image Registry")] AppRepo -->|"CI: 改 image tag (PR / commit)"| CfgRepo["Config repo (Helm/Kustomize)"] CfgRepo -->|"poll / webhook"| Argo["ArgoCD (叢集內)"] Argo -->|"比對 desired vs live, sync"| K8s["Kubernetes"] K8s -->|pull image| Reg Argo -->|"OutOfSync / Degraded 告警"| Slack["通知"]
  • Application CRD:定義「哪個 Git repo 的哪個路徑/revision」要部署到「哪個叢集的哪個 namespace」。
  • Repo server 把 Helm/Kustomize 渲染成 manifest(desired state);application controller 與叢集 live state 比對,顯示 Synced/OutOfSync 與健康狀態 Healthy/Progressing/Degraded。
  • Auto-sync + selfHeal:有人手動 kubectl edit 造成漂移(drift),ArgoCD 會自動改回 Git 的狀態;prune:Git 刪掉的資源叢集也刪掉。
  • Sync waves / hooks:控制順序,例如先跑 DB migration Job(PreSync hook)再更新 Deployment。
  • App of Apps / ApplicationSet:用一個 Application 管理多個 Application,或依叢集/環境清單自動產生。
  • 回滾 = git revert,所有變更都有審核紀錄(PR)與作者。
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: events-api-prod
  namespace: argocd
spec:
  project: tracking
  source:
    repoURL: https://git.example.com/platform/k8s-config.git
    targetRevision: main
    path: apps/events-api/overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: tracking
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]
    retry: { limit: 5, backoff: { duration: 10s, factor: 2, maxDuration: 3m } }
面試官想聽到什麼

「GitOps 的好處是:Git 是唯一事實來源、所有變更可審核可回滾、叢集憑證不必交給 CI(pull 模型,安全性較好)、drift 會被自動偵測修正、災難復原時可從 Git 重建整個叢集。缺點是 secret 要另外處理、多一層 repo 與流程、緊急修復也得走 Git。」若被問 Helm vs Kustomize,回答「對外發佈或高度參數化用 Helm,自家服務多環境差異用 Kustomize,兩者也可並用(Kustomize 引用 Helm chart),ArgoCD 兩者都支援」。

CI/CD Pipeline 設計:GitHub Actions、Jenkins、Drone、Ansible、Terraform 高頻

CI(Continuous Integration)確保每次合併都經過自動驗證;CD 分成 Continuous Delivery(隨時可部署,正式環境需人工按下)與 Continuous Deployment(通過就自動上線)。一條成熟的 pipeline:

flowchart LR A["Push / PR"] --> B["Lint + Type check"] B --> C["Unit test"] C --> D["Integration test (DB, Redis 容器)"] D --> E["Build image (tag = git SHA)"] E --> F["Scan (Trivy, SAST, 依賴漏洞)"] F --> G["Push to Registry"] G --> H["Deploy dev (自動)"] H --> I["Staging + E2E / 壓測"] I --> J{"人工核准 / 自動 canary 分析"} J -->|通過| K["Prod (canary → 全量)"] J -->|失敗| L["Rollback + 通知"]

設計原則

  • Fail fast:便宜快速的檢查放前面(lint 幾秒、unit test 一兩分鐘),貴的(整合測試、E2E)放後面;能平行的就平行。
  • Build once, deploy many:同一個 image(同一個 digest)從 dev 一路升級(promote)到 prod,環境差異只放在設定(ConfigMap/values),不要每個環境重新 build,否則測過的不是上線的。
  • Artifact 版本化:image tag 用 <semver>-<git short SHA> 或直接 SHA,不可變(registry 開啟 tag immutability);部署以 digest 鎖定更嚴謹。可追溯:從線上 Pod 的 image 反查 commit、PR、build log。
  • 可重現:lock file、固定 base image、CI runner 用容器。
  • 快取:依賴快取(uv cache、Go mod cache)、Docker layer 快取(BuildKit cache-from/cache-to)。
  • 安全:secret 存在 CI 的 secret store;雲端認證用 OIDC 換短期憑證(GitHub Actions → AWS IAM Role),不放長期 access key;image 簽章(cosign)與 SBOM。
  • Promotion:dev → staging → prod 的升級可以是 GitOps repo 中對應 overlay 的 PR(自動或人工核准),每一步都有紀錄。
  • DB migration 獨立成步驟(或 ArgoCD PreSync Job),而且要向後相容。

GitHub Actions 完整範例

# .github/workflows/ci.yml
name: ci
on:
  pull_request:
  push:
    branches: [main]

concurrency:                       # 同一分支新 push 取消舊的 run
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  IMAGE: ghcr.io/${{ github.repository_owner }}/events-api

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
        with: { enable-cache: true }
      - run: uv sync --frozen
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run mypy app

  test:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_USER: app, POSTGRES_PASSWORD: app, POSTGRES_DB: events_test }
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U app" --health-interval 5s --health-timeout 3s --health-retries 10
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    env:
      DATABASE_URL: postgresql://app:app@localhost:5432/events_test
      REDIS_URL: redis://localhost:6379/0
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
        with: { enable-cache: true }
      - run: uv sync --frozen
      - run: uv run pytest -q --cov=app --cov-report=xml --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-report
          path: |
            report.xml
            coverage.xml

  build-scan-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: echo "tag=$(date +%Y%m%d)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          load: true                      # 先載入本機給 scanner 掃
          tags: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}
          severity: CRITICAL,HIGH
          exit-code: "1"                  # 有高風險漏洞就讓 pipeline 失敗
          ignore-unfixed: true
      - run: docker push ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}

  deploy-dev:
    runs-on: ubuntu-latest
    needs: build-scan-push
    environment: dev                       # 可在 GitHub 設定 environment 的保護規則與核准者
    steps:
      - uses: actions/checkout@v4
        with:
          repository: example-org/k8s-config
          token: ${{ secrets.CONFIG_REPO_TOKEN }}
      - name: Bump image tag (GitOps)
        run: |
          cd apps/events-api/overlays/dev
          kustomize edit set image ${{ env.IMAGE }}=${{ env.IMAGE }}:${{ needs.build-scan-push.outputs.tag }}
          git config user.name ci-bot
          git config user.email ci-bot@example.com
          git commit -am "events-api(dev): ${{ needs.build-scan-push.outputs.tag }}"
          git push
      # 之後 ArgoCD 偵測到 config repo 變更並 sync;prod 則開 PR 由人核准
實戰提示

第三方 action 最好用 commit SHA 釘版本(uses: owner/action@<sha>),避免 tag 被改寫造成供應鏈攻擊;permissions 預設最小,只在需要的 job 開權限。

Jenkins 與 Drone

Jenkins

老牌自架 CI,plugin 生態最大、彈性最高(Groovy pipeline),常見於大型企業與內網環境。缺點是維運成本高(controller 升級、plugin 相容性、安全更新),agent 管理要自己做(可用 K8s plugin 動態起 Pod 當 agent)。

Drone(現為 Harness 旗下)

容器原生:每個 step 就是一個容器,設定是簡單的 YAML,自架輕量。適合想要「像 GitHub Actions 但自架」的團隊。

// Jenkinsfile(Declarative Pipeline)
pipeline {
  agent { kubernetes { yamlFile 'ci/agent-pod.yaml' } }
  options { timeout(time: 30, unit: 'MINUTES'); disableConcurrentBuilds() }
  environment { IMAGE = "registry.example.com/tracking/events-api:${env.GIT_COMMIT.take(7)}" }
  stages {
    stage('Lint & Test') {
      parallel {
        stage('Lint') { steps { sh 'uv run ruff check .' } }
        stage('Test') { steps { sh 'uv run pytest -q --junitxml=report.xml' } }
      }
    }
    stage('Build & Push') {
      when { branch 'main' }
      steps {
        withCredentials([usernamePassword(credentialsId: 'registry', usernameVariable: 'U', passwordVariable: 'P')]) {
          sh 'echo $P | docker login registry.example.com -u $U --password-stdin'
          sh 'docker build -t $IMAGE . && docker push $IMAGE'
        }
      }
    }
    stage('Deploy prod') {
      when { branch 'main' }
      input { message 'Deploy to prod?' }
      steps { sh './ci/bump-gitops.sh prod $IMAGE' }
    }
  }
  post { always { junit 'report.xml' } failure { slackSend channel: '#deploy', message: "FAILED ${env.BUILD_URL}" } }
}
# .drone.yml
kind: pipeline
type: docker
name: default
steps:
  - name: test
    image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
    commands:
      - uv sync --frozen
      - uv run pytest -q
  - name: publish
    image: plugins/docker
    settings:
      repo: registry.example.com/tracking/events-api
      tags: ["${DRONE_COMMIT_SHA:0:7}"]
      username: { from_secret: registry_user }
      password: { from_secret: registry_password }
    when: { branch: [main], event: [push] }

Ansible:無 agent 的設定管理

Ansible 透過 SSH 連到目標機器執行 module,不需在目標安裝 agent(push 模式)。用在 VM / 實體機的設定(安裝套件、部署 Nginx 設定、調整 sysctl、滾動重啟服務),K8s 時代仍常用於 node 層、資料庫主機、跳板機等。

Inventory
目標主機清單與分組,可靜態檔或動態(從 AWS/GCP API 撈)。
Playbook
YAML 描述「對哪些主機、依序執行哪些 task」。
Module
冪等的操作單元:apt、copy、template、systemd、sysctl、user…
Handler
只在被 notify 且有變更時執行,例如設定檔改了才 reload Nginx。
Role
可重用的 task/template/handler 組合。
冪等(idempotent)
跑一次和跑十次結果相同;第二次執行應顯示 changed=0。避免用 shell/command 做模組能做的事,非用不可時加 creates/changed_when。
# inventory/prod.ini
[api]
api-1.internal ansible_host=10.0.1.11
api-2.internal ansible_host=10.0.1.12

[api:vars]
ansible_user=deploy
app_port=8000
# playbooks/api.yml
- name: Configure API hosts
  hosts: api
  become: true
  serial: 1                        # 一次一台,等於滾動更新
  vars:
    nofile_limit: 65535
  tasks:
    - name: Install packages
      ansible.builtin.apt:
        name: [nginx, chrony]
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Tune kernel for many connections
      ansible.posix.sysctl:
        name: "{{ item.key }}"
        value: "{{ item.value }}"
        state: present
        reload: true
      loop:
        - { key: net.core.somaxconn, value: "4096" }
        - { key: net.ipv4.ip_local_port_range, value: "10240 65000" }

    - name: Render nginx site
      ansible.builtin.template:
        src: templates/api.conf.j2
        dest: /etc/nginx/sites-enabled/api.conf
        mode: "0644"
      notify: Reload nginx

    - name: Ensure nginx running
      ansible.builtin.systemd:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Reload nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded
ansible -i inventory/prod.ini api -m ping
ansible-playbook -i inventory/prod.ini playbooks/api.yml --check --diff   # dry run,看會改什麼
ansible-playbook -i inventory/prod.ini playbooks/api.yml --limit api-1.internal

IaC 與 Terraform 概念

Infrastructure as Code 讓 VPC、子網路、叢集、資料庫、IAM 等雲端資源由程式碼描述、review、版本控制。Terraform(與其開源分支 OpenTofu)是最普遍的工具:

  • 宣告式:寫 desired state,terraform plan 計算差異,terraform apply 執行。
  • Provider:對接 AWS/GCP/K8s 等 API。Module:可重用的元件。
  • State:記錄「程式碼中的資源 ↔ 雲端實際 ID」的對應。團隊必須用 remote state + locking(S3 + 鎖、GCS backend),否則兩人同時 apply 會互相覆蓋;state 可能含敏感資料,要加密與控管存取。
  • Drift:有人在 console 手動改資源,下次 plan 會顯示差異。原則是「一律透過程式碼改」。
  • 與 Ansible 的分工:Terraform 負責建立基礎設施(provisioning),Ansible 負責機器內部的設定(configuration management)。
# main.tf(HCL)
terraform {
  backend "s3" {
    bucket       = "example-tfstate"
    key          = "tracking/prod.tfstate"
    region       = "ap-northeast-1"
    use_lockfile = true          # 新版 Terraform 支援 S3 原生鎖;舊做法是搭配 DynamoDB table
    encrypt      = true
  }
}

resource "aws_s3_bucket" "raw_events" {
  bucket = "example-raw-events-prod"
}

resource "aws_s3_bucket_lifecycle_configuration" "raw_events" {
  bucket = aws_s3_bucket.raw_events.id
  rule {
    id     = "tiering"
    status = "Enabled"
    filter {}
    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 180
      storage_class = "GLACIER"
    }
    expiration {
      days = 730
    }
  }
}
面試官想聽到什麼

設計 pipeline 題目時,講清楚階段順序與理由(fail fast)、artifact 不可變且「build once, promote everywhere」、如何回滾、secret 怎麼管、怎麼確保部署成功(rollout status、canary 指標分析、自動回滾)。被問 Ansible 時強調冪等與 --check --diff;被問 Terraform 時強調 state 與 locking。

雲端服務對照:AWS vs GCP 與選型 高頻

職缺列出 EC2、S3、Lambda、ECS、API Gateway、Load Balancer。面試不會要你背所有服務,而是要你知道每一類問題用哪種服務、各自的取捨。

類別AWSGCP何時用 / 重點
虛擬機EC2Compute Engine (GCE)完全掌控 OS、長時間運行、特殊硬體(GPU)、自管 K8s node。搭配 Auto Scaling Group / Managed Instance Group
物件儲存S3Cloud Storage (GCS)原始事件、log、模型檔、data lake、靜態檔。幾乎無限容量,按容量 + 請求數 + 流出流量計費,有多種儲存層級
Function(FaaS)LambdaCloud Run functions(前身 Cloud Functions)事件驅動的短任務(S3 上傳觸發、webhook、排程小工作)。Lambda 單次最長 15 分鐘
Serverless 容器App Runner、ECS on FargateCloud Run直接跑容器、自動擴縮(Cloud Run 可縮到 0)、按使用計費;適合中低流量 API 與內部工具
容器編排ECS(AWS 自家)、EKS(K8s)GKEECS 簡單、與 AWS 深度整合;EKS/GKE 是標準 K8s、生態豐富、可攜。GKE 的 Autopilot 由 Google 管 node
關聯式 DBRDS、AuroraCloud SQL、AlloyDB、Spanner託管 MySQL/PostgreSQL,自動備份、Multi-AZ failover、read replica。Spanner 是全球分散式強一致 SQL
NoSQLDynamoDBBigtable、FirestoreDynamoDB:key-value/document,單位數毫秒延遲,按需或預置容量。Bigtable:寬欄式,適合巨量時序/使用者畫像(HBase API 相容)
快取ElastiCache (Redis/Valkey/Memcached)Memorystore託管 Redis
訊息佇列 / 串流SQS、SNS、Kinesis、MSK (Kafka)Pub/Sub、Managed KafkaSQS 是 pull 式佇列(標準佇列至少一次、可能亂序;FIFO 佇列有序但吞吐較低);SNS 是 pub/sub 扇出;Pub/Sub 兼具扇出與持久化、可設 ordering key
負載平衡ALB(L7)、NLB(L4)、CLB(舊)External/Internal Application LB(L7)、Network LB(L4)ALB:HTTP 路由、gRPC、WebSocket、WAF 整合。NLB:TCP/UDP、極高吞吐、低延遲、固定 IP、保留 client IP。GCP 的 global external Application LB 用單一 anycast IP 服務全球
API GatewayAPI Gateway(REST/HTTP/WebSocket API)API Gateway、Apigee認證、限流、API key、usage plan、請求轉換,前接 Lambda 或後端服務。高 QPS 時按請求計費可能很貴
監控 / 日誌CloudWatch(Metrics、Logs、Alarms)、X-RayCloud Monitoring、Cloud Logging、Cloud Trace雲端原生指標與告警;很多團隊另外自建 Prometheus + Grafana + OpenTelemetry
身分與權限IAM(User、Role、Policy)IAM(Principal、Role、Service Account)見下一節
資料倉儲 / 大數據Redshift、Athena、EMR、GlueBigQuery、Dataproc、Dataflow報表與 ad-hoc 分析;BigQuery 是 serverless、按掃描量或 slot 計費
Container RegistryECRArtifact Registry放 Docker image,搭配漏洞掃描

運算服務怎麼選

flowchart TD S["要跑一段程式"] --> Q1{"執行時間短、事件驅動、流量零星?"} Q1 -->|是| F["Lambda / Cloud Run functions"] Q1 -->|否| Q2{"已容器化、不想管叢集?"} Q2 -->|是| R["Cloud Run / ECS Fargate"] Q2 -->|否| Q3{"多服務、需要 K8s 生態 (HPA, operator, GitOps)?"} Q3 -->|是| K["EKS / GKE"] Q3 -->|否| Q4{"需要特殊 kernel 設定、GPU、極致成本控制?"} Q4 -->|是| V["EC2 / GCE (+ Ansible)"] Q4 -->|否| E["ECS on EC2 或 VM"]
Appier 情境

以廣告平台常見做法為例:高 QPS、低延遲的 bidder 與事件收集服務適合跑在 K8s(EKS/GKE)或 VM 上,前面接 L4 NLB 或自管 Nginx/HAProxy,避免 API Gateway 按請求計費與額外延遲;原始事件寫入 Kafka / Kinesis / Pub/Sub 後落地到 S3/GCS 作為 data lake,再用 Spark(EMR/Dataproc)或 BigQuery 做報表;Lambda / Cloud Run 則適合做低頻的內部 webhook、報表匯出、圖片處理等。

VPC 網路、IAM、S3、Serverless 與成本優化 進階

VPC 網路基礎

flowchart TB Internet(("Internet")) --- IGW["Internet Gateway"] subgraph VPC["VPC 10.0.0.0/16"] subgraph AZA["AZ-a"] PubA["Public subnet 10.0.0.0/24: ALB, NAT GW, Bastion"] PrivA["Private subnet 10.0.10.0/24: K8s nodes, App"] DBA["DB subnet 10.0.20.0/24: RDS primary"] end subgraph AZB["AZ-b"] PubB["Public subnet 10.0.1.0/24: ALB, NAT GW"] PrivB["Private subnet 10.0.11.0/24: K8s nodes, App"] DBB["DB subnet 10.0.21.0/24: RDS standby"] end end IGW --- PubA IGW --- PubB PrivA -->|"0.0.0.0/0 經 NAT"| PubA PrivB -->|"0.0.0.0/0 經 NAT"| PubB PrivA --> DBA
  • VPC:一個區域(region)內的私有網路,以 CIDR 定義位址空間(例如 10.0.0.0/16,約 65,536 個位址)。要事先規劃,避免與公司其他 VPC 或機房重疊(之後要 peering / VPN 會衝突)。
  • Subnet:在 AWS 上一個 subnet 屬於單一 AZ(GCP 的 subnet 則是 regional)。「public」與「private」的差別在於路由表:public subnet 的 0.0.0.0/0 指向 Internet Gateway;private subnet 指向 NAT Gateway 或沒有對外路由。
  • NAT Gateway:讓 private subnet 的機器能主動連出(拉 image、呼叫外部 API),但外部無法主動連入。放在 public subnet,每個 AZ 一個以免單點故障與跨 AZ 流量費。NAT 按小時 + 處理的 GB 計費,流量大時很貴——存取 S3/DynamoDB 應改用 VPC Gateway Endpoint(不經 NAT 且不收資料處理費),其他服務可用 Interface Endpoint(PrivateLink)。
  • 高可用:服務至少跨 2–3 個 AZ;但跨 AZ 傳輸要收費,chatty 的服務間呼叫要注意(K8s 可用 topology-aware routing)。
Security GroupNetwork ACL
作用層級ENI / instance(也可套在 EKS Pod)Subnet
狀態Stateful:允許了進來的連線,回應自動放行Stateless:進出兩個方向都要明確允許(包含回應用的 ephemeral port 範圍)
規則只有 allowallow 與 deny,依規則編號順序比對,第一個符合即生效
可參照可引用其他 security group(例如「只允許來自 ALB SG 的 8000 port」)只能用 CIDR
典型用途主要的存取控制手段粗粒度的子網路防護、封鎖特定 IP 範圍

IAM 與最小權限

  • 最小權限(least privilege):只給完成工作所需的動作與資源,例如只能 s3:PutObject 到特定 bucket 的特定 prefix,而不是 s3:* on *。
  • 用 Role 不用長期 key:EC2 用 instance profile;EKS 用 IRSA(IAM Roles for Service Accounts)或 EKS Pod Identity;GKE 用 Workload Identity;CI 用 OIDC federation。全部都是自動輪替的短期憑證。
  • Policy 評估邏輯:預設拒絕 → 任何明確 Deny 優先 → 有 Allow 才放行。另有 SCP(組織層)、permission boundary、resource policy(如 bucket policy)共同決定。
  • 人員使用 SSO + MFA,root 帳號鎖起來;以 CloudTrail / Cloud Audit Logs 稽核;定期用 IAM Access Analyzer 找出未使用的權限。
  • 多帳號 / 多專案隔離環境(prod 與 dev 分開),爆炸半徑更小。
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "WriteRawEventsOnly",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::example-raw-events-prod/ingest/*"
    },
    {
      "Sid": "ReadModelArtifacts",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::example-models/bidder/*"
    }
  ]
}

S3:一致性、presigned URL 與常見設計

  • 一致性:自 2020 年 12 月起,S3 對所有 GET/PUT/LIST/DELETE 提供強的 read-after-write 一致性(寫入成功後立即讀得到新版本、LIST 立即可見),不再是過去的最終一致。但同一 key 的並行寫入仍是 last-writer-wins,沒有鎖;需要條件寫入時可用 If-None-Match / If-Match conditional write。
  • 不是檔案系統:沒有真正的目錄(prefix 只是 key 的一部分)、不能 append 或部分修改物件、rename = copy + delete。
  • 效能:每個 prefix 每秒可支援數千次請求(官方數字為 PUT 類 3,500、GET 類 5,500 RPS per prefix),用多個 prefix 平行即可擴展;大檔用 multipart upload。
  • 小檔問題:每個事件一個物件會造成大量請求費用與下游 Spark 讀取緩慢,應在上游 batch 成較大的檔案(例如每分鐘一個 Parquet/壓縮 JSON 檔,以 dt=2025-01-01/hour=13/ 分區)。
  • Presigned URL:後端用自己的憑證對「某個操作 + 某個 key + 到期時間」簽章,產生一個暫時的 URL 交給 client,client 直接上傳/下載 S3,流量不經過你的伺服器。適合使用者上傳大檔、私有報表下載。有效期由 ExpiresIn 決定,SigV4 最長 7 天,且若以臨時憑證(role)簽發,憑證過期時 URL 也失效。
import boto3
from botocore.config import Config

s3 = boto3.client("s3", region_name="ap-northeast-1", config=Config(signature_version="s3v4"))

def presign_report_download(report_id: str, user_id: str) -> str:
    # 先做授權檢查:這個 user 能否看這份報表(presigned URL 本身任何人拿到都能用)
    key = f"reports/{user_id}/{report_id}.csv.gz"
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": "example-reports", "Key": key,
                "ResponseContentDisposition": f'attachment; filename="{report_id}.csv.gz"'},
        ExpiresIn=600,   # 10 分鐘
    )

def presign_sdk_upload(app_id: str, batch_id: str) -> dict:
    # presigned POST 可限制大小與 content-type
    return s3.generate_presigned_post(
        Bucket="example-raw-events-prod",
        Key=f"ingest/app={app_id}/{batch_id}.json.gz",
        Conditions=[["content-length-range", 1, 10 * 1024 * 1024],
                    {"Content-Type": "application/gzip"}],
        Fields={"Content-Type": "application/gzip"},
        ExpiresIn=300,
    )

Serverless 的優缺點

優點

不用管機器與修補;按實際執行計費,閒置成本接近 0;自動擴展;與雲端事件(S3、SQS、排程)原生整合;適合流量不可預測或很低的工作。

缺點

Cold start:新實例要初始化 runtime 與你的程式碼(Python/Node 通常數百毫秒,JVM 可能更久,VPC 內與大型套件會更慢),對延遲敏感服務不友善(可用 provisioned concurrency / min instances 緩解,但就要付閒置費用);執行時間上限(Lambda 15 分鐘);並發上限(帳號區域層級);無狀態,連線池難以重用(大量 Lambda 打爆 RDS 連線數,需 RDS Proxy);高持續流量時比 VM/容器貴;本機測試與觀測較難;廠商鎖定。

成本優化

手段說明注意
Spot / Preemptible使用雲端閒置容量,折扣常見到七折至九折以上(依機型與時段變動)可能隨時被收回:AWS 提前 2 分鐘通知、GCP Spot VM 約 30 秒。只放可中斷、可重試的工作;混用多種機型提高可得性
Savings Plans / Reserved / CUD承諾 1 或 3 年使用量換折扣用於穩定的基線容量
AutoscalingHPA + Cluster Autoscaler/Karpenter,夜間低峰自動縮設好 min 與縮容穩定窗口
Right-sizing依實際使用(p95)調整 requests 與機型,VPA recommender 可給建議requests 過高 = 付錢買閒置;過低 = 擁擠與被驅逐
儲存分層S3 lifecycle 轉 IA/Glacier、刪除過期資料、壓縮與欄式格式Glacier 取回有延遲與費用
網路費用減少跨 AZ / 跨區傳輸、用 VPC endpoint 取代 NAT、CDN 減少 egress常被忽略的大宗
Graviton / ARM同效能價格較低需 multi-arch image(docker buildx --platform linux/amd64,linux/arm64)
標籤與可見性資源加 team/service tag,做成本分攤(FinOps)看得到才能優化
面試官想聽到什麼

被問「你的服務如何在 AWS 上部署成高可用?」時,從網路講起:VPC 跨多 AZ、public subnet 只放 LB 與 NAT、應用與 DB 在 private subnet、Security Group 只開必要 port 且以 SG 互相引用、ALB/NLB 健康檢查、Auto Scaling 或 EKS 跨 AZ、RDS Multi-AZ、IAM role 最小權限、S3 存資料並用 VPC endpoint、CloudWatch 告警。再補一句成本:基線用 Savings Plans、尖峰用 autoscaling、批次用 spot。

Git 進階:rebase、cherry-pick、bisect、reflog 與分支策略 高頻 基礎

職缺明列 Git。實習生面試常問「merge 和 rebase 差在哪?」「不小心 reset 掉 commit 怎麼救?」「你們團隊用什麼 branching strategy?」。先建立正確的心智模型:commit 是不可變的快照,以 parent 指標串成 DAG;branch 只是一個指向 commit 的可移動指標;HEAD 指向目前的 branch。

Merge vs Rebase

flowchart LR subgraph M["merge: 保留分岔歷史"] direction LR m1((A)) --> m2((B)) --> m3((C)) --> mM((M)) m2 --> mf1((D)) --> mf2((E)) --> mM end subgraph R["rebase: 重放成線性歷史"] direction LR r1((A)) --> r2((B)) --> r3((C)) --> rf1(("D'")) --> rf2(("E'")) end
mergerebase
做法產生一個有兩個 parent 的 merge commit把 feature 的 commit 一個個「重放」到新的 base 上,產生新的 commit(新 hash)
歷史真實保留何時分岔、何時合併;圖較雜線性、乾淨,易讀易 bisect
衝突一次解決全部衝突可能每個 commit 都要解一次
風險無改寫歷史改寫歷史:已推送且他人基於其上工作的分支 rebase 後要 force push,會造成別人歷史分岔
適用合併到共享主幹(或用 squash merge)整理自己尚未分享 / 只有自己用的 feature branch、同步 main 的最新變更

黃金守則:不要 rebase 別人也在用的公開分支。自己的 PR 分支 rebase 後要推送時用 git push --force-with-lease(若遠端被別人更新過會拒絕),而不是 --force。

# 讓自己的 feature branch 跟上 main
git fetch origin
git rebase origin/main            # 遇衝突:修改檔案 → git add → git rebase --continue(或 --abort 放棄)
git push --force-with-lease

# 互動式 rebase:在發 PR 前整理 commit(squash 修正、改訊息、調順序、刪除)
git rebase -i origin/main
#   pick   3f9c2ab feat(events): add batch ingestion endpoint
#   fixup  a1b2c3d fix typo           ← 併入上一個 commit 並丟棄訊息
#   reword 7d8e9f0 add tests          ← 修改 commit message
#   drop   1122aab debug print        ← 刪除
# 小技巧:git commit --fixup=<sha> 之後 git rebase -i --autosquash 自動排好

cherry-pick、bisect、reflog

# cherry-pick:把某個 commit 的變更套到目前分支(例如把 hotfix 帶到 release 分支)
git switch release/1.8
git cherry-pick 3f9c2ab               # 產生新 commit;-x 會在訊息附上來源 hash
git cherry-pick A^..B                 # 一段範圍

# bisect:二分搜尋找出「哪個 commit 引入 bug」——O(log n) 次測試
git bisect start
git bisect bad                        # 目前版本有 bug
git bisect good v1.7.0                # 這個版本沒問題
#   Git checkout 中間的 commit,測完告訴它 good / bad,重複直到找到
git bisect run uv run pytest tests/test_dedup.py -q   # 全自動:exit 0 = good,非 0 = bad(125 = skip)
git bisect reset

# reflog:HEAD 與 branch 移動的本機紀錄(預設保留約 90 天),救援神器
git reset --hard HEAD~3               # 糟了,三個 commit 不見了
git reflog                            # 找到 reset 前的位置,例如 HEAD@{1}
git reset --hard HEAD@{1}             # 或 git branch rescue <sha>
# 刪掉的 branch、rebase 弄壞的歷史,都能從 reflog 找回(只要還沒被 gc)

# 其他常用
git stash push -m "wip" ; git stash pop
git restore --staged file.py          # 取消 add
git revert <sha>                      # 對已推送的 commit 產生「反向 commit」,不改寫歷史
git log --oneline --graph --decorate --all
git blame -L 120,160 app/ingest.py ; git log -S "dedup_key" -p   # 找某段程式碼何時被加入
常見陷阱

git reset --hard 會丟掉未 commit 的修改,這些 reflog 救不回(reflog 只記錄 commit 的移動)。已推送到共享分支的錯誤用 git revert,不要用 reset + force push。密碼不小心 commit 並推送了:先撤銷(rotate)該密碼,再考慮用 git filter-repo 清除歷史——因為一旦推送就要假設已外洩。

Branching strategy

Trunk-based developmentGit FlowGitHub Flow
結構所有人頻繁(每天)合併小改動到 main;短命 feature branch(一兩天)main、develop、feature/*、release/*、hotfix/*main + 短命 feature branch + PR
搭配Feature flag 隱藏未完成功能、強 CI、持續部署排定版本發佈持續部署
優點合併衝突小、整合快、適合 CD版本管理清楚,適合要維護多版本的軟體(App、SDK、on-prem)簡單
缺點需要好的測試與 flag 紀律分支多、長命分支合併痛苦、不適合每天多次部署的 web 服務多版本維護較弱

Commit message 規範

feat(ingest): batch events into 1-minute gzip files on S3

Writing one object per event caused ~40k PUT/s and slow Spark scans.
Buffer events per (app_id, minute) and flush when 5 MB or 60 s.

- add EventBuffer with size/time based flush
- graceful shutdown flushes pending buffers on SIGTERM

Refs: TRACK-123

Conventional Commits:type(scope): subject,type 常見有 feat、fix、refactor、perf、test、docs、chore、ci;subject 用祈使句、50 字元左右;body 說明為什麼而不只是做了什麼。好處:可自動產生 changelog 與 semver。

解 merge conflict 的流程

  1. git status 看哪些檔案衝突;衝突區塊以 <<<<<<< HEAD、=======、>>>>>>> branch 標示。
  2. 理解兩邊的意圖,而不是單純選一邊——看 git log -p --merge 或 PR 說明,必要時找作者確認。設 git config merge.conflictstyle zdiff3 可同時看到共同祖先版本,更容易判斷。
  3. 編輯成正確結果,刪除標記,跑測試(語意衝突不會被 Git 偵測到,例如對方改了函式簽名、你新增了呼叫)。
  4. git add 後 git commit(merge)或 git rebase --continue。
  5. 預防勝於治療:小 PR、頻繁同步 main、避免大範圍格式化與功能修改混在一起。
面試官想聽到什麼

「merge 保留真實歷史、rebase 產生線性歷史但改寫 commit;自己的分支可以 rebase,共享分支不行,force push 要用 --force-with-lease。誤刪 commit 用 reflog 救、找出引入 bug 的 commit 用 bisect、已上線的錯誤用 revert。」再講你偏好的流程(例如 trunk-based + squash merge + Conventional Commits),並說明理由。

Appier 情境:Bidder 依 QPS 擴展、Spot 節點跑批次 深入

Appier 情境

以下以廣告平台常見做法為例(非 Appier 內部架構):bidder 服務接收 ad exchange 的 bid request,必須在約 100ms 內回應,流量隨時段與活動劇烈波動;另一方面每天有大量離線批次(報表聚合、特徵計算、模型訓練)可容忍中斷但吃大量運算資源。

Bidder:為什麼用 QPS 而不只用 CPU 擴展?

  • CPU 是落後指標:流量暴增時,CPU 拉高 → metrics-server 抓到(約 15 秒以上)→ HPA 計算 → Pod 啟動 → 通過 readiness,可能已過一兩分鐘;期間延遲飆升、bid timeout 就是直接損失營收。
  • bidder 的瓶頸可能不是 CPU(例如等 Redis / 特徵服務、連線數),CPU 使用率不能反映飽和。
  • QPS per Pod 與容量的對應關係可以壓測得出(例如單 Pod 在 p99 < 50ms 下能撐 800 QPS),直接把目標設在安全水位(例如 600)。
# 方案一:HPA + Prometheus Adapter 暴露的自訂指標(Pods 類型)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: bidder, namespace: rtb }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: bidder }
  minReplicas: 30                    # 依歷史低峰 + 安全餘裕,避免縮太小來不及長回來
  maxReplicas: 400
  metrics:
    - type: Pods
      pods:
        metric: { name: bid_requests_per_second }     # adapter 由 rate(bid_requests_total[1m]) 轉出
        target: { type: AverageValue, averageValue: "600" }
    - type: Resource                  # 多指標時取「建議副本數最大」的那個,CPU 當保險
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 65 } }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - { type: Percent, value: 100, periodSeconds: 15 }
        - { type: Pods, value: 20, periodSeconds: 15 }
    scaleDown:
      stabilizationWindowSeconds: 600   # 慢慢縮,避免流量回彈時來不及
      policies: [{ type: Percent, value: 5, periodSeconds: 60 }]
---
# 方案二:KEDA(內部會建立並管理 HPA),直接寫 PromQL
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: bidder, namespace: rtb }
spec:
  scaleTargetRef: { name: bidder }
  minReplicaCount: 30
  maxReplicaCount: 400
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        query: sum(rate(bid_requests_total{service="bidder"}[1m]))
        threshold: "600"              # 每個副本的目標值

完整的擴展設計還需要:

  1. Node 層擴展要跟得上:Pod 擴了但 node 不夠會 Pending。用 Karpenter / Cluster Autoscaler,並保留少量overprovisioning(低優先級的 placeholder Pod 佔位,高優先級 bidder 要排程時把它們擠掉,立刻有空間)。
  2. 啟動要快:image 小、模型與設定預載、startupProbe 合理、readiness 只在暖機完成後才通過(避免冷 Pod 一進來就 timeout)。
  3. 可預期的尖峰用排程擴容:大型購物節前預先拉高 minReplicas(CronJob 修改 HPA 或 KEDA cron trigger)。
  4. 過載保護:擴容來不及時,bidder 應快速回 no-bid(load shedding)而不是排隊等到超時;readiness 在 in-flight 過高時暫時回 503。
  5. 縮容平穩:PDB、preStop + graceful shutdown,確保縮容時沒有 bid request 被中斷。
  6. 跨 AZ 分散:topologySpreadConstraints,避免單一 AZ 故障影響太大。

Spot 節點跑批次

# Spot node pool 打上 taint,只有明確容忍的批次工作會被排上去
# (EKS 受管節點群組的標籤為 eks.amazonaws.com/capacityType=SPOT;
#  Karpenter 為 karpenter.sh/capacity-type=spot;GKE 為 cloud.google.com/gke-spot=true)
apiVersion: batch/v1
kind: Job
metadata: { name: feature-backfill-20250101, namespace: analytics }
spec:
  parallelism: 50
  completions: 500                 # 500 個分片,每個 Pod 處理一片
  completionMode: Indexed          # Pod 取得 JOB_COMPLETION_INDEX,分片可重跑且冪等
  backoffLimitPerIndex: 3
  podFailurePolicy:
    rules:
      - action: Ignore             # 被 spot 回收造成的中斷不計入失敗次數
        onPodConditions: [{ type: DisruptionTarget }]
  template:
    spec:
      restartPolicy: Never
      priorityClassName: batch-low
      tolerations:
        - { key: "spot", operator: "Equal", value: "true", effect: "NoSchedule" }
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - { key: karpenter.sh/capacity-type, operator: In, values: ["spot"] }
      terminationGracePeriodSeconds: 90   # 小於 AWS 2 分鐘通知,來得及存 checkpoint
      containers:
        - name: worker
          image: registry.example.com/analytics/backfill:3.1.0
          args: ["--date", "2025-01-01", "--checkpoint", "s3://example-ckpt/backfill/"]
          resources:
            requests: { cpu: "2", memory: 4Gi }
            limits: { memory: 4Gi }
  • 可中斷設計:工作切成小分片、每片冪等(寫入以分片 ID 覆蓋,而非 append)、定期 checkpoint 到 S3,被回收後從 checkpoint 續跑。
  • 中斷處理:AWS Node Termination Handler 或 Karpenter 的 interruption handling 收到 2 分鐘通知後 cordon + drain node,Pod 收到 SIGTERM 存檔退出。
  • 多樣化:允許多種機型與多個 AZ,降低同時被回收的機率;Karpenter 會挑選價格與可得性較佳的組合。
  • Spark on K8s:driver 放 on-demand(driver 掛掉整個 job 重來),executor 放 spot(掉了只重算該 task 的分區)。
  • 線上服務不放 spot,或只把一部分可替換的副本放 spot,且保證 on-demand 基線足以承載。
面試官想聽到什麼

「延遲敏感的 bidder 我會用 QPS(或 in-flight requests)當主要擴展指標、CPU 當保險;擴容要快、縮容要慢;node 層用 Karpenter 加 overprovisioning;可預期的尖峰提前擴;擴不及時用 load shedding 保護 p99。批次工作放 spot,前提是可分片、冪等、可 checkpoint,並以 taint/toleration 隔離。」能把 HPA 公式、stabilization window、PDB、graceful shutdown 串在一起講,就是 senior 等級的回答。

面試自測題

容器和 VM 的差別是什麼?容器的隔離是靠什麼實作的?

VM 透過 hypervisor 虛擬化硬體,每台有自己的 kernel,隔離強但重;容器是共用 host kernel 的 process,用 namespaces(PID、NET、MNT、UTS、IPC、USER、CGROUP)隔離「看得到什麼」、用 cgroups 限制 CPU、記憶體、PID 數、IO,用 overlay2 等 union FS 提供分層的 rootfs。所以容器啟動快(就是 exec 一個 process)、密度高、image 小,但安全邊界較弱(kernel 漏洞可 escape)。追問:需要強隔離可用 gVisor 或 Firecracker microVM;容器內 /proc/cpuinfo 看到的是 host 的 CPU,worker 數要依 cgroup limit 設定。

為什麼在 Dockerfile 後面的 RUN 刪除檔案不會讓 image 變小?怎麼解決?

每個 RUN/COPY 產生一個唯讀 layer,image 是所有 layer 的疊加。在後面的 layer 刪檔只是加了一個 whiteout 標記讓檔案「看不見」,前一層仍包含該檔案,image 大小不變。解法:在同一個 RUN 中安裝並清理(apt-get install ... && rm -rf /var/lib/apt/lists/*),或用 multi-stage build 只把需要的產物複製到乾淨的 runtime stage。可用 docker history 或 dive 檢查每層大小。

Dockerfile 寫 CMD python app.py 有什麼問題?

這是 shell form,實際 PID 1 是 /bin/sh -c,sh 不轉發 SIGTERM,應用收不到停止信號,只能在寬限期後被 SIGKILL,造成請求中斷、資料未 flush。另外 PID 1 在 namespace 內沒有預設信號處理,未註冊 handler 時 SIGTERM 會被忽略;PID 1 也要負責回收 zombie。解法:exec form CMD ["python", "app.py"]、應用註冊 SIGTERM handler 做 graceful shutdown、用 tini/dumb-init 當 PID 1(或 docker run --init);entrypoint shell script 最後用 exec "$@"。

描述 Kubernetes 的架構,以及建立一個 Deployment 後發生了什麼事。

Control plane:kube-apiserver(唯一入口、唯一讀寫 etcd、做 authn/authz/admission)、etcd(Raft 強一致儲存)、scheduler(filter + score 選 node)、controller-manager(各種 reconcile loop)、cloud-controller-manager。Node:kubelet(透過 CRI 建容器、跑 probe、回報狀態)、kube-proxy(iptables/IPVS 實作 Service)、container runtime(containerd)。流程:kubectl 送到 API server 寫入 etcd → Deployment controller 建 ReplicaSet → ReplicaSet controller 建 Pod → scheduler bind 到 node → kubelet 拉 image 起容器、跑 probes → Ready 後 EndpointSlice controller 把 Pod 加入 Service。所有元件透過 watch API server 協作,level-triggered 的 reconcile 讓系統能自癒。

liveness、readiness、startup probe 差在哪?liveness 檢查 DB 連線會怎樣?

readiness 失敗 → 從 Service endpoints 移除、不重啟,用來表達「暫時不能接流量」;liveness 失敗 → 重啟容器,只用來偵測「卡死且無法自行恢復」;startup 在成功前停用另外兩者,給慢啟動應用足夠時間。若 liveness 檢查 DB:DB 一變慢,所有 Pod 同時被重啟,重啟又帶來連線風暴把 DB 壓垮,形成雪崩,而且重啟根本修不好 DB。正確做法是 liveness 只檢查 process 自身(event loop 是否有反應),依賴問題用降級、circuit breaker 或 readiness(且要小心共同依賴讓全部 Pod 同時 not ready)。

requests 與 limits 的作用?為什麼有人建議不設 CPU limit?

requests 是排程依據與保證量(scheduler 只看 requests,CPU request 轉成 cgroup weight);limits 是硬上限(CPU limit 變 CFS quota,超用被 throttle;memory limit 超用被 OOMKilled,exit 137)。CPU 是可壓縮資源,設了 limit 即使 node 有閒置 CPU 也會被限制,短時間的 burst(多執行緒同時跑)會在 100ms period 內用光 quota 而被暫停,p99 延遲大幅上升,對延遲敏感服務傷害很大。因此常見做法是 CPU 只設足夠的 request(保證公平分配)、不設或設寬鬆 limit;memory 則一定設 limit(通常等於 request),因為記憶體無法壓縮、超用會影響整台 node。代價是 Pod 無法成為 Guaranteed QoS,多租戶叢集也可能需要 LimitRange 規範。

三種 QoS class 是什麼?OOMKilled 與 Evicted 差在哪?

Guaranteed:每個容器的 CPU 與 memory 都有 requests 且等於 limits;Burstable:有設但不符合 Guaranteed;BestEffort:都沒設。node 記憶體壓力時 kubelet 先驅逐 BestEffort,再驅逐使用量超過 request 最多的 Burstable,Guaranteed 最後。OOMKilled 是容器超過自己的 memory limit,由 kernel cgroup OOM killer 殺掉,容器在同一個 Pod 中重啟;Evicted 是 node 整體資源不足,kubelet 主動終止整個 Pod,由 controller 在其他地方重建。

為什麼 rolling update 時會出現少量 502?怎麼做到真正零停機?

Pod 刪除時,kubelet 執行 preStop 並送 SIGTERM,與 EndpointSlice 更新、kube-proxy / Ingress / 雲端 LB 移除後端是平行發生的。若應用一收到 SIGTERM 就關閉 listener,LB 還沒更新前送來的請求就失敗。解法:preStop sleep 數秒讓流量先停;應用處理 SIGTERM(停接新請求、完成進行中請求、關連線、keep-alive 連線回 Connection: close);terminationGracePeriodSeconds 大於 preStop + drain 時間;Deployment 設 maxUnavailable: 0 搭配正確的 readiness probe,確保新 Pod 真的 ready 才砍舊的;再配合 PDB 與 Dockerfile exec form 讓信號送得到。

Pod 一直 CrashLoopBackOff,你的排查步驟?

(1) kubectl describe pod 看 Events、Last State 與 Exit Code:137 + OOMKilled 是記憶體,137 非 OOM 可能是 liveness 被殺,1 是應用錯誤,127 是指令不存在。(2) kubectl logs --previous 看上一次崩潰前的 log。(3) 常見原因:缺環境變數或 Secret、設定錯、依賴連不上就 exit、migration 失敗、liveness 太早開始檢查(缺 startupProbe)、image 架構不符。(4) 若一啟動就退出看不到東西,可暫時把 command 改成 sleep infinity 再 exec 進去手動執行,或用 kubectl debug 複製一份 Pod 除錯。(5) 若是新版本造成的,先 rollout undo 止血。

Pod 一直 Pending 的可能原因?

scheduler 找不到合適的 node:requests 大於任何 node 的可分配資源、nodeSelector/affinity 沒有符合的 node、node 有 taint 而 Pod 沒有 toleration、PVC 未綁定(StorageClass 錯誤或磁碟在另一個 zone)、超過 namespace 的 ResourceQuota(這種其實是建立 Pod 時就被拒絕)、Pod 數達 node 上限。用 kubectl describe pod 看 FailedScheduling 訊息(例如 0/10 nodes are available: 10 Insufficient memory),並確認 Cluster Autoscaler/Karpenter 是否有在擴 node 以及為何沒擴(可能達到 node group 上限)。

Deployment、StatefulSet、DaemonSet 各用在什麼情境?

Deployment:無狀態服務,Pod 可互換,支援 rolling update 與 rollback(API、bidder)。StatefulSet:需要穩定身分與儲存的服務,Pod 名稱固定(kafka-0)、搭配 headless Service 有穩定 DNS、每個 Pod 有自己的 PVC、依序啟動與更新(Kafka、ZooKeeper、資料庫)。DaemonSet:每個 node 一個,用於 node 層的 agent(log 收集、監控 exporter、CNI、storage driver)。另外 Job/CronJob 用於跑到完成的批次。

HPA 怎麼計算副本數?為什麼要用 QPS 而不是 CPU 擴展 bidder?

desired = ceil(current × currentMetric / targetMetric),有約 10% 容忍度,多指標時取最大值,並受 behavior 的擴縮速率與 stabilization window 限制(縮容預設 300 秒)。CPU 使用率以 requests 為分母,所以沒有 requests 就無法依 CPU 擴展。bidder 用 QPS 的原因:CPU 是落後指標,且瓶頸可能是 IO 或下游;QPS per Pod 可由壓測對應到延遲 SLO,直接反映容量。實作上用 Prometheus Adapter 或 KEDA 提供自訂指標,擴容積極、縮容保守,搭配 Karpenter 與 overprovisioning 讓 node 跟得上,並以 load shedding 處理擴容來不及的瞬間。

什麼是 GitOps?ArgoCD 怎麼運作?和 CI 直接 kubectl apply 比有什麼好處?

GitOps 以 Git 作為期望狀態的唯一事實來源,由叢集內的 agent 持續拉取並 reconcile。ArgoCD 的 Application 定義 repo/path/revision 與目標叢集;repo server 渲染 Helm/Kustomize 得到 desired state,controller 與 live state 比對,顯示 Synced/OutOfSync 與健康狀態,auto-sync 時自動套用,selfHeal 修正手動漂移,prune 刪除 Git 已移除的資源。好處:CI 不需持有叢集 admin 憑證(pull 模型更安全)、每個變更都有 PR 審核與紀錄、回滾 = git revert、可從 Git 重建叢集、drift 可偵測。缺點:secret 要另外處理(Sealed Secrets、External Secrets)、流程多一個 repo。

設計一條 CI/CD pipeline,從 PR 到 production。

PR 階段:lint/format/type check → unit test → 用容器起 DB/Redis 跑整合測試(平行化、快取依賴),作為合併門檻。合併到 main:build image(tag 用 git SHA,不可變)→ 漏洞掃描與 SBOM、簽章 → push registry → 更新 GitOps repo 的 dev overlay,ArgoCD 自動部署 → 跑 smoke/E2E → promotion 到 staging(同一個 image digest)→ prod 經人工核准或自動 canary(Argo Rollouts 以錯誤率、p99 延遲分析,失敗自動回滾)。原則:fail fast、build once deploy many、環境差異只在設定、DB migration 向後相容且獨立步驟、雲端認證用 OIDC 短期憑證、部署可觀測且可一鍵回滾。

什麼是冪等?Ansible 為什麼強調冪等?

冪等是「執行一次和多次結果相同」。Ansible 的 module 會先檢查目前狀態,只有不符合期望時才變更(例如 apt: state=present 已安裝就不動),所以 playbook 可以安全地重複執行,用於修正漂移,也能用 --check --diff 預覽。若用 shell 執行 echo x >> file 就不冪等,跑兩次會寫兩行,需改用 lineinfile 或加 creates/changed_when。同樣概念出現在 K8s 的 apply、Terraform、API 設計與訊息處理。

Security Group 與 NACL 差在哪?private subnet 的機器怎麼連外網?

Security Group 套在 instance/ENI,stateful(回應自動放行),只有 allow 規則,可引用其他 SG;NACL 套在 subnet,stateless(進出都要允許,含 ephemeral port),有 allow/deny 並依編號順序比對。private subnet 的路由表把 0.0.0.0/0 指到放在 public subnet 的 NAT Gateway,可主動連出但外部無法連入;每個 AZ 放一個 NAT 避免單點與跨 AZ 費用;存取 S3/DynamoDB 用 Gateway Endpoint 省下 NAT 處理費。

S3 的一致性模型?presigned URL 是什麼、有什麼安全考量?

S3 自 2020 年底起對 PUT/GET/LIST/DELETE 提供強的 read-after-write 一致性,但同一 key 的並行寫入是 last-writer-wins,沒有鎖(可用條件寫入)。presigned URL 是後端用自身憑證簽出的暫時 URL,允許持有者在期限內對特定 key 做特定操作,讓 client 直接上傳/下載而不經後端,節省頻寬。安全考量:產生前要做授權檢查(URL 拿到就能用)、期限盡量短、上傳用 presigned POST 限制大小與 content-type、簽發者權限最小化、臨時憑證過期時 URL 也會失效、bucket 保持 private 並阻擋 public access。

Lambda 的 cold start 是什麼?什麼情況不適合用 serverless?

Cold start 是沒有可用的暖實例時,平台要建立執行環境、載入 runtime 與程式碼、執行初始化程式碼,造成首個請求額外延遲;套件大、VPC 設定、JVM 會更慢。緩解:provisioned concurrency / min instances、縮小部署套件、把初始化移出 handler 以重用連線、選啟動快的語言。不適合:持續高 QPS(比容器/VM 貴)、嚴格低延遲(RTB bidder)、長時間任務(Lambda 上限 15 分鐘)、需要大量長連線或連線池到 RDB(需 RDS Proxy)、需要 GPU 或特殊環境。

merge 和 rebase 差在哪?什麼時候不能 rebase?

merge 產生 merge commit,保留真實分岔歷史,不改寫既有 commit;rebase 把 commit 重放到新的 base 上,得到線性歷史但 commit hash 全部改變。已推送且他人依賴的共享分支不能 rebase,否則別人的歷史會分岔、需要痛苦地修復。自己的 feature branch 可以 rebase 跟上 main、用 interactive rebase 整理 commit,推送時用 --force-with-lease。合進 main 常用 squash merge 或 rebase merge 保持主線整潔。

不小心 git reset --hard 掉了三個 commit,怎麼救?怎麼找出哪個 commit 引入了 bug?

用 git reflog 找到 reset 前 HEAD 的位置(如 HEAD@{1}),git reset --hard HEAD@{1} 或 git branch rescue <sha> 救回;reflog 是本機紀錄,預設保留約 90 天,但未 commit 的修改救不回。找 bug 用 git bisect:標記一個 bad、一個 good,Git 二分 checkout,搭配 git bisect run <測試指令> 全自動,O(log n) 步找到元兇;線性且每個 commit 都能編譯通過的歷史讓 bisect 更好用。

批次工作放 spot instance 要注意什麼?

Spot 可能隨時被回收(AWS 提前 2 分鐘通知、GCP 約 30 秒),所以工作必須:切成小分片、冪等(以分片 ID 覆寫結果)、定期 checkpoint 到 S3/GCS、收到 SIGTERM 時存檔退出(grace period 小於通知時間)。K8s 上用 taint/toleration 與 node affinity 讓只有批次工作落在 spot node,用 Indexed Job + podFailurePolicy 讓中斷不計入失敗,Node Termination Handler 或 Karpenter 處理中斷事件;分散多機型、多 AZ 提高可得性。Spark 的 driver 放 on-demand、executor 放 spot。線上服務的基線容量不放 spot。

Helm 和 Kustomize 怎麼選?

Helm 是套件管理 + Go template,有 release 與 rollback 概念,適合發佈給他人安裝、參數化程度高的元件(第三方軟體多以 chart 發佈)。Kustomize 不用範本,以 base + overlay patch 疊加純 YAML,內建於 kubectl,適合自家服務的多環境差異,review 容易。兩者可並用(Kustomize 引用 Helm chart 再 patch),ArgoCD 都支援。選擇時考慮:團隊熟悉度、是否需要對外發佈、參數複雜度、可讀性。