Tài liệu này đi qua bộ manifest Kubernetes tối thiểu để chạy một ứng dụng Laravel kèm MySQL trên cluster: namespace, ConfigMap, Secret, Deployment cho ứng dụng và StatefulSet cho MySQL. Với mỗi file, bài giải thích vì sao viết như vậy, vì sao MySQL dùng StatefulSet thay vì Deployment, headless Service để làm gì, PVC được tạo ra thế nào, và những lỗi hay gặp khi viết bộ manifest Kubernetes đầu tiên.

1. Tổ chức thư mục manifest Kubernetes

Cách đơn giản nhất là đặt manifest Kubernetes ngay trong repo ứng dụng, cạnh Dockerfile, ví dụ trong thư mục .k8s/. Thêm /.k8s vào .dockerignore để YAML không bị đóng gói cùng code. Một bộ đủ dùng gồm:

FileNội dung
namespace.yamlNamespace riêng cho ứng dụng
configmap.yamlBiến môi trường không nhạy cảm
secret.yamlBiến nhạy cảm (không commit giá trị thật)
app-deployment.yamlDeployment Laravel kèm initContainer và Service ClusterIP
mysql-statefulset.yamlStatefulSet MySQL kèm headless Service
ingress.yamlIngress đưa traffic vào, cấp chứng chỉ (bài sau)

Nếu bạn chuyển thể manifest Kubernetes từ một dự án khác, hãy rà lại mọi tên tham chiếu (ConfigMap, Secret, label, Service). Tên cũ sót lại là nguồn lỗi số một của bộ manifest Kubernetes đầu tiên, sẽ nói ở mục 6.

2. Manifest Kubernetes cho namespace, ConfigMap và Secret

Manifest Kubernetes ngắn nhất cũng là cái phải apply đầu tiên:

apiVersion: v1
kind: Namespace
metadata:
  name: app

Mọi manifest Kubernetes còn lại đều khai báo namespace: app. Gom vào một namespace giúp xem toàn bộ ứng dụng bằng một lệnh kubectl get all -n app, và khi cần dọn môi trường thử nghiệm thì xoá namespace là xoá hết.

Manifest Kubernetes của ConfigMap và Secret đi theo đúng cách trong bài ConfigMap và Secret: biến môi trường Laravel trên Kubernetes: biến thường nằm ở ConfigMap, biến nhạy cảm nằm ở Secret, cả hai được nạp vào container bằng envFrom. Cấu trúc mẫu (giá trị thay bằng <...>):

# configmap.yaml (trích)
data:
  APP_ENV: production
  APP_DEBUG: "false"
  APP_URL: https://app.example.com
  DB_CONNECTION: mysql
  DB_HOST: mysql                # tên headless Service của MySQL
  DB_PORT: "3306"
  DB_DATABASE: <...>
  DB_USERNAME: <...>
  CACHE_DRIVER: file
  SESSION_DRIVER: file
  QUEUE_CONNECTION: database

# secret.yaml (chỉ liệt kê tên key)
type: Opaque
stringData:
  APP_KEY: <...>
  DB_PASSWORD: <...>
  REDIS_PASSWORD: <...>

DB_HOST không phải IP mà là tên Service. Pod Laravel gọi MySQL bằng DNS nội bộ của cluster, nên MySQL có bị dời sang node khác, đổi IP thì manifest Kubernetes của ứng dụng vẫn đúng.

3. Deployment cho ứng dụng Laravel

Manifest Kubernetes của ứng dụng, lược bớt phần lặp:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: app
spec:
  replicas: 1
  selector:
    matchLabels: {app: web}
  template:
    metadata:
      labels: {app: web}
    spec:
      imagePullSecrets:
        - name: regcred                    # registry riêng tư
      initContainers:
      - name: migrate
        image: <registry>/laravel-app:<tag>
        command: ["sh", "-c", "php artisan migrate --force"]
        envFrom:
          - configMapRef: {name: app-config}
          - secretRef: {name: app-secret}
      containers:
      - name: web
        image: <registry>/laravel-app:<tag>
        ports:
        - containerPort: 80
        resources:
          requests: {memory: "300Mi", cpu: "150m"}
          limits:   {memory: "700Mi", cpu: "250m"}
        envFrom:
          - configMapRef: {name: app-config}
          - secretRef: {name: app-secret}

initContainer chạy migrate trước khi app nhận request

initContainer dùng chung image với container chính, chạy php artisan migrate --force rồi thoát. Chỉ khi nó thoát thành công, container chính mới được khởi động. Trên Cloud VPS, migrate là một bước trong pipeline deploy; ở đây, nhờ vài dòng trong manifest Kubernetes, mỗi pod mới tự đảm bảo database đã đúng schema. Cái giá phải nhớ: migrate chạy lại mỗi lần pod khởi động, và khi tăng replicas, nhiều initContainer có thể cùng lúc chạy migrate trên một database. Khi đó nên chuyển migrate thành một Job riêng chạy một lần mỗi lần deploy.

Đừng dùng hook postStart cho các việc như php artisan optimize. postStart chạy song song với tiến trình chính của container, Kubernetes không đảm bảo nó xong trước khi app nhận request. Việc bắt buộc phải xong trước nên đặt ở initContainer hoặc làm sẵn lúc build image.

Image riêng tư, tag và imagePullPolicy

Image ở registry riêng tư khiến manifest Kubernetes cần thêm một Secret kiểu kubernetes.io/dockerconfigjson (ở đây tên regcred), tham chiếu qua imagePullSecrets. Nên gắn tag riêng cho mỗi lần build (số phiên bản hoặc mã commit). Nếu giữ một tag cố định như latest trong khi nội dung image thay đổi, bạn buộc phải đặt imagePullPolicy: Always, nếu không node sẽ dùng bản cũ còn trong cache, và rollback cũng khó vì không biết pod đang chạy bản nào.

4. Service: dùng ClusterIP, không dùng LoadBalancer

Cách nhanh nhất để mở ứng dụng ra ngoài là cho chính Service của nó type: LoadBalancer. Như đã phân tích trong bài Ingress và NodeBalancer: đưa traffic vào cluster Kubernetes, mỗi Service LoadBalancer kéo theo một NodeBalancer riêng: chạy được với một site thử nghiệm, nhưng không nhân lên được theo số khách. Service của ứng dụng nên là ClusterIP, traffic vào qua một Ingress dùng chung:

apiVersion: v1
kind: Service
metadata: {name: web, namespace: app}
spec:
  type: ClusterIP
  selector: {app: web}
  ports:
  - {name: http, port: 80, targetPort: 80}

Chỉ khai báo cổng mà container thật sự lắng nghe. Nginx trong container thường chỉ nghe cổng 80, TLS được xử lý ở Ingress, nên khai thêm cổng 443 là thừa.

5. MySQL: vì sao StatefulSet chứ không phải Deployment

Chạy MySQL ngay trong cluster giúp dựng nhanh một môi trường hoàn chỉnh. Deployment hợp với pod "vô danh", thay cái nào cũng được. Database thì khác:

DeploymentStatefulSet
Tên podNgẫu nhiên, đổi mỗi lần tạo lạiCố định theo số thứ tự (…-0, …-1)
VolumeMọi pod dùng chung PVC khai báo sẵnvolumeClaimTemplates: mỗi pod một PVC riêng
Khi cập nhậtMặc định tạo pod mới trước rồi xoá pod cũXoá pod cũ rồi mới tạo lại, lần lượt từng pod
DNSChỉ có tên ServiceMỗi pod có tên DNS riêng qua headless Service

Dòng "khi cập nhật" quan trọng nhất với MySQL dùng volume ReadWriteOnce. Deployment muốn chạy pod mới song song pod cũ, nhưng volume chỉ gắn được vào một node một lúc, và hai tiến trình MySQL cùng mở một thư mục dữ liệu là công thức hỏng dữ liệu. Manifest Kubernetes của MySQL, lược phần biến môi trường:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: app
spec:
  serviceName: mysql            # phải trùng tên headless Service
  replicas: 1
  selector:
    matchLabels: {app: mysql}
  template:
    metadata:
      labels: {app: mysql}
    spec:
      containers:
      - name: mysql
        image: mysql:8.0
        resources:
          requests: {memory: "400Mi", cpu: "200m"}
          limits:   {memory: "700Mi", cpu: "300m"}
        env:
          - {name: MYSQL_RANDOM_ROOT_PASSWORD, value: "true"}
          # MYSQL_DATABASE, MYSQL_USER lấy từ ConfigMap, MYSQL_PASSWORD lấy từ Secret
        volumeMounts:
          - {name: data, mountPath: /var/lib/mysql, subPath: mysql}
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: linode-block-storage-retain
        resources:
          requests: {storage: 10Gi}

PVC sinh ra từ volumeClaimTemplates

StatefulSet tự tạo PVC tên data-mysql-0, theo quy tắc <tên template>-<tên StatefulSet>-<số thứ tự>. CSI driver của Linode thấy PVC này và tạo một Block Storage volume tương ứng, gắn vào node đang chạy pod. StorageClass linode-block-storage-retain nghĩa là xoá PVC thì volume vẫn còn, đúng tinh thần bài Volume, PersistentVolume, PVC và StorageClass trên Kubernetes: an toàn cho dữ liệu, nhưng volume bỏ quên vẫn bị tính tiền.

Dòng subPath: mysql trong manifest Kubernetes trên có lý do: volume mới định dạng ext4 luôn có thư mục lost+found ở gốc, và MySQL từ chối khởi tạo dữ liệu trong thư mục không rỗng. Mount một thư mục con là cách tránh lỗi này.

Headless Service: clusterIP None

apiVersion: v1
kind: Service
metadata:
  name: mysql
  namespace: app
spec:
  clusterIP: None
  selector: {app: mysql}
  ports:
    - {protocol: TCP, port: 3306, targetPort: 3306}

clusterIP: None biến Service thành "headless": không có IP ảo, không qua kube-proxy, DNS trả thẳng IP của pod. Kèm serviceName trong StatefulSet, mỗi pod có thêm tên DNS ổn định dạng mysql-0.mysql.<namespace>.svc.cluster.local. Với một replica, Laravel chỉ cần DB_HOST: mysql. Chi tiết xem trang StatefulSets trên kubernetes.io.

MYSQL_RANDOM_ROOT_PASSWORD để image tự sinh mật khẩu root và in một lần ra log lúc khởi tạo. Ứng dụng chỉ dùng user riêng, không ai cần root trong vận hành hằng ngày.

6. Lỗi thường gặp ở bộ manifest Kubernetes đầu tiên

  • Tên ConfigMap, Secret không khớp. File ConfigMap đặt một tên, Deployment tham chiếu tên khác (thường là tên cũ của dự án được chép sang). Pod kẹt ở CreateContainerConfigError. Thống nhất một tiền tố cho mọi tài nguyên.
  • Mỗi app một NodeBalancer vì Service LoadBalancer, như mục 4.
  • Mật khẩu nằm nhầm trong ConfigMap. DB_PASSWORD, REDIS_PASSWORD hay khoá object storage bị để trong ConfigMap song song với Secret. ConfigMap không dành cho dữ liệu nhạy cảm, ai đọc được namespace là đọc được nó.
  • Secret commit vào Git. stringData hay base64 đều không phải mã hoá. Chỉ commit file mẫu không có giá trị, giá trị thật tạo bằng kubectl create secret hoặc công cụ quản lý bí mật.
  • Ghim pod theo tên node. Khối nodeAffinity trỏ tới kubernetes.io/hostname của một node cụ thể sẽ hỏng ngay khi node bị thay. Nếu cần ghim, dùng label của node pool.

7. Thứ tự apply và cách kiểm tra

kubectl apply -f .k8s/namespace.yaml     # namespace trước
kubectl apply -f .k8s/                   # phần còn lại

kubectl get all,pvc -n app
kubectl describe pod -n app -l app=web           # đọc Events khi pod kẹt
kubectl logs -n app deploy/app -c migrate        # log migrate
kubectl logs -n app mysql-0

Khi apply manifest Kubernetes xong mà pod không lên, phần Events của kubectl describe pod gần như luôn chỉ ra nguyên nhân: thiếu ConfigMap, kéo image bị từ chối, PVC chưa gắn được, hay initContainer thoát với mã lỗi.

8. Checklist cho bộ manifest Kubernetes

  • Mọi tài nguyên cùng một namespace và cùng tiền tố tên, tham chiếu khớp nhau.
  • Service của ứng dụng là ClusterIP, traffic vào qua Ingress.
  • Biến nhạy cảm chỉ nằm trong Secret, Secret thật không nằm trong Git.
  • Image có tag riêng cho từng bản build.
  • MySQL dùng StatefulSet, StorageClass Retain, subPath khi volume mới.
  • Không manifest Kubernetes nào ghim theo hostname của node.

Bài tiếp theo

ingress-nginx và cert-manager: HTTPS tự động trên Kubernetes. Cài hai add-on đầu tiên bằng Helm, cấp chứng chỉ Let's Encrypt qua HTTP-01 và xử lý challenge bị kẹt.