Deploy lên Kubernetes
Helm chart trong deploy/helm/hango chạy cùng một stack với compose.yaml nhưng trên Kubernetes: hango, OpenTelemetry Collector, Postgres và Grafana LGTM, cùng Langfuse tự host như một phần thêm tùy chọn. Trang này giải thích từng đối tượng Kubernetes mà chart tạo ra, rồi hướng dẫn cài đặt, dùng output thật từ lần thử trên cụm k3s cục bộ của Rancher Desktop.
Kubernetes trong năm phút
| Đối tượng | Nó là gì | Dùng ở đây để làm gì |
|---|---|---|
| Pod | Một hoặc nhiều container chạy cùng nhau. | Mọi thành phần đều chạy trong một pod. Bạn hiếm khi tạo pod trực tiếp. |
| Deployment | Giữ N pod giống hệt nhau luôn chạy và thay từng pod một khi cập nhật. | hango và collector, những thứ không giữ dữ liệu. |
| StatefulSet | Giống Deployment, nhưng mỗi pod có tên ổn định và đĩa riêng. | Postgres và LGTM, những thứ phải giữ dữ liệu. |
| PersistentVolumeClaim (PVC) | Một yêu cầu xin đĩa. Cụm cấp đĩa (local-path trên k3s) và đĩa sống lâu hơn pod. | Thư mục dữ liệu của Postgres và LGTM. |
| Service | Một tên DNS và IP ảo ổn định đứng trước một nhóm pod. | hango-test, otelcol, lgtm, postgres: các pod tìm nhau qua những tên này. |
| ConfigMap | Cài đặt không bí mật, đưa cho pod dưới dạng biến môi trường hoặc file. | Biến môi trường của hango và file cấu hình của collector. |
| Secret | Giống ConfigMap, dành cho giá trị nhạy cảm. Chỉ được mã hóa base64, nên hãy giới hạn ai được đọc. | Mật khẩu Postgres, key xuất dữ liệu lên cloud. |
| ServiceAccount | Danh tính mà một pod dùng để nói chuyện với Kubernetes API. | hango có một cái nhưng không được cấp API token vì không cần. |
| Ingress | Một quy tắc định tuyến: request cho host X đi tới Service Y. | Cổng vào công khai tùy chọn tới hango. |
| Ingress controller | Chương trình thực thi các quy tắc Ingress (Traefik, ingress-nginx). | Traefik có sẵn trong k3s. Ingress không làm gì nếu thiếu controller. |
| Probe | Một health check do kubelet chạy. Liveness lỗi thì khởi động lại container, readiness lỗi thì gỡ pod khỏi Service của nó. | hango: /healthz cho liveness, /readyz cho readiness. |
Helm là một trình quản lý gói cho các đối tượng này. Một chart là thư mục chứa các template YAML cùng values.yaml. helm install render các template với giá trị của bạn rồi áp dụng kết quả. Một release là một lần cài có tên của một chart.
Chart deploy những gì
| Thành phần | Cờ | Mặc định | Đối tượng |
|---|---|---|---|
| hango | hango.enabled | bật | Deployment, Service, ServiceAccount, ConfigMap, Secret |
| Collector | otelcol.enabled | bật | Deployment, Service otelcol, ConfigMap, Secret |
| Postgres | postgres.enabled | bật | StatefulSet, Service postgres, Secret, PVC |
| LGTM | lgtm.enabled | bật | StatefulSet, Service lgtm, PVC |
| Ingress | ingress.enabled | tắt | Ingress |
| Langfuse | langfuse.enabled | tắt | Toàn bộ những gì từ chart Langfuse chính thức |
hango gửi mọi telemetry tới collector qua OTLP/HTTP. Collector chia ra LGTM và, khi đã đặt key, tới Langfuse Cloud và Grafana Cloud, y hệt compose.
Cấu hình collector không được chép vào chart. deploy/helm/hango/files/otelcol-config.yaml là một symlink tới deploy/otelcol/config.yaml, nên compose và Kubernetes luôn chạy cùng một file. Helm đi theo symlink trong helm template, helm install và helm package.
Collector xuất tới tên host lgtm, nên các Service otelcol, lgtm và postgres có tên cố định. Hệ quả là mỗi namespace chỉ có một release. Bản thân hango được đặt tên theo release. Khi lgtm.enabled=false, chart đặt LGTM_ENABLED=false trên collector, làm cho nhánh lgtm bỏ dữ liệu thay vì retry tới một host không tồn tại.
Điều kiện cần có
Rancher Desktop đã bật Kubernetes (cái này cho bạn k3s, Traefik và một storage class mặc định), cùng helm và kubectl trong PATH. Nếu kubeconfig của bạn có nhiều cụm, hãy luôn nêu tên cụm một cách tường minh để gõ nhầm không thể đụng nhầm cụm khác:
export PATH=$HOME/.rd/bin:$PATH
kubectl --context rancher-desktop get nodes
helm list --kube-context rancher-desktop -ACài đặt
Lấy subchart Langfuse (luôn cần)
Chart khai báo chart Langfuse là một dependency. git bỏ qua file charts/langfuse-*.tgz đã tải về, và Helm từ chối render một chart mà dependency đã khai báo bị thiếu, kể cả khi langfuse.enabled=false. Trên một bản clone mới, hãy chạy lệnh này một lần trước mọi helm template, helm install hay helm lint:
helm dependency update deploy/helm/hangoNó đọc URL của repository từ Chart.yaml, nên không cần helm repo add, và nó tái tạo Chart.lock đã commit (ghim ở 2.1.5).
Image của hango chưa được publish
ghcr.io/phihdn/hango (giá trị mặc định của image.repository) chưa tồn tại, nên một lần cài mặc định sẽ kết thúc bằng ImagePullBackOff. Hãy tự build image rồi chỉ chart tới nó. Với một registry, gắn tag và push với một tag duy nhất cho mỗi lần build, rồi truyền repository và tag:
docker build -t registry.example.com/me/hango:0.1.0 .
docker push registry.example.com/me/hango:0.1.0
helm install hango deploy/helm/hango -n hango --create-namespace \
--set image.repository=registry.example.com/me/hango --set image.tag=0.1.0Với registry riêng, xem Registry và image.
Cài cục bộ trên Rancher Desktop
k3s nhìn thấy được image build bằng docker engine (dockerd/moby), nên không cần registry. Dùng pullPolicy=Never để Kubernetes không cố kéo hango:dev từ internet. Nếu Rancher Desktop dùng engine containerd thì docker build k3s không thấy: hãy build bằng nerdctl --namespace k8s.io build -t hango:dev . (namespace k8s.io là namespace mà k3s đọc), hoặc push lên một registry.
docker build -t hango:dev .
helm install hango-test deploy/helm/hango \
--kube-context rancher-desktop -n hango-test --create-namespace \
--set image.repository=hango --set image.tag=dev --set image.pullPolicy=Never
kubectl --context rancher-desktop -n hango-test wait --for=condition=Ready pod --all --timeout=300s
kubectl --context rancher-desktop -n hango-test get pods,svc,pvcOutput từ lần chạy thử:
NAME READY STATUS RESTARTS AGE
pod/hango-test-f57598c6-vm5pd 1/1 Running 0 28s
pod/lgtm-0 1/1 Running 0 28s
pod/otelcol-66b4556f89-5t5lv 1/1 Running 0 28s
pod/postgres-0 1/1 Running 0 28s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/hango-test ClusterIP 10.43.6.168 <none> 8080/TCP 28s
service/lgtm ClusterIP 10.43.102.129 <none> 3000/TCP,3200/TCP,4317/TCP,4318/TCP,9090/TCP 28s
service/lgtm-headless ClusterIP None <none> 3000/TCP 28s
service/otelcol ClusterIP 10.43.99.173 <none> 4317/TCP,4318/TCP,13133/TCP 28s
service/postgres ClusterIP 10.43.50.41 <none> 5432/TCP 28s
service/postgres-headless ClusterIP None <none> 5432/TCP 28s
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
persistentvolumeclaim/data-lgtm-0 Bound pvc-3a7e8a64-8b94-4bef-802e-4b2379a86dd2 5Gi RWO local-path
persistentvolumeclaim/data-postgres-0 Bound pvc-df9baf75-bf23-431a-86e3-d74b4a0466d4 2Gi RWO local-pathCác Service *-headless không có IP ảo. StatefulSet cần chúng để cấp cho mỗi pod một tên DNS riêng (postgres-0.postgres-headless); với traffic bình thường hãy dùng các Service postgres và lgtm thông thường.
Truy cập bằng port-forward
kubectl port-forward tạo một đường hầm từ một cổng cục bộ tới một Service bên trong cụm. Nó không cần Ingress và là cách nhanh nhất để xem qua. Mỗi lệnh chạy liên tục, nên hãy dùng một terminal cho mỗi forward hoặc thêm &, và dừng nó bằng Ctrl-C hoặc kill.
kubectl --context rancher-desktop -n hango-test port-forward svc/hango-test 18080:8080
kubectl --context rancher-desktop -n hango-test port-forward svc/lgtm 13000:3000 13200:3200$ curl -si localhost:18080/healthz
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok"}
$ curl -si localhost:18080/readyz
HTTP/1.1 200 OK
X-Trace-Id: 48074052fe5cda84ebc02eb1494da362
{"checks":{},"ready":true}Grafana khi đó ở localhost:13000 (admin/admin). API của Tempo xác nhận trace đã đi qua hango, collector rồi LGTM:
$ curl -s "localhost:13200/api/search?limit=5"
{"traces":[{"traceID":"2dc6470c313f2f4859f20616a256f4c","rootServiceName":"hango","rootTraceName":"GET /readyz", ...}]}
$ curl -s localhost:13200/api/traces/48074052fe5cda84ebc02eb1494da362 # the X-Trace-Id above
{"batches":[{"resource":{"attributes":[{"key":"deployment.environment.name","value":{"stringValue":"prod"}}, ...Collector ghi vài dòng log Exporting failed ... connection refused cho lgtm:4317 nếu nó khởi động trước khi LGTM sẵn sàng. Nó sẽ retry, và dữ liệu tới nơi khi LGTM đã sẵn sàng.
Ingress
Ingress cần một controller. k3s có sẵn Traefik, và deploy/helm/hango/examples/values-k3s-traefik.yaml đặt ingress.enabled, className: traefik và host: hango.localhost:
helm upgrade hango-test deploy/helm/hango --kube-context rancher-desktop -n hango-test \
--reset-then-reuse-values -f deploy/helm/hango/examples/values-k3s-traefik.yaml
kubectl --context rancher-desktop -n hango-test get ingress
curl -s http://hango.localhost/healthzNAME CLASS HOSTS ADDRESS PORTS AGE
hango-test traefik hango.localhost 192.168.64.4 80 0s
{"status":"ok"}Traefik cần vài giây để học một quy tắc mới, nên request đầu tiên có thể trả về 404 page not found. Mặc định ingress.enabled là false và ingress.className để trống, vì chưa chọn controller nào. Để dùng TLS, đặt ingress.tls.enabled=true và ingress.tls.secretName là một Secret chứa tls.crt và tls.key. Đừng mở Grafana trong pod LGTM ra ngoài: nó vẫn giữ tài khoản đăng nhập admin/admin.
Nâng cấp, gỡ cài đặt, bật và tắt từng phần
# Change a value or roll out a new image tag
helm upgrade hango-test deploy/helm/hango --kube-context rancher-desktop -n hango-test --reset-then-reuse-values --set image.tag=v2
# Turn a component off (or on again)
helm upgrade hango-test deploy/helm/hango --kube-context rancher-desktop -n hango-test --reset-then-reuse-values --set lgtm.enabled=false
# Remove everything (the postgres Secret is kept, see below)
helm uninstall hango-test --kube-context rancher-desktop -n hango-test
kubectl --context rancher-desktop -n hango-test delete secret postgres
kubectl --context rancher-desktop delete namespace hango-testHãy dùng --reset-then-reuse-values thay vì --reuse-values khi nâng cấp: nó lấy mặc định mới của chart trước rồi áp lại các giá trị bạn đã đặt, nên một giá trị mà phiên bản chart mới thêm vào values.yaml sẽ không bị thiếu. (Giữ file values của riêng bạn và truyền -f mỗi lần cũng được.)
Tag image dev là tag có thể thay đổi. Khi image.pullPolicy để trống (mặc định), chart dùng Always cho tag dev và IfNotPresent cho mọi tag khác, nên hãy dùng một tag duy nhất cho mỗi lần build (như v2 ở trên). Đặt image.pullPolicy=Never cho image build cục bộ.
Mỗi thành phần đều có một cờ enabled, xem deploy/helm/hango/values.yaml. Khi otelcol.enabled=false và không có hango.config.otlpEndpoint, hango khởi động với việc xuất telemetry bị tắt (HANGO_OTEL_ENABLED=false). Đặt hango.config.otlpEndpoint để dùng một collector ở nơi khác. Các PVC do StatefulSet tạo không bị helm uninstall xóa, nên xóa namespace mới là cách giải phóng các đĩa.
Secret mật khẩu Postgres được sinh tự động (postgres) mang helm.sh/resource-policy: keep, nên helm uninstall để nó lại cùng với PVC. Postgres chỉ đọc POSTGRES_PASSWORD khi nó khởi tạo một volume trống lần đầu, nên một mật khẩu ngẫu nhiên mới sau khi cài lại sẽ không khớp với dữ liệu đã có trên đĩa. Một lần helm install sau đó của cùng release trong cùng namespace sẽ nhận lại Secret được giữ và mật khẩu của nó (lần cài lại khi thử in ra các hash mật khẩu giống hệt nhau). Hệ quả: đặt postgres.password sau lần cài đầu tiên không đổi gì trong database, và xóa namespace mới là cách xóa Secret. Mật khẩu được sinh bằng lookup của Helm, hàm này không trả về gì dưới helm template, nên Argo CD và Flux (render bằng helm template) sẽ ghi một mật khẩu ngẫu nhiên mới ở mỗi lần sync. Với các công cụ đó, hãy tự tạo Secret và đặt postgres.existingSecret (key POSTGRES_PASSWORD).
Database và bootstrap key
Với postgres.enabled=true, pod hango đọc POSTGRES_PASSWORD từ Secret postgres, và Kubernetes thay nó vào HANGO_DATABASE_URL=postgres://hango:$(POSTGRES_PASSWORD)@postgres:5432/hango?sslmode=disable. URL không bao giờ được lưu trong Secret hay file values. Mật khẩu được đưa vào URL nguyên trạng, nên mật khẩu trong postgres.existingSecret phải an toàn cho URL (chữ cái và chữ số). hango áp dụng các migration lúc khởi động. Nếu Postgres chưa sẵn sàng, hango thoát và Kubernetes khởi động lại nó cho đến khi Postgres sẵn sàng.
Để dùng một Postgres bên ngoài chart, đặt postgres.enabled=false và đưa URL vào Secret của hango dưới khóa HANGO_DATABASE_URL.
hango.existingSecret thay thế Secret của hango do chart quản lý chứ không bổ sung vào nó, nên mọi key hango cần đều phải nằm trong Secret bạn tạo: HANGO_BOOTSTRAP_ADMIN_KEY, và HANGO_DATABASE_URL khi postgres.enabled=false. Các giá trị dưới hango.secret.* bị bỏ qua khi nó được đặt. Các key Langfuse và Grafana Cloud thuộc về collector chứ không thuộc về hango: chúng nằm trong Secret riêng có tên trong otelcol.existingSecret. Vì vậy platform admin key nằm trong Secret của hango bên dưới. hango lưu hash của nó ở lần khởi động đầu tiên với database trống và bỏ qua biến này về sau:
KEY="hango_platform_$(openssl rand -base64 48 | tr -dc '1-9A-HJ-NP-Za-km-z' | head -c 44)"
kubectl --context rancher-desktop -n hango-test create secret generic hango-env \
--from-literal=HANGO_BOOTSTRAP_ADMIN_KEY="$KEY"
helm upgrade hango-test deploy/helm/hango -n hango-test --kube-context rancher-desktop \
--reset-then-reuse-values --set hango.existingSecret=hango-envHãy giữ key ở nơi an toàn: hango không thể hiển thị lại nó. Việc revoke hoặc rotate key qua API (POST /v1/users/{id}/api-keys, rồi DELETE /v1/api-keys/{id}) được giữ nguyên qua các lần khởi động lại. Nếu platform không còn key nào dùng được, hãy đặt một key mới vào secret và khởi động lại pod để khôi phục.
Registry và image
Các lệnh build, push và cài nằm ở Cài đặt. image.tag mặc định lấy appVersion của chart (dev). Với registry riêng, hãy tạo một pull Secret và tham chiếu nó:
kubectl create secret docker-registry my-registry -n hango \
--docker-server=registry.example.com --docker-username=me --docker-password='<password>'
helm upgrade --install hango deploy/helm/hango -n hango --set 'imagePullSecrets[0].name=my-registry' --set image.repository=registry.example.com/hangoKey của Langfuse và Grafana Cloud
Đừng bao giờ để key trong một file values được commit. Hãy tạo một Secret và chỉ chart tới nó. Các key trong Secret có cùng tên biến với .env.example. Một key chưa đặt sẽ giữ nhánh đó tắt, nên chỉ thêm những key bạn có, và không bao giờ thêm giá trị rỗng.
kubectl --context rancher-desktop -n hango-test create secret generic otelcol-cloud \
--from-literal=LANGFUSE_PUBLIC_KEY='pk-lf-...' \
--from-literal=LANGFUSE_SECRET_KEY='sk-lf-...' \
--from-literal=GRAFANA_CLOUD_INSTANCE_ID='123456' \
--from-literal=GRAFANA_CLOUD_API_TOKEN='glc_...' \
--from-literal=GRAFANA_CLOUD_OTLP_ENDPOINT='https://otlp-gateway-prod-us-east-0.grafana.net/otlp'
helm upgrade hango-test deploy/helm/hango --kube-context rancher-desktop -n hango-test \
--reset-then-reuse-values --set otelcol.existingSecret=otelcol-cloudCollector chỉ đọc Secret lúc khởi động. Lần upgrade đầu tiên đặt otelcol.existingSecret sẽ rollout lại pod vì tên Secret đổi. Sau đó, Helm không thấy được các chỉnh sửa trên một Secret do bạn tự quản lý, nên helm upgrade với giá trị không đổi sẽ không khởi động lại gì: sau khi đổi Secret hãy chạy kubectl --context rancher-desktop -n hango-test rollout restart deployment/otelcol. Điều tương tự đúng với hango.existingSecret và kubectl rollout restart deployment/hango-test. Với các Secret do chart quản lý (giá trị inline otelcol.cloud.*, hango.secret.*), pod template mang một hash của các giá trị, nên helm upgrade tự rollout lại pod. Nơi lấy key nằm ở observability-cloud-setup.md. Các giá trị inline (otelcol.cloud.langfuse.publicKey và các key tương tự) cũng dùng được để thử nhanh cục bộ bằng --set, nhưng chúng sẽ nằm trong Secret của Helm release trên cụm. hango có cùng mẫu này: hango.existingSecret hoặc hango.secret.NAME=value, được nạp thành biến môi trường.
Langfuse tự host
langfuse.enabled=true cài chart Langfuse chính thức (ghim ở 2.1.5 trong Chart.yaml) như một subchart. Nó nặng: nó mang theo Postgres, ClickHouse, Redis và một kho tương thích S3 của riêng nó, và nó đòi phải cài sẵn ClickHouse operator và cert-manager trong cụm trước (xem README của chart đó). Thiếu chúng, helm template và helm install dừng với lỗi ClickHouse operator CRDs not found. Các test của chart này chỉ render nó (với --set langfuse.clickhouse.crdCheck=false) chứ không cài nó. Mọi thứ dưới khóa langfuse: ngoài enabled được truyền cho chart đó. Vì dependency tự mang tên langfuse và các giá trị riêng của nó cũng bắt đầu bằng langfuse, cài đặt của nó lồng hai lần, ví dụ langfuse.langfuse.logging.level (langfuse đầu tiên chọn subchart, cái thứ hai là khóa bên trong subchart):
langfuse:
enabled: true
langfuse:
logging:
level: debugSau khi cài, hãy tạo một project trong giao diện Langfuse và đặt key của nó, cùng LANGFUSE_BASE_URL trỏ tới Service web của Langfuse, vào Secret ở trên.
Subchart được tải về bằng helm dependency update deploy/helm/hango, xem Cài đặt.
Graceful shutdown
Khi Kubernetes dừng một pod, nó làm hai việc cùng lúc: nó gỡ pod khỏi các endpoint của Service vì pod đang bị chấm dứt (một /readyz đang lỗi không đóng vai trò gì ở đây), và nó bắt đầu chuỗi dừng. Kube-proxy và các ingress controller như Traefik cần vài giây để nhận ra thay đổi endpoint, nên hango trước tiên chờ (sleep) trong một hook preStop (hango.preStopSeconds, 5 giây, dùng action sleep có sẵn vì image distroless không có shell). Sau đó container nhận SIGTERM, hango đánh dấu /readyz lỗi, drain trong ngân sách HANGO_SHUTDOWN_TIMEOUT (15 giây) và flush telemetry. Nếu container vẫn còn chạy sau terminationGracePeriodSeconds (30 giây, tính từ lúc bắt đầu preStop) thì Kubernetes gửi SIGKILL, nên thời gian ân hạn phải lớn hơn preStop cộng ngân sách (5 + 15 = 20 < 30). Chart từ chối render khi điều đó không đúng. Xóa pod trong lần thử (dòng log readyz cuối ở :09.017, SIGTERM đến khoảng 5 giây sau lệnh xóa):
$ kubectl --context rancher-desktop -n hango-test delete pod hango-test-67bd5dc7c6-gdxkk
pod "hango-test-67bd5dc7c6-gdxkk" deleted from hango-test namespace # 7 seconds
{"time":"...02:45:09.017...","level":"INFO","msg":"request","route":"GET /readyz","status":200, ...}
{"time":"...02:45:09.623...","level":"INFO","msg":"draining: readiness now failing","delay":1000000000,"budget":13000000000}
{"time":"...02:45:10.624...","level":"INFO","msg":"http server stopped"}
{"time":"...02:45:10.624...","level":"INFO","msg":"hango stopped"}Container tự thoát, nằm trong thời gian ân hạn, và Deployment khởi động một pod thay thế.
Xử lý sự cố
ErrImageNeverPull: image không có trên node. Build nó bằng docker engine (hoặcnerdctl --namespace k8s.iotrên containerd), hoặc push lên một registry rồi bỏpullPolicy=Never.ImagePullBackOfftrênghcr.io/phihdn/hango: image đó chưa được publish, hãy build và push image của bạn (xem Cài đặt).CreateContainerConfigErrortrên collector hoặc hango: Secret có tên trongotelcol.existingSecrethoặchango.existingSecretkhông tồn tại.- Pod kẹt ở
Pending:kubectl describe pod NAMEcho biết vì sao, thường là một PVC đang chờ storage class. - Xem log bằng
kubectl --context rancher-desktop -n hango-test logs deploy/hango-test. helm template deploy/helm/hangorender YAML mà không cài, là cách tốt nhất để thấy một giá trị thay đổi điều gì.