ข้ามไปยังเนื้อหา

The Helm chart

deploy/helm/shopmicro/ — Helm chart ที่ package ทั้งระบบสำหรับ Kubernetes ทุก binary ที่ Module 13 containerize Service images → กลายเป็น Kubernetes Deployment: catalog, order, และ payment แยกกัน; notification และ notification-worker เป็น consumer ที่ไม่มี inbound port; และ gateway อยู่หน้าทุกตัวพร้อม Service (และ Ingress แบบ optional) บน HTTP 8080 gRPC service สองตัวที่ pod อื่นเรียกหา — catalog กับ order — ได้ ClusterIP Service เพื่อให้ชื่อ Service resolve ได้ภายใน cluster ส่วน ConfigMap ถือ env ที่ไม่ลับ (KAFKA_BROKERS, RABBITMQ_URL), Secret ถือ database URL และ templates/_helpers.tpl รวมชื่อกับ label ไว้ที่เดียวเพื่อ tag ทุก object ให้เหมือนกัน

ทุกอย่างขับด้วย values.yaml: image repository และ tag, จำนวน replica ต่อ service, และ endpoint ของ infrastructure ที่ใช้ร่วมกัน chart นี้ตั้งใจ ไม่ package Postgres, Kafka หรือ RabbitMQ เข้าไปด้วย แต่ถือว่ามีคนจัดการจากภายนอกแล้ว values.yaml จึงแค่ชี้ไปยังตำแหน่งที่ระบบเหล่านั้นรันอยู่ บทนี้สร้างและ lint chart ส่วน Deploying to the cluster → จะ install chart เข้า cluster จริงแล้วรัน curl smoke test เดียวกับที่ใช้มาทั้งคอร์ส เพียงแต่เปลี่ยนจาก go run เป็น pod

Docker Compose Compose stack → รันทั้ง 6 service บนเครื่องเดียวอยู่แล้ว Kubernetes ตอบคำถามที่ Compose ไม่ตอบ: พวกนี้รันข้ามหลายเครื่องอย่างไร, restart ตัวเองเมื่อ crash อย่างไร, roll out เวอร์ชันใหม่โดยไม่ downtime อย่างไร, และ scale notification-worker เป็นห้า replica โดยไม่แตะ service อื่นอย่างไร Helm chart คือวิธีอธิบายทั้งหมดนั้นเป็น package เดียวที่ version และ parameterize ได้ — helm install ครั้งเดียว แล้ว cluster reconcile ความเป็นจริงให้ตรงกับ manifest ที่ chart render จะเขียน Kubernetes YAML ดิบด้วยมือก็ได้ แต่ต้องก๊อป Deployment boilerplate เดิมหกครั้ง แล้วไล่แก้ image tag ข้ามไฟล์เป็นสิบทุกครั้งที่ release ส่วน templating ของ Helm ยุบทั้งหมดนั้นลงเหลือ values.yaml บวก template หนึ่งตัวต่อ object แต่ละ kind

สิ่งสำคัญที่สุดใน chart นี้คือรายละเอียดที่มองไม่เห็นตอน dev บนเครื่อง: CATALOG_GRPC_ADDR มีความหมายสองแบบ ขึ้นกับว่า pod ไหนเป็นคนอ่าน

ใน catalog pod ค่านี้คือ bind address เพราะ main ของ catalog ส่งต่อให้ net.Listen จึงต้องเป็น :50051 คือ listen บน port นั้นทุก interface

ส่วนใน order และ gateway pod ค่าเดียวกันนี้คือ dial address เพราะสอง service นั้นส่งต่อให้ grpc.NewClient เพื่อเข้าถึง Catalog จึงต้องเป็น catalog:50051 ที่เป็น DNS name ที่ Kubernetes ให้กับ Service ของ Catalog

ตอนรันบนเครื่อง สองบทบาทนี้ยุบเหลือ :50051 ค่าเดียว เพราะ net.Listen(":50051") กับการ dial localhost:50051 ชี้ไปที่ host เดียวกัน The gRPC Server → จึงใช้ค่าเดียวครอบสองบทบาทได้ แต่ใน cluster สองค่านี้ต่างกันจริง chart จึง set CATALOG_GRPC_ADDR ต่อ Deployment แทนที่จะ set ครั้งเดียวใน ConfigMap ที่ใช้ร่วมกัน เข้าใจว่า “ตัวแปรเดียวกันเป็น listen address ฝั่ง server และ dial address ฝั่ง client” ได้เมื่อไร การต่อ gRPC service ใน Kubernetes ก็เข้าใจง่ายไปกว่าครึ่ง

ที่เหลือเป็นมาตรฐาน และแต่ละทางเลือกมีเหตุผลของตัวเอง:

  • ConfigMap เทียบกับ Secret host ของ KAFKA_BROKERS และ RABBITMQ_URL ไม่ใช่ความลับจึงอยู่ใน ConfigMap ส่วน database URL พก credential มาด้วยจึงอยู่ใน Secret Kubernetes จัดการสองอย่างนี้ต่างกัน — อย่างน้อย Secret ก็ไม่โผล่ใน get -o yaml ธรรมดาโดย default และ encrypt at rest ได้ การแยกให้ชัดแบบนี้แปลว่า ConfigMap ที่รั่วจะไม่ลาก password ออกไปด้วย
  • Deployment ไหนได้ Service Service มีไว้ให้ชื่อที่เสถียรและ load-balance traffic เข้าไปยัง กลุ่ม pod catalog และ order ต้องมีเพราะ pod อื่น dial เข้ามา ส่วน gateway ต้องมีเพราะ client หรือ Ingress ยิงเข้ามา ขณะที่ payment, notification และ notification-worker เป็น consumer ล้วน ไม่มีใครเชื่อมเข้ามาหา มีแต่เอื้อม ออกไป หา Kafka และ RabbitMQ จึงไม่ได้ Service เลย เพราะ Service ที่ไม่มีคนใช้ก็เป็นแค่ขยะ
  • Readiness และ liveness probe gateway expose /healthz ไว้ grpc-gateway → probe จึงเป็น httpGet ธรรมดา ส่วน gRPC service ไม่ได้เสิร์ฟ HTTP chart นี้จึง probe ด้วย tcpSocket บน gRPC port ซึ่งพอจะบอกได้ว่า process listen อยู่ (ทางเลือกที่แกร่งกว่าคือ native grpc probe ของ Kubernetes ซึ่งต้องให้ server implement service grpc.health.v1 มาตรฐาน เพิ่มทีหลังเป็น follow-up เล็ก ๆ ที่คุ้มค่า ตามที่ระบุใน Pros & cons)

Helm chart เทียบกับ kubectl apply manifest ที่เขียนเองดิบ ๆ (หรือ Kustomize)

  • Pros: values.yaml เดียว parameterize image tag, replica, และ resource ข้ามทุก object ดังนั้น release เป็นการแก้บรรทัดเดียวแทนที่จะ find-and-replace ข้ามไฟล์เป็นสิบ; helm install/upgrade/rollback ให้ release ที่ version, atomic พร้อมคำสั่ง rollback จริง; chart เป็น artifact ชิ้นเดียวที่ทีมอื่น install ได้โดยไม่ต้องอ่านโครงสร้างภายใน
  • Cons: Go-template-ใน-YAML ของ Helm อ่านและ debug ยากกว่า manifest แบน ๆ จริง ๆ — {{- }} whitespace trim ที่หายไปหรือ .Values path ที่ผิดผลิต YAML ที่ invalid แบบสับสน; สำหรับ deployment เล็ก ๆ ที่ static, model overlay-a-base ของ Kustomize (ไม่มี templating language เลย) ง่ายกว่า และ manifest ดิบโปร่งใสที่สุด Helm คุ้มความซับซ้อนเมื่อคุณมี object ที่ parameterize เยอะและ release จริงให้จัดการ — ซึ่งระบบนี้มี

ถือ Postgres/Kafka/RabbitMQ เป็นแบบจัดการจากภายนอก เทียบกับ bundle เป็น subchart dependency ของ chart

  • Pros: chart focus อยู่ที่ stateless service ของ ShopMicro เอง และ infra production มักถูกจัดการแยกอยู่แล้ว — cloud Postgres, managed Kafka — พร้อม backup, scaling และ lifecycle ของตัวเองที่ chart ของ stateless app ไม่ควรเป็นเจ้าของ values.yaml จึงแค่ชี้ไปยังตำแหน่งเหล่านั้น
  • Cons: chart ไม่ self-contained — helm install อย่างเดียวไม่ให้ระบบที่รันได้ คุณต้อง provision infra ก่อนแล้ว set endpoint — ส่วนการ bundle subchart Postgres/Kafka/RabbitMQ ของ Bitnami จะทำให้คำสั่งเดียวยกทุกอย่างขึ้น ซึ่งดีกว่าจริงสำหรับ demo หรือ test cluster ชั่วคราว trade คือ “รูปทรง production จริง” เทียบกับ “คำสั่งเดียวได้ทุกอย่าง” และคอร์สนี้เลือกอย่างแรก โดยมี subchart เป็นทางเลือกที่บันทึกไว้
apiVersion: v2
name: shopmicro
description: The ShopMicro microservices system — catalog, order, payment, notification, and the API gateway
type: application
version: 0.1.0
appVersion: "1.0.0"
image:
repository: shopmicro # images are shopmicro/<service>, built in Module 13
tag: latest
pullPolicy: IfNotPresent
# Shared infrastructure is externally managed. Point these at wherever
# Postgres, Kafka, and RabbitMQ run — in-cluster Service DNS is shown here.
infra:
kafkaBrokers: kafka:9092
rabbitmqURL: amqp://shopmicro:shopmicro@rabbitmq:5672/
postgres:
host: postgres
port: 5432
user: shopmicro
password: shopmicro # override via --set or a values file in real use
services:
catalog:
replicas: 1
grpcPort: 50051
db: catalog
order:
replicas: 1
grpcPort: 50052
db: orders
payment:
replicas: 1
notification:
replicas: 1
notificationWorker:
replicas: 2
gateway:
replicas: 2
httpPort: 8080
service:
type: ClusterIP
ingress:
enabled: false
host: shopmicro.local
{{/* Common labels applied to every object. */}}
{{- define "shopmicro.labels" -}}
app.kubernetes.io/part-of: shopmicro
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}
{{/* Selector labels for a single service, given its name as the context. */}}
{{- define "shopmicro.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/part-of: shopmicro
{{- end -}}
{{/* The Postgres URL for a given database name, assembled from infra values. */}}
{{- define "shopmicro.pgurl" -}}
postgres://{{ .root.Values.infra.postgres.user }}:{{ .root.Values.infra.postgres.password }}@{{ .root.Values.infra.postgres.host }}:{{ .root.Values.infra.postgres.port }}/{{ .db }}?sslmode=disable
{{- end -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: shopmicro-config
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
data:
KAFKA_BROKERS: {{ .Values.infra.kafkaBrokers | quote }}
RABBITMQ_URL: {{ .Values.infra.rabbitmqURL | quote }}
apiVersion: v1
kind: Secret
metadata:
name: shopmicro-db
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
type: Opaque
stringData:
CATALOG_DB_URL: {{ include "shopmicro.pgurl" (dict "root" $ "db" .Values.services.catalog.db) | quote }}
ORDER_DB_URL: {{ include "shopmicro.pgurl" (dict "root" $ "db" .Values.services.order.db) | quote }}

บันทึกเป็น templates/configmap.yaml และ templates/secret.yaml database URL ถูกประกอบจากค่า infra.postgres โดย helper shopmicro.pgurl ดังนั้น credential อยู่ที่เดียวเป๊ะ

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
app.kubernetes.io/name: catalog
spec:
replicas: {{ .Values.services.catalog.replicas }}
selector:
matchLabels:
{{- include "shopmicro.selectorLabels" (dict "name" "catalog") | nindent 6 }}
template:
metadata:
labels:
{{- include "shopmicro.selectorLabels" (dict "name" "catalog") | nindent 8 }}
spec:
containers:
- name: catalog
image: "{{ .Values.image.repository }}/catalog:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.services.catalog.grpcPort }}
env:
# Bind address: catalog LISTENS on this port.
- name: CATALOG_GRPC_ADDR
value: ":{{ .Values.services.catalog.grpcPort }}"
- name: CATALOG_DB_URL
valueFrom:
secretKeyRef:
name: shopmicro-db
key: CATALOG_DB_URL
readinessProbe:
tcpSocket:
port: {{ .Values.services.catalog.grpcPort }}
initialDelaySeconds: 3
livenessProbe:
tcpSocket:
port: {{ .Values.services.catalog.grpcPort }}
initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: catalog
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
spec:
selector:
{{- include "shopmicro.selectorLabels" (dict "name" "catalog") | nindent 4 }}
ports:
- port: {{ .Values.services.catalog.grpcPort }}
targetPort: {{ .Values.services.catalog.grpcPort }}

บันทึกเป็น templates/catalog.yaml Service ชื่อ catalog บน port 50051 คือสิ่งที่ทำให้ catalog:50051 resolve ได้จาก pod อื่น templates/order.yaml ใช้รูปทรงเดียวกันเป๊ะ ต่างกันสองจุด: bind ORDER_GRPC_ADDR: ":50052" และเพิ่ม env ที่ทำให้ Order เป็น client ของ Catalog และ Kafka:

env:
- name: ORDER_GRPC_ADDR
value: ":{{ .Values.services.order.grpcPort }}"
# Dial address: order CONNECTS to Catalog's Service by name.
- name: CATALOG_GRPC_ADDR
value: "catalog:{{ .Values.services.catalog.grpcPort }}"
- name: ORDER_DB_URL
valueFrom:
secretKeyRef: { name: shopmicro-db, key: ORDER_DB_URL }
- name: KAFKA_BROKERS
valueFrom:
configMapKeyRef: { name: shopmicro-config, key: KAFKA_BROKERS }

นั่นคือชื่อ CATALOG_GRPC_ADDR เดียวกับ catalog pod แต่ถือค่า dial (catalog:50051) แทนค่า bind (:50051) — ความต่างที่ Why ชี้ไว้ ทำให้เป็นรูปธรรม

ทั้งสองตัวใช้ pattern เดียวกับ catalog Deployment แต่ ไม่มี Service และไม่มี port เพราะไม่มีอะไรเชื่อมเข้ามา ต่างกันแค่ image กับ env ที่ดึงไปใช้:

  • payment: KAFKA_BROKERS จาก ConfigMap ไม่มี DB ไม่มี probe บน port (consumer ล้วนไม่มี readiness endpoint; ละ probe หรือเพิ่ม health port เล็ก ๆ ทีหลัง)
  • notification: KAFKA_BROKERS และ RABBITMQ_URL จาก ConfigMap
  • notification-worker: RABBITMQ_URL อย่างเดียว และ replicas: {{ .Values.services.notificationWorker.replicas }} (default เป็น 2 — การ scale-out แบบ competing-consumers ที่ The Send Worker → สร้างตอนนี้เป็นแค่จำนวน replica)
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
app.kubernetes.io/name: gateway
spec:
replicas: {{ .Values.services.gateway.replicas }}
selector:
matchLabels:
{{- include "shopmicro.selectorLabels" (dict "name" "gateway") | nindent 6 }}
template:
metadata:
labels:
{{- include "shopmicro.selectorLabels" (dict "name" "gateway") | nindent 8 }}
spec:
containers:
- name: gateway
image: "{{ .Values.image.repository }}/gateway:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.services.gateway.httpPort }}
env:
- name: GATEWAY_HTTP_ADDR
value: ":{{ .Values.services.gateway.httpPort }}"
- name: CATALOG_GRPC_ADDR
value: "catalog:{{ .Values.services.catalog.grpcPort }}"
- name: ORDER_GRPC_ADDR
value: "order:{{ .Values.services.order.grpcPort }}"
readinessProbe:
httpGet:
path: /healthz
port: {{ .Values.services.gateway.httpPort }}
initialDelaySeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: {{ .Values.services.gateway.httpPort }}
initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: gateway
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
spec:
type: {{ .Values.services.gateway.service.type }}
selector:
{{- include "shopmicro.selectorLabels" (dict "name" "gateway") | nindent 4 }}
ports:
- port: {{ .Values.services.gateway.httpPort }}
targetPort: {{ .Values.services.gateway.httpPort }}
{{- if .Values.services.gateway.ingress.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gateway
labels:
{{- include "shopmicro.labels" . | nindent 4 }}
spec:
rules:
- host: {{ .Values.services.gateway.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gateway
port:
number: {{ .Values.services.gateway.httpPort }}
{{- end }}

บันทึกเป็น templates/gateway.yaml gateway dial ไปที่ catalog:50051 และ order:50052 ที่เป็นชื่อ Service ทั้งคู่ ส่วน /healthz ของ gateway ขับ HTTP probe จริง {{- if .Values.services.gateway.ingress.enabled }} เป็นตัวคุม Ingress และปิดไว้โดย default บน laptop คุณจะใช้ port-forward ในบทหน้า ส่วนบน cluster จริงค่อยเปิดใช้งาน

คุณไม่ต้องมี cluster รันอยู่ก็เช็คได้ว่า chart ฟอร์แมตถูกต้อง เริ่มจาก lint ก่อน:

Terminal window
helm lint deploy/helm/shopmicro
==> Linting deploy/helm/shopmicro
1 chart(s) linted, 0 chart(s) failed

แล้ว render template local ด้วยค่า default แล้วอ่าน YAML ที่ Kubernetes จะได้รับจริง — ไม่มี cluster เกี่ยว:

Terminal window
helm template shopmicro deploy/helm/shopmicro | grep -A2 "CATALOG_GRPC_ADDR"

คุณควรเห็นตัวแปรเดียวกัน render สองแบบต่างกัน พิสูจน์การแยก bind-vs-dial:

- name: CATALOG_GRPC_ADDR
value: ":50051" # in the catalog Deployment (bind)
--
- name: CATALOG_GRPC_ADDR
value: "catalog:50051" # in the order Deployment (dial)
--
- name: CATALOG_GRPC_ADDR
value: "catalog:50051" # in the gateway Deployment (dial)

ยืนยันว่า consumer ไม่มี Service ด้วยการนับ — ห้า Service คือผิด สาม Service คือถูก (catalog, order, gateway):

Terminal window
helm template shopmicro deploy/helm/shopmicro | grep -c "^kind: Service"
3

Override ค่าโดยไม่แก้ไฟล์ เพื่อดู templating ทำงาน — เพิ่ม replica ของ worker:

Terminal window
helm template shopmicro deploy/helm/shopmicro \
--set services.notificationWorker.replicas=5 \
| grep -B4 "name: notification-worker" | grep replicas
replicas: 5

ตรวจสอบความเข้าใจ:

  • CATALOG_GRPC_ADDR เป็น :50051 ใน Deployment หนึ่งและ catalog:50051 ในอีกสองตัว ทำไมนั่นถูกไม่ใช่ bug และทำไม dev local ไม่เคยเผยความต่าง?
  • ทำไม payment, notification, และ notification-worker ไม่ได้ Service ส่วน catalog, order, และ gateway ได้?
  • database URL อยู่ใน Secret และ broker address อยู่ใน ConfigMap อะไรจะพลาดถ้า DB URL อยู่ใน ConfigMap แทน?
  • การ scale notification-worker เป็น 5 replica คือการแก้ค่าบรรทัดเดียว ทำไมถึงปลอดภัยสำหรับ service นี้โดยเฉพาะ และ The Send Worker → สร้างอะไรไว้ที่ทำให้ปลอดภัย?

deploy/helm/shopmicro/ package ทั้ง 6 binary ของ ShopMicro เป็น Helm chart เดียว: Deployment catalog, order, และ payment, notification และ notification-worker เป็น consumer ที่ไม่มี Service, และ Deployment gateway พร้อม Service (และ Ingress optional) บน 8080 — ทั้งหมด parameterize ผ่าน values.yaml สำหรับ image tag, replica, และ infra endpoint ที่จัดการจากภายนอก _helpers.tpl รวม label และประกอบ Postgres URL; ConfigMap พก KAFKA_BROKERS/RABBITMQ_URL ที่ไม่ลับ และ Secret พก DB URL แนวคิดสำคัญที่ทำให้ gRPC บน Kubernetes เข้าใจง่ายขึ้นคือ CATALOG_GRPC_ADDR เป็น bind address (:50051) ใน catalog pod แต่เป็น dial address (catalog:50051) ใน order และ gateway pod ตัวแปรเดียวกันแต่ set ต่อ Deployment เพราะ cluster แยกบทบาท listen กับ connect ที่ localhost ยุบรวมไว้

helm lint และ helm template พิสูจน์ว่า chart render manifest ออกมา valid โดยเห็นการแยก bind กับ dial และนับ Service ได้ครบสามตัวใน output ตั้งแต่ก่อน cluster จะได้แตะ บทถัดไป Deploying to the cluster → จะ install chart นี้เข้า Kubernetes cluster จริง รัน migration เป็น pre-install Job แล้วขับ order flow ด้วย curl ชุดเดิม — PENDING → CONFIRMED — โดยใช้ pod แทน go run