❤️ AZDIGI chính thức cập nhật hệ thống blog mới hoàn chỉnh. Tuy nhiên có thể một số bài viết bị sai lệch hình ảnh, hãy ấn nút Báo cáo bài viết ở cuối bài để AZDIGI cập nhật trong thời gian nhanh nhất. Chân thành cám ơn.

Ở các bài trước trong serie Helm, chúng ta đã đi từ phần nền tảng như chart là gì, values hoạt động ra sao, đến cách cài và tùy biến chart có sẵn. Nếu bạn muốn xem lại phần cấu trúc chart, có thể mở thêm bài 3 về anatomy của Helm chart để đối chiếu các file và helper template. Nhưng khi bắt đầu triển khai ứng dụng tự viết, nhất là các app nội bộ, API nhỏ hoặc service phục vụ riêng cho dự án, bạn sẽ sớm gặp một câu hỏi rất thực tế: không lẽ mỗi lần deploy lại tự viết manifest YAML từ đầu?

Đó là lúc Helm chart riêng phát huy giá trị. Với Node.js nói riêng, đây là hướng đi rất đáng học vì phần lớn ứng dụng backend nhỏ hiện nay đều có cấu trúc khá giống nhau: có container image, có biến môi trường, có Service, có health check, có lúc cần Ingress và có nhu cầu override theo từng môi trường.

Trong bài 6 này, chúng ta sẽ làm lab từ đầu: tạo một app Express.js đơn giản, đóng gói bằng Docker, tạo Helm chart bằng helm create, chỉnh lại chart để phù hợp với ứng dụng Node.js, rồi deploy thật lên cluster local bằng Helm. Cuối bài, bạn sẽ có một chart hoàn chỉnh đủ tốt để dùng làm base cho các app Node.js đơn giản.

Nếu bạn cần môi trường Linux để thực hành Docker, Helm, kubectl hoặc dựng cluster lab, một VPS như Pro VPS từ 99.000đ/tháng là đủ để bắt đầu với các bài lab kiểu này.

Vì sao cần biết tự đóng gói app riêng thành chart?

Vì sao cần biết tự đóng gói app riêng thành chart?

Public chart rất tiện, nhưng không phải lúc nào cũng phù hợp. Có ba tình huống phổ biến:

  • Bạn có app nội bộ, không có chart public tương ứng.
  • Bạn muốn kiểm soát rõ Deployment, Service, probes, resources và biến môi trường.
  • Bạn muốn chuẩn hóa cách deploy nhiều app cùng một kiểu trong team.

Nếu chỉ có một app nhỏ, bạn vẫn có thể dùng manifest YAML thuần. Nhưng càng về sau, số file sẽ càng nhiều: Deployment, Service, ConfigMap, Ingress, có khi thêm HPA, Secret, PVC. Khi đó Helm giúp bạn:

  • Gom toàn bộ thành một package deploy được.
  • Tách phần cấu hình ra values.yaml để override dễ.
  • Tái sử dụng chart cho nhiều môi trường như dev, staging, production.
  • Version hóa chart và application rõ ràng hơn.

Nói tóm lại, biết tự đóng gói app thành chart giúp bạn đi từ “deploy được” sang “deploy có tổ chức”.

Chuẩn bị Node.js app demo

Trong lab này, chúng ta tạo một app Express.js rất đơn giản với hai endpoint:

  • / trả về JSON giới thiệu ứng dụng
  • /health dùng cho health check của Kubernetes

Cấu trúc thư mục demo:

nodejs-helm-demo/
├── index.js
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
└── chart/

Tạo project và cài Express

mkdir nodejs-helm-demo
cd nodejs-helm-demo
npm init -y
npm install express

Nội dung `index.js`

const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
const appName = process.env.APP_NAME || 'nodejs-helm-demo';
const env = process.env.NODE_ENV || 'development';
const message = process.env.APP_MESSAGE || 'Hello from Helm chart';

app.get('/', (req, res) => { res.json({ app: appName, env, message, time: new Date().toISOString() }); });

app.get('/health', (req, res) => { res.status(200).json({ status: 'ok' }); });

app.listen(port, '0.0.0.0', () => { console.log(`${appName} listening on port ${port}`); });

Update `package.json`

Thêm script start để container chạy đơn giản hơn:

{
  "scripts": {
    "start": "node index.js"
  }
}

Thử chạy local:

npm start
curl http://127.0.0.1:3000/
curl http://127.0.0.1:3000/health

Lý do mình thêm /health ngay từ đầu là để sau này chart dùng lại trực tiếp cho livenessProbe, readinessProbestartupProbe, không phải “chữa cháy” sau.

Dockerfile cho Node.js app

Tiếp theo là containerize ứng dụng.

`Dockerfile`

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN chown -R node:node /app
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["npm", "start"]

`.dockerignore`

node_modules
npm-debug.log
Dockerfile
.dockerignore

Có hai điểm đáng chú ý ở Dockerfile này.

Thứ nhất, dùng npm ci --omit=dev thay vì npm install để cài dependency ổn định hơn trong môi trường build.

Thứ hai, nếu bạn chuyển sang chạy bằng user node, hãy nhớ đảm bảo thư mục ứng dụng có quyền phù hợp. Trong lúc test lab, mình đã gặp lỗi pod CrashLoopBackOffnpm không đọc được /app/package.json. Nguyên nhân là file được copy vào với quyền sở hữu của root, nhưng container lại chạy bằng user thường. Dòng RUN chown -R node:node /app xử lý đúng tình huống này.

Build image:

docker build -t nodejs-helm-demo:1.0.0 .

Build và push image lên registry

Về nguyên tắc, chart của bạn nên trỏ đến image đã có trên registry để cluster nào cũng kéo được. Có hai cách phổ biến.

Cách 1: Push lên Docker Hub

docker tag nodejs-helm-demo:1.0.0 yourdockerhub/nodejs-helm-demo:1.0.0
docker push yourdockerhub/nodejs-helm-demo:1.0.0

Khi đó trong values.yaml bạn sẽ dùng:

image:
  repository: yourdockerhub/nodejs-helm-demo
  tag: "1.0.0"
  pullPolicy: IfNotPresent

Cách 2: Dùng image local cho lab

Nếu đang test với kind, bạn có thể load image local trực tiếp:

kind load docker-image nodejs-helm-demo:1.0.0 --name helm-lab

Lab trong bài này dùng cách local với kind để triển khai nhanh. Trong production, bạn gần như luôn nên dùng registry.

Tạo chart bằng `helm create` và customize

Tạo chart bằng `helm create` và customize

Tạo chart khởi tạo:

helm create chart

Helm scaffold sẵn rất nhiều file, nhưng không phải file nào cũng phù hợp. Với app Node.js đơn giản, chúng ta giữ lại phần cần thiết và chỉnh lại cho gọn hơn.

Chỉnh `Chart.yaml`

Chỉnh `Chart.yaml`
apiVersion: v2
name: nodejs-helm-demo
description: Helm chart deploy Node.js Express demo application
type: application
version: 0.1.0
appVersion: "1.0.0"
home: https://azdigi.com
sources:
  - https://github.com/expressjs/express
keywords:
  - nodejs
  - express
  - helm
  - kubernetes
maintainers:
  - name: AZDIGI Lab

Ở đây cần phân biệt:

  • version: phiên bản chart
  • appVersion: phiên bản ứng dụng

Nhiều bạn mới làm Helm thường dùng một số cho cả hai. Không sai hoàn toàn, nhưng về lâu dài nên tách ra để dễ quản lý.

Thiết kế `values.yaml` cho Node.js app

Thiết kế `values.yaml` cho Node.js app

Đây là file quan trọng nhất vì nó quyết định mức độ linh hoạt của chart.

replicaCount: 1

image: repository: nodejs-helm-demo tag: "1.0.1" pullPolicy: IfNotPresent

app: port: 3000 env: APP_NAME: nodejs-helm-demo APP_MESSAGE: "Hello from Helm chart" NODE_ENV: production

service: type: ClusterIP port: 80

ingress: enabled: false className: "" annotations: {} hosts: - host: nodejs-helm-demo.local paths: - path: / pathType: Prefix tls: []

resources: requests: cpu: 100m memory: 128Mi limits: cpu: 250m memory: 256Mi

livenessProbe: httpGet: path: /health port: http

readinessProbe: httpGet: path: /health port: http

startupProbe: httpGet: path: /health port: http

Mục tiêu ở đây là tách rõ ba nhóm cấu hình:

  • image
  • app config
  • hạ tầng Kubernetes

Làm như vậy sẽ dễ override hơn khi deploy sang môi trường khác.

Deployment template với probes, resources và checksum ConfigMap

Deployment template với probes, resources và checksum ConfigMap

Phần Deployment là nơi chart khác biệt rõ nhất so với YAML copy-paste.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "chart.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "chart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "chart.selectorLabels" . | nindent 8 }}
    spec:
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.app.port }}
          envFrom:
            - configMapRef:
                name: {{ include "chart.fullname" . }}
          livenessProbe:
            {{- toYaml .Values.livenessProbe | nindent 12 }}
          readinessProbe:
            {{- toYaml .Values.readinessProbe | nindent 12 }}
          startupProbe:
            {{- toYaml .Values.startupProbe | nindent 12 }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Điểm rất nên lưu ý là checksum/config. Trong lúc test, mình đã thử helm upgrade --set app.env.APP_MESSAGE='Hello from override values' và thấy ConfigMap đổi rồi nhưng pod chưa nhận cấu hình mới ngay. Lý do là Deployment không có thay đổi ở Pod template, nên Kubernetes không rollout lại. Thêm checksum annotation sẽ buộc rollout khi ConfigMap đổi. Đây là một best practice nhỏ nhưng rất đáng giá.

Service template

Service template
apiVersion: v1
kind: Service
metadata:
  name: {{ include "chart.fullname" . }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: http
  selector:
    {{- include "chart.selectorLabels" . | nindent 4 }}

App chạy cổng 3000 trong container, nhưng Service có thể expose cổng 80 để dễ dùng hơn.

ConfigMap cho app config

ConfigMap cho app config
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "chart.fullname" . }}
data:
  PORT: {{ .Values.app.port | quote }}
  APP_NAME: {{ .Values.app.env.APP_NAME | quote }}
  APP_MESSAGE: {{ .Values.app.env.APP_MESSAGE | quote }}
  NODE_ENV: {{ .Values.app.env.NODE_ENV | quote }}

Với app demo, ConfigMap là đủ. Nếu có secret như token, password, API key, hãy tách sang Secret chứ không để trong ConfigMap.

Optional: Ingress template

Optional: Ingress template

Nếu cluster của bạn đã có ingress controller, có thể bật Ingress bằng values:

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: nodejs-demo.local
      paths:
        - path: /
          pathType: Prefix

Trong lab local, mình không bắt buộc bật Ingress vì mục tiêu chính là hiểu chart. Test bằng port-forward sẽ đơn giản hơn.

Test deployment locally

Mình dùng kind cho bài lab này. Các bước triển khai thực tế như sau.

Tạo cluster kind

kind create cluster --name helm-lab

Validate chart trước khi cài

helm lint chart
helm template demo chart > rendered.yaml

helm lint giúp bắt lỗi cấu trúc, còn helm template giúp bạn xem manifest render ra trước khi apply. Đây là thói quen nên có.

Cài chart

kind load docker-image nodejs-helm-demo:1.0.1 --name helm-lab
helm upgrade --install nodejs-demo chart -n helm-lab --create-namespace
kubectl rollout status deployment/nodejs-demo-nodejs-helm-demo -n helm-lab

Kết quả lab thực tế:

  • Helm release cài thành công
  • Deployment rollout thành công
  • Pod ở trạng thái Running
  • Service và ConfigMap được tạo đúng

Verify app hoạt động với port-forward

Verify app hoạt động với port-forward

Sau khi deploy, kiểm tra nhanh bằng port-forward:

kubectl port-forward -n helm-lab svc/nodejs-demo-nodejs-helm-demo 8080:80

Ở terminal khác:

curl http://127.0.0.1:8080/
curl http://127.0.0.1:8080/health

Output thực tế mình nhận được:

{"app":"nodejs-helm-demo","env":"production","message":"Hello from Helm chart","time":"2026-03-30T14:27:03.225Z"}

và:

{"status":"ok"}

Đây là bước xác nhận quan trọng: không chỉ “Helm báo deploy thành công”, mà ứng dụng thực sự trả response đúng.

Demo values override và customization

Bây giờ thử thay đổi message và số replica ngay từ lệnh Helm:

helm upgrade nodejs-demo chart \
  -n helm-lab \
  --set app.env.APP_MESSAGE='Hello from override values' \
  --set replicaCount=2

Kiểm tra lại:

kubectl get deploy -n helm-lab nodejs-demo-nodejs-helm-demo
kubectl port-forward -n helm-lab svc/nodejs-demo-nodejs-helm-demo 8081:80
curl http://127.0.0.1:8081/

Kết quả thực tế sau khi thêm checksum annotation vào Deployment:

{"app":"nodejs-helm-demo","env":"production","message":"Hello from override values","time":"2026-03-30T14:28:00.704Z"}

Đây chính là giá trị lớn của Helm: cùng một chart, bạn chỉ cần thay values là có thể đổi hành vi ứng dụng.

Best practices cho Node.js charts

1) Health checks đúng cách

Đừng dùng / cho probe nếu endpoint đó phụ thuộc logic phức tạp. Tốt nhất hãy có /health hoặc /ready đơn giản, phản hồi nhanh, ít phụ thuộc.

  • startupProbe: giúp app có thời gian khởi động ban đầu
  • readinessProbe: xác định pod đã sẵn sàng nhận traffic chưa
  • livenessProbe: phát hiện tiến trình bị treo

2) Environment variables

Biến môi trường nên tách khỏi image càng nhiều càng tốt. App chung một image, nhưng dev và production có thể khác:

  • message
  • log level
  • port
  • feature flags
  • endpoint nội bộ

ConfigMap cho dữ liệu thường, Secret cho dữ liệu nhạy cảm.

3) Resource limits

Đừng bỏ trống hoàn toàn nếu chart hướng tới production. Với Node.js app nhỏ, bạn có thể bắt đầu bằng mức an toàn như bài lab rồi đo thực tế để chỉnh tiếp.

Nếu request quá thấp, app có thể bị bó tài nguyên. Nếu limit quá chặt, Node.js dễ bị OOM hoặc throttling khi tăng tải.

4) Security contexts

Một số chart public mặc định vẫn khá “thoáng”. Với app tự viết, nên đặt tiêu chuẩn sớm:

  • runAsNonRoot: true
  • drop toàn bộ capability không cần thiết
  • allowPrivilegeEscalation: false
  • seccompProfile: RuntimeDefault

Không phải app nào cũng chạy được với readOnlyRootFilesystem: true, nên hãy bật khi bạn đã test kỹ.

So sánh với cách dùng public chart

So sánh với cách dùng public chart

Dùng public chart phù hợp khi bạn triển khai phần mềm phổ biến như MySQL, Redis, WordPress, Grafana, Prometheus… vì chart đã được cộng đồng hoặc vendor duy trì.

Nhưng với app Node.js tự viết, nếu cố “nhét” vào chart generic, bạn thường gặp các vấn đề:

  • values quá nhiều, khó hiểu;
  • cấu trúc chart không phản ánh đúng ứng dụng;
  • khó kiểm soát probe, env, labels, naming theo ý muốn;
  • debug mất thời gian hơn vì chart không sinh ra để phục vụ riêng app của bạn.

Chart riêng không có nghĩa là phức tạp hơn. Với app đơn giản, chart riêng còn dễ hiểu hơn rất nhiều vì mọi thứ bám sát đúng use case của bạn.

Tổng kết

Trong bài này, chúng ta đã đi hết một vòng thực chiến:

  • tạo app Express.js có /health
  • viết Dockerfile và build image thật
  • tạo chart bằng helm create
  • chỉnh Chart.yaml, values.yaml, Deployment, Service, ConfigMap, Ingress
  • deploy thật bằng helm upgrade --install
  • verify bằng port-forward
  • thử override values để thay đổi cấu hình ứng dụng
  • xử lý luôn hai lỗi thường gặp: quyền file trong container và ConfigMap đổi nhưng pod chưa rollout

Nếu bạn nắm được flow này, từ bài sau bạn có thể áp dụng cho rất nhiều backend nhỏ khác như NestJS, Fastify, Koa hoặc các API nội bộ viết bằng Node.js.

FAQ về custom Helm chart cho Node.js

Có nên tạo một chart riêng cho từng app Node.js không?

Nếu app có Deployment, Service, probes và biến môi trường riêng thì nên tách chart riêng hoặc ít nhất tách từ một chart base nội bộ. Làm vậy giúp values rõ ràng hơn và tránh kéo theo những option không dùng tới từ chart generic.

Khi nào nên dùng umbrella chart thay vì chart đơn?

Chart đơn hợp với một service độc lập. Nếu bạn có frontend, backend, worker hoặc thêm Redis/PostgreSQL phụ trợ, umbrella chart sẽ dễ quản lý dependency và release hơn.

Nên để env trong values hay ConfigMap/Secret riêng?

Values là nơi khai báo đầu vào của chart. Sau đó template có thể render ra ConfigMap hoặc Secret. Dữ liệu thường nên đi vào ConfigMap, dữ liệu nhạy cảm nên đi vào Secret. Không nên nhét secret thẳng vào file commit chung nếu team dùng Git.

Làm sao tái sử dụng chart cho nhiều app nhưng vẫn giữ đơn giản?

Cách dễ nhất là giữ chart base đủ mỏng: image, env, Service, probes, resources, ingress. Những phần đặc thù như CronJob, migration job hay sidecar thì thêm bằng values có điều kiện, hoặc tách sang chart khác. Đừng cố biến một chart thành bộ khung cho mọi trường hợp.

Vì sao đổi ConfigMap mà pod không tự nhận cấu hình mới?

Kubernetes không tự rollout Deployment chỉ vì nội dung ConfigMap đổi. Bạn cần tạo thay đổi ở pod template, phổ biến nhất là thêm checksum annotation dựa trên template ConfigMap như ví dụ trong bài này.

Chia sẻ:
Bài viết đã được kiểm duyệt bởi AZDIGI Team

Về tác giả

Trần Thắng

Trần Thắng

Chuyên gia tại AZDIGI với nhiều năm kinh nghiệm trong lĩnh vực web hosting và quản trị hệ thống.

Hơn 10 năm phục vụ 80.000+ khách hàng

Bắt đầu dự án web của bạn với AZDIGI