Chuyển đến nội dung
Deploy lên Kubernetes

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ượngNó là gìDùng ở đây để làm gì
PodMộ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.
DeploymentGiữ 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.
StatefulSetGiố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.
ServiceMộ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.
ConfigMapCà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.
SecretGiố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.
ServiceAccountDanh 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.
IngressMộ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 controllerChươ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.
ProbeMộ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ầnCờMặc địnhĐối tượng
hangohango.enabledbậtDeployment, Service, ServiceAccount, ConfigMap, Secret
Collectorotelcol.enabledbậtDeployment, Service otelcol, ConfigMap, Secret
Postgrespostgres.enabledbậtStatefulSet, Service postgres, Secret, PVC
LGTMlgtm.enabledbậtStatefulSet, Service lgtm, PVC
Ingressingress.enabledtắtIngress
Langfuselangfuse.enabledtắtToà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 -A

Cà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/hango

Nó đọ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.0

Vớ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,pvc

Output 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-path

Cá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/healthz
NAME         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-test

Hã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-env

Hã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/hango

Key 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-cloud

Collector 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: debug

Sau 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ặc nerdctl --namespace k8s.io trên containerd), hoặc push lên một registry rồi bỏ pullPolicy=Never.
  • ImagePullBackOff trên ghcr.io/phihdn/hango: image đó chưa được publish, hãy build và push image của bạn (xem Cài đặt).
  • CreateContainerConfigError trên collector hoặc hango: Secret có tên trong otelcol.existingSecret hoặc hango.existingSecret không tồn tại.
  • Pod kẹt ở Pending: kubectl describe pod NAME cho 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/hango render YAML mà không cài, là cách tốt nhất để thấy một giá trị thay đổi điều gì.