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

Managed Postgres

data module พิสูจน์ interface บน AWS RDS แล้ว ตอนนี้เรา implement input และ output ทั้งห้าตัวเดียวกัน บน GCP Cloud SQL และ Azure Database for PostgreSQL แล้วทำสิ่งที่ทั้งหมดนี้มีไว้เพื่อ: เปลี่ยน db_host, db_port, db_name, db_user และ db_password ให้เป็น Kubernetes Secret ที่ ShopMicro อ่านเป็น DATABASE_URL

ผลตอบแทนของ interface ที่สม่ำเสมอเห็นได้ตรงนี้ unit ของ live/gcp/data/ กับ live/azure/data/ มีรูปร่างเหมือน AWS แบบ byte-for-byte — dependency "network" เดียวกัน input เดียวกัน — เพราะต่างกันแค่ implementation เท่านั้น แต่ความต่างนั้นก็เป็นเรื่องจริง: private connectivity คือ VPC security group บน AWS, private service access บน GCP และ delegated subnet บวก private DNS zone บน Azure การตั้งชื่อความต่างเหล่านั้นคือครึ่งหนึ่งของบทเรียน

นี่คือจุดที่ “หนึ่ง interface สาม cloud” พิสูจน์คุณค่าหรือพังทลาย ถ้า managed database แต่ละตัว expose output คนละชุด downstream consumer ทุกตัว — ShopMicro unit, connection secret, CI — จะต้องมี code path สามทาง ด้วยการบังคับให้ Cloud SQL กับ Azure Database emit db_host/db_port/db_name/db_user/db_password เป๊ะ ตัว consumer จึงยัง cloud-agnostic: สร้าง DATABASE_URL เดียวแบบเดียวกันไม่ว่า database จะรันอยู่ที่ไหนจริง ๆ

managed-DB layer ยังเป็นจุดที่ private-networking model ของแต่ละ cloud ต่างกันมากที่สุด จึงเป็นที่ที่ซื่อตรงที่สุดในการแสดงว่า “multi-cloud” ไม่ได้มาฟรี interface ซ่อนความต่างจาก consumer แต่ไม่ซ่อนจาก คุณ คนที่เขียน module นั่นคือ trade ที่ถูกต้อง — รับความซับซ้อนไว้ครั้งเดียวใน module เพื่อให้สิบห้าอย่างที่อยู่ downstream ไม่ต้องจ่ายราคานั้นทุกตัว

One interface hiding three private-networking models vs cloud-native modules

  • Pros: consumer สร้าง connection string แบบเดียวกันทุกที่ การสลับ cloud ไม่แตะ ShopMicro และ diff ระหว่าง live/gcp/data กับ live/azure/data คือ input ไม่ใช่ logic
  • Cons: interface เป็น lowest-common-denominator — surface Cloud SQL read replica หรือ Azure zone-redundant HA ไม่ได้โดยไม่ปล่อย output เฉพาะ cloud ออกมา เมื่อคุณต้องการสิ่งเหล่านั้น คุณขยาย interface อย่างตั้งใจ แทนที่จะ special-case consumer

Building the connection secret in Terraform vs letting the app assemble it

  • Pros: Terraform ถือทุกชิ้นส่วนไว้ใน state อยู่แล้ว จึงเขียน Secret เดียวที่มี DATABASE_URL พร้อมใช้ได้ ส่วน app แค่อ่าน env var แล้วไม่ต้องรู้เรื่อง host/port/credential เลย
  • Cons: วิธีนี้ผูก data unit เข้ากับ Kubernetes provider และรูปร่าง secret ที่ app คาดหวัง ทางเลือกอีกทาง — ยื่นค่าทั้งห้าให้ app แล้วให้ service ประกอบ URL เอง — ช่วย decouple แต่ก็ผลัก URL-encoding กับการจัดการ null เข้าไปในทุก service

Cloud SQL เข้าถึง VPC ผ่าน private service access: ช่วง IP ที่ reserve ไว้แล้ว peer เข้ากับ network ของคุณ แล้ว instance จะ bind เข้ากับช่วงนั้นด้วย ipv4_enabled = false

variable "name" { type = string }
variable "network_id" { type = string } # the VPC self_link / id
variable "subnet_ids" { type = list(string) }
variable "db_name" {
type = string
default = "shopmicro"
}
# reserve a range and peer it for private service access
resource "google_compute_global_address" "psa" {
name = "${var.name}-psa"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = var.network_id
}
resource "google_service_networking_connection" "psa" {
network = var.network_id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.psa.name]
}
resource "random_password" "db" {
length = 24
special = false
}
resource "google_sql_database_instance" "this" {
name = var.name
database_version = "POSTGRES_16"
region = "us-central1"
depends_on = [google_service_networking_connection.psa]
settings {
tier = "db-custom-1-3840" # 1 vCPU / 3.75 GB
ip_configuration {
ipv4_enabled = false
private_network = var.network_id
enable_private_path_for_google_cloud_services = true
}
}
deletion_protection = false # teaching platform
}
resource "google_sql_database" "this" {
name = var.db_name
instance = google_sql_database_instance.this.name
}
resource "google_sql_user" "this" {
name = "shopmicro"
instance = google_sql_database_instance.this.name
password = random_password.db.result
}
output "db_host" { value = google_sql_database_instance.this.private_ip_address }
output "db_port" { value = 5432 }
output "db_name" { value = google_sql_database.this.name }
output "db_user" { value = google_sql_user.this.name }
output "db_password" {
value = random_password.db.result
sensitive = true
}

สังเกตว่า db_port เป็นค่าคงที่ 5432 — Cloud SQL ไม่ expose attribute ของ port ดังนั้น module จึงจ่ายค่าคงที่ให้เพื่อรักษา interface ให้ครบ นั่นคือ interface ที่รับ quirk ของ cloud ไว้แทน consumer

Flexible Server ของ Azure ไปแบบ private ผ่าน delegated subnet บวก private DNS zone ที่ link เข้ากับ VNet

variable "name" { type = string }
variable "network_id" { type = string } # the VNet id
variable "subnet_ids" { type = list(string) } # first is the delegated DB subnet
variable "db_name" {
type = string
default = "shopmicro"
}
variable "resource_group_name" {
type = string
default = "clouddeploy"
}
data "azurerm_resource_group" "this" {
name = var.resource_group_name
}
resource "azurerm_private_dns_zone" "pg" {
name = "${var.name}.postgres.database.azure.com"
resource_group_name = data.azurerm_resource_group.this.name
}
resource "azurerm_private_dns_zone_virtual_network_link" "pg" {
name = "${var.name}-link"
private_dns_zone_name = azurerm_private_dns_zone.pg.name
resource_group_name = data.azurerm_resource_group.this.name
virtual_network_id = var.network_id
}
resource "random_password" "db" {
length = 24
special = false
}
resource "azurerm_postgresql_flexible_server" "this" {
name = var.name
resource_group_name = data.azurerm_resource_group.this.name
location = data.azurerm_resource_group.this.location
version = "16"
delegated_subnet_id = var.subnet_ids[0]
private_dns_zone_id = azurerm_private_dns_zone.pg.id
public_network_access_enabled = false
administrator_login = "shopmicro"
administrator_password = random_password.db.result
storage_mb = 32768
sku_name = "B_Standard_B1ms"
depends_on = [azurerm_private_dns_zone_virtual_network_link.pg]
}
resource "azurerm_postgresql_flexible_server_database" "this" {
name = var.db_name
server_id = azurerm_postgresql_flexible_server.this.id
charset = "UTF8"
collation = "en_US.utf8"
}
output "db_host" { value = azurerm_postgresql_flexible_server.this.fqdn }
output "db_port" { value = 5432 }
output "db_name" { value = azurerm_postgresql_flexible_server_database.this.name }
output "db_user" { value = azurerm_postgresql_flexible_server.this.administrator_login }
output "db_password" {
value = random_password.db.result
sensitive = true
}

สาม cloud สาม mechanism ของ private-networking — VPC security group (AWS), private service access peering (GCP), delegated subnet + private DNS zone (Azure) — และ output ชุดเดียวกันเป๊ะ

ตอนนี้ถึงจุดประสงค์ของ output ทั้งห้า: ประกอบทั้งห้าเป็น DATABASE_URL ใน Kubernetes Secret นี่คือชิ้นเล็ก ๆ ของ ShopMicro unit ที่แสดงตรงนี้เพราะเป็นปลายทางที่ output ของ data ป้อนเข้าไป และ cloud-agnostic — เหมือนกันบนทุก cloud เพราะ interface เหมือนกัน:

dependency "data" { config_path = "../data" }
resource "kubernetes_secret" "db" {
metadata {
name = "shopmicro-db"
namespace = "shopmicro"
}
data = {
DATABASE_URL = format(
"postgres://%s:%s@%s:%d/%s",
dependency.data.outputs.db_user,
dependency.data.outputs.db_password,
dependency.data.outputs.db_host,
dependency.data.outputs.db_port,
dependency.data.outputs.db_name,
)
}
}

เพราะ password เดินทางมาเป็น sensitive output จึงไหล dependency → secret โดยไม่เคยพิมพ์ลง log เลย ShopMicro mount DATABASE_URL เป็น env var แล้วไม่เคยรู้ว่าชี้ไปที่ database ของ cloud ไหน

apply data unit ของแต่ละ cloud แล้วยืนยันว่า output มีรูปร่างเดียวกัน จากนั้นยืนยันว่า secret ถูกสร้างขึ้นจริง

Terminal window
# GCP
cd live/gcp/data && terragrunt apply
terragrunt output db_host # => "10.42.0.3" (private IP, no public route)
terragrunt output db_port # => 5432
# Azure
cd ../../azure/data && terragrunt apply
terragrunt output db_host # => "clouddeploy.postgres.database.azure.com"
terragrunt output db_password # => <sensitive>

ทุก cloud emit db_host/db_port/db_name/db_user/db_password — ความเหมือนนั้นคือสิ่งที่ทำให้ connection secret เป็น cloud-agnostic ตอนนี้ยืนยัน secret และการเชื่อมต่อจริงจากภายใน cluster:

Terminal window
kubectl -n shopmicro get secret shopmicro-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d
# => postgres://shopmicro:...@10.42.0.3:5432/shopmicro (host varies by cloud)
kubectl -n shopmicro run pg --rm -it --restart=Never --image=postgres:16 -- \
psql "$(kubectl -n shopmicro get secret shopmicro-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d)" -c '\conninfo'
# => You are connected to database "shopmicro" ...

สุดท้าย terragrunt run --all plan ข้ามทั้งสาม tree ของ live/<cloud>/ ควรแสดง network → cluster → data ที่ converge ทุกที่ โดย data unit ต่างกันแค่ input เท่านั้น — นั่นคือหลักฐานว่า interface ยืนหยัดได้

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

  1. AWS, GCP และ Azure ทำให้ database เป็น private คนละแบบ บอก mechanism ของแต่ละตัว
  2. Cloud SQL ไม่มี attribute ของ port ดังนั้น module จึง return ค่าคงที่ 5432 ทำไมทำแบบนั้นใน module ถึงดีกว่าให้ consumer special-case GCP?
  3. output ชุดเดียวกันเป๊ะให้ประโยชน์อะไรกับ code ของ connection-secret และจะพังตรงไหนถ้าคุณต้องการ Cloud SQL read replica?
  4. ไล่ตาม database password ตั้งแต่ตอนสร้างจนถึง env var ของ pod password มองเห็นเป็น plaintext ที่ step ไหนบ้าง และอะไรทำให้ step เหล่านั้นปลอดภัย?

เรา implement data interface อีกสองครั้ง — GCP Cloud SQL ผ่าน private service access และ Azure Database for PostgreSQL ผ่าน delegated subnet กับ private DNS zone — หลัง output ทั้งห้าตัวเป๊ะ ๆ ที่เวอร์ชัน AWS emit จากนั้นเราเปลี่ยน output เหล่านั้นเป็น DATABASE_URL Secret แบบ cloud-agnostic ที่พา password ที่ generate ขึ้นมาจาก state ไปยัง pod โดยไม่เคย log ออกมาเลย private-networking model ต่างกันจริงต่อ cloud interface รับความต่างนั้นไว้เพื่อให้ไม่มีอะไร downstream ต้องแคร์

ตอนนี้ platform มี network, cluster, identity และ data บนทั้งสาม cloud — ทุก dependency ที่ ShopMicro ต้องใช้ ถัดไป Deploying ShopMicro → วาง application ลงบนแต่ละ cluster ด้วย helm_release โดยต่อ database secret นี้และ keyless identity จาก IAM module เข้าไป