
บทความนี้อธิบายแนวทางการสร้าง cluster ขนาด 3 เครื่องด้วย HashiCorp Nomad (scheduler/orchestrator) และ Consul (service discovery + health check) รันบน openSUSE Tumbleweed ซึ่งเป็น rolling-release distro ที่แพ็กเกจใหม่ล่าสุดเสมอและมี btrfs + snapper คอย snapshot/rollback ให้ในตัว เหมาะสำหรับผู้ที่ใช้ Docker Swarm อยู่แล้วและต้องการ orchestrator ที่ยืดหยุ่นกว่า รองรับ workload หลายประเภท (container, binary, VM, Java) และดูแลรักษาง่ายกว่าในระยะยาว
Docker Swarm ยังใช้งานได้และเรียบง่ายดี แต่มีข้อจำกัดที่ชัดเจนขึ้นเรื่อย ๆ เมื่อ workload โตขึ้น:
--device ใน SwarmNomad + Consul ตอบโจทย์เหล่านี้ได้ดีขึ้น โดยยังคงความเรียบง่ายในการติดตั้งไว้ใกล้เคียงกับ Swarm (ทั้งคู่เป็น single binary เขียนด้วย Go ไม่ต้องพึ่ง database ภายนอก)
Tumbleweed เป็น rolling-release distro ของ openSUSE ที่แต่ละแพ็กเกจผ่านการทดสอบอัตโนมัติด้วย openQA ก่อนปล่อยเข้า repo ทำให้ได้ทั้งความใหม่และความเสถียรในเวลาเดียวกัน จุดเด่นที่เหมาะกับการเป็น Nomad/Consul host:
zypper โดยไม่ต้องผ่านขั้นตอน transactional shell หรือ reboot ทุกครั้งเหมือน immutable distro ทำให้ debug/ปรับแต่งหน้างานได้เร็วกว่าzypper ติดตั้ง/อัปเดต/ถอนแพ็กเกจ จะสร้าง snapshot ให้อัตโนมัติ ถ้าอัปเดตแล้วระบบมีปัญหา snapper rollback แล้ว reboot กลับ snapshot เดิมได้ทันที ได้ความปลอดภัยแบบ immutable distro บางส่วนโดยไม่ต้องเสียความยืดหยุ่นของระบบเขียนได้ปกติzypper install podman แล้วใช้ผ่าน Nomad podman driver ได้ตรง ๆ เช่นเดียวกับแนวทางเดิมข้อควรรู้: เพราะ Tumbleweed เป็น rolling release ควรอัปเดตด้วย sudo zypper dup (dist-upgrade) แทน zypper update ธรรมดา เพื่อให้จัดการการเปลี่ยนแปลง dependency ระหว่างเวอร์ชันแพ็กเกจได้ถูกต้อง และควร reboot หลังอัปเดตทุกครั้งที่ kernel เปลี่ยนเวอร์ชัน (เช็คด้วย zypper ps -s ว่ามี process ไหนยังใช้ library เก่าค้างอยู่หรือไม่)
ตัวอย่างนี้ใช้เครื่อง 3 ตัว โดยให้แต่ละเครื่องรันทั้ง Consul server และ Nomad server + Nomad client พร้อมกัน (เหมาะกับ cluster ขนาดเล็ก-กลาง ที่ต้องการ HA แบบ 3-node quorum โดยไม่ต้องมีเครื่องแยกสำหรับ control plane)
| Hostname | IP ตัวอย่าง | บทบาท |
|---|---|---|
| node-01 | 10.0.10.11 | Consul server, Nomad server + client |
| node-02 | 10.0.10.12 | Consul server, Nomad server + client |
| node-03 | 10.0.10.13 | Consul server, Nomad server + client |
flowchart TB
subgraph Cluster["Nomad + Consul Cluster (3 nodes, Tumbleweed)"]
direction LR
N1["node-01
Consul server
Nomad server+client"]
N2["node-02
Consul server
Nomad server+client"]
N3["node-03
Consul server
Nomad server+client"]
N1 <-->|Raft consensus
Gossip| N2
N2 <-->|Raft consensus
Gossip| N3
N1 <-->|Raft consensus
Gossip| N3
end
Client["nomad job run app.nomad.hcl
(CLI / CI pipeline)"] -->|HTTP API :4646| Cluster
Cluster -->|register/deregister service
+ health check| Consul["Consul catalog
+ DNS :8600"]
Consul -->|service discovery| App1["podman container
web-app on node-02"]
จุดสำคัญ: Consul ทำหน้าที่เป็น service catalog + health check + KV store, ส่วน Nomad ทำหน้าที่เป็น scheduler ที่ตัดสินใจว่างานไหนควรรันบนโหนดไหน ทั้งสองระบบแยกกันอย่างอิสระ (ไม่เหมือน Swarm ที่รวม scheduling กับ networking/discovery ไว้ในตัวเดียว) ทำให้ upgrade หรือ debug แยกส่วนกันได้ง่ายกว่า
ทำตามขั้นตอนนี้ บนทุกโหนด (node-01, node-02, node-03):
# อัปเดตระบบให้ล่าสุดก่อนเริ่ม (แนะนำ dup แทน update เพราะ Tumbleweed เป็น rolling release)
sudo zypper dup -y
# หากมี kernel เปลี่ยนเวอร์ชันจากขั้นตอนนี้ ให้ reboot ก่อนทำขั้นตอนถัดไป
sudo reboot
# ตรวจสอบ hostname และตั้งชื่อให้ตรงตามตาราง
sudo hostnamectl set-hostname node-01 # เปลี่ยนเลขตามเครื่อง
# ติดตั้งเครื่องมือพื้นฐานที่ต้องใช้ (Tumbleweed แบบ minimal install อาจไม่มีบางตัวติดมาให้)
sudo zypper install -y curl unzip firewalld podman
# เปิด firewall port ที่ Consul/Nomad ต้องใช้
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-port=8300-8302/tcp # Consul RPC/Serf
sudo firewall-cmd --permanent --add-port=8300-8302/udp
sudo firewall-cmd --permanent --add-port=8500/tcp # Consul HTTP API/UI
sudo firewall-cmd --permanent --add-port=8600/tcp # Consul DNS
sudo firewall-cmd --permanent --add-port=8600/udp
sudo firewall-cmd --permanent --add-port=4646-4648/tcp # Nomad HTTP/RPC/Serf (ทั้งหมดต้องเปิด TCP)
sudo firewall-cmd --permanent --add-port=4648/udp # Nomad Serf gossip ใช้ UDP ด้วย ไม่ใช่แค่ TCP
sudo firewall-cmd --reload
# สร้าง user/group และไดเรกทอรีข้อมูล
sudo useradd --system --home /var/opt/consul --shell /usr/sbin/nologin consul
sudo useradd --system --home /var/opt/nomad --shell /usr/sbin/nologin nomad
sudo mkdir -p /var/opt/consul/data /etc/consul.d
sudo mkdir -p /var/opt/nomad/data /etc/nomad.d
sudo chown -R consul:consul /var/opt/consul
sudo chown -R nomad:nomad /var/opt/nomad
# ดาวน์โหลด static binary (เลือกเวอร์ชัน stable ล่าสุดตอนติดตั้งจริง)
curl -fsSL -o /tmp/consul.zip \
https://releases.hashicorp.com/consul/1.20.1/consul_1.20.1_linux_amd64.zip
curl -fsSL -o /tmp/nomad.zip \
https://releases.hashicorp.com/nomad/1.9.4/nomad_1.9.4_linux_amd64.zip
sudo unzip /tmp/consul.zip -d /usr/local/bin/
sudo unzip /tmp/nomad.zip -d /usr/local/bin/
sudo chmod +x /usr/local/bin/consul /usr/local/bin/nomad
หมายเหตุเรื่องพอร์ต 4648: พอร์ตกลุ่ม Nomad HTTP (4646) และ RPC (4647) ใช้แค่ TCP แต่พอร์ต Serf gossip (4648) Nomad ต้องใช้ ทั้ง TCP และ UDP เพราะ gossip protocol (Serf) อาศัย UDP เป็นหลักในการกระจายสถานะสมาชิกคลัสเตอร์ ถ้าเปิดแค่ TCP บรรทัดเดียวตามช่วง
4646-4648/tcpโดยไม่เปิด UDP เพิ่ม จะทำให้ gossip ทำงานได้ไม่สมบูรณ์หรือ flaky แม้ Raft/RPC จะยังทำงานได้ก็ตาม
ข้อดีของ Tumbleweed ในขั้นตอนนี้: เพราะ root filesystem เขียนได้ปกติ คำสั่ง
zypper installและunzip ... -d /usr/local/bin/ข้างต้นมีผลทันทีโดยไม่ต้องผ่าน transactional shell หรือ reboot เหมือน immutable distro การอัปเกรดเวอร์ชัน Nomad/Consul ในอนาคตก็แค่ดาวน์โหลด binary ใหม่มาทับแล้วsystemctl restartได้เลย ระบบจะสร้าง snapper snapshot ให้อัตโนมัติทุกครั้งที่zypperทำงาน จึง rollback OS-level ได้เผื่อไว้อยู่ดีแม้จะไม่ได้ immutable
ทำครั้งเดียวบนเครื่องใดเครื่องหนึ่ง แล้วคัดลอกค่า key ไปใช้ในทุกโหนด:
consul keygen
# ตัวอย่างผลลัพธ์: 8gG3s+2n5xY1zJvQdR7hQmWn9F0kL4pXoT6cVb2Ns0A=
/etc/consul.d/consul.hcl (ปรับ node_name และ bind_addr ตามแต่ละเครื่อง)datacenter = "dc1"
data_dir = "/var/opt/consul/data"
node_name = "node-01" # เปลี่ยนตามเครื่อง
bind_addr = "10.0.10.11" # IP ของเครื่องนี้
client_addr = "0.0.0.0"
retry_join = ["10.0.10.11", "10.0.10.12", "10.0.10.13"]
bootstrap_expect = 3
server = true
encrypt = "8gG3s+2n5xY1zJvQdR7hQmWn9F0kL4pXoT6cVb2Ns0A="
ui_config {
enabled = true
}
connect {
enabled = true # เตรียมไว้เผื่อใช้ Consul Connect (service mesh) ในอนาคต
}
/etc/systemd/system/consul.service[Unit]
Description=Consul agent
After=network-online.target
Wants=network-online.target
[Service]
User=consul
Group=consul
ExecStart=/usr/local/bin/consul agent -config-dir=/etc/consul.d
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
Restart=on-failure
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now consul
consul members # ต้องเห็นครบทั้ง 3 โหนดเป็น alive หลังรันครบทุกเครื่อง
nomad operator gossip keyring generate
คำสั่งนี้จะสุ่ม key แบบ base64 มาให้หนึ่งชุด ให้คัดลอกค่าไปใส่ในฟิลด์ encrypt ของทุกเครื่องที่เป็น Nomad server (ค่าต้องเหมือนกันทุกเครื่อง) — key จะถูก persist ไว้ใน data directory ของแต่ละ agent เองหลัง start ครั้งแรก จึงไม่จำเป็นต้องส่งค่านี้ซ้ำอีกหลัง cluster bootstrap เสร็จแล้ว
/etc/nomad.d/nomad.hcl (ปรับ name และ bind_addr ตามแต่ละเครื่อง)datacenter = "dc1"
data_dir = "/var/opt/nomad/data"
name = "node-01" # เปลี่ยนตามเครื่อง
bind_addr = "0.0.0.0"
plugin_dir = "/opt/nomad/plugins" # ที่เก็บ task driver plugin เพิ่มเติม เช่น nomad-driver-podman
server {
enabled = true
bootstrap_expect = 3
server_join {
retry_join = ["10.0.10.11", "10.0.10.12", "10.0.10.13"]
}
encrypt = "<ผลลัพธ์จาก gossip keyring generate>"
}
client {
enabled = true
options {
"driver.allowlist" = "podman,exec,raw_exec"
}
}
consul {
address = "127.0.0.1:8500"
}
plugin "nomad-driver-podman" {
config {
volumes {
enabled = true
}
}
}
Tumbleweed มี podman ให้ติดตั้งผ่าน zypper ตามขั้นตอนในบทที่ 4 อยู่แล้ว แต่ Nomad ไม่รู้จัก podman โดยตรง ต้องติดตั้ง community task driver plugin เพิ่ม โดยวางไว้ที่ path ที่ประกาศด้วย plugin_dir ในไฟล์ nomad.hcl ด้านบน (/opt/nomad/plugins):
sudo mkdir -p /opt/nomad/plugins
curl -fsSL -o /tmp/nomad-driver-podman.zip \
https://releases.hashicorp.com/nomad-driver-podman/0.6.2/nomad-driver-podman_0.6.2_linux_amd64.zip
sudo unzip /tmp/nomad-driver-podman.zip -d /opt/nomad/plugins/
เพราะ plugin_dir ถูกใส่ไว้ใน nomad.hcl ตั้งแต่ตอนสร้างไฟล์ config แล้ว ขั้นตอนนี้เหลือแค่ดาวน์โหลดตัว plugin ไปวางในโฟลเดอร์นั้น ไม่ต้องกลับไปแก้ config เพิ่มอีก
/etc/systemd/system/nomad.service[Unit]
Description=Nomad agent
After=network-online.target consul.service
Wants=network-online.target
[Service]
User=nomad
Group=nomad
ExecStart=/usr/local/bin/nomad agent -config=/etc/nomad.d
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
Restart=on-failure
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now nomad
nomad server members # ต้องเห็นครบ 3 โหนดเป็น alive/leader
nomad node status # ต้องเห็นครบ 3 โหนดเป็น client ready
หลังตั้งค่า consul { address = "127.0.0.1:8500" } ใน nomad.hcl แล้ว Nomad จะ:
service { ... } block) เข้า Consul catalog ให้อัตโนมัติ — ถ้า group ไหนไม่มี service block เลย แปลว่าจะไม่มี service ของ group นั้นถูกลงทะเบียนกับ Consul เลย และชื่อ <name>.service.consul สำหรับ group นั้นจะ resolve ไม่ได้web-app.service.consul แทนการจำ IP:port
sequenceDiagram
participant U as ผู้ดูแลระบบ
participant N as Nomad server
participant C as Nomad client (node ที่ถูกเลือก)
participant CS as Consul
U->>N: nomad job run app.nomad.hcl
N->>N: ประเมิน constraints/resources เลือกโหนด
N->>C: สั่งรัน task (podman container)
C->>CS: register service + health check
CS-->>C: ตอบรับ, เริ่ม polling health check
Note over CS: หาก health check fail ต่อเนื่อง
CS-->>N: อัปเดตสถานะ service ไม่ healthy
N->>C: reschedule task ตาม policy
สมมติเดิมใน Docker Swarm มี service คำสั่งประมาณนี้:
docker service create --name web-app --replicas 3 \
--publish 8080:80 nginx:latest
เขียนเป็น Nomad job spec เทียบเท่ากัน ไฟล์ web-app.nomad.hcl:
job "web-app" {
datacenters = ["dc1"]
type = "service"
group "web" {
count = 3
network {
port "http" {
static = 8080
to = 80
}
}
service {
name = "web-app"
port = "http"
provider = "consul"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "nginx" {
driver = "podman"
config {
image = "docker.io/library/nginx:latest"
ports = ["http"]
}
resources {
cpu = 200 # MHz
memory = 128 # MB
}
}
}
}
nomad job run web-app.nomad.hcl
nomad job status web-app
dig @127.0.0.1 -p 8600 web-app.service.consul # ตรวจสอบ service discovery ผ่าน Consul DNS
Nomad จะกระจาย 3 replica ไปยัง 3 โหนดโดยอัตโนมัติตามค่า count = 3 และ bin-packing ของ scheduler โดยไม่ต้องระบุโหนดปลายทางเอง เช่นเดียวกับพฤติกรรมของ Swarm mode เดิม
บทนี้อธิบายโครงสร้างไฟล์ job spec ของ Nomad ทีละชั้น พร้อมเทียบ block ต่อ block กับไฟล์ YAML ที่ใช้กับ docker stack deploy / docker-compose.yml เพื่อให้ผู้ที่คุ้นเคยกับ Docker Swarm อ่าน job spec ของ Nomad ได้เร็วขึ้น
Nomad แบ่งโครงสร้างงานออกเป็น 3 ชั้น ต่างจาก Docker Compose/Stack ที่มีเพียงชั้นเดียวคือ "service":
flowchart TD
J["Job
(1 ไฟล์ = แอปพลิเคชันหรือ stack ทั้งชุด)"]
J --> G1["Group: web
(หน่วยที่ scheduler จัดวางลงโหนดเดียวกัน
คล้าย Pod ใน Kubernetes)"]
J --> G2["Group: api"]
G1 --> T1["Task: nginx
(1 container/process)"]
G1 --> T2["Task: log-shipper
(sidecar, ถ้ามี)"]
G2 --> T3["Task: api-server"]
จุดสำคัญที่ต่างจาก Swarm: Group คือหน่วย scheduling — task ทุกตัวใน group เดียวกันจะถูกวางบนโหนดเดียวกันเสมอ และแชร์ network namespace กันได้ (คล้าย Pod ของ Kubernetes) ส่วน Docker Swarm หนึ่ง "service" เทียบเท่ากับ "group ที่มี task เดียว" เป็นส่วนใหญ่ — ถ้าต้องการรัน sidecar (เช่น log shipper, proxy) ควบคู่กับ container หลักแบบอยู่โหนดเดียวกันเสมอ Nomad ทำได้เป็นธรรมชาติกว่าโดยไม่ต้องพึ่ง placement constraint แบบที่ Swarm ต้องทำ
| ระดับ | Nomad stanza | หน้าที่ | เทียบเท่าใน docker-compose/stack YAML |
|---|---|---|---|
| Job | datacenters |
จำกัดว่า job รันได้ใน datacenter ไหน | ไม่มีเทียบเท่าตรง ๆ (Swarm ไม่มีแนวคิด datacenter) |
| Job | type (service/batch/system/sysbatch) |
ประเภทงาน | deploy.mode: replicated / global |
| Job | update |
canary, rolling update strategy ระดับ job | deploy.update_config |
| Group | count |
จำนวน instance | deploy.replicas |
| Group | network |
ประกาศ port ที่ต้องใช้ | ports: |
| Group | volume |
ประกาศ volume (host/CSI) ที่ group ใช้ | volumes: (top-level) |
| Group | restart |
นโยบาย restart เมื่อ task ล้ม | deploy.restart_policy |
| Group | service |
ลงทะเบียนกับ Consul + health check — ถ้าไม่ประกาศ group นั้นจะไม่ปรากฏใน Consul catalog เลย | healthcheck: (บางส่วน) + Swarm internal DNS |
| Task | driver |
เลือก task driver (podman/docker/exec ฯลฯ) | ไม่มี (Swarm ผูกกับ Docker engine ตายตัว) |
| Task | config |
ค่าเฉพาะของ driver เช่น image, args | image:, command: |
| Task | env |
environment variable | environment: |
| Task | resources |
cpu/memory ที่ขอ | deploy.resources.limits/reservations |
| Task | template |
render ไฟล์ config จาก Consul KV/Vault แล้ว reload task อัตโนมัติเมื่อค่าเปลี่ยน | ไม่มีเทียบเท่าตรง ๆ (ปกติต้องพึ่ง external tool) |
| Task | volume_mount |
mount volume ที่ประกาศไว้ระดับ group | volumes: (ใน service) |
| Task | lifecycle (prestart/poststart/poststop) |
รัน task ก่อน/หลัง task หลัก (init container pattern) | ไม่มีเทียบเท่าตรง ๆ |
ข้อสังเกตสำคัญ: Nomad ไม่มี depends_on แบบ Docker Compose โดยตรง การควบคุมลำดับการ start ทำผ่าน lifecycle stanza (prestart hook) หรืออาศัย Consul health check เป็นตัวบอก readiness ให้ service อื่นรอผ่าน template ร่วมกับ consul-template แทน
docker-stack.yml เดิม:
version: "3.8"
services:
web:
image: nginx:latest
ports:
- "8080:80"
environment:
- API_URL=http://api:3000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 10s
timeout: 2s
retries: 3
deploy:
replicas: 3
restart_policy:
condition: on-failure
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
api:
image: myrepo/api:1.2.0
environment:
- DB_HOST=db.internal
volumes:
- api-data:/data
deploy:
replicas: 2
resources:
limits:
cpus: "0.50"
memory: 256M
volumes:
api-data:
ในไฟล์ Swarm ด้านบน web เรียก api ได้ทันทีผ่านชื่อ http://api:3000 เพราะทั้งสอง service อยู่บน overlay network เดียวกัน และ Swarm ลงทะเบียนชื่อ service เข้า internal DNS ให้อัตโนมัติเสมอ ไม่มีขั้นตอนแยกต่างหาก — เวลาย้ายมา Nomad+Consul จุดนี้คือสิ่งที่ต้องประกาศเองผ่าน service block ในทุก group ที่ต้องการให้ group อื่นค้นเจอผ่าน DNS ได้ (ดูหมายเหตุท้ายตัวอย่างด้านล่าง)
job.nomad.hcl เทียบเท่า (รวม 2 service ไว้ใน job เดียว คนละ group):
job "myapp" {
datacenters = ["dc1"]
type = "service"
update {
max_parallel = 1
min_healthy_time = "10s"
auto_revert = true
}
group "web" {
count = 3
network {
port "http" {
static = 8080
to = 80
}
}
restart {
attempts = 3
interval = "5m"
delay = "10s"
mode = "fail"
}
service {
name = "web"
port = "http"
provider = "consul"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "nginx" {
driver = "podman"
env {
API_URL = "http://api.service.consul:3000" # ใช้ Consul DNS แทนชื่อ service แบบ Swarm
}
config {
image = "docker.io/library/nginx:latest"
ports = ["http"]
}
resources {
cpu = 200
memory = 128
}
}
}
group "api" {
count = 2
network {
port "api" {
static = 3000 # fix พอร์ตไว้ที่ 3000 ให้ตรงกับ API_URL ที่ web hardcode ไว้
to = 3000
}
}
volume "api-data" {
type = "host"
source = "api-data" # ต้องประกาศ host_volume นี้ไว้ใน client stanza ของ nomad.hcl ก่อน
read_only = false
}
service {
name = "api"
port = "api"
provider = "consul"
check {
type = "tcp"
interval = "10s"
timeout = "2s"
}
}
task "api-server" {
driver = "podman"
env {
DB_HOST = "db.internal"
}
volume_mount {
volume = "api-data"
destination = "/data"
}
config {
image = "myrepo/api:1.2.0"
ports = ["api"]
}
resources {
cpu = 500 # ~0.5 vCPU
memory = 256
}
}
}
}
หมายเหตุสำคัญ — ทำไมต้องเพิ่ม
network+serviceให้ groupapi: ต้นฉบับของตัวอย่างนี้มักถูกเขียนโดยไม่มีserviceblock ในกลุ่มapiเลย (มีแต่volumeกับtask) ซึ่งถ้าเขียนแบบนั้นจริงnginxใน groupwebจะ resolve ชื่อapi.service.consulไม่ได้เลย เพราะไม่มีใครลงทะเบียน service ชื่อapiกับ Consul — ต่างจาก Docker Swarm ที่ทุก service ถูกใส่เข้า internal DNS ให้อัตโนมัติโดยไม่ต้องประกาศอะไรเพิ่ม การเพิ่มnetworkblock (เพื่อจองพอร์ตให้ Nomad รู้จัก) และserviceblock (เพื่อลงทะเบียนกับ Consul พร้อม health check) จึงจำเป็นเสมอสำหรับทุก group ที่ต้องการให้ group อื่นเรียกผ่าน Consul DNS ได้ — ในตัวอย่างนี้ตั้งพอร์ตเป็นstatic = 3000เพื่อให้ตรงกับค่าที่API_URLhardcode ไว้ตายตัว ถ้าต้องการใช้ dynamic port (ปล่อยให้ Nomad สุ่มพอร์ตให้ตามคำแนะนำในหัวข้อ 11.7) ฝั่งwebต้อง discover พอร์ตจริงผ่าน Consul (เช่น query DNS แบบ SRV record หรือใช้templatestanza ดึงค่าจาก Consul catalog) แทนการ hardcode:3000ไว้ตรง ๆ
หมายเหตุเรื่อง volume: Docker Compose ใช้ named volume ที่ Docker engine จัดการให้อัตโนมัติ ส่วน Nomad ต้องประกาศ
host_volumeล่วงหน้าในไฟล์ config ของ client (nomad.hcl) ก่อน job จึงจะ mount ได้ ตัวอย่างเช่น เพิ่ม block นี้ในทุกโหนดที่อาจรัน taskapi-server:client { host_volume "api-data" { path = "/var/opt/nomad/volumes/api-data" read_only = false } }ถ้าต้องการ volume ที่ follow container ไปได้ทุกโหนดแบบ dynamic (ไม่ผูกกับโหนดใดโหนดหนึ่งตายตัว) ต้องใช้ CSI plugin แทน host_volume
service block เอง — Nomad ไม่ auto-register service ให้แบบที่ Swarm ทำกับ internal DNS เป็นค่าเริ่มต้นtemplate stanza ทดแทนการทำ config injection แบบ manual — ปกติใน Swarm ต้องใช้ Docker config/secret หรือ entrypoint script custom แต่ Nomad ใช้ template render จาก Consul KV/Vault ให้อัตโนมัติ พร้อม reload task เมื่อค่าที่ template อ่านเปลี่ยนupdate { canary = N } ทำให้ deploy เวอร์ชันใหม่บางส่วนไปทดสอบก่อน แล้วค่อย promote ทั้งหมด ซึ่ง Swarm ไม่มีความสามารถนี้ในตัวหลังจากเข้าใจโครงสร้างนี้แล้ว การเขียน job spec เพิ่มเติมสำหรับ workload อื่น ๆ จะทำได้โดยเพิ่ม group/task ใหม่ในไฟล์เดียวกัน หรือแยกเป็นไฟล์ job ใหม่ตามความเหมาะสมของแต่ละแอปพลิเคชัน
type = "system" — เทียบเท่ากับ Docker Swarm Global ModeSwarm มีโหมด deploy.mode: global สำหรับ service ที่ต้องรันบนทุกโหนดในคลัสเตอร์เสมอ (เช่น log shipper, monitoring agent, security agent) — ฝั่ง Nomad เทียบเท่ากับการตั้ง type = "system" ที่ระดับ job
หลักการทำงาน:
count เลย — ถ้าใส่ไปก็จะถูก Nomad เพิกเฉย เพราะจำนวน allocation ถูกกำหนดโดยจำนวนโหนดที่ผ่าน constraint ไม่ใช่ตัวเลขคงที่
flowchart TB
subgraph Cluster["Nomad Cluster (3 client nodes)"]
N1["node-01"]
N2["node-02"]
N3["node-03"]
end
J["job type = system
(ไม่ต้องกำหนด count)"] -->|1 allocation ต่อโหนด| N1
J -->|1 allocation ต่อโหนด| N2
J -->|1 allocation ต่อโหนด| N3
ตัวอย่าง job spec:
job "node-exporter" {
datacenters = ["dc1"]
type = "system" # เทียบเท่า deploy.mode: global
group "agent" {
# ไม่ต้องมี count — Nomad รันให้ครบทุกโหนดเอง
network {
mode = "host"
}
task "node-exporter" {
driver = "podman"
config {
image = "prom/node-exporter:latest"
network_mode = "host"
}
resources {
cpu = 100
memory = 64
}
}
}
}
จำกัดให้รันเฉพาะบางโหนด: Swarm ทำผ่าน --constraint label ตอนสร้าง global service ส่วน Nomad ใช้ constraint stanza เช่นเดียวกัน:
job "gpu-agent" {
datacenters = ["dc1"]
type = "system"
constraint {
attribute = "${node.class}"
value = "gpu-node"
}
group "agent" {
task "gpu-monitor" {
driver = "podman"
config {
image = "myrepo/gpu-monitor:latest"
}
resources {
cpu = 100
memory = 64
}
}
}
}
ผลคือรันครบทุกโหนดที่ตรง constraint เท่านั้น ไม่ใช่ทุกโหนดในคลัสเตอร์
เผื่อเจอในเอกสาร — sysbatch: Nomad ยังมี type = "sysbatch" ซึ่งพฤติกรรมเหมือน system (รันครบทุกโหนด) แต่เป็น one-shot คือรันจบแล้วจบเลย ไม่ต้องอยู่ยาว เหมาะกับงานจำพวก node bootstrap script, one-time cleanup, หรือ batch job ที่ต้องทำบนทุกเครื่องครั้งเดียว — จุดนี้ Swarm ไม่มีเทียบเท่าตรง ๆ เพราะ Swarm ไม่มีแนวคิด batch job เลย
สรุปตาราง type ทั้งหมดของ Nomad:
type |
พฤติกรรม | เทียบเท่า Swarm |
|---|---|---|
service |
รันตาม count ที่กำหนด กระจายไปตาม scheduler bin-pack |
deploy.mode: replicated |
system |
รัน 1 ต่อทุกโหนดที่ผ่าน constraint, long-running | deploy.mode: global |
batch |
รันจบแล้วหยุด ตามจำนวน count ที่กำหนด |
ไม่มีเทียบเท่าตรง ๆ |
sysbatch |
รันจบแล้วหยุด แต่รันครบทุกโหนด | ไม่มีเทียบเท่าตรง ๆ |
บทนี้รวมคำสั่ง CLI ของ Nomad ที่ใช้บ่อยที่สุดในงานดูแลระบบประจำวัน โดยเทียบกับคำสั่ง Docker/Docker Swarm ที่คุ้นเคยอยู่แล้ว เพื่อให้ขึ้นมือได้เร็ว พร้อมทริคที่ควรรู้ไว้ใช้งานจริง
| ต้องการทำอะไร | คำสั่ง Docker/Swarm | คำสั่ง Nomad เทียบเท่า |
|---|---|---|
| ดูรายการ container ที่รันอยู่ | docker ps |
nomad job status (ดูภาพรวมทุก job) หรือ nomad node status (ดูตามโหนด) |
| ดูรายการ service ทั้งหมด | docker service ls |
nomad job status |
| ดู replica/allocation ของ service หนึ่งตัว | docker service ps <service> |
nomad job status <job> (ส่วน Allocations) |
| ดู log | docker service logs -f <service> |
nomad alloc logs -f <alloc-id> [task] |
| exec เข้า container | docker exec -it <container> sh |
nomad alloc exec -i -t <alloc-id> <task> /bin/sh |
| deploy/update stack | docker stack deploy -c file.yml <stack> |
nomad job run <file>.nomad.hcl |
| ลบ stack/service | docker stack rm <stack> |
nomad job stop -purge <job> |
| scale service | docker service scale <service>=5 |
nomad job scale <job> <group> 5 |
| อัปเดต image เวอร์ชันใหม่ | docker service update --image ... |
แก้ image ใน job spec แล้ว nomad job run ซ้ำ |
| rollback | docker service rollback <service> |
nomad job revert <job> <version> |
| ดู node ในคลัสเตอร์ | docker node ls |
nomad node status |
| ปิดโหนดชั่วคราวเพื่อบำรุงรักษา | docker node update --availability drain <node> |
nomad node drain -enable <node-id> |
| inspect รายละเอียด | docker service inspect <service> |
nomad job inspect <job> |
| ดู resource usage | docker stats |
nomad alloc status <alloc-id> (ส่วน Resource Utilization) |
# ตรวจสอบไฟล์ job spec ว่า syntax ถูกต้องก่อน run จริง (เทียบเท่า docker compose config)
nomad job validate web-app.nomad.hcl
# *** ทริคสำคัญที่สุด *** — plan ก่อน run ทุกครั้ง เพื่อดูว่า Nomad จะเปลี่ยนแปลงอะไรบ้าง
# คล้าย `terraform plan` แต่ Docker/Swarm ไม่มีความสามารถนี้เลย
nomad job plan web-app.nomad.hcl
# deploy/update job จริง (เทียบเท่า docker stack deploy)
nomad job run web-app.nomad.hcl
# ดูสถานะภาพรวมของ job (replica ที่ running/pending/failed)
nomad job status web-app
# ดูรายละเอียดแบบเจาะลึกทุก allocation รวม event history
nomad job status -verbose web-app
# ปรับจำนวน replica โดยไม่ต้องแก้ไฟล์แล้ว run ใหม่ (เทียบเท่า docker service scale)
nomad job scale web-app web 5
# ดูประวัติเวอร์ชันของ job (submit index, ใครแก้เมื่อไหร่)
nomad job history web-app
# rollback กลับไปเวอร์ชันก่อนหน้า (เทียบเท่า docker service rollback)
nomad job revert web-app 3
# หยุด job ชั่วคราว (ยังเก็บ history ไว้ ยกเลิก drain ได้ด้วย nomad job run ใหม่)
nomad job stop web-app
# หยุดและลบ job ออกจาก state ทั้งหมด (เทียบเท่า docker stack rm — ระวัง! ลบ history ด้วย)
nomad job stop -purge web-app
# กรณีตั้ง canary ไว้ใน update stanza ต้อง promote เองหลังตรวจสอบ canary แล้วโอเค
nomad job promote web-app
ใน Nomad "allocation" คือหน่วยของ instance หนึ่งตัวที่ถูก schedule ไปรันจริงบนโหนดใดโหนดหนึ่ง (เทียบเท่ากับ container instance หนึ่งตัวใน docker service ps):
# ดู allocation ทั้งหมดของ job พร้อม alloc ID (ใช้ ID นี้กับคำสั่งด้านล่าง)
nomad job status web-app
# ดูรายละเอียด allocation ตัวใดตัวหนึ่ง (event timeline, resource usage, health check ล่าสุด)
nomad alloc status <alloc-id>
# ดู log แบบ tail -f (เทียบเท่า docker service logs -f)
nomad alloc logs -f <alloc-id>
# ดูเฉพาะ stderr หรือระบุ task ในกรณี group มีหลาย task
nomad alloc logs -stderr <alloc-id> nginx
# exec เข้าไปข้างใน container เพื่อ debug (เทียบเท่า docker exec -it)
nomad alloc exec -i -t <alloc-id> nginx /bin/sh
# เรียกดูไฟล์ใน allocation directory (log ไฟล์, artifact ที่ดาวน์โหลดมา) โดยไม่ต้อง exec เข้าไป
nomad alloc fs <alloc-id> /alloc/logs/
# ดูสถานะ Consul cluster membership (เทียบเท่า docker node ls ฝั่ง Consul)
consul members
# ดูสถานะ Nomad server (leader/follower)
nomad server members
# ดูรายการโหนด client ทั้งหมดและสถานะ (เทียบเท่า docker node ls)
nomad node status
# ดูรายละเอียดโหนดใดโหนดหนึ่งแบบเจาะลึก (driver ที่พร้อมใช้, resource คงเหลือ)
nomad node status -verbose <node-id>
# *** ทริคสำคัญ *** — ก่อน reboot โหนดเพื่ออัปเดต Tumbleweed ให้ drain โหนดก่อนเสมอ
# Nomad จะค่อย ๆ ย้าย allocation ออกไปโหนดอื่นอย่างนุ่มนวลก่อนโหนดนี้ปิดจริง
nomad node drain -enable -deadline 1h <node-id>
# หลังโหนดกลับมาออนไลน์แล้ว ปลด drain เพื่อให้รับงานใหม่ได้อีกครั้ง
nomad node drain -disable <node-id>
# กันโหนดไม่ให้รับงานใหม่ชั่วคราว โดยไม่ต้องย้ายงานเดิมออก (ต่างจาก drain)
nomad node eligibility -disable <node-id>
# ดู log แบบ real-time ของ agent ตัวที่รันคำสั่งนี้อยู่ (debug ปัญหา cluster/scheduling)
nomad monitor -log-level=DEBUG
# ดูสถานะ Raft consensus ของ Nomad server (เช็คว่า leader คือใคร, quorum ปกติไหม)
nomad operator raft list-peers
# รวบรวม debug bundle (log, metrics, pprof) ของทั้งคลัสเตอร์ไว้ไฟล์เดียว ส่งให้ทีม/เปิด ticket ได้สะดวก
nomad operator debug -duration=2m
# ดู metrics แบบ Prometheus format (เอาไปต่อ Grafana ได้ทันที)
curl http://127.0.0.1:4646/v1/metrics?format=prometheus
nomad job plan ก่อน nomad job run เสมอ — เป็นนิสัยที่สำคัญที่สุด เพราะ plan จะบอกล่วงหน้าว่าจะมีการ restart กี่ replica, สร้าง/ลบอะไรบ้าง ก่อนที่จะกระทบ production จริง (Docker Swarm ไม่มีขั้นตอน dry-run แบบนี้)NOMAD_ADDR ไว้ล่วงหน้า (เช่น export NOMAD_ADDR=http://127.0.0.1:4646) จะได้ไม่ต้องพิมพ์ -address ทุกคำสั่ง โดยเฉพาะเวลาต้องสั่งงานข้ามเครื่องผ่าน SSHnomad -autocomplete-install แล้วเปิด terminal ใหม่ จะกด Tab auto-complete job name/alloc ID ได้ ประหยัดเวลาก็อป alloc ID มากwatch คู่กับ nomad job status เวลา deploy เพื่อดู progress การ rolling update แบบ real-time: watch -n2 nomad job status web-appnomad job run -detach สำหรับใช้ใน CI/CD pipeline — คำสั่งจะ return ทันทีไม่ต้องรอ deployment เสร็จ (ค่อยไป poll สถานะแยกด้วย nomad job status อีกที)nomad job stop กับ nomad job stop -purge — แบบแรกแค่หยุดงานแต่ยังเก็บ job definition/history ไว้ใน state (สั่ง nomad job run ไฟล์เดิมซ้ำก็กลับมาได้ทันที) ส่วน -purge คือลบทิ้งถาวรเหมือน docker stack rm ถ้าไม่แน่ใจอย่าใส่ -purgenomad job status -json | jq เอาไว้ทำ script ตรวจสุขภาพ cluster อัตโนมัติ หรือดึงค่าไปแจ้งเตือนผ่าน monitoring เดิมที่มีอยู่nomad alloc exec ปลอดภัยกว่า SSH เข้าเครื่องจริง เพราะจำกัดสิทธิ์อยู่แค่ใน task/container นั้น ไม่ต้องแจก SSH key ให้ทีมเข้าโหนด Tumbleweed โดยตรงบทนี้ลงรายละเอียดสองเรื่องที่ job spec ตัวอย่างในบทที่ 8-9 แตะไว้แค่ผิวเผิน คือการจัดการ volume (ที่เก็บข้อมูล persist) และ network (การเชื่อมต่อระหว่าง service) ซึ่งเป็นจุดที่ Nomad มีแนวคิดต่างจาก Docker/Swarm พอสมควร
Nomad รองรับ volume 3 แบบหลัก ต่างจาก Docker ที่มักใช้แค่ named volume หรือ bind mount:
| ประเภท | ลักษณะ | เหมาะกับงานแบบไหน |
|---|---|---|
ephemeral_disk |
พื้นที่ scratch ผูกกับ alloc directory บนโหนดที่ task รันอยู่ ข้อมูลหายเมื่อ alloc ถูกลบ (เว้นแต่ตั้ง migrate = true) |
cache, temp file, งานที่ไม่ต้อง persist ข้าม deploy |
host_volume |
mount path บนเครื่อง host ที่ประกาศไว้ล่วงหน้าในทุก client ที่ต้องการให้ mount ได้ (คล้าย hostPath ของ Kubernetes) | data ที่ต้อง persist และรู้อยู่แล้วว่าจะรันบนโหนดไหน (เช่น sticky app, single-instance database) |
| CSI volume | ขอ volume แบบ dynamic ผ่าน CSI plugin (NFS, Ceph, GlusterFS, JuiceFS ฯลฯ) ให้ Nomad จัดสรร/attach ให้อัตโนมัติ | ข้อมูลที่ต้อง follow container ไปได้ทุกโหนด หรือใช้ storage backend แบบ shared/distributed |
flowchart LR
subgraph Node["Nomad client node"]
AllocFS["ephemeral_disk
(อยู่ใน alloc dir)"]
HostVol["host_volume
(path คงที่บนเครื่องนี้)"]
end
CSIPlugin["CSI plugin
(เช่น NFS/GlusterFS/JuiceFS)"] -->|dynamic attach| Node
Storage["Storage backend
(shared/distributed)"] --> CSIPlugin
group "web" {
ephemeral_disk {
size = 300 # MB
sticky = true # พยายามวาง alloc ใหม่กลับโหนดเดิมถ้าเป็นไปได้ (ลด cache miss)
migrate = true # ถ้าย้ายโหนดใหม่จริง ให้ copy ข้อมูลเดิมข้ามไปด้วย
}
...
}
ไม่ต้อง mount อะไรเพิ่มในระดับ task — ข้อมูลจะอยู่ใต้ ${NOMAD_ALLOC_DIR} ให้ทุก task ใน group เดียวกันแชร์กันได้อัตโนมัติ
ขั้นที่ 1 — ประกาศไว้ในทุก client ที่ต้องการให้ mount ได้ (/etc/nomad.d/nomad.hcl):
client {
enabled = true
host_volume "app-data" {
path = "/var/opt/nomad/volumes/app-data"
read_only = false
}
}
เตรียม directory จริงบนเครื่องก่อนด้วย:
sudo mkdir -p /var/opt/nomad/volumes/app-data
sudo chown nomad:nomad /var/opt/nomad/volumes/app-data
sudo systemctl restart nomad
ขั้นที่ 2 — อ้างอิงใน job spec:
group "api" {
volume "app-data" {
type = "host"
source = "app-data" # ต้องตรงกับชื่อที่ประกาศใน client stanza
read_only = false
}
task "api-server" {
volume_mount {
volume = "app-data"
destination = "/data"
}
...
}
}
ข้อจำกัดที่ต้องรู้:
host_volumeผูกกับโหนดใดโหนดหนึ่งตายตัว ถ้าโหนดนั้นล่ม Nomad จะ ไม่ ย้าย task ไปโหนดอื่นให้อัตโนมัติ (เพราะ path เดียวกันไม่มีอยู่บนโหนดอื่น) เหมาะกับ workload ที่ยอมรับ downtime แบบ single-node ได้ ถ้าต้องการ HA ข้ามโหนดจริง ๆ ให้ใช้ CSI volume แทน
CSI (Container Storage Interface) เปิดให้ Nomad ต่อกับ storage backend ภายนอกได้หลากหลาย ขั้นตอนคร่าว ๆ:
controller และ node plugin)nomad volume register เพื่อขึ้นทะเบียน volume ที่มีอยู่แล้ว หรือ nomad volume create เพื่อสร้างใหม่แบบ dynamictype = "csi"# volume.hcl — ไฟล์ registration แยกต่างหาก
type = "csi"
id = "app-shared-data"
name = "app-shared-data"
plugin_id = "nfs-plugin"
capability {
access_mode = "multi-node-multi-writer"
attachment_mode = "file-system"
}
nomad volume register volume.hcl
# ใน job spec
group "api" {
volume "shared" {
type = "csi"
source = "app-shared-data"
attachment_mode = "file-system"
access_mode = "multi-node-multi-writer"
}
task "api-server" {
volume_mount {
volume = "shared"
destination = "/data"
}
...
}
}
เพราะสาย self-hosted infra มักมี GlusterFS cluster รันอยู่แล้ว หัวข้อนี้ลงรายละเอียดวิธีต่อ Nomad เข้ากับ GlusterFS แบบ 2 แนวทาง ให้เลือกตามความซับซ้อนที่ยอมรับได้
แนวทางที่ 1 (แนะนำสำหรับคลัสเตอร์ขนาดเล็ก-กลาง) — mount GlusterFS volume ตรงด้วย glusterfs-fuse แล้วใช้เป็น host_volume
วิธีนี้ไม่ต้องพึ่ง CSI plugin เลย อาศัยธรรมชาติของ GlusterFS ที่เป็น distributed filesystem อยู่แล้ว — mount volume เดียวกันไว้ที่ path เดียวกันบนทุก Nomad client node แล้วประกาศเป็น host_volume ธรรมดา เพราะเนื้อหาที่เห็นจากทุกโหนดคือ volume เดียวกันจริง ๆ (ไม่ใช่ path local แยกกันคนละก้อนแบบ host_volume ทั่วไป) จึงทำให้ task ที่ถูก reschedule ไปโหนดไหนก็เห็นข้อมูลเดิมเสมอ ต่างจาก host_volume ปกติที่ผูกกับโหนดเดียวตายตัว
ขั้นที่ 1 — ติดตั้ง GlusterFS client บนทุก Nomad client node:
sudo zypper install -y glusterfs
ขั้นที่ 2 — mount volume ที่มีอยู่แล้วจาก GlusterFS cluster (สมมติ peer คือ gluster-01.internal และชื่อ volume คือ appdata) ทดสอบ mount ก่อนด้วยมือ:
sudo mkdir -p /mnt/gluster/appdata
sudo mount -t glusterfs gluster-01.internal:/appdata /mnt/gluster/appdata
ถ้า mount สำเร็จ ให้ทำให้ติดถาวรผ่าน /etc/fstab (ใช้ backup-volfile-servers ระบุ peer สำรอง กันกรณี gluster-01 ล่มตอน boot):
# /etc/fstab
gluster-01.internal:/appdata /mnt/gluster/appdata glusterfs defaults,_netdev,backup-volfile-servers=gluster-02.internal:gluster-03.internal 0 0
หรือถ้าอยาก manage ผ่าน systemd แทน (คุมง่ายกว่าเวลา debug บน Tumbleweed) สร้าง /etc/systemd/system/mnt-gluster-appdata.mount:
[Unit]
Description=GlusterFS appdata volume
After=network-online.target
Wants=network-online.target
[Mount]
What=gluster-01.internal:/appdata
Where=/mnt/gluster/appdata
Type=glusterfs
Options=backup-volfile-servers=gluster-02.internal:gluster-03.internal
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mnt-gluster-appdata.mount
ขั้นที่ 3 — ประกาศเป็น host_volume ในทุก client (/etc/nomad.d/nomad.hcl):
client {
enabled = true
host_volume "gluster-appdata" {
path = "/mnt/gluster/appdata"
read_only = false
}
}
sudo systemctl restart nomad
ขั้นที่ 4 — ใช้ใน job spec เหมือน host_volume ปกติทุกประการ:
group "api" {
count = 2
volume "gluster-appdata" {
type = "host"
source = "gluster-appdata"
read_only = false
}
task "api-server" {
volume_mount {
volume = "gluster-appdata"
destination = "/data"
}
...
}
}
เพราะ path /mnt/gluster/appdata mount volume เดียวกันจากทุกโหนด ทั้ง 2 instance ของ api-server (count = 2) จึงเห็นไฟล์เดียวกันแบบ real-time ไม่ว่า Nomad จะ schedule ไปลงโหนดไหน — ได้พฤติกรรมใกล้เคียง shared storage แบบ CSI โดยไม่ต้องรัน CSI plugin เลย ข้อควรระวังคือแอปต้องรองรับการเขียนไฟล์แบบ concurrent จากหลาย instance เอง (GlusterFS ให้ POSIX consistency ระดับไฟล์ แต่ไม่ได้แก้ปัญหา race condition ระดับ application ให้)
แนวทางที่ 2 (dynamic provisioning เต็มรูปแบบ) — ต่อผ่าน CSI + Heketi
ถ้าต้องการให้ Nomad สร้าง/ลบ volume ของ GlusterFS แบบ dynamic ผ่าน nomad volume create เหมือนที่ Kubernetes ทำกับ StorageClass ต้องใช้ CSI driver จริง ซึ่งฝั่ง GlusterFS ทำงานผ่าน Heketi (REST API ที่คุม GlusterFS cluster แทน) เป็นตัวกลาง
ข้อควรรู้ก่อนเลือกแนวทางนี้: ระบบนิเวศของ GlusterFS CSI driver ไม่ได้ active พัฒนาต่อเนื่องเท่า driver ของค่ายใหญ่ (AWS EBS, GCP PD) และ Heketi เองก็เข้าสู่โหมด maintenance มานานแล้ว ควรทดสอบใน staging ให้ละเอียดก่อนใช้ production จริง ถ้าไม่จำเป็นต้อง provision volume แบบ dynamic บ่อย ๆ แนวทางที่ 1 ด้านบนมักจะเสถียรกว่าและดูแลง่ายกว่าในระยะยาว
ขั้นที่ 1 — เตรียม Heketi ให้ชี้ไปที่ GlusterFS cluster เดิม (ติดตั้ง/ตั้งค่า Heketi เป็นอีกงานแยกต่างหาก นอกขอบเขตบทความนี้ — Heketi ต้องรู้จัก node/device ของ GlusterFS cluster ผ่าน heketi-cli topology load ก่อน)
ขั้นที่ 2 — รัน CSI controller plugin เป็น Nomad job (plugin-gluster-controller.nomad.hcl):
job "plugin-gluster-csi-controller" {
datacenters = ["dc1"]
type = "service"
group "controller" {
task "plugin" {
driver = "docker"
config {
image = "dragma/gluster-heketi-csi-driver"
args = [
"controller",
"--endpoint=unix://csi/csi.sock",
"--v=5",
"--heketiurl=http://heketi.service.consul:8080",
]
}
csi_plugin {
id = "glusterfs"
type = "controller"
mount_dir = "/csi"
}
resources {
cpu = 500
memory = 256
}
}
}
}
ขั้นที่ 3 — รัน CSI node plugin เป็น system job เพื่อให้ลงครบทุกโหนด (plugin-gluster-nodes.nomad.hcl) — ใช้หลักการเดียวกับหัวข้อ 9.5:
job "plugin-gluster-csi-nodes" {
datacenters = ["dc1"]
type = "system" # ให้ลงทุกโหนดเหมือนหัวข้อ 9.5
group "nodes" {
task "plugin" {
driver = "docker"
config {
image = "dragma/gluster-heketi-csi-driver"
privileged = true # node plugin ต้อง mount disk บนโหนดจริง จึงต้อง privileged
args = [
"nodeserver",
"--endpoint=unix://csi/csi.sock",
"--nodeid=${node.unique.id}",
]
}
csi_plugin {
id = "glusterfs"
type = "node"
mount_dir = "/csi"
}
resources {
cpu = 250
memory = 128
}
}
}
}
nomad job run plugin-gluster-csi-controller.nomad.hcl
nomad job run plugin-gluster-csi-nodes.nomad.hcl
nomad plugin status glusterfs # เช็คว่า controller/node plugin healthy ครบ
ขั้นที่ 4 — สร้าง/ขึ้นทะเบียน volume:
# gluster-volume.hcl
type = "csi"
id = "app-shared-gluster"
name = "app-shared-gluster"
plugin_id = "glusterfs"
capacity_min = "10GiB"
capacity_max = "20GiB"
capability {
access_mode = "multi-node-multi-writer"
attachment_mode = "file-system"
}
nomad volume create gluster-volume.hcl
nomad volume status app-shared-gluster
ขั้นที่ 5 — ใช้ volume ใน job spec:
group "api" {
volume "shared" {
type = "csi"
source = "app-shared-gluster"
attachment_mode = "file-system"
access_mode = "multi-node-multi-writer"
}
task "api-server" {
volume_mount {
volume = "shared"
destination = "/data"
}
...
}
}
ข้อดีของแนวทางนี้เทียบกับแนวทางที่ 1 คือ Nomad รู้จัก lifecycle ของ volume เอง (สร้าง/ลบ/attach/detach ผ่าน nomad volume command) และย้าย task ข้ามโหนดพร้อม attach volume ใหม่ให้อัตโนมัติเมื่อโหนดเดิมล่ม แต่แลกมาด้วยความซับซ้อนของการดูแล Heketi + CSI plugin เพิ่มอีกชั้นหนึ่ง
เนื่องจากในสาย self-hosted infra มักจะมี JuiceFS ใช้งานอยู่แล้วด้วยเช่นกัน ตัวนี้ก็มี CSI driver ให้ใช้ได้เช่นเดียวกัน (juicefs-csi-driver) โดยขั้นตอนโครงสร้างจะคล้ายกับ GlusterFS ข้างต้น (controller + node job, แค่เปลี่ยน image และ args ตาม driver)
| ต้องการอะไร | Docker | Nomad |
|---|---|---|
| Scratch space ชั่วคราว | tmpfs mount |
ephemeral_disk |
| Bind mount path บน host | -v /host/path:/container/path |
host_volume |
| Named volume ที่ engine จัดการเอง | docker volume create |
ไม่มีเทียบเท่าตรง ๆ — ต้องเลือก host_volume (static) หรือ CSI (dynamic) แทน |
| Volume แบบ shared storage ข้ามโหนด | Docker volume plugin (เช่น NFS plugin) แยกติดตั้งเอง | CSI plugin (มาตรฐานเดียวกับที่ Kubernetes ใช้ ต่อ driver เดิมได้เลย) |
Nomad มี 3 network mode ให้เลือกในระดับ group:
flowchart TB
subgraph Host["mode = host (ค่าเริ่มต้น)"]
H1["Task ใช้ network namespace เดียวกับ host โดยตรง
ไม่มี network isolation ระหว่าง task"]
end
subgraph Bridge["mode = bridge"]
B1["Group ได้ network namespace แยกของตัวเอง
คล้าย Pod ของ Kubernetes / bridge network ของ Docker"]
end
subgraph CNI["mode = cni"]
C1["ใช้ CNI plugin กำหนด topology เอง
รองรับ multi-network, custom routing"]
end
| Mode | ลักษณะ | เทียบเท่าใน Docker |
|---|---|---|
host (default) |
Task แชร์ network namespace กับ host โดยตรง ไม่มี isolation ระหว่าง task | --network host |
bridge |
แต่ละ group ได้ network namespace แยกของตัวเอง ทำ port mapping ผ่าน NAT | Docker default bridge network |
cni |
ใช้ CNI plugin ที่กำหนดเอง (เช่น multi-homed network, custom VLAN) | Docker custom network plugin |
group "web" {
network {
mode = "bridge" # แยก network namespace ต่อ group
port "http" {
static = 8080 # port คงที่ (ต้องไม่ชนกันข้าม job บนโหนดเดียวกัน)
to = 80 # port ภายใน container
}
port "metrics" {
to = 9090 # ไม่ระบุ static -> Nomad สุ่ม port ว่างให้อัตโนมัติ (dynamic port)
}
}
task "nginx" {
driver = "podman"
config {
image = "docker.io/library/nginx:latest"
ports = ["http", "metrics"]
}
# อ้างอิง dynamic port ที่ Nomad สุ่มให้ผ่าน environment variable ที่ Nomad inject ให้อัตโนมัติ
env {
METRICS_PORT = "${NOMAD_PORT_metrics}"
}
}
}
ทริค: ใช้ dynamic port (ไม่ใส่ static) ให้มากที่สุดเท่าที่ทำได้ เพราะ Nomad จะเลือก port ว่างให้เองและป้องกัน port ชนกันเวลารันหลาย instance บนโหนดเดียวกัน — ต่างจาก Docker Swarm ที่มักต้อง publish port ตายตัวทำให้จำกัดจำนวน replica ต่อโหนดได้ แต่ถ้าใช้ dynamic port กับ service ที่ service อื่นต้องต่อเข้ามาโดยตรงผ่าน DNS name (เหมือนตัวอย่าง api ในหัวข้อ 9.3) ฝั่งที่เรียกต้อง discover พอร์ตจริงผ่าน Consul แทนการ hardcode หมายเลขพอร์ตไว้ตรง ๆ ในโค้ด/environment variable
เพราะเปิด connect { enabled = true } ไว้ใน Consul config ตั้งแต่บทที่ 5 แล้ว Nomad สามารถประกาศ sidecar proxy ให้แต่ละ service คุยกันผ่าน mTLS ได้ โดยตัวแอปพลิเคชันเองไม่ต้องรู้เรื่อง TLS/certificate เลยแม้แต่บรรทัดเดียว — หัวข้อนี้ขยายรายละเอียดของฟีเจอร์นี้แบบเต็มรูปแบบ
Consul Connect (และ network { mode = "bridge" } ทุกกรณี) ต้องพึ่ง CNI reference plugins ในการสร้าง network namespace แยกต่อ allocation — ต้องติดตั้งบน ทุก Nomad client node ที่จะรัน job แบบ bridge/Connect ก่อนใช้งาน:
# รันบนทุกโหนดที่จะรัน job แบบ bridge mode หรือ Consul Connect
ARCH_CNI=$( [ "$(uname -m)" = aarch64 ] && echo arm64 || echo amd64 )
curl -L -o /tmp/cni-plugins.tgz \
"https://github.com/containernetworking/plugins/releases/download/v1.5.1/cni-plugins-linux-${ARCH_CNI}-v1.5.1.tgz"
sudo mkdir -p /opt/cni/bin
sudo tar -C /opt/cni/bin -xzf /tmp/cni-plugins.tgz
# เปิดให้ traffic ที่ข้าม bridge ถูกประมวลผลผ่าน iptables (จำเป็นสำหรับ Tumbleweed/เคอร์เนลใหม่)
sudo modprobe br_netfilter
echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-iptables
echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-ip6tables
sudo systemctl restart nomad
Nomad จะมองหา plugin binary ที่ /opt/cni/bin เป็นค่าเริ่มต้น (ปรับ path ได้ผ่าน cni_path ใน client stanza) ถ้าไม่ติดตั้งขั้นตอนนี้ก่อน job ที่ใช้ mode = "bridge" หรือ connect { } จะ schedule ไม่ผ่านเลย
เมื่อประกาศ connect { sidecar_service {} } ใน service block ของ group ใดก็ตาม (group นั้นต้องตั้ง network { mode = "bridge" } ด้วย) Nomad จะ:
connect-proxy-<service-name> ใน nomad alloc status) — Envoy ตัวนี้คือ sidecar proxy127.0.0.1 เท่านั้น ไม่เห็น TLS เลย
sequenceDiagram
participant Web as task: nginx (web)
participant PW as sidecar: Envoy (web)
participant PA as sidecar: Envoy (api)
participant Api as task: api-server
Web->>PW: HTTP request ไป 127.0.0.1:3000 (plain TCP)
Note over PW,PA: mTLS tunnel ระหว่าง sidecar
(cert ออกโดย Consul CA อัตโนมัติ)
PW->>PA: encrypted request ผ่าน mTLS
PA->>Api: forward เป็น plain TCP ไปยัง task จริง
Api-->>PA: response
PA-->>PW: encrypted response ผ่าน mTLS
PW-->>Web: response (plain TCP)
จุดสำคัญ: ตัวแอป (nginx, api-server) ไม่ต้อง implement TLS เอง แม้แต่บรรทัดเดียว — มันแค่คุยกับ 127.0.0.1:<local_bind_port> เหมือนเป็น service ที่รันอยู่เครื่องเดียวกัน ส่วนการเข้ารหัสจริงเกิดขึ้นระหว่าง sidecar สองตัวที่ Nomad/Consul จัดการให้ทั้งหมด
Consul รองรับ 2 วิธีในการเข้าร่วม service mesh:
| แนวทาง | ลักษณะ | เหมาะกับ |
|---|---|---|
| Sidecar proxy (แนะนำ, ใช้บ่อยที่สุด) | Envoy ทำหน้าที่ TLS/routing แทนแอปทั้งหมด แอปไม่ต้องแก้โค้ดเลย | แอปที่มีอยู่แล้ว (legacy), แอปที่เขียนด้วยภาษา/framework ใดก็ได้ |
| Connect-native | แอปเรียก Consul Connect API เอง (มี library ให้หลายภาษา) เพื่อขอ cert และเปิด TLS listener ตรง ๆ โดยไม่ผ่าน sidecar | แอปที่เขียนใหม่และยอมแลก performance overhead ของ proxy เพิ่ม เพื่อลด hop และควบคุม connection ได้ละเอียดกว่า |
หัวข้อนี้เน้นที่ sidecar proxy เพราะเป็นวิธีที่ใช้กับ container ทั่วไป (podman/docker image) ได้ทันทีโดยไม่ต้องแก้โค้ดแอปเลย ตรงกับโจทย์ของคนย้ายจาก Docker Swarm มากที่สุด
ใช้กับ service ที่แค่ต้องการ "เข้าร่วม mesh" เพื่อให้ service อื่นเรียกเข้ามาแบบเข้ารหัสได้ แต่ตัวมันเองไม่ได้เรียกออกไปหาใครเพิ่ม (เช่น database service):
group "db" {
count = 1
network {
mode = "bridge"
}
service {
name = "db"
port = "5432" # พอร์ตที่ task ฟังอยู่จริงภายใน network namespace
connect {
sidecar_service {} # เปิด sidecar เฉยๆ ไม่มี upstream ขาออก
}
check {
type = "tcp"
interval = "10s"
timeout = "2s"
# health check ของ Consul ยิงตรงเข้า task ผ่าน local address ได้เลย
# เพราะ check เกิดจาก Consul agent บนโหนดเดียวกัน ไม่ผ่าน mesh
}
}
task "postgres" {
driver = "podman"
config {
image = "docker.io/library/postgres:16"
}
env {
POSTGRES_PASSWORD = "changeme-use-vault-in-production"
}
resources {
cpu = 500
memory = 512
}
}
}
ขยายจากตัวอย่าง myapp ในหัวข้อ 9.3 ให้ทุกชั้นคุยกันผ่าน Connect แทนการเรียกผ่าน Consul DNS ตรง ๆ (สังเกตว่าเปลี่ยนจาก network { mode = "host" } แบบเดิมมาเป็น mode = "bridge" ทั้งหมด และเปลี่ยน env ของแต่ละ task ให้ชี้ไปที่ 127.0.0.1:<local_bind_port> แทน <service>.service.consul:<port>):
job "myapp-mesh" {
datacenters = ["dc1"]
type = "service"
group "db" {
network {
mode = "bridge"
}
service {
name = "db"
port = "5432"
connect {
sidecar_service {}
}
}
task "postgres" {
driver = "podman"
config {
image = "docker.io/library/postgres:16"
}
resources {
cpu = 500
memory = 512
}
}
}
group "api" {
count = 2
network {
mode = "bridge"
}
service {
name = "api"
port = "3000"
connect {
sidecar_service {
proxy {
upstreams {
destination_name = "db"
local_bind_port = 5432
}
}
}
}
}
task "api-server" {
driver = "podman"
env {
# เรียก db ผ่าน sidecar ของตัวเอง ไม่ผ่าน Consul DNS
DB_HOST = "127.0.0.1"
DB_PORT = "5432"
}
config {
image = "myrepo/api:1.2.0"
}
resources {
cpu = 500
memory = 256
}
}
}
group "web" {
count = 3
network {
mode = "bridge"
port "http" {
static = 8080
to = 80
}
}
service {
name = "web"
port = "http"
provider = "consul"
connect {
sidecar_service {
proxy {
upstreams {
destination_name = "api"
local_bind_port = 3000
}
}
}
}
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "nginx" {
driver = "podman"
env {
# เรียก api ผ่าน sidecar ของตัวเอง ไม่ใช่ api.service.consul อีกต่อไป
API_URL = "http://127.0.0.1:3000"
}
config {
image = "docker.io/library/nginx:latest"
ports = ["http"]
}
resources {
cpu = 200
memory = 128
}
}
}
}
หมายเหตุ — เลือกใช้แนวทางไหนระหว่าง Consul DNS (หัวข้อ 9.3) กับ Connect mesh (หัวข้อนี้): ทั้งสองแนวทางเป็นคนละเรื่องกันและเลือกใช้ตามความต้องการ — Consul DNS (
<service>.service.consul) คือ plain-text service discovery เรียบง่าย เหมาะกับ traffic ภายในเครือข่ายที่เชื่อถือได้อยู่แล้วหรือกรณีที่ไม่ต้องการ overhead ของ proxy ส่วน Connect mesh เพิ่มการเข้ารหัส mTLS และ access-control (intentions) ให้ แลกกับ latency ที่เพิ่มขึ้นเล็กน้อยจากการที่ traffic ต้องผ่าน sidecar สองต่อ (ต้นทาง-ปลายทาง) และต้องใช้ bridge mode เท่านั้น — สามารถใช้ผสมกันได้ในระบบเดียว เช่น service ภายในที่อ่อนไหวต่อข้อมูล (เช่น db, payment) ใช้ Connect ส่วน service ที่ไม่ sensitive ใช้ DNS ธรรมดาก็ได้
การเปิด mTLS ให้เท่านั้นยังไม่ได้จำกัดว่า "ใครคุยกับใครได้บ้าง" — ค่าเริ่มต้นของ Consul (ถ้าไม่เปิด ACL) คือ allow-all ทุก service คุยกันได้หมด ต้องประกาศ intentions เพื่อกำหนดสิทธิ์แบบ allow-list ชัดเจน (โดยเฉพาะเมื่อเปิด production จริงควรทำเสมอ):
วิธีที่แนะนำ (Consul 1.9+) — เขียนเป็น config entry service-intentions:
# intentions-db.hcl
Kind = "service-intentions"
Name = "db"
Sources = [
{
Name = "api"
Action = "allow"
},
{
Name = "*"
Action = "deny"
}
]
consul config write intentions-db.hcl
ผลคือมีแค่ service ชื่อ api เท่านั้นที่เรียก db ผ่าน mesh ได้ — service อื่นแม้จะรู้ชื่อ db ก็เรียกไม่ผ่าน (sidecar proxy จะปฏิเสธ connection ตั้งแต่ handshake) ต่อให้ network เปิดถึงกันในระดับ IP ก็ตาม
วิธีเร็ว ๆ สำหรับทดสอบ (CLI shorthand):
consul intention create -allow api db
consul intention create -deny "*" db
ตรวจสอบ intentions ที่มีอยู่:
consul intention list
consul intention check api db # เช็คว่า api เรียก db ได้ไหมตาม intentions ปัจจุบัน
exposeเมื่อใช้ mode = "bridge" traffic ทุกอย่างที่เข้าสู่ task จะถูก sidecar ดักไว้หมด ซึ่งรวมถึง HTTP health check ที่ Consul agent ยิงเข้ามาด้วย — ถ้า health check endpoint (เช่น /health) ไม่ได้อยู่ในเส้นทางที่ mesh อนุญาต Consul checker อาจยิงเข้าไม่ถึง ต้องใช้ expose block เปิด path นั้นให้เข้าถึงได้โดยไม่ต้องผ่าน mTLS handshake เต็มรูปแบบ:
group "api" {
network {
mode = "bridge"
port "healthcheck" {
to = -1 # dynamic port เฉพาะไว้ให้ expose ใช้
}
}
service {
name = "api"
port = "3000"
connect {
sidecar_service {
proxy {
expose {
path {
path = "/health"
protocol = "http"
local_path_port = 3000
listener_port = "healthcheck"
}
}
}
}
}
check {
type = "http"
path = "/health"
port = "healthcheck" # เช็คผ่าน path ที่ expose ไว้ ไม่ใช่ port หลักของ service
interval = "10s"
timeout = "2s"
}
}
task "api-server" {
...
}
}
# ดู service ที่ลงทะเบียนใน Consul ทั้งหมด (รวม sidecar proxy ที่ลงทะเบียนคู่กัน)
consul catalog services
# sidecar proxy คือ task แยกต่างหากในอัลโลเคชันเดียวกัน ชื่อ connect-proxy-<service>
nomad alloc status <alloc-id>
# ดู log ของ Envoy sidecar โดยตรง (debug ปัญหา mTLS handshake, routing)
nomad alloc logs <alloc-id> connect-proxy-api
# เข้าไปดู Envoy admin API ภายใน sidecar (สถิติ connection, cluster ที่รู้จัก ฯลฯ)
nomad alloc exec -i -t <alloc-id> connect-proxy-api /bin/sh
# ภายใน shell ของ sidecar:
curl -s localhost:19000/clusters
curl -s localhost:19000/stats | grep upstream
# เช็คว่า Consul ออก cert และ Connect enabled ให้ service นั้นจริงหรือยัง
consul connect ca get-config
ทริค: ถ้า sidecar proxy ขึ้นสถานะ unhealthy หรือ connection ระหว่าง service หลุดบ่อย ให้เช็ค 3 จุดนี้ก่อนเสมอ — (1) connect { enabled = true } เปิดอยู่ในทุก Consul server จริงหรือไม่ (2) CNI plugins ติดตั้งครบตามหัวข้อ 11.8.1 หรือยัง (3) intentions (หัวข้อ 11.8.6) บล็อกการเชื่อมต่อไว้อยู่หรือเปล่า — ทั้งสามจุดนี้คือสาเหตุที่พบบ่อยที่สุดของปัญหา Connect mesh ใช้งานไม่ได้
| ฟีเจอร์ | Docker Swarm | Nomad + Consul |
|---|---|---|
| Network ข้ามโหนด | Overlay network (VXLAN) สร้างอัตโนมัติต่อ stack | ใช้ host network + Consul service discovery หรือ Consul Connect mesh (ไม่ต้องมี overlay tunnel) |
| Service-to-service discovery | Swarm internal DNS | Consul DNS (*.service.consul) + KV store |
| Encryption ระหว่าง service | ต้องเปิด --opt encrypted ต่อ overlay network เอง |
Consul Connect ให้ mTLS อัตโนมัติผ่าน sidecar โดยไม่ต้องตั้งค่า network แยก |
| Port isolation ต่อ instance | Published port ระดับ service | bridge mode + dynamic port ต่อ allocation |
| Custom network topology | จำกัดเฉพาะ overlay/bridge ที่ Docker รองรับ | เปิดผ่าน CNI plugin ได้อิสระกว่า |
| ฟีเจอร์ | Docker Swarm | Nomad + Consul |
|---|---|---|
| Container runtime ที่รองรับ | Docker engine เท่านั้น | podman, docker, containerd, exec, raw_exec, Java, qemu ฯลฯ (ผ่าน task driver) |
| Service discovery | Swarm internal DNS | Consul catalog + DNS + KV store (ใช้ประโยชน์อื่นได้ด้วย เช่น app config) |
| Scheduling constraints | จำกัด (label constraints พื้นฐาน) | Constraint, affinity, spread, device plugin (GPU) ละเอียดกว่า |
| Multi-datacenter/region | ทำได้ยาก | ออกแบบมาเพื่อ multi-datacenter federation โดยตรง |
| Rolling update / rollback | มีในตัว | มีในตัว พร้อม canary deployment |
| Non-container workload | ไม่รองรับ | รองรับผ่าน raw_exec/exec driver |
| การพัฒนา/อัปเดตของผู้ผลิต | ช้าลงต่อเนื่องหลายปี | ยังพัฒนาต่อเนื่องสม่ำเสมอ |
| ความซับซ้อนในการติดตั้งเริ่มต้น | ต่ำมาก (docker swarm init) |
สูงกว่าเล็กน้อย (ต้องตั้งค่า Consul แยกจาก Nomad) |
sudo zypper dup -y แล้ว reboot ทีละโหนด (sudo zypper dup -y && sudo reboot) เพื่อให้ Consul/Nomad quorum ยังอยู่ครบ 2 ใน 3 เสมอระหว่างอัปเดต หากอัปเดตแล้วมีปัญหาใช้ snapper list ดู snapshot แล้ว snapper rollback กลับเวอร์ชันก่อนหน้าได้ทันที/var/opt/consul/data, /var/opt/nomad/data) เป็นประจำ โดยเฉพาะก่อนอัปเกรดเวอร์ชันใหญ่service block เสมอ — เป็นข้อผิดพลาดที่พบบ่อยเวลาย้ายจาก Swarm เพราะ Swarm auto-register DNS ให้ทุก service โดยไม่ต้องประกาศอะไรเพิ่ม แต่ Nomad ไม่ทำให้อัตโนมัติnomad node status และ consul members หลัง reboot ทุกครั้งเพื่อยืนยันว่าโหนดกลับเข้า cluster ครบเวิร์กโหลดบางประเภทต้องเข้าถึง hardware หรือ namespace ของ host โดยตรง ซึ่งเกินขอบเขตที่ task driver config ทั่วไปของ Nomad ออกแบบไว้ให้ (ตั้งใจให้ restrictive เพื่อความปลอดภัยเป็นค่าเริ่มต้น) หัวข้อนี้รวบรวม 3 กรณีที่พบบ่อยที่สุด พร้อมวิธีแก้ในแต่ละแบบ
privileged + cgroup: host + bind mount /sys/fs/cgroup)ตัวอย่าง docker-compose ทั่วไปของ DOMjudge judgehost (ระบบตัดสินอัตโนมัติสำหรับการแข่งขันเขียนโปรแกรม) มักหน้าตาแบบนี้:
services:
judgehost:
image: domjudge/judgehost:latest
privileged: true
cgroup: host
environment:
DAEMON_ID: 1
DOMSERVER_BASEURL: https://domjudge.xxx.dev/
JUDGEDAEMON_USERNAME: judge
JUDGEDAEMON_PASSWORD: password1234
CONTAINER_TIMEZONE: Asia/Bangkok
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
restart: unless-stopped
deploy:
replicas: 20
resources:
limits:
cpus: '2'
memory: 4G
โดยธรรมชาติของ judgehost มันต้องสร้าง/จัดการ cgroup ของ host จริงเพื่อ sandbox โค้ดที่ผู้เข้าแข่งขันส่งเข้ามา จึงต้องการสิทธิ์สูงกว่า container ทั่วไปมาก มาดูกันทีละ field:
| Docker Compose | Nomad (driver ปกติ) | หมายเหตุ |
|---|---|---|
privileged: true |
config { privileged = true } (ทั้ง docker และ podman driver รองรับ) |
docker driver ต้องเปิด allow_privileged = true ที่ plugin config ของทุก client ก่อน ไม่งั้น job ถูกปฏิเสธ |
volumes: - /sys/fs/cgroup:/sys/fs/cgroup:rw |
config { volumes = ["/sys/fs/cgroup:/sys/fs/cgroup:rw"] } |
podman driver เปิด volumes.enabled เป็น true เป็นค่าเริ่มต้นอยู่แล้ว |
cgroup: host |
ไม่มี field เทียบเท่าโดยตรงในทั้งสอง driver | ต้องใช้ทางอ้อม ดูด้านล่าง |
จุดที่ติดคือ cgroup: host ไม่มี field ให้ตั้งใน HCL config ของทั้ง docker driver และ podman driver เลย — เรื่องนี้สำคัญกว่าที่คิดด้วย เพราะ podman เองมี default cgroup namespace ต่างกันตาม cgroup version: บน cgroups v1 default เป็น host แต่บน cgroups v2 (ซึ่งเป็นค่ามาตรฐานของ Tumbleweed ปัจจุบัน) default เป็น private — แปลว่าถ้าไม่ตั้งอะไรเพิ่ม container จะไม่เห็น cgroup hierarchy จริงของ host และ judgehost จะทำงานไม่ได้
วิธีแก้: สลับ task นี้ไปใช้ raw_exec driver แล้วสั่ง podman run เองแบบเต็ม flag
เมื่อ structured driver config ไม่มี field ที่ต้องการ วิธีมาตรฐานของ Nomad คือให้ Nomad สั่ง podman run ตรง ๆ ผ่าน raw_exec (มีอยู่แล้วใน driver.allowlist ของ nomad.hcl ตามที่ตั้งไว้ในหัวข้อ 6.2):
job "domjudge-judgehost" {
datacenters = ["dc1"]
type = "service"
group "judgehost" {
count = 20 # เทียบเท่า replicas: 20
# แนะนำแยกโหนดเฉพาะสำหรับงานนี้ เพราะ privileged + cgroupns=host
# คือสิทธิ์ระดับเกือบเท่า root บน host จริง ไม่ควรปนกับ workload อื่น
constraint {
attribute = "${node.class}"
value = "judge-runner"
}
restart {
attempts = 3
interval = "5m"
delay = "15s"
mode = "delay" # restart ไม่เลิกแม้ครบโควตา ใกล้เคียง restart: unless-stopped ของ Swarm
}
task "judgehost" {
driver = "raw_exec"
config {
command = "/usr/bin/podman"
args = [
"run", "--rm",
"--name", "judgehost-${NOMAD_ALLOC_INDEX}",
"--privileged",
"--cgroupns=host",
"-v", "/sys/fs/cgroup:/sys/fs/cgroup:rw",
"-e", "DAEMON_ID=${NOMAD_ALLOC_INDEX}",
"-e", "DOMSERVER_BASEURL=https://domjudge.xxx.dev/",
"-e", "JUDGEDAEMON_USERNAME=judge",
"-e", "JUDGEDAEMON_PASSWORD=password1234",
"-e", "CONTAINER_TIMEZONE=Asia/Bangkok",
"docker.io/domjudge/judgehost:latest",
]
}
resources {
cores = 2 # เทียบเท่า cpus: '2' — จองทั้งคอร์แทนแบ่ง MHz
memory = 4096 # เทียบเท่า memory: 4G
}
}
}
}
จุดที่ควรรู้เพิ่ม:
${NOMAD_ALLOC_INDEX} ให้เลข index 0-19 อัตโนมัติต่อ replica — สะดวกกว่า Swarm ตรงที่ไม่ต้องพึ่ง template พิเศษเพื่อให้แต่ละ instance มี DAEMON_ID ไม่ซ้ำกัน (ของเดิมใน compose ใส่ DAEMON_ID: 1 ตายตัว ซึ่งถ้า scale จริงทุก replica จะได้ค่าเดียวกันหมด)resources.cores = 2 คือการจองคอร์ทั้งคอร์แบบ exclusive ใกล้เคียงความหมายของ cpus: '2' มากกว่าการกะ MHz เดา ๆraw_exec ที่ shell ออกไปเรียก podman run ตรง ๆ log ยังดูผ่าน nomad alloc logs ได้ปกติ (stdout/stderr ของ process podman เอง) แต่ nomad alloc exec จะเข้าไปข้าง ใน container ไม่ได้เหมือน driver ปกติ ต้องใช้ podman exec ตรงจาก host แทนถ้าต้อง debug ข้างในคำเตือนเรื่องความปลอดภัย: เพราะแต่ละ instance มีสิทธิ์ระดับ root บน host จริง (privileged + cgroupns=host + เขียน
/sys/fs/cgroupได้) นี่คือความเสี่ยงด้าน security ที่สูงกว่า workload อื่นทั้งหมดในไฟล์นี้ การแยกโหนดด้วยconstraintตามตัวอย่างข้างต้นไม่ใช่แค่ best practice แต่ควรทำจริงจัง — อย่าปล่อยให้ judgehost วิ่งปนกับโหนดที่รัน service อื่นของระบบ
Nomad มี device plugin framework (ตั้งแต่ Nomad 0.9) ให้ scheduler รู้จักและจัดสรร hardware device อย่าง GPU ให้ task ได้โดยตรง คล้ายกับที่ CPU/memory/disk ถูกจัดการอยู่แล้ว ปลั๊กอินหลักที่ HashiCorp ดูแลเองคือ nomad-device-nvidia สำหรับ Nvidia GPU โดยเฉพาะ
Prerequisites (บนโหนดที่มี GPU):
nvidia-smiหมายเหตุเรื่อง driver: ตัวอย่างและเอกสารทางการทั้งหมดของ
nomad-device-nvidiaผูกกับdriver = "docker"โดยเฉพาะ เพราะพึ่ง NVIDIA Container Toolkit ที่ hook เข้ากับ docker daemon โดยตรง — ถ้าใช้ podman driver เป็นหลักเหมือนในไฟล์นี้ ไม่มีเอกสารยืนยันอย่างเป็นทางการว่าใช้ร่วมกับ podman driver ได้ราบรื่นเท่ากัน แนะนำให้สลับเฉพาะ task ที่ต้องใช้ GPU มาใช้driver = "docker"แทน (Nomad รันหลาย driver ผสมกันในคลัสเตอร์เดียวได้ปกติ ไม่ต้องเปลี่ยนทั้งคลัสเตอร์)
ขั้นตอนติดตั้ง:
# 1. ติดตั้ง Nvidia driver + NVIDIA Container Toolkit บนโหนดที่มี GPU ก่อน (ตามเอกสารของ Nvidia)
# 2. ทดสอบว่า docker มองเห็น GPU แล้ว
docker run --rm --gpus all nvidia/cuda:12.8.1-base-ubuntu22.04 nvidia-smi
# 3. ดาวน์โหลด plugin binary ไปไว้ที่ plugin_dir ของ Nomad
sudo mkdir -p /opt/nomad/plugins
curl -fsSL -o /tmp/nomad-device-nvidia.zip \
https://releases.hashicorp.com/nomad-device-nvidia/1.1.0/nomad-device-nvidia_1.1.0_linux_amd64.zip
sudo unzip /tmp/nomad-device-nvidia.zip -d /opt/nomad/plugins/
เพิ่ม config ใน nomad.hcl ของโหนดที่มี GPU:
plugin "nomad-device-nvidia" {
config {
enabled = true
ignored_gpu_ids = []
fingerprint_period = "1m"
}
}
sudo systemctl restart nomad
nomad node status -stats <node-id> # ควรเห็น device nvidia/gpu/<รุ่น GPU> อยู่ในรายการ
ตัวอย่าง job spec ที่ขอใช้ GPU:
job "gpu-inference" {
datacenters = ["dc1"]
type = "batch"
group "worker" {
constraint {
attribute = "${node.class}"
value = "gpu-node" # แนะนำแยกโหนดที่มี GPU ออกจากโหนดอื่นด้วย node_class
}
task "infer" {
driver = "docker"
config {
image = "nvidia/cuda:12.8.1-base-ubuntu22.04"
command = "nvidia-smi"
}
resources {
cpu = 2000
memory = 4096
device "nvidia/gpu" {
count = 1
# เลือกรุ่นเฉพาะได้ผ่าน affinity/constraint บน device attribute
affinity {
attribute = "${device.model}"
value = "Tesla K80"
weight = 50
}
}
}
}
}
}
nomad job run gpu-inference.nomad.hcl
nomad alloc logs <alloc-id> # ควรเห็น output ของ nvidia-smi พร้อมรายละเอียด GPU
Nomad ไม่มี device plugin อย่างเป็นทางการสำหรับ AMD/Intel GPU หรือสำหรับ workload ที่ใช้ Vulkan ทั่วไป (ต่างจาก Nvidia ที่ HashiCorp ดูแล device plugin ให้เอง) ในทางปฏิบัติ Vulkan บน Linux ทำงานผ่าน Mesa driver (RADV สำหรับ AMD, ANV สำหรับ Intel) ซึ่งเข้าถึง GPU ผ่าน DRM device node ธรรมดา (/dev/dri/renderD*) ไม่ต้องพึ่ง runtime พิเศษแบบ NVIDIA Container Toolkit — วิธีที่ใช้ได้คือ device passthrough แบบ generic ผ่าน field devices ที่มีอยู่แล้วในทั้ง docker driver และ podman driver
ขั้นที่ 1 — เช็คว่า host มองเห็น GPU ผ่าน DRM หรือยัง:
ls -l /dev/dri/
# ควรเห็น renderD128 (หรือเลขอื่น) และ card0
vulkaninfo --summary # ถ้าติดตั้งแพ็กเกจ vulkan-tools แล้วควรแสดงชื่อ GPU ที่เจอ
stat -c '%g' /dev/dri/renderD128 # ดู GID ของกลุ่มที่เป็นเจ้าของ device node (ปกติคือ render หรือ video)
ขั้นที่ 2 — job spec ส่ง device เข้า container ผ่าน podman driver:
group "vulkan-worker" {
constraint {
attribute = "${node.class}"
value = "gpu-node"
}
task "vulkan-app" {
driver = "podman"
config {
image = "myrepo/vulkan-app:latest"
devices = [
"/dev/dri/renderD128:/dev/dri/renderD128:rwm",
"/dev/dri/card0:/dev/dri/card0:rwm",
]
}
resources {
cpu = 1000
memory = 2048
}
}
}
ข้อควรรู้:
- วิธีนี้ทำงานได้กับทุก vendor ที่ผ่าน Mesa (AMD RADV, Intel ANV) รวมถึงงาน VAAPI/hardware transcoding ทั่วไปด้วย (เช่นที่ Jellyfin/Plex ใช้)
- ต่างจาก Nvidia ตรงที่ Nomad scheduler ไม่รู้จัก GPU ตัวนี้ในฐานะ resource — ไม่มี fingerprinting ไม่มี
devicestanza ให้ผูก affinity/count เหมือนหัวข้อ 14.2 ดังนั้นถ้ามีหลาย task แย่งใช้ GPU ตัวเดียวกันบนโหนดเดียวกัน Nomad จะไม่ป้องกัน over-provision ให้ ต้องคุมเองผ่านconstraintและจำกัดจำนวน allocation ต่อโหนดให้เหมาะสม- ถ้า container รันด้วย non-root user ต้องเช็คว่า user นั้นมีสิทธิ์เข้าถึง device node ตาม permission ที่ตั้งไว้ (
rwm) — วิธีที่ตรงไปตรงมาที่สุดคือกำหนดuserของ task ให้ตรงกับ UID/GID ที่มีสิทธิ์บน host (ดูค่าจากstat -c '%g' /dev/dri/renderD128ในขั้นที่ 1) หรือรันเป็น root ถ้ายอมรับความเสี่ยงนั้นได้- image ที่ใช้ต้องมี userspace driver stack ตรงกับที่ต้องการเอง (เช่นแพ็กเกจ
mesa-vulkan-drivers,vulkan-tools) — Mesa ค่อนข้าง forgiving เรื่อง version mismatch ระหว่าง host kernel driver กับ userspace ในคอนเทนเนอร์ เทียบกับ Nvidia ที่มักต้องการให้เวอร์ชันตรงกันเป๊ะกว่า
| กรณี | Field ที่ขาดจาก driver ปกติ | วิธีแก้ | Driver ที่แนะนำ |
|---|---|---|---|
| DOMjudge judgehost | cgroup: host |
raw_exec + เรียก podman run --cgroupns=host เอง |
raw_exec |
| Nvidia GPU (CUDA/compute) | ไม่มี device plugin ติดตั้งมาให้ ต้องเพิ่มเอง | ติดตั้ง nomad-device-nvidia + NVIDIA Container Toolkit |
docker |
| Vulkan / AMD / Intel GPU | ไม่มี device plugin อย่างเป็นทางการเลย | ส่ง /dev/dri/* ผ่าน field devices ตรง ๆ |
docker หรือ podman |
การย้ายจาก Docker Swarm มาใช้ Nomad ร่วมกับ Consul บน openSUSE Tumbleweed ให้ผลลัพธ์ที่:
zypper ได้ตรง ๆ ผ่าน nomad-driver-podman โดยไม่ต้องติดตั้ง Docker daemon เพิ่มtype = "system"), shared storage แบบ self-hosted (GlusterFS/JuiceFS ผ่าน host_volume หรือ CSI), และ service-to-service encryption แบบ zero-code ผ่าน Consul Connectcluster ขนาด 3 โหนดตามตัวอย่างนี้เพียงพอสำหรับ HA ระดับ lab/small production และสามารถขยายเพิ่มโหนด client (โดยไม่ต้องเป็น server) ได้ในอนาคตโดยไม่กระทบ quorum เดิม