jvinhit//lab

Search posts

Type to search across journal entries.

↑ ↓ navigate ↵ open esc close

Kubernetes cho Frontend Developer · Phần 3 — YAML trong 20 phút và container là gì

YAML đối chiếu với JSON, 5 quy tắc sống còn, các kiểu dữ liệu gây bẫy và 4 khối của mọi manifest K8s; rồi container giải quyết gì, khác VM ra sao, Dockerfile, image, container, cơ chế layer và bộ lệnh Docker cần thuộc.

Toàn bộ Kubernetes được cấu hình bằng YAML, và toàn bộ thứ Kubernetes chạy là container. Hai chủ đề này là hai nửa của bài: nửa đầu cho bạn đủ YAML để viết manifest không lỗi thụt lề và không bị bẫy kiểu dữ liệu ("true" hay true, 1.10 hay "1.10"); nửa sau trả lời container là gì, khác máy ảo thế nào, Dockerfile, image và container khác nhau ra sao, và vì sao build lần hai nhanh hơn lần đầu.

Đọc xong bạn viết được một file YAML mô tả app của mình mà kubectl chấp nhận, đọc được bất kỳ manifest nào người khác viết nhờ nhận ra bốn khối cố định, và chạy được container nginx đầu tiên, chui vào trong, sửa file, rồi chứng kiến mọi thay đổi biến mất khi container bị xoá. Bài học cuối cùng đó (container là ephemeral) là nguyên tắc nền tảng của K8s.

Bài này tiếp nối Phần 2: bạn sẽ dùng lại docker exec, ls -la, cat và ý tưởng listen 0.0.0.0 ở đó.

YAML trong 20 phút

YAML là JSON nhưng dùng thụt lề thay vì ngoặc. Nếu bạn đọc được package.json, bạn đọc được YAML sau 20 phút; phần còn lại là tránh vài cái bẫy.

Đối chiếu JSON và YAML

Cùng một cấu hình, viết hai cách:

{
  "name": "my-app",
  "replicas": 3,
  "enabled": true,
  "ports": [80, 443],
  "meta": {
    "env": "prod"
  }
}
name: my-app
replicas: 3
enabled: true
ports:
  - 80
  - 443
meta:
  env: prod

Ánh xạ một-một: object thành các dòng key: value cùng mức thụt lề, mảng thành các dòng bắt đầu bằng -, object lồng nhau thành thụt lề sâu hơn. YAML cũng chấp nhận cú pháp “flow” giống JSON trên một dòng, và bạn sẽ thấy nó rải rác trong manifest thật:

ports: [80, 443]
httpGet: { path: /healthz, port: 80 }

Hai cách viết tương đương. Dạng flow gọn cho object nhỏ; dạng block dễ đọc cho cấu trúc lớn. Đừng trộn hai dạng trong cùng một khối nếu không cần.

Năm quy tắc sống còn

  1. Chỉ dùng dấu cách, tuyệt đối không dùng Tab. YAML sẽ báo lỗi found character that cannot start any token. Hãy set editor về 2 spaces (VS Code: "[yaml]": { "editor.tabSize": 2, "editor.insertSpaces": true } trong settings).
  2. Thụt lề quyết định cấu trúc. Lệch 1 space là sai nghĩa, không phải sai cú pháp: file vẫn parse được nhưng key rơi vào object khác. Đây là loại lỗi khó thấy nhất vì không có thông báo lỗi.
  3. Dấu - tạo phần tử mảng. Mỗi phần tử một dòng, cùng mức thụt lề với nhau.
  4. Sau dấu : phải có một dấu cách. name:my-app là sai (YAML hiểu cả cụm là một chuỗi), name: my-app mới đúng.
  5. --- ngăn cách nhiều document trong cùng một file. Rất hay dùng để gộp Deployment + Service vào một file app.yaml, và kubectl apply -f app.yaml tạo cả hai.

Lỗi thụt lề phổ biến nhất trong manifest K8s là mảng các object. Hãy nhìn kỹ hai cách viết này, chỉ khác nhau hai dấu cách:

# ĐÚNG: name và image cùng thuộc một phần tử mảng
containers:
  - name: web
    image: nginx:1.27-alpine

# SAI: image bị đẩy ra khỏi phần tử, K8s báo "unknown field image"
containers:
  - name: web
  image: nginx:1.27-alpine

Quy tắc: sau dấu - và một dấu cách là bắt đầu object; mọi key tiếp theo của cùng object phải thẳng hàng với key đầu tiên (ở đây là thẳng với name).

Các kiểu dữ liệu hay gây bẫy

YAML tự đoán kiểu dữ liệu từ hình dạng của giá trị. Đoán đúng phần lớn thời gian, và sai đúng vào những lúc quan trọng:

chuoi_thuong: hello
so: 8080
so_nhung_muon_la_chuoi: '8080' # nháy để ép về string
boolean: true
null_value: null
version_nguy_hiem: 1.10 # YAML hiểu là số 1.1 !
version_dung: '1.10' # luôn đặt nháy cho version
co_dau_hai_cham: 'http://example.com:8080' # phải có nháy

# Chuỗi nhiều dòng - GIỮ xuống dòng (dùng cho nginx.conf, script)
script: |
  server {
    listen 80;
  }

# Chuỗi nhiều dòng - GỘP thành 1 dòng (dùng cho mô tả dài)
mo_ta: >
  Dong nay va
  dong nay se thanh mot dong.

Ba bẫy trong đoạn trên sẽ cắn bạn trong K8s:

  • 1.10 thành 1.1. Tag image node:1.10 hay version: 1.10 mất số 0. Luôn đặt nháy cho mọi thứ trông giống version.
  • "8080" và 8080 là hai kiểu khác nhau, và K8s rất khắt khe. Trường data của ConfigMap chỉ nhận string: viết PORT: 8080 (số) là kubectl apply từ chối với cannot unmarshal number into Go struct field ConfigMap.data of type string. Phải viết PORT: "8080". Ngược lại, containerPort: "80" (chuỗi) cũng bị từ chối vì trường đó cần số. Khi thấy lỗi unmarshal, nhìn ngay vào nháy.
  • | giữ xuống dòng, > gộp dòng. Bạn dùng | khi nhét cả file nginx.conf hoặc script shell vào ConfigMap. Dùng nhầm > là nginx.conf thành một dòng và nginx -t báo lỗi cú pháp.

Cạm bẫy kinh điển: viết ports: - 80 trên cùng một dòng. Sai. Phần tử mảng phải xuống dòng và thụt vào. Ngoài ra on, off, yes, no đều bị YAML 1.1 (phiên bản parser mà nhiều công cụ vẫn dùng) hiểu là boolean. Nếu bạn muốn chuỗi "no" thì phải viết "no" có nháy. Trường hợp nổi tiếng nhất là country: NO (Na Uy) biến thành country: false. Trong K8s, mọi giá trị boolean nằm trong data của ConfigMap đều phải có nháy: FEATURE_NEW_CHECKOUT: "true".

Vài chi tiết nhỏ nữa: dấu # bắt đầu comment (YAML có comment, JSON không); chuỗi thường không cần nháy nhưng nháy chưa bao giờ sai, nên khi lưỡng lự cứ đặt nháy; nháy đơn và nháy kép đều dùng được, nháy kép cho phép escape kiểu \n.

Cấu trúc chung của mọi file K8s

Mọi manifest K8s đều có đúng bốn khối cấp cao này. Nhớ được là bạn đọc được mọi file YAML K8s, dù là Deployment, Ingress hay một CRD của cert-manager:

apiVersion: apps/v1 # phiên bản API - quyết định schema hợp lệ
kind: Deployment # loại tài nguyên
metadata: # thông tin nhận dạng: tên, namespace, labels
  name: my-frontend
  namespace: production
  labels:
    app: my-frontend
spec: # TRẠNG THÁI MONG MUỐN - phần bạn viết
  replicas: 3
  # ...
# status:                  # TRẠNG THÁI THỰC TẾ - K8s tự ghi, bạn KHÔNG viết
  • apiVersion + kind cùng nhau xác định schema. apps/v1 cho Deployment, v1 cho Pod / Service / ConfigMap, networking.k8s.io/v1 cho Ingress, autoscaling/v2 cho HPA. Sai apiVersion là lỗi no matches for kind; cách tra nhanh là kubectl api-resources.
  • metadata.name là định danh trong namespace; metadata.labels là chất keo nối các tài nguyên (phần 6).
  • spec là phần bạn viết, khác nhau hoàn toàn theo kind.
  • status là phần K8s tự ghi. Khi bạn kubectl get deploy my-frontend -o yaml, bạn thấy cả status; đừng copy nó vào file trong Git.

Khi quên một trường nằm ở đâu, đừng đoán, hỏi cluster:

kubectl explain deployment.spec.strategy
kubectl explain pod.spec.containers.livenessProbe
kubectl explain ingress.spec.rules --recursive

Và kiểm tra file trước khi apply thật:

kubectl apply -f app.yaml --dry-run=client     # kiểm tra cú pháp YAML + schema cơ bản, không cần cluster
kubectl apply -f app.yaml --dry-run=server     # gửi lên apiserver validate đầy đủ, không ghi gì

Trong VS Code, extension “YAML” của Red Hat cùng schema Kubernetes cho bạn autocomplete và gạch đỏ ngay khi gõ. Đây là khoản đầu tư 2 phút tiết kiệm nhiều giờ.


Thực hành 1.3

  1. Mở yamllint.com (hoặc cài yamllint bằng pip install yamllint / brew install yamllint), dán thử một file YAML tự viết mô tả cấu hình app của bạn: tên, số replica, mảng các port, một object env với vài biến.
  2. Cố tình dùng Tab một lần ở dòng thụt lề để thấy lỗi. Ghi nhớ thông điệp lỗi.
  3. Viết lại file đó theo đúng bốn khối K8s (apiVersion: v1, kind: ConfigMap, metadata, data). Trong data, thử viết PORT: 8080 không nháy. Nếu đã có kubectl (phần 5 cài), chạy kubectl apply -f file.yaml --dry-run=client để thấy K8s từ chối; chưa có thì ghi nhớ lỗi để kiểm tra ở phần 5.
  4. Thêm một chuỗi nhiều dòng bằng | chứa ba dòng của một khối server { } nginx. Dán vào yamllint, xác nhận hợp lệ.

Kết quả mong đợi: file YAML của bạn parse sạch trên yamllint; bạn đã nhìn thấy lỗi Tab; bạn biết data của ConfigMap cần chuỗi có nháy. Mẫu đúng:

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-app-config
data:
  APP_NAME: my-app
  REPLICAS: '3'
  PORT: '8080'
  FEATURE_X: 'true'
  nginx-snippet: |
    server {
      listen 80;
    }

Container và Docker: khái niệm cốt lõi

Container giải quyết vấn đề gì

Câu kinh điển: “Ở máy tôi chạy được mà”. Nguyên nhân: máy bạn Node 20, máy server Node 16; máy bạn có biến môi trường, server không; máy bạn macOS, server Ubuntu; máy bạn có sharp build cho ARM, server cần bản x86.

Container đóng gói cả ứng dụng lẫn môi trường chạy của nó (đúng phiên bản Node, đúng thư viện hệ thống, đúng file cấu hình) thành một khối duy nhất. Khối đó chạy giống hệt nhau ở mọi nơi: máy bạn, máy đồng nghiệp, CI, và cluster production.

Container khác Virtual Machine thế nào

Tiêu chíVirtual MachineContainer
Chứa gìCả một hệ điều hành đầy đủChỉ app + thư viện, dùng chung kernel máy chủ
Dung lượngNhiều GBVài MB đến vài trăm MB
Thời gian khởi độngHàng chục giây đến vài phútDưới 1 giây
Số lượng trên 1 máyVài cáiHàng trăm cái
VIRTUAL MACHINES CONTAINERS Hardware Host OS Hypervisor App Bins/Libs Guest OS App Bins/Libs Guest OS App Bins/Libs Guest OS Hardware Host OS Docker Engine (shared kernel) App Bins/Libs App Bins/Libs App Bins/Libs
VM chồng thêm cả một Guest OS lên hypervisor; container chia sẻ kernel của Host OS và chỉ mang theo app cùng thư viện của nó.

Về mặt kỹ thuật, container không phải một “máy” gì cả: nó là một tiến trình Linux bình thường, được kernel cô lập bằng namespaces (tiến trình thấy filesystem, mạng, PID riêng) và giới hạn bằng cgroups (bao nhiêu CPU, bao nhiêu RAM). Vì vậy nó khởi động nhanh như chạy một lệnh, và vì vậy requests / limits ở phần 9 mới có tác dụng: chúng là tham số cgroup. Nếu bạn muốn xem tận bên trong, bài Container internals trên blog đi sâu hơn mức cần cho series này.

Hệ quả thực tế của “dùng chung kernel”: container Linux chạy trên macOS hay Windows thực ra đang chạy trong một máy ảo Linux nhỏ mà Docker Desktop tạo ra. Đó là lý do Docker Desktop ăn RAM và lý do bạn đôi khi thấy khác biệt giữa local và cluster (ví dụ kiến trúc CPU: Mac M-series build image ARM, cluster cloud thường là x86; phần 4 nói cách xử lý).

Ba khái niệm phải phân biệt rõ

Khái niệmLà gìTương đương trong JS
DockerfileCông thức, mô tả cách xây imagepackage.json + script build
ImageSản phẩm đã build, bất biến, có tag versionPackage đã publish lên npm
ContainerMột phiên bản image đang chạyMột instance của class

Ghi chú: một image có thể tạo ra hàng trăm container chạy song song. Image là class, container là object. Đây là nền tảng để hiểu vì sao K8s có thể “nhân bản” app của bạn thành 10 bản chỉ bằng một con số replicas: 10: nó chỉ đơn giản new thêm 10 instance từ cùng một class.

Hai hệ quả bạn cần cảm nhận được:

  • Sửa file bên trong container không ảnh hưởng tới image. Xoá container, tạo lại từ image, mọi thứ trở về như lúc build. Muốn thay đổi bền, sửa Dockerfile và build image mới (tag mới).
  • Hai container từ cùng một image không chia sẻ gì với nhau ngoài phần chỉ đọc của image. Container A ghi file, container B không thấy. Đó là lý do frontend trên K8s phải stateless (phần 9).

Cơ chế layer: vì sao build lần 2 nhanh hơn

Mỗi lệnh trong Dockerfile tạo ra một layer (lớp) được cache. Khi build lại, Docker chỉ build lại từ layer bị thay đổi trở xuống; các layer phía trên được dùng lại nguyên vẹn.

DOCKERFILE IMAGE LAYERS FROM node:24-alpine WORKDIR /app COPY package*.json . RUN npm ci COPY . . writable layer (per container) COPY . . RUN npm ci COPY package*.json . WORKDIR /app FROM node:24-alpine (base) read-only, cached & shared ↑
Mỗi chỉ thị Dockerfile là một layer chỉ đọc, được cache và chia sẻ; container chỉ thêm một lớp ghi mỏng ở trên cùng.

Đây là lý do có quy tắc vàng: copy package.json và cài dependency trước, copy source code sau. Source code đổi liên tục, dependency thì ít đổi. Sắp xếp như vậy, layer npm ci (chậm nhất, vài phút) luôn được cache; chỉ layer COPY . . và npm run build phải chạy lại. Đảo ngược thứ tự thì mỗi lần sửa một dòng React là Docker cài lại toàn bộ node_modules.

# Đúng thứ tự: manifest trước, source sau
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Layer còn được chia sẻ giữa các image: 10 image cùng FROM nginx:1.27-alpine chỉ chiếm một bản nginx trên đĩa. Và layer là lý do tại sao xoá file ở lệnh sau không làm image nhỏ đi: file vẫn nằm trong layer trước, chỉ bị “che” đi. Muốn image nhỏ, đừng đưa thứ thừa vào từ đầu, hoặc dùng multi-stage build (phần 4).

Lệnh Docker cần thuộc

# Build image từ Dockerfile trong thư mục hiện tại
docker build -t my-app:v1 .

# Chạy container, map cổng 8080 máy thật -> 80 trong container
docker run -d -p 8080:80 --name web my-app:v1

# Xem container đang chạy / tất cả container (kể cả đã dừng)
docker ps
docker ps -a

# Xem log
docker logs -f web

# Chui vào trong container đang chạy để debug
docker exec -it web sh

# Dừng và xoá
docker stop web && docker rm web

# Liệt kê / xoá image
docker images
docker rmi my-app:v1

# Dọn rác (image, container, cache không dùng)
docker system prune -a

Các cờ của docker run bạn sẽ dùng nhiều nhất, mỗi cờ có một người anh em trong K8s:

CờTác dụngTương đương K8s (gặp ở phần sau)
-dChạy nền, trả lại terminalPod luôn chạy nền
-p 8080:80Map cổng máy thật 8080 vào cổng 80 của containerService port / targetPort
--name webĐặt tên để tham chiếu thay vì IDmetadata.name
-e API_URL=...Đặt biến môi trườngenv, envFrom + ConfigMap
-v ./data:/dataMount thư mục máy thật vào containervolumes + volumeMounts
--rmTự xoá container khi dừngPod bị xoá khi Deployment không cần nữa
-itTerminal tương tác (dùng với sh)kubectl exec -it

Hai lệnh nhỏ nữa đáng biết: docker inspect web in toàn bộ metadata (IP, env, mount, lệnh khởi động) dạng JSON; docker stats xem CPU / RAM thực tế của từng container, tiền thân của kubectl top ở phần 9.

Cạm bẫy: cú pháp -p 8080:80 nghĩa là cổng-máy-thật : cổng-trong-container. Nhớ sai thứ tự là lỗi phổ biến nhất của người mới: -p 80:8080 mở cổng 80 trên máy bạn (cần quyền admin, thường fail) và trỏ vào cổng 8080 trong container nơi không có gì listen. Ngoài ra, app bên trong container phải listen ở 0.0.0.0 chứ không phải 127.0.0.1, nếu không sẽ không nhận được request từ bên ngoài container, dù bạn map cổng đúng. Phần 2 đã giải thích vì sao; phần 11 sẽ gặp lại nó với Next.js.

Một chi tiết về docker logs: nó chỉ thấy những gì tiến trình chính ghi ra stdout / stderr. App ghi log vào file app.log bên trong container thì docker logs trống, và kubectl logs ở K8s cũng trống. Nguyên tắc từ giờ: log ra stdout, để hệ thống bên ngoài thu gom.


Thực hành 1.4

  1. Cài Docker Desktop (macOS / Windows) hoặc Docker Engine (Linux). Trên Windows, đảm bảo Docker Desktop dùng backend WSL2. Kiểm tra bằng docker version (phải in cả Client lẫn Server) và docker run --rm hello-world.
  2. Chạy docker run -d -p 8080:80 --name test nginx:alpine. Lần đầu Docker tải image về (vài giây); các lần sau dùng ngay.
  3. Mở http://localhost:8080. Phải thấy trang chào “Welcome to nginx!”. Chạy docker ps để thấy container test với cột PORTS là 0.0.0.0:8080->80/tcp.
  4. Chạy docker exec -it test sh, rồi cd /usr/share/nginx/html && ls, rồi cat index.html. Đây chính là thư mục mà phần 4 sẽ copy dist/ của bạn vào.
  5. Vẫn trong container, thử echo "<h1>Hello</h1>" > index.html, rồi F5 trình duyệt. Trang đổi thành Hello. Gõ exit.
  6. Chạy docker logs test để thấy dòng access log của các request bạn vừa F5, kèm status code 200. Đó là stdout của nginx.
  7. Chạy docker rm -f test rồi tạo lại container bằng đúng lệnh ở bước 2. Mở lại trình duyệt: trang trở về mặc định “Welcome to nginx!”.
  8. Thử cố tình sai: docker run -d -p 80:8080 --name sai nginx:alpine rồi curl -I http://localhost:80. Đọc lỗi (thường là Connection refused hoặc reset, vì trong container không có gì ở cổng 8080). Dọn bằng docker rm -f sai.

Kết quả mong đợi: bước 5 trang đổi, bước 7 trang trở về mặc định. Bài học rút ra: container là ephemeral, mọi thay đổi bên trong sẽ mất khi container bị xoá. Đây là nguyên tắc nền tảng của K8s: Pod có thể bị xoá và tạo lại bất cứ lúc nào, nên mọi thứ cần giữ phải nằm trong image (build lại) hoặc bên ngoài container (ConfigMap, volume, database).


Tóm tắt

  • YAML là JSON dùng thụt lề: 2 dấu cách, không Tab, - cho mảng, dấu cách sau :, --- ngăn nhiều document trong một file.
  • Bẫy kiểu dữ liệu: 1.10 thành 1.1, no / yes / on / off thành boolean, 8080 là số còn "8080" là chuỗi. data của ConfigMap chỉ nhận chuỗi, nên mọi số và boolean trong đó phải có nháy. | giữ xuống dòng, > gộp dòng.
  • Mọi manifest K8s có bốn khối: apiVersion, kind, metadata, spec; status là của K8s. kubectl explain và --dry-run là hai công cụ kiểm tra trước khi apply.
  • Container đóng gói app cùng môi trường chạy thành một khối chạy giống nhau ở mọi nơi; nhẹ và nhanh hơn VM vì dùng chung kernel; bản chất là tiến trình Linux được cô lập bằng namespaces và giới hạn bằng cgroups.
  • Dockerfile là công thức, image là sản phẩm bất biến có tag, container là instance đang chạy. Image là class, container là object; replicas: 10 chỉ là new mười lần.
  • Mỗi lệnh Dockerfile là một layer được cache; copy package.json và cài dependency trước, copy source sau để npm ci luôn được cache.
  • -p host:container, listen 0.0.0.0, log ra stdout, và container là ephemeral: bốn điều từ Docker mà mọi phần K8s sau đều dựa vào.

Đọc thêm