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:
| File | Nội dung |
|---|---|
namespace.yaml | Namespace riêng cho ứng dụng |
configmap.yaml | Biến môi trường không nhạy cảm |
secret.yaml | Biến nhạy cảm (không commit giá trị thật) |
app-deployment.yaml | Deployment Laravel kèm initContainer và Service ClusterIP |
mysql-statefulset.yaml | StatefulSet MySQL kèm headless Service |
ingress.yaml | Ingress đư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: appMọ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:
| Deployment | StatefulSet | |
|---|---|---|
| Tên pod | Ngẫu nhiên, đổi mỗi lần tạo lại | Cố định theo số thứ tự (…-0, …-1) |
| Volume | Mọi pod dùng chung PVC khai báo sẵn | volumeClaimTemplates: mỗi pod một PVC riêng |
| Khi cập nhật | Mặ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 |
| DNS | Chỉ có tên Service | Mỗ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_PASSWORDhay 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.
stringDatahay 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ằngkubectl create secrethoặc công cụ quản lý bí mật. - Ghim pod theo tên node. Khối
nodeAffinitytrỏ tớikubernetes.io/hostnamecủ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-0Khi 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,
subPathkhi 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.
