/static/codemoomoo2.png

การใช้ HashiCorp Nomad ร่วมกับ Consul บน openSUSE Tumbleweed เพื่อทดแทน Docker Swarm

บทความนี้อธิบายแนวทางการสร้าง 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) และดูแลรักษาง่ายกว่าในระยะยาว

สารบัญ

  1. ทำไมต้องย้ายออกจาก Docker Swarm
  2. ทำไมต้อง openSUSE Tumbleweed
  3. ภาพรวมสถาปัตยกรรม
  4. เตรียมเครื่อง Tumbleweed ทั้ง 3 ตัว
  5. ติดตั้งและตั้งค่า Consul
  6. ติดตั้งและตั้งค่า Nomad
  7. เชื่อม Nomad เข้ากับ Consul
  8. ตัวอย่าง Job: Deploy Web App แบบ Docker Swarm เดิม
  9. โครงสร้าง Nomad Job Spec โดยละเอียด เทียบกับ Docker Stack YAML
  10. คำสั่งจัดการ Nomad ที่ควรรู้ เทียบกับคำสั่ง Docker พร้อมทริค
  11. การจัดการ Volume และ Network ใน Nomad โดยละเอียด
  12. เทียบฟีเจอร์ Docker Swarm vs Nomad+Consul
  13. ข้อควรระวังและแนวทางดูแลรักษา
  14. เวิร์กโหลดที่ต้องมีสิทธิ์พิเศษ: Judgehost, GPU และ Vulkan
  15. สรุป

1. ทำไมต้องย้ายออกจาก Docker Swarm

Docker Swarm ยังใช้งานได้และเรียบง่ายดี แต่มีข้อจำกัดที่ชัดเจนขึ้นเรื่อย ๆ เมื่อ workload โตขึ้น:

Nomad + Consul ตอบโจทย์เหล่านี้ได้ดีขึ้น โดยยังคงความเรียบง่ายในการติดตั้งไว้ใกล้เคียงกับ Swarm (ทั้งคู่เป็น single binary เขียนด้วย Go ไม่ต้องพึ่ง database ภายนอก)

2. ทำไมต้อง openSUSE Tumbleweed

Tumbleweed เป็น rolling-release distro ของ openSUSE ที่แต่ละแพ็กเกจผ่านการทดสอบอัตโนมัติด้วย openQA ก่อนปล่อยเข้า repo ทำให้ได้ทั้งความใหม่และความเสถียรในเวลาเดียวกัน จุดเด่นที่เหมาะกับการเป็น Nomad/Consul host:

ข้อควรรู้: เพราะ Tumbleweed เป็น rolling release ควรอัปเดตด้วย sudo zypper dup (dist-upgrade) แทน zypper update ธรรมดา เพื่อให้จัดการการเปลี่ยนแปลง dependency ระหว่างเวอร์ชันแพ็กเกจได้ถูกต้อง และควร reboot หลังอัปเดตทุกครั้งที่ kernel เปลี่ยนเวอร์ชัน (เช็คด้วย zypper ps -s ว่ามี process ไหนยังใช้ library เก่าค้างอยู่หรือไม่)

3. ภาพรวมสถาปัตยกรรม

ตัวอย่างนี้ใช้เครื่อง 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 แยกส่วนกันได้ง่ายกว่า

4. เตรียมเครื่อง Tumbleweed ทั้ง 3 ตัว

ทำตามขั้นตอนนี้ บนทุกโหนด (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

5. ติดตั้งและตั้งค่า Consul

5.1 สร้าง encryption key และ config ร่วม

ทำครั้งเดียวบนเครื่องใดเครื่องหนึ่ง แล้วคัดลอกค่า key ไปใช้ในทุกโหนด:

consul keygen
# ตัวอย่างผลลัพธ์: 8gG3s+2n5xY1zJvQdR7hQmWn9F0kL4pXoT6cVb2Ns0A=

5.2 ไฟล์ config /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) ในอนาคต
}

5.3 systemd unit /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 หลังรันครบทุกเครื่อง

6. ติดตั้งและตั้งค่า Nomad

6.1 สร้าง Gossip key ของ Nomad (คนละชุดกับ Consul)

nomad operator gossip keyring generate

คำสั่งนี้จะสุ่ม key แบบ base64 มาให้หนึ่งชุด ให้คัดลอกค่าไปใส่ในฟิลด์ encrypt ของทุกเครื่องที่เป็น Nomad server (ค่าต้องเหมือนกันทุกเครื่อง) — key จะถูก persist ไว้ใน data directory ของแต่ละ agent เองหลัง start ครั้งแรก จึงไม่จำเป็นต้องส่งค่านี้ซ้ำอีกหลัง cluster bootstrap เสร็จแล้ว

6.2 ไฟล์ config /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
    }
  }
}

6.3 ติดตั้ง podman driver plugin

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 เพิ่มอีก

6.4 systemd unit /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

7. เชื่อม Nomad เข้ากับ Consul

หลังตั้งค่า consul { address = "127.0.0.1:8500" } ใน nomad.hcl แล้ว Nomad จะ:

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

8. ตัวอย่าง Job: Deploy Web App แบบ Docker Swarm เดิม

สมมติเดิมใน 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 เดิม

9. โครงสร้าง Nomad Job Spec โดยละเอียด (เทียบกับ Docker Stack YAML)

บทนี้อธิบายโครงสร้างไฟล์ job spec ของ Nomad ทีละชั้น พร้อมเทียบ block ต่อ block กับไฟล์ YAML ที่ใช้กับ docker stack deploy / docker-compose.yml เพื่อให้ผู้ที่คุ้นเคยกับ Docker Swarm อ่าน job spec ของ Nomad ได้เร็วขึ้น

9.1 ลำดับชั้นของ Job Spec: Job → Group → Task

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 ต้องทำ

9.2 ตารางเทียบ stanza หลักของ Nomad กับ key ใน Docker Compose/Stack YAML

ระดับ 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 แทน

9.3 ตัวอย่างเทียบเต็มรูปแบบ: 2 services (web + api)

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 ให้ group api: ต้นฉบับของตัวอย่างนี้มักถูกเขียนโดยไม่มี service block ในกลุ่ม api เลย (มีแต่ volume กับ task) ซึ่งถ้าเขียนแบบนั้นจริง nginx ใน group web จะ resolve ชื่อ api.service.consul ไม่ได้เลย เพราะไม่มีใครลงทะเบียน service ชื่อ api กับ Consul — ต่างจาก Docker Swarm ที่ทุก service ถูกใส่เข้า internal DNS ให้อัตโนมัติโดยไม่ต้องประกาศอะไรเพิ่ม การเพิ่ม network block (เพื่อจองพอร์ตให้ Nomad รู้จัก) และ service block (เพื่อลงทะเบียนกับ Consul พร้อม health check) จึงจำเป็นเสมอสำหรับทุก group ที่ต้องการให้ group อื่นเรียกผ่าน Consul DNS ได้ — ในตัวอย่างนี้ตั้งพอร์ตเป็น static = 3000 เพื่อให้ตรงกับค่าที่ API_URL hardcode ไว้ตายตัว ถ้าต้องการใช้ dynamic port (ปล่อยให้ Nomad สุ่มพอร์ตให้ตามคำแนะนำในหัวข้อ 11.7) ฝั่ง web ต้อง discover พอร์ตจริงผ่าน Consul (เช่น query DNS แบบ SRV record หรือใช้ template stanza ดึงค่าจาก Consul catalog) แทนการ hardcode :3000 ไว้ตรง ๆ

หมายเหตุเรื่อง volume: Docker Compose ใช้ named volume ที่ Docker engine จัดการให้อัตโนมัติ ส่วน Nomad ต้องประกาศ host_volume ล่วงหน้าในไฟล์ config ของ client (nomad.hcl) ก่อน job จึงจะ mount ได้ ตัวอย่างเช่น เพิ่ม block นี้ในทุกโหนดที่อาจรัน task api-server:

client {
  host_volume "api-data" {
    path      = "/var/opt/nomad/volumes/api-data"
    read_only = false
  }
}

ถ้าต้องการ volume ที่ follow container ไปได้ทุกโหนดแบบ dynamic (ไม่ผูกกับโหนดใดโหนดหนึ่งตายตัว) ต้องใช้ CSI plugin แทน host_volume

9.4 สรุปความต่างเชิงแนวคิด

หลังจากเข้าใจโครงสร้างนี้แล้ว การเขียน job spec เพิ่มเติมสำหรับ workload อื่น ๆ จะทำได้โดยเพิ่ม group/task ใหม่ในไฟล์เดียวกัน หรือแยกเป็นไฟล์ job ใหม่ตามความเหมาะสมของแต่ละแอปพลิเคชัน

9.5 type = "system" — เทียบเท่ากับ Docker Swarm Global Mode

Swarm มีโหมด deploy.mode: global สำหรับ service ที่ต้องรันบนทุกโหนดในคลัสเตอร์เสมอ (เช่น log shipper, monitoring agent, security agent) — ฝั่ง Nomad เทียบเท่ากับการตั้ง type = "system" ที่ระดับ job

หลักการทำงาน:

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 รันจบแล้วหยุด แต่รันครบทุกโหนด ไม่มีเทียบเท่าตรง ๆ

10. คำสั่งจัดการ Nomad ที่ควรรู้ (เทียบกับคำสั่ง Docker) พร้อมทริค

บทนี้รวมคำสั่ง CLI ของ Nomad ที่ใช้บ่อยที่สุดในงานดูแลระบบประจำวัน โดยเทียบกับคำสั่ง Docker/Docker Swarm ที่คุ้นเคยอยู่แล้ว เพื่อให้ขึ้นมือได้เร็ว พร้อมทริคที่ควรรู้ไว้ใช้งานจริง

10.1 ตารางเทียบคำสั่ง Docker ↔ Nomad แบบเร็ว

ต้องการทำอะไร คำสั่ง 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)

10.2 คำสั่งจัดการ Job

# ตรวจสอบไฟล์ 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

10.3 คำสั่งดู Allocation, Log และ exec เข้าไปดีบัก

ใน 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/

10.4 คำสั่งจัดการ Node และ Cluster

# ดูสถานะ 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>

10.5 คำสั่ง Debug/Monitor ระดับคลัสเตอร์

# ดู 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

10.6 ทริคที่ควรรู้ไว้ใช้งานจริง

11. การจัดการ Volume และ Network ใน Nomad โดยละเอียด

บทนี้ลงรายละเอียดสองเรื่องที่ job spec ตัวอย่างในบทที่ 8-9 แตะไว้แค่ผิวเผิน คือการจัดการ volume (ที่เก็บข้อมูล persist) และ network (การเชื่อมต่อระหว่าง service) ซึ่งเป็นจุดที่ Nomad มีแนวคิดต่างจาก Docker/Swarm พอสมควร

11.1 ภาพรวมประเภท Volume ใน Nomad

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

11.2 ephemeral_disk — ตัวอย่าง config

group "web" {
  ephemeral_disk {
    size    = 300     # MB
    sticky  = true     # พยายามวาง alloc ใหม่กลับโหนดเดิมถ้าเป็นไปได้ (ลด cache miss)
    migrate = true     # ถ้าย้ายโหนดใหม่จริง ให้ copy ข้อมูลเดิมข้ามไปด้วย
  }
  ...
}

ไม่ต้อง mount อะไรเพิ่มในระดับ task — ข้อมูลจะอยู่ใต้ ${NOMAD_ALLOC_DIR} ให้ทุก task ใน group เดียวกันแชร์กันได้อัตโนมัติ

11.3 host_volume — ตัวอย่าง config เต็มรูปแบบ

ขั้นที่ 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 แทน

11.4 CSI volume — dynamic storage ข้ามโหนด

CSI (Container Storage Interface) เปิดให้ Nomad ต่อกับ storage backend ภายนอกได้หลากหลาย ขั้นตอนคร่าว ๆ:

  1. รัน CSI plugin เป็น Nomad job (มีทั้งแบบ controller และ node plugin)
  2. nomad volume register เพื่อขึ้นทะเบียน volume ที่มีอยู่แล้ว หรือ nomad volume create เพื่อสร้างใหม่แบบ dynamic
  3. อ้างอิง volume ID นั้นใน job spec ด้วย type = "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"
    }
    ...
  }
}

11.4.1 ตัวอย่างเต็มรูปแบบ: ต่อ Nomad เข้ากับ GlusterFS

เพราะสาย 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)

11.5 เทียบ Volume: Docker vs Nomad

ต้องการอะไร 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 เดิมได้เลย)

11.6 Nomad Networking Modes: host, bridge, cni

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

11.7 ตัวอย่าง config: static port, dynamic port, bridge mode

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

11.8 Consul Connect (service mesh) — เข้ารหัส traffic ระหว่าง service ด้วย mTLS ผ่าน sidecar proxy โดยอัตโนมัติ

เพราะเปิด connect { enabled = true } ไว้ใน Consul config ตั้งแต่บทที่ 5 แล้ว Nomad สามารถประกาศ sidecar proxy ให้แต่ละ service คุยกันผ่าน mTLS ได้ โดยตัวแอปพลิเคชันเองไม่ต้องรู้เรื่อง TLS/certificate เลยแม้แต่บรรทัดเดียว — หัวข้อนี้ขยายรายละเอียดของฟีเจอร์นี้แบบเต็มรูปแบบ

11.8.1 ข้อกำหนดเบื้องต้น: ติดตั้ง CNI plugins

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 ไม่ผ่านเลย

11.8.2 หลักการทำงานของ sidecar proxy

เมื่อประกาศ connect { sidecar_service {} } ใน service block ของ group ใดก็ตาม (group นั้นต้องตั้ง network { mode = "bridge" } ด้วย) Nomad จะ:

  1. สั่ง Consul ออก leaf certificate ให้ service นั้นโดยอัตโนมัติ (ออกจาก Consul CA ภายใน หรือจะเปลี่ยนไปใช้ Vault เป็น CA provider ก็ได้) พร้อม rotate cert ให้เป็นระยะโดยไม่ต้องยุ่งเอง
  2. รัน Envoy เป็น task เสริมอีกตัวหนึ่งในอัลโลเคชันเดียวกัน (มองเห็นเป็น task ชื่อ connect-proxy-<service-name> ใน nomad alloc status) — Envoy ตัวนี้คือ sidecar proxy
  3. Route traffic ทั้งขาเข้าและขาออกของ task ผ่าน Envoy sidecar โดยอัตโนมัติผ่าน iptables ภายใน network namespace ของ allocation (task ไม่รู้ตัวด้วยซ้ำว่ามี proxy คั่นอยู่)
  4. Sidecar proxy สองฝั่ง (ต้นทาง-ปลายทาง) เจรจา mTLS กันเองโดยใช้ certificate ที่ Consul ออกให้ — แอปพลิเคชันคุยกับ sidecar ของตัวเองผ่าน plain TCP บน 127.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 จัดการให้ทั้งหมด

11.8.3 connect-native vs sidecar proxy

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 มากที่สุด

11.8.4 ตัวอย่างที่ 1 — service เดี่ยวเปิด sidecar (ไม่มี upstream)

ใช้กับ 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
    }
  }
}

11.8.5 ตัวอย่างที่ 2 — เชื่อม 3 ชั้น web → api → db ผ่าน mesh ทั้งหมด

ขยายจากตัวอย่าง 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 ธรรมดาก็ได้

11.8.6 ควบคุมสิทธิ์การเข้าถึงด้วย Consul Intentions

การเปิด 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 ปัจจุบัน

11.8.7 เปิด path สำหรับ health check ผ่าน sidecar ด้วย 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" {
    ...
  }
}

11.8.8 ตรวจสอบ/debug Connect mesh

# ดู 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 ใช้งานไม่ได้

11.9 เทียบ Networking: Docker Swarm Overlay vs Nomad+Consul

ฟีเจอร์ 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 ได้อิสระกว่า

12. เทียบฟีเจอร์ Docker Swarm vs Nomad+Consul

ฟีเจอร์ 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)

13. ข้อควรระวังและแนวทางดูแลรักษา

14. เวิร์กโหลดที่ต้องมีสิทธิ์พิเศษ: Judgehost, GPU และ Vulkan

เวิร์กโหลดบางประเภทต้องเข้าถึง hardware หรือ namespace ของ host โดยตรง ซึ่งเกินขอบเขตที่ task driver config ทั่วไปของ Nomad ออกแบบไว้ให้ (ตั้งใจให้ restrictive เพื่อความปลอดภัยเป็นค่าเริ่มต้น) หัวข้อนี้รวบรวม 3 กรณีที่พบบ่อยที่สุด พร้อมวิธีแก้ในแต่ละแบบ

14.1 กรณีศึกษา: DOMjudge Judgehost (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
      }
    }
  }
}

จุดที่ควรรู้เพิ่ม:

คำเตือนเรื่องความปลอดภัย: เพราะแต่ละ instance มีสิทธิ์ระดับ root บน host จริง (privileged + cgroupns=host + เขียน /sys/fs/cgroup ได้) นี่คือความเสี่ยงด้าน security ที่สูงกว่า workload อื่นทั้งหมดในไฟล์นี้ การแยกโหนดด้วย constraint ตามตัวอย่างข้างต้นไม่ใช่แค่ best practice แต่ควรทำจริงจัง — อย่าปล่อยให้ judgehost วิ่งปนกับโหนดที่รัน service อื่นของระบบ

14.2 การใช้งาน GPU (Nvidia) ใน Nomad

Nomad มี device plugin framework (ตั้งแต่ Nomad 0.9) ให้ scheduler รู้จักและจัดสรร hardware device อย่าง GPU ให้ task ได้โดยตรง คล้ายกับที่ CPU/memory/disk ถูกจัดการอยู่แล้ว ปลั๊กอินหลักที่ HashiCorp ดูแลเองคือ nomad-device-nvidia สำหรับ Nvidia GPU โดยเฉพาะ

Prerequisites (บนโหนดที่มี GPU):

หมายเหตุเรื่อง 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

14.3 Vulkan และ GPU ที่ไม่ใช่ Nvidia (AMD, Intel) ผ่าน device passthrough

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
    }
  }
}

ข้อควรรู้:

14.4 สรุปเทียบ 3 กรณี

กรณี 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

15. สรุป

การย้ายจาก Docker Swarm มาใช้ Nomad ร่วมกับ Consul บน openSUSE Tumbleweed ให้ผลลัพธ์ที่:

cluster ขนาด 3 โหนดตามตัวอย่างนี้เพียงพอสำหรับ HA ระดับ lab/small production และสามารถขยายเพิ่มโหนด client (โดยไม่ต้องเป็น server) ได้ในอนาคตโดยไม่กระทบ quorum เดิม