Nomad
HashiCorp Nomad 完全ガイド — ワークロードオーケストレーションの全容
1. はじめに
1.1 Nomad とは何か
HashiCorp Nomad は、コンテナ化されたアプリケーションだけでなく、レガシーなバイナリ、Java アプリケーション、仮想マシンなど、あらゆる種類のワークロードをデプロイ・管理するための汎用ワークロードオーケストレーターである。Kubernetes がコンテナオーケストレーションに特化しているのに対し、Nomad は「あらゆるワークロードをあらゆるインフラストラクチャ上で実行する」という設計哲学を持つ。
Nomad は単一バイナリで動作し、軽量でありながらも大規模環境(数万ノード規模)に対応できるスケーラビリティを備えている。HashiCorp のエコシステム(Consul、Vault、Terraform)との緊密な統合により、サービスディスカバリ、シークレット管理、インフラストラクチャプロビジョニングをシームレスに実現できる。
1.2 Nomad が解決する課題
現代のインフラストラクチャ運用において、以下のような課題が存在する。
- 多様なワークロードの統一管理: コンテナだけでなく、レガシーアプリケーション、バッチジョブ、システムデーモンなどを単一のプラットフォームで管理する必要性
- マルチリージョン・マルチクラウド対応: 複数のデータセンターやクラウドプロバイダーにまたがるデプロイメント
- 運用の複雑さの軽減: Kubernetes のような重厚なオーケストレーターの学習コストと運用負荷
- 段階的な導入: 既存インフラへの影響を最小限に抑えた段階的なオーケストレーション導入
1.3 Nomad の主要な特徴
| 特徴 | 説明 |
|---|---|
| 単一バイナリ | 外部依存なし、軽量デプロイ |
| マルチワークロード | コンテナ、VM、バイナリ、Java など |
| マルチリージョン | ネイティブなマルチリージョンフェデレーション |
| 宣言的ジョブ仕様 | HCL によるジョブ定義 |
| Bin Packing | 効率的なリソース配置 |
| ローリングアップデート | ゼロダウンタイムデプロイ |
| サービスディスカバリ | Consul との統合 |
| シークレット管理 | Vault との統合 |
| ACL | きめ細かなアクセス制御 |
| Web UI | 組み込みダッシュボード |
1.4 Nomad と Kubernetes の比較
Nomad と Kubernetes はしばしば比較されるが、それぞれ異なる設計思想に基づいている。
| 観点 | Nomad | Kubernetes |
|---|---|---|
| アーキテクチャ | 単一バイナリ、軽量 | 多数のコンポーネント、複雑 |
| 対応ワークロード | コンテナ、VM、バイナリ等 | 主にコンテナ |
| 学習コスト | 比較的低い | 高い |
| スケーラビリティ | 数万ノード(単一クラスタ) | 数千ノード(単一クラスタ) |
| エコシステム | HashiCorp スタック中心 | CNCF エコシステム、巨大 |
| サービスメッシュ | Consul Connect | Istio, Linkerd 等 |
| パッケージ管理 | Nomad Pack | Helm |
| セットアップ難易度 | 低い | 高い |
| コミュニティ規模 | 中程度 | 非常に大きい |
Kubernetes は事実上の業界標準であるが、Nomad は以下のようなケースで特に有効である。
- レガシーアプリケーションのオーケストレーションが必要な場合
- 運用チームが小規模で、シンプルなソリューションが求められる場合
- HashiCorp スタックを既に利用している場合
- マルチリージョンデプロイが主要な要件である場合
2. アーキテクチャ
2.1 全体構成
Nomad のアーキテクチャは、**サーバー(Server)とクライアント(Client)**の2つの主要コンポーネントで構成される。
┌─────────────────────────────────────────────────────┐
│ Nomad Cluster │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Server │──│ Server │──│ Server │ (Raft) │
│ │ (Leader)│ │(Follower)│ │(Follower)│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────┴────────────┴────────────┴────┐ │
│ │ RPC / gRPC │ │
│ └────┬────────────┬────────────┬────┘ │
│ │ │ │ │
│ ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │
│ │ Client │ │ Client │ │ Client │ │
│ │ (Node) │ │ (Node) │ │ (Node) │ │
│ │┌───────┐│ │┌───────┐│ │┌───────┐│ │
│ ││ Alloc ││ ││ Alloc ││ ││ Alloc ││ │
│ ││ Alloc ││ ││ Alloc ││ ││ Alloc ││ │
│ │└───────┘│ │└───────┘│ │└───────┘│ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────┘
2.2 サーバー(Server)
サーバーはクラスタの「頭脳」であり、以下の責務を担う。
- ジョブの評価とスケジューリング: ジョブ仕様を受け取り、適切なノードへのワークロード配置を決定
- 状態管理: クラスタの全状態を Raft コンセンサスプロトコルで管理
- リーダー選出: Raft によるリーダー選出とフェイルオーバー
- API エンドポイント: CLI、UI、外部システムからのリクエストを処理
Raft コンセンサス
サーバーは Raft コンセンサスプロトコルを使用して、クラスタ状態の一貫性を保証する。推奨構成は3台または5台のサーバーで、過半数(クォーラム)が動作していれば、クラスタは正常に機能する。
# サーバー設定例
server {
enabled = true
bootstrap_expect = 3
# Raft のパフォーマンスチューニング
raft_protocol = 3
# サーバー間の暗号化
encrypt = "cg8StVXbQJ0gPvMd9o7yrg=="
# データディレクトリ
data_dir = "/opt/nomad/data"
# サーバージョイン
server_join {
retry_join = [
"nomad-server-1.example.com",
"nomad-server-2.example.com",
"nomad-server-3.example.com"
]
}
}
2.3 クライアント(Client)
クライアントは実際にワークロードを実行するノードであり、以下の役割を果たす。
- タスクの実行: サーバーから割り当てられたタスクをタスクドライバーを通じて実行
- リソース管理: CPU、メモリ、ディスク、ネットワークのリソースをサーバーに報告
- ヘルスチェック: 実行中のタスクの健全性を監視
- フィンガープリンティング: ノードの特性(OS、アーキテクチャ、利用可能なドライバーなど)を自動検出
# クライアント設定例
client {
enabled = true
# サーバーへの接続
servers = [
"nomad-server-1.example.com:4647",
"nomad-server-2.example.com:4647",
"nomad-server-3.example.com:4647"
]
# ノードクラス(スケジューリング制約に使用)
node_class = "compute-optimized"
# メタデータ(スケジューリング制約に使用)
meta {
rack = "rack-1"
zone = "us-east-1a"
team = "platform"
}
# ホストボリューム
host_volume "data" {
path = "/opt/data"
read_only = false
}
# リソース予約(OS 用)
reserved {
cpu = 500
memory = 512
disk = 1024
}
}
2.4 リージョンとデータセンター
Nomad はネイティブにマルチリージョン・マルチデータセンターをサポートする。
┌──────────────────────┐ ┌──────────────────────┐
│ Region: us-east │ │ Region: eu-west │
│ │ │ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ DC: us-east-1 │ │ │ │ DC: eu-west-1 │ │
│ │ Server × 3 │◄─┼─────┼─►│ Server × 3 │ │
│ │ Client × N │ │ │ │ Client × N │ │
│ └────────────────┘ │ │ └────────────────┘ │
│ │ │ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ DC: us-east-2 │ │ │ │ DC: eu-west-2 │ │
│ │ Client × N │ │ │ │ Client × N │ │
│ └────────────────┘ │ │ └────────────────┘ │
└──────────────────────┘ └──────────────────────┘
# リージョンとデータセンターの設定
datacenter = "us-east-1"
region = "us-east"
# 他リージョンとのフェデレーション
server {
enabled = true
bootstrap_expect = 3
# リージョン間通信
server_join {
retry_join = ["provider=aws tag_key=nomad-server tag_value=true"]
}
}
2.5 通信とポート
Nomad は以下のポートを使用する。
| ポート | プロトコル | 用途 |
|---|---|---|
| 4646 | HTTP | API、UI |
| 4647 | RPC | サーバー間、クライアント-サーバー間通信 |
| 4648 | Serf (TCP/UDP) | ゴシッププロトコル(WAN/LAN) |
# ポートとアドレスの設定
addresses {
http = "0.0.0.0"
rpc = "0.0.0.0"
serf = "0.0.0.0"
}
ports {
http = 4646
rpc = 4647
serf = 4648
}
# TLS 設定
tls {
http = true
rpc = true
ca_file = "/opt/nomad/tls/ca.pem"
cert_file = "/opt/nomad/tls/server.pem"
key_file = "/opt/nomad/tls/server-key.pem"
verify_server_hostname = true
verify_https_client = true
}
3. ジョブ仕様(Job Specification)
3.1 ジョブの階層構造
Nomad のジョブ仕様は階層的な構造を持ち、以下のように整理される。
Job
├── Group (Task Group)
│ ├── Task
│ │ ├── Driver (docker, exec, java, etc.)
│ │ ├── Resources (CPU, Memory, Network)
│ │ ├── Artifacts
│ │ ├── Templates
│ │ └── Services
│ ├── Task
│ ├── Network
│ ├── Volume
│ ├── Scaling
│ └── Service
├── Group
│ └── ...
├── Constraint
├── Affinity
├── Spread
└── Update
3.2 ジョブタイプ
Nomad は4種類のジョブタイプを提供する。
Service ジョブ
長時間実行されるサービス。デフォルトのジョブタイプ。
job "web-api" {
datacenters = ["us-east-1", "us-east-2"]
type = "service"
# アップデート戦略
update {
max_parallel = 2
min_healthy_time = "30s"
healthy_deadline = "5m"
auto_revert = true
canary = 1
}
# マイグレーション戦略
migrate {
max_parallel = 1
health_check = "checks"
min_healthy_time = "15s"
healthy_deadline = "5m"
}
group "api" {
count = 6
# ネットワーク設定
network {
port "http" {
to = 8080
}
port "metrics" {
to = 9090
}
}
# サービス登録(Consul 連携)
service {
name = "web-api"
port = "http"
tags = ["urlprefix-/api"]
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "3s"
}
# Consul Connect (サービスメッシュ)
connect {
sidecar_service {
proxy {
upstreams {
destination_name = "database"
local_bind_port = 5432
}
}
}
}
}
# ボリューム
volume "data" {
type = "host"
source = "data"
read_only = false
}
# リスケジュール戦略
reschedule {
attempts = 10
interval = "24h"
delay = "30s"
delay_function = "exponential"
max_delay = "1h"
unlimited = false
}
task "api-server" {
driver = "docker"
config {
image = "registry.example.com/web-api:v2.1.0"
ports = ["http", "metrics"]
# Docker 固有の設定
labels {
service = "web-api"
env = "production"
}
# ログ設定
logging {
type = "json-file"
config {
max-size = "10m"
max-file = "3"
}
}
# ulimit 設定
ulimit {
nofile = "65536:65536"
}
}
# ボリュームマウント
volume_mount {
volume = "data"
destination = "/app/data"
}
# リソース制約
resources {
cpu = 1000 # MHz
memory = 512 # MB
}
# 環境変数
env {
APP_ENV = "production"
LOG_LEVEL = "info"
DB_HOST = "${NOMAD_UPSTREAM_ADDR_database}"
}
# テンプレート(Consul / Vault 連携)
template {
data = <<-EOF
{{- with secret "secret/data/web-api" }}
DB_PASSWORD={{ .Data.data.db_password }}
API_KEY={{ .Data.data.api_key }}
{{- end }}
{{- range service "redis" }}
REDIS_ADDR={{ .Address }}:{{ .Port }}
{{- end }}
EOF
destination = "secrets/env.txt"
env = true
change_mode = "restart"
}
# Vault 連携
vault {
policies = ["web-api"]
}
# ログローテーション
logs {
max_files = 10
max_file_size = 50
}
# シグナル設定
kill_timeout = "30s"
kill_signal = "SIGTERM"
# リスタートポリシー
restart {
attempts = 3
interval = "5m"
delay = "15s"
mode = "delay"
}
}
# サイドカータスク
task "log-shipper" {
driver = "docker"
config {
image = "fluent/fluent-bit:latest"
}
resources {
cpu = 100
memory = 128
}
lifecycle {
hook = "poststart"
sidecar = true
}
}
}
}
Batch ジョブ
短期間のタスクやバッチ処理に使用する。
job "data-pipeline" {
datacenters = ["us-east-1"]
type = "batch"
# 定期実行(cron)
periodic {
cron = "0 */6 * * *"
prohibit_overlap = true
time_zone = "Asia/Tokyo"
}
group "etl" {
count = 1
# エフェメラルディスク
ephemeral_disk {
size = 5000 # MB
migrate = false
sticky = false
}
task "extract-transform-load" {
driver = "docker"
config {
image = "registry.example.com/etl-pipeline:v1.3.0"
command = "/app/run-etl.sh"
args = ["--date", "${NOMAD_META_run_date}"]
}
resources {
cpu = 4000
memory = 4096
}
# アーティファクト(外部ファイルのダウンロード)
artifact {
source = "s3://my-bucket/config/etl-config.yaml"
destination = "local/config/"
options {
aws_access_key_id = "ACCESS_KEY"
aws_access_key_secret = "SECRET_KEY"
}
}
template {
data = <<-EOF
{{- with secret "secret/data/etl" }}
SOURCE_DB_URL={{ .Data.data.source_db_url }}
TARGET_DB_URL={{ .Data.data.target_db_url }}
{{- end }}
EOF
destination = "secrets/db.env"
env = true
}
vault {
policies = ["etl-pipeline"]
}
}
}
}
System ジョブ
全ノード(または制約に一致するノード)で1つずつ実行されるデーモン型ジョブ。
job "node-exporter" {
datacenters = ["us-east-1", "us-east-2"]
type = "system"
# システムジョブのアップデート戦略
update {
max_parallel = 1
min_healthy_time = "10s"
healthy_deadline = "3m"
stagger = "30s"
}
group "monitoring" {
network {
port "metrics" {
static = 9100
}
}
service {
name = "node-exporter"
port = "metrics"
tags = ["monitoring", "prometheus"]
check {
type = "http"
path = "/metrics"
interval = "30s"
timeout = "5s"
}
}
task "node-exporter" {
driver = "docker"
config {
image = "prom/node-exporter:v1.6.1"
network_mode = "host"
pid_mode = "host"
ports = ["metrics"]
volumes = [
"/proc:/host/proc:ro",
"/sys:/host/sys:ro",
"/:/rootfs:ro",
]
args = [
"--path.procfs=/host/proc",
"--path.sysfs=/host/sys",
"--path.rootfs=/rootfs",
"--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)",
]
}
resources {
cpu = 100
memory = 64
}
}
}
}
Sysbatch ジョブ
全ノードで一度だけ実行されるバッチジョブ。
job "security-scan" {
datacenters = ["us-east-1"]
type = "sysbatch"
group "scanner" {
task "cis-benchmark" {
driver = "exec"
config {
command = "/usr/local/bin/cis-scanner"
args = ["--output", "/alloc/data/results.json"]
}
resources {
cpu = 500
memory = 256
}
}
}
}
3.3 制約(Constraint)、アフィニティ(Affinity)、スプレッド(Spread)
Constraint(ハード制約)
条件を満たすノードにのみ配置する。
job "gpu-training" {
# カーネルバージョンの制約
constraint {
attribute = "${attr.kernel.version}"
operator = "version"
value = ">= 5.4.0"
}
group "training" {
# GPU が利用可能なノードのみ
constraint {
attribute = "${attr.driver.docker.volumes.enabled}"
value = "true"
}
constraint {
attribute = "${node.class}"
value = "gpu-enabled"
}
# Distinct Hosts: 各アロケーションを異なるホストに配置
constraint {
operator = "distinct_hosts"
value = "true"
}
# Distinct Property: 異なるラックに配置
constraint {
operator = "distinct_property"
attribute = "${meta.rack}"
}
task "train" {
driver = "docker"
config {
image = "registry.example.com/ml-training:latest"
# GPU デバイスのマウント
devices = ["/dev/nvidia0"]
privileged = true
}
resources {
cpu = 8000
memory = 16384
device "nvidia/gpu" {
count = 1
constraint {
attribute = "${device.attr.memory}"
operator = ">="
value = "8 GiB"
}
affinity {
attribute = "${device.model}"
value = "Tesla V100"
weight = 75
}
}
}
}
}
}
Affinity(ソフト制約)
優先的に配置するが、必須ではない。
group "api" {
# SSD ストレージを持つノードを優先
affinity {
attribute = "${meta.storage_type}"
value = "ssd"
weight = 80 # -100 〜 100
}
# 特定のゾーンを優先
affinity {
attribute = "${meta.zone}"
value = "us-east-1a"
weight = 50
}
}
Spread(分散配置)
アロケーションを特定の属性に基づいて分散させる。
group "api" {
count = 12
# アベイラビリティゾーンに均等に分散
spread {
attribute = "${meta.zone}"
weight = 100
target "us-east-1a" { percent = 34 }
target "us-east-1b" { percent = 33 }
target "us-east-1c" { percent = 33 }
}
# ラックに均等に分散
spread {
attribute = "${meta.rack}"
weight = 50
}
}
4. スケジューリング
4.1 スケジューリングの流れ
Nomad のスケジューリングは以下の段階で行われる。
ジョブ登録 → 評価(Evaluation)→ プラン(Plan)→ 割り当て(Allocation)
- 評価(Evaluation): ジョブの状態変更(登録、更新、ノード障害など)をトリガーに生成される
- プランニング: スケジューラがノードの選定、リソースの割り当てを計算
- アロケーション: 実際にタスクをノードに割り当てて実行
┌──────────┐ ┌────────────┐ ┌──────────┐ ┌────────────┐
│ Job │───►│ Evaluation │───►│ Plan │───►│ Allocation │
│ Submit │ │ Queue │ │ Queue │ │ Execute │
└──────────┘ └────────────┘ └──────────┘ └────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Scheduler │ │ Task Driver │
│ (bin-pack / │ │ (docker, │
│ spread) │ │ exec, etc.) │
└──────────────┘ └──────────────┘
4.2 スケジューラの種類
| スケジューラ | ジョブタイプ | 説明 |
|---|---|---|
| Service | service | Bin packing とスプレッドアルゴリズムを使用 |
| Batch | batch | 未使用リソースを効率的に活用 |
| System | system, sysbatch | 全対象ノードでの実行を保証 |
4.3 Bin Packing vs Spread
Nomad は2つのスケジューリング戦略を提供する。
Bin Packing(デフォルト): ノードを可能な限り詰めて使い、未使用ノードを最小化する。コスト最適化に有効。
Spread: ノード間で均等にワークロードを分散する。可用性の向上に有効。
# スケジューラ設定(サーバー設定)
server {
default_scheduler_config {
# スケジューラアルゴリズム
scheduler_algorithm = "spread" # or "binpack"
# プリエンプション(優先度ベースの追い出し)
preemption_config {
batch_scheduler_enabled = true
system_scheduler_enabled = true
service_scheduler_enabled = true
sysbatch_scheduler_enabled = true
}
# メモリオーバーサブスクリプション
memory_oversubscription_enabled = true
}
}
4.4 プリエンプション(Preemption)
優先度の高いジョブが、優先度の低いジョブを追い出してリソースを確保する仕組み。
# 高優先度ジョブ
job "critical-service" {
priority = 90 # 1-100(デフォルト: 50)
group "api" {
task "server" {
resources {
cpu = 4000
memory = 8192
}
}
}
}
# 低優先度ジョブ(プリエンプションされる可能性がある)
job "background-worker" {
priority = 20
group "worker" {
task "processor" {
resources {
cpu = 2000
memory = 4096
}
}
}
}
4.5 メモリオーバーサブスクリプション
Nomad 1.1 以降、メモリのオーバーサブスクリプションが可能。memory_max で実際の上限を設定し、memory でスケジューリング時の予約量を指定する。
task "api" {
resources {
cpu = 1000
memory = 256 # スケジューリング時の予約量
memory_max = 1024 # 実際のメモリ上限
}
}
5. タスクドライバー
5.1 組み込みタスクドライバー
Nomad は複数のタスクドライバーを組み込みで提供する。
| ドライバー | 説明 | ユースケース |
|---|---|---|
| docker | Docker コンテナ | コンテナ化されたアプリケーション |
| exec | ネイティブバイナリ | Linux バイナリの直接実行 |
| java | Java アプリケーション | JAR ファイルの実行 |
| raw_exec | 権限なしバイナリ実行 | 分離不要なスクリプト |
| qemu | QEMU 仮想マシン | VM ワークロード |
5.2 Docker ドライバー
最も広く使用されるドライバー。
task "web" {
driver = "docker"
config {
image = "nginx:1.25"
# ポートマッピング
ports = ["http", "https"]
# ボリュームマウント
volumes = [
"local/nginx.conf:/etc/nginx/nginx.conf:ro",
"secrets/tls:/etc/nginx/tls:ro",
]
# Docker ネットワーク
network_mode = "bridge"
# DNS 設定
dns_servers = ["10.0.0.2"]
dns_search_domains = ["service.consul"]
# ヘルスチェック
healthchecks {
disable = false
}
# Docker Auth
auth {
username = "user"
password = "pass"
}
# sysctl 設定
sysctl = {
"net.core.somaxconn" = "65535"
}
# capabilities
cap_add = ["NET_BIND_SERVICE"]
cap_drop = ["ALL"]
# セキュリティ
readonly_rootfs = true
pids_limit = 100
}
}
5.3 Exec ドライバー
ネイティブバイナリを chroot/cgroups で分離して実行する。
task "app" {
driver = "exec"
config {
command = "/usr/local/bin/my-app"
args = [
"--config", "${NOMAD_TASK_DIR}/config.yaml",
"--port", "${NOMAD_PORT_http}",
]
# pid/ipc 名前空間の分離
pid_mode = "private"
ipc_mode = "private"
}
# バイナリのダウンロード
artifact {
source = "https://releases.example.com/my-app/v1.2.0/my-app-linux-amd64"
destination = "local/my-app"
mode = "file"
headers {
Authorization = "Bearer ${TOKEN}"
}
}
}
5.4 Java ドライバー
task "spring-app" {
driver = "java"
config {
jar_path = "local/app.jar"
jvm_options = [
"-Xms512m",
"-Xmx2g",
"-XX:+UseG1GC",
"-Dspring.profiles.active=production",
"-Dserver.port=${NOMAD_PORT_http}",
]
}
artifact {
source = "https://artifactory.example.com/libs-release/my-app/1.0.0/app.jar"
destination = "local/"
}
resources {
cpu = 2000
memory = 2560
}
}
5.5 外部(コミュニティ)タスクドライバー
プラグインシステムにより、以下のような外部ドライバーも利用可能。
| ドライバー | 説明 |
|---|---|
| podman | Podman コンテナ |
| containerd | containerd ランタイム |
| firecracker | Firecracker microVM |
| nspawn | systemd-nspawn コンテナ |
| windows_iis | Windows IIS |
| lxc | LXC コンテナ |
| pot | FreeBSD jail (pot) |
| rookout | Rookout デバッグ |
| Singularity | HPC コンテナ |
| ECS | AWS ECS リモート実行 |
# プラグインディレクトリの設定
plugin_dir = "/opt/nomad/plugins"
# Podman ドライバーの設定例
plugin "nomad-driver-podman" {
config {
socket_path = "unix:///run/podman/podman.sock"
volumes {
enabled = true
}
}
}
6. ネットワーキング
6.1 ネットワークモード
Nomad は複数のネットワークモードをサポートする。
group "api" {
network {
# モード選択
mode = "bridge" # none, host, bridge, cni/<name>
# 動的ポート割り当て
port "http" {
to = 8080 # コンテナ内ポート
}
# 静的ポート割り当て
port "metrics" {
static = 9090
to = 9090
}
# DNS 設定
dns {
servers = ["10.0.0.2"]
searches = ["service.consul"]
options = ["ndots:2"]
}
}
}
6.2 CNI プラグイン
Container Network Interface (CNI) プラグインとの統合により、高度なネットワーク設定が可能。
// /opt/cni/config/mynet.conflist
{
"cniVersion": "0.4.0",
"name": "mynet",
"plugins": [
{
"type": "bridge",
"bridge": "nomad-br0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"ranges": [
[{ "subnet": "172.20.0.0/16" }]
],
"routes": [
{ "dst": "0.0.0.0/0" }
]
}
},
{
"type": "portmap",
"capabilities": { "portMappings": true }
},
{
"type": "firewall"
}
]
}
# CNI ネットワークの使用
group "api" {
network {
mode = "cni/mynet"
port "http" {
to = 8080
}
}
}
6.3 Consul Connect(サービスメッシュ)
Consul Connect との統合により、mTLS ベースのサービスメッシュを実現する。
job "web-app" {
group "frontend" {
network {
mode = "bridge"
port "http" {
to = 3000
}
}
service {
name = "frontend"
port = "3000"
connect {
sidecar_service {
proxy {
# バックエンド API への接続
upstreams {
destination_name = "backend-api"
local_bind_port = 8080
}
# Redis への接続
upstreams {
destination_name = "redis"
local_bind_port = 6379
}
# プロキシ設定
config {
protocol = "http"
local_connect_timeout_ms = 5000
handshake_timeout_ms = 10000
}
# Envoy の公開設定
expose {
path {
path = "/health"
protocol = "http"
local_path_port = 3000
listener_port = "http"
}
}
}
}
# サイドカータスクのリソース
sidecar_task {
resources {
cpu = 100
memory = 128
}
}
}
}
task "frontend" {
driver = "docker"
config {
image = "registry.example.com/frontend:v2.0"
}
env {
BACKEND_URL = "http://${NOMAD_UPSTREAM_ADDR_backend_api}"
REDIS_URL = "redis://${NOMAD_UPSTREAM_ADDR_redis}"
}
resources {
cpu = 500
memory = 256
}
}
}
}
6.4 Consul Connect の Intention(アクセス制御)
# Consul の Intention 設定(Terraform で管理)
resource "consul_config_entry" "frontend_to_backend" {
kind = "service-intentions"
name = "backend-api"
config_json = jsonencode({
Sources = [
{
Name = "frontend"
Action = "allow"
Precedence = 6
Type = "consul"
}
]
})
}
7. ストレージ
7.1 ストレージの種類
Nomad は複数のストレージ方式をサポートする。
| 種類 | 説明 | 永続性 |
|---|---|---|
| Ephemeral Disk | アロケーションのライフサイクルに紐づく一時ディスク | △(migrate 可能) |
| Host Volume | ホストマシン上のディレクトリ | ○ |
| CSI Volume | Container Storage Interface プラグインによるボリューム | ○ |
7.2 エフェメラルディスク
group "cache" {
ephemeral_disk {
size = 1000 # MB
migrate = true # ノード移動時にデータを移行
sticky = true # 同じノードへの再配置を優先
}
task "redis" {
driver = "docker"
config {
image = "redis:7"
args = ["--dir", "/alloc/data"]
}
}
}
7.3 ホストボリューム
# クライアント設定(nomad.hcl)
client {
host_volume "mysql-data" {
path = "/opt/mysql/data"
read_only = false
}
host_volume "certs" {
path = "/etc/ssl/certs"
read_only = true
}
}
# ジョブでの使用
group "database" {
volume "db-data" {
type = "host"
source = "mysql-data"
read_only = false
}
task "mysql" {
driver = "docker"
config {
image = "mysql:8.0"
}
volume_mount {
volume = "db-data"
destination = "/var/lib/mysql"
}
}
}
7.4 CSI ボリューム
Container Storage Interface(CSI)による動的ボリュームプロビジョニング。
# CSI プラグインのデプロイ(コントローラー)
job "ebs-csi-controller" {
datacenters = ["us-east-1"]
type = "service"
group "controller" {
task "plugin" {
driver = "docker"
config {
image = "amazon/aws-ebs-csi-driver:v1.20.0"
args = [
"--endpoint=unix:///csi/csi.sock",
"--logtostderr",
"--v=5",
]
}
csi_plugin {
id = "aws-ebs"
type = "controller"
mount_dir = "/csi"
}
resources {
cpu = 500
memory = 256
}
}
}
}
# CSI プラグインのデプロイ(ノード)
job "ebs-csi-node" {
datacenters = ["us-east-1"]
type = "system"
group "node" {
task "plugin" {
driver = "docker"
config {
image = "amazon/aws-ebs-csi-driver:v1.20.0"
privileged = true
args = [
"--endpoint=unix:///csi/csi.sock",
"--logtostderr",
"--v=5",
]
}
csi_plugin {
id = "aws-ebs"
type = "node"
mount_dir = "/csi"
}
resources {
cpu = 100
memory = 128
}
}
}
}
# CSI ボリューム登録
resource "nomad_volume" "postgres_data" {
type = "csi"
plugin_id = "aws-ebs"
volume_id = "postgres-data"
name = "postgres-data"
external_id = "vol-0abcdef1234567890"
capability {
access_mode = "single-node-writer"
attachment_mode = "file-system"
}
mount_options {
fs_type = "ext4"
mount_flags = ["noatime"]
}
}
# CSI ボリュームの使用
group "database" {
volume "postgres-data" {
type = "csi"
source = "postgres-data"
read_only = false
attachment_mode = "file-system"
access_mode = "single-node-writer"
}
task "postgres" {
driver = "docker"
config {
image = "postgres:15"
}
volume_mount {
volume = "postgres-data"
destination = "/var/lib/postgresql/data"
}
resources {
cpu = 2000
memory = 4096
}
}
}
8. サービスディスカバリと Consul 連携
8.1 ネイティブサービスディスカバリ
Nomad 1.3 以降、Consul なしでのネイティブサービスディスカバリが可能。
group "api" {
service {
name = "web-api"
provider = "nomad" # Nomad ネイティブ
port = "http"
tags = ["v2", "production"]
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "3s"
}
}
task "api" {
# テンプレートで他のサービスを参照
template {
data = <<-EOF
{{- range nomadService "redis" }}
REDIS_ADDR={{ .Address }}:{{ .Port }}
{{- end }}
EOF
destination = "local/env.txt"
env = true
}
}
}
8.2 Consul サービス登録
group "api" {
service {
name = "web-api"
provider = "consul" # Consul 連携
port = "http"
tags = ["v2", "production", "urlprefix-/api"]
# タグ付きアドレス
tagged_addresses {
public_wan = "203.0.113.10"
}
# メタデータ
meta {
version = "2.1.0"
team = "platform"
}
# ヘルスチェック
check {
name = "HTTP Health"
type = "http"
path = "/health"
interval = "10s"
timeout = "3s"
check_restart {
limit = 3
grace = "60s"
ignore_warnings = false
}
}
check {
name = "TCP Check"
type = "tcp"
interval = "5s"
timeout = "2s"
}
check {
name = "gRPC Health"
type = "grpc"
interval = "10s"
timeout = "3s"
grpc_service = "my.service.v1"
grpc_use_tls = true
tls_server_name = "web-api.service.consul"
}
}
}
8.3 テンプレートによるサービスディスカバリ
Nomad のテンプレートエンジン(consul-template ベース)を活用して、動的なサービスディスカバリが可能。
task "nginx" {
driver = "docker"
config {
image = "nginx:1.25"
volumes = [
"local/nginx.conf:/etc/nginx/nginx.conf:ro",
]
}
# Nginx のアップストリーム設定を動的生成
template {
data = <<-EOF
upstream backend {
{{- range service "backend-api" }}
server {{ .Address }}:{{ .Port }} weight=1;
{{- end }}
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /health {
return 200 "OK";
}
}
EOF
destination = "local/nginx.conf"
change_mode = "signal"
change_signal = "SIGHUP"
splay = "30s"
}
}
9. Vault 連携とシークレット管理
9.1 Vault 連携の設定
# Nomad サーバー/クライアントの Vault 設定
vault {
enabled = true
address = "https://vault.service.consul:8200"
# Vault トークン(サーバーのみ)
token = "s.XXXXXXXXXXXXXXXXXXXXXX"
# TLS 設定
tls_ca_file = "/opt/nomad/tls/vault-ca.pem"
tls_cert_file = "/opt/nomad/tls/vault-client.pem"
tls_key_file = "/opt/nomad/tls/vault-client-key.pem"
# トークン作成の設定
create_from_role = "nomad-cluster"
# 名前空間(Enterprise)
namespace = "admin"
}
9.2 Vault ポリシーとロール
# Vault ポリシー(vault-policy.hcl)
path "secret/data/{{identity.entity.aliases.auth_token_xxxx.metadata.nomad_namespace}}/{{identity.entity.aliases.auth_token_xxxx.metadata.nomad_job_id}}/*" {
capabilities = ["read"]
}
path "database/creds/{{identity.entity.aliases.auth_token_xxxx.metadata.nomad_job_id}}" {
capabilities = ["read"]
}
path "pki/issue/{{identity.entity.aliases.auth_token_xxxx.metadata.nomad_job_id}}" {
capabilities = ["create", "update"]
}
# Vault ロールの作成
vault write auth/token/roles/nomad-cluster \
disallowed_policies="nomad-server" \
token_explicit_max_ttl=0 \
orphan=true \
token_period="72h" \
renewable=true
9.3 ジョブでの Vault 利用
job "web-api" {
group "api" {
task "server" {
vault {
policies = ["web-api-policy"]
change_mode = "restart"
env = true
# Vault 名前空間(Enterprise)
namespace = "engineering"
}
# KV シークレットの取得
template {
data = <<-EOF
{{- with secret "secret/data/web-api/config" }}
DB_PASSWORD={{ .Data.data.password }}
API_SECRET={{ .Data.data.api_secret }}
ENCRYPTION_KEY={{ .Data.data.encryption_key }}
{{- end }}
EOF
destination = "secrets/app.env"
env = true
change_mode = "restart"
}
# 動的データベースクレデンシャル
template {
data = <<-EOF
{{- with secret "database/creds/web-api-role" }}
DB_USER={{ .Data.username }}
DB_PASS={{ .Data.password }}
{{- end }}
EOF
destination = "secrets/db.env"
env = true
change_mode = "restart"
}
# PKI 証明書の自動発行
template {
data = <<-EOF
{{- with pkiCert "pki/issue/web-api" "common_name=web-api.service.consul" "ttl=24h" }}
{{ .Cert }}
{{- end }}
EOF
destination = "secrets/tls/cert.pem"
change_mode = "restart"
}
template {
data = <<-EOF
{{- with pkiCert "pki/issue/web-api" "common_name=web-api.service.consul" "ttl=24h" }}
{{ .Key }}
{{- end }}
EOF
destination = "secrets/tls/key.pem"
change_mode = "restart"
}
}
}
}
10. デプロイメント戦略
10.1 ローリングアップデート
job "web-api" {
update {
# 同時にアップデートするアロケーション数
max_parallel = 2
# ヘルシーとみなすまでの最小時間
min_healthy_time = "30s"
# ヘルシー判定のデッドライン
healthy_deadline = "5m"
# 進捗がない場合のデッドライン
progress_deadline = "10m"
# 失敗時の自動ロールバック
auto_revert = true
# アップデート間の待機時間
stagger = "10s"
}
group "api" {
count = 6
task "server" {
driver = "docker"
config {
image = "registry.example.com/web-api:v2.2.0" # 新バージョン
}
}
}
}
10.2 ブルー/グリーンデプロイ
カナリアデプロイを活用したブルー/グリーン戦略。
job "web-api" {
update {
max_parallel = 0 # 手動プロモーションまで待機
canary = 6 # count と同じ数のカナリア = ブルー/グリーン
# 手動プロモーション
auto_promote = false
auto_revert = true
}
group "api" {
count = 6
service {
name = "web-api"
port = "http"
# カナリアインスタンスのタグ
canary_tags = ["canary", "v2.2.0"]
# 本番インスタンスのタグ
tags = ["production", "v2.1.0"]
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "3s"
}
}
task "server" {
driver = "docker"
config {
image = "registry.example.com/web-api:v2.2.0"
}
}
}
}
# デプロイの状態確認
nomad job deployments -latest web-api
# カナリアのプロモーション(ブルーからグリーンへ切り替え)
nomad deployment promote <deployment-id>
# ロールバック
nomad deployment fail <deployment-id>
10.3 カナリアデプロイ
job "web-api" {
update {
max_parallel = 2
canary = 2 # 2つのカナリアインスタンス
auto_promote = true # ヘルスチェック通過後に自動プロモート
# プロモーションまでの待機時間(ヘルスチェック)
min_healthy_time = "60s"
healthy_deadline = "5m"
auto_revert = true
}
group "api" {
count = 6
# カナリアが先にデプロイされ、ヘルスチェック通過後に残りがアップデートされる
}
}
10.4 マルチリージョンデプロイ
job "global-api" {
type = "service"
# マルチリージョン設定
multiregion {
strategy {
max_parallel = 1 # 一度に1リージョンずつ
on_failure = "fail_all" # 1リージョンで失敗したら全停止
}
region "us-east" {
count = 3
datacenters = ["us-east-1", "us-east-2"]
meta {
region_name = "US East"
}
}
region "eu-west" {
count = 3
datacenters = ["eu-west-1"]
meta {
region_name = "EU West"
}
}
region "ap-northeast" {
count = 2
datacenters = ["ap-northeast-1"]
meta {
region_name = "AP Northeast"
}
}
}
group "api" {
task "server" {
driver = "docker"
config {
image = "registry.example.com/global-api:v3.0.0"
}
resources {
cpu = 1000
memory = 512
}
}
}
}
11. オートスケーリング
11.1 Nomad Autoscaler
Nomad Autoscaler は水平スケーリングとクラスターのスケーリングを提供する。
# Autoscaler の設定ファイル(autoscaler.hcl)
nomad {
address = "http://nomad.service.consul:4646"
}
apm "prometheus" {
driver = "prometheus"
config = {
address = "http://prometheus.service.consul:9090"
}
}
target "aws-asg" {
driver = "aws-asg"
config = {
aws_region = "us-east-1"
}
}
strategy "target-value" {
driver = "target-value"
}
strategy "threshold" {
driver = "threshold"
}
11.2 水平アプリケーションスケーリング
job "web-api" {
group "api" {
count = 3
scaling {
enabled = true
min = 2
max = 20
policy {
# Prometheus メトリクスに基づくスケーリング
evaluation_interval = "30s"
cooldown = "3m"
check "cpu_usage" {
source = "prometheus"
query = "avg(nomad_client_allocs_cpu_total_percent{task='api-server'})"
strategy "target-value" {
target = 70 # CPU 使用率 70% を目標
}
}
check "request_rate" {
source = "prometheus"
query = "sum(rate(http_requests_total{service='web-api'}[5m]))"
strategy "target-value" {
target = 1000 # 1000 req/s を目標
}
}
# Datadog メトリクスに基づくスケーリング
check "queue_depth" {
source = "datadog"
query = "avg:queue.depth{service:web-api}"
strategy "threshold" {
upper_bound = 100
lower_bound = 10
delta = 2
}
}
}
}
task "api-server" {
driver = "docker"
config {
image = "registry.example.com/web-api:v2.1.0"
}
resources {
cpu = 1000
memory = 512
}
}
}
}
11.3 クラスターオートスケーリング
# クラスタースケーリングポリシー
scaling "cluster_policy" {
enabled = true
min = 3
max = 50
policy {
evaluation_interval = "1m"
cooldown = "5m"
check "cpu_allocated" {
source = "nomad-apm"
query = "percentage-allocated_cpu"
strategy "target-value" {
target = 80
}
}
check "memory_allocated" {
source = "nomad-apm"
query = "percentage-allocated_memory"
strategy "target-value" {
target = 80
}
}
target "aws-asg" {
aws_asg_name = "nomad-client-asg"
node_class = "compute-optimized"
node_drain_deadline = "5m"
}
}
}
12. 変数とテンプレート
12.1 Nomad 変数(Variables)
Nomad 1.4 以降、Nomad ネイティブの変数管理機能が利用可能。
# 変数の設定
nomad var put nomad/jobs/web-api/config \
db_host="db.example.com" \
db_port="5432" \
log_level="info"
# 名前空間付き変数
nomad var put -namespace production \
nomad/jobs/web-api/secrets \
api_key="sk-abc123" \
db_password="secret"
# 変数の確認
nomad var get nomad/jobs/web-api/config
# 変数の一覧
nomad var list
# ジョブでの変数参照
task "api" {
template {
data = <<-EOF
{{- with nomadVar "nomad/jobs/web-api/config" }}
DB_HOST={{ .db_host }}
DB_PORT={{ .db_port }}
LOG_LEVEL={{ .log_level }}
{{- end }}
{{- with nomadVar "nomad/jobs/web-api/secrets" }}
API_KEY={{ .api_key }}
DB_PASSWORD={{ .db_password }}
{{- end }}
EOF
destination = "secrets/env.txt"
env = true
}
}
12.2 ランタイム変数
Nomad は多数のランタイム変数を提供する。
task "app" {
env {
# ノード情報
NODE_ID = "${node.unique.id}"
NODE_NAME = "${node.unique.name}"
NODE_DC = "${node.datacenter}"
NODE_CLASS = "${node.class}"
# アロケーション情報
ALLOC_ID = "${NOMAD_ALLOC_ID}"
ALLOC_NAME = "${NOMAD_ALLOC_NAME}"
ALLOC_INDEX = "${NOMAD_ALLOC_INDEX}"
# ジョブ情報
JOB_NAME = "${NOMAD_JOB_NAME}"
GROUP_NAME = "${NOMAD_GROUP_NAME}"
TASK_NAME = "${NOMAD_TASK_NAME}"
NAMESPACE = "${NOMAD_NAMESPACE}"
REGION = "${NOMAD_REGION}"
DC = "${NOMAD_DC}"
# ネットワーク情報
HOST_IP = "${NOMAD_IP_http}"
HOST_PORT = "${NOMAD_PORT_http}"
ADDR = "${NOMAD_ADDR_http}"
# ディレクトリ
ALLOC_DIR = "${NOMAD_ALLOC_DIR}"
TASK_DIR = "${NOMAD_TASK_DIR}"
SECRET_DIR = "${NOMAD_SECRETS_DIR}"
# リソース情報
CPU_LIMIT = "${NOMAD_CPU_LIMIT}"
MEMORY_LIMIT = "${NOMAD_MEMORY_LIMIT}"
}
}
12.3 テンプレート機能
task "app" {
# 設定ファイルの動的生成
template {
data = <<-EOF
# アプリケーション設定
server:
port: {{ env "NOMAD_PORT_http" }}
host: {{ env "NOMAD_IP_http" }}
# サービスディスカバリ
upstream_services:
{{- range service "backend-api" }}
- host: {{ .Address }}
port: {{ .Port }}
tags: {{ .Tags | join "," }}
{{- end }}
# Consul KV からの設定取得
database:
{{- with key "config/web-api/database" }}
{{ . }}
{{- end }}
# 条件分岐
{{- if eq (env "NOMAD_DC") "us-east-1" }}
region: east
{{- else }}
region: west
{{- end }}
# ループ
allowed_origins:
{{- range $key, $pairs := tree "config/web-api/cors" }}
- {{ .Value }}
{{- end }}
# タイムスタンプ
generated_at: {{ timestamp }}
EOF
destination = "local/config.yaml"
change_mode = "signal"
change_signal = "SIGHUP"
# テンプレートのレンダリング間隔
splay = "30s"
# テンプレートエラー時の動作
error_on_missing_key = true
# パーミッション
perms = "0644"
# 左右のデリミタ変更(テンプレートエンジンの衝突回避)
left_delimiter = "[["
right_delimiter = "]]"
}
# バイナリファイルのテンプレート
template {
source = "local/tls/ca-bundle.pem.tpl"
destination = "secrets/tls/ca-bundle.pem"
change_mode = "restart"
}
}
13. ACL(アクセス制御リスト)
13.1 ACL の基本概念
Nomad の ACL システムは、Consul の ACL システムに似た設計で、ポリシーベースのアクセス制御を提供する。
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Token │───►│ Policy │───►│ Rules │
│ │ │ │ │ │
│ (認証) │ │ (権限) │ │ (操作) │
└──────────┘ └──────────┘ └──────────┘
13.2 ACL の有効化
# サーバー設定
acl {
enabled = true
# トークンの TTL
token_ttl = "30s"
policy_ttl = "60s"
# ロール/ポリシーの TTL
role_ttl = "60s"
}
# ACL ブートストラップ(最初のマネジメントトークン生成)
nomad acl bootstrap
# 出力例:
# Accessor ID = a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Secret ID = s3cr3t-t0k3n-xxxx-xxxx-xxxxxxxxxxxx
# Name = Bootstrap Token
# Type = management
# Global = true
# Create Time = 2025-01-01T00:00:00Z
# Expiry Time = <none>
# Policies = n/a
13.3 ACL ポリシー
# 開発者用ポリシー(developer-policy.hcl)
namespace "default" {
policy = "read"
capabilities = [
"submit-job",
"read-job",
"list-jobs",
"read-logs",
"read-fs",
]
}
namespace "development" {
policy = "write"
capabilities = [
"submit-job",
"dispatch-job",
"read-job",
"list-jobs",
"read-logs",
"read-fs",
"alloc-exec",
"alloc-lifecycle",
]
}
# ノード操作(読み取りのみ)
node {
policy = "read"
}
# Quota の読み取り
quota {
policy = "read"
}
# ホストボリュームの読み取り
host_volume "*" {
policy = "read"
}
# 管理者用ポリシー(admin-policy.hcl)
namespace "*" {
policy = "write"
capabilities = [
"submit-job",
"dispatch-job",
"read-job",
"list-jobs",
"read-logs",
"read-fs",
"alloc-exec",
"alloc-lifecycle",
"alloc-node-exec",
"csi-register-plugin",
"csi-write-volume",
"csi-read-volume",
"csi-list-volume",
"csi-mount-volume",
"list-scaling-policies",
"read-scaling-policy",
"scale-job",
"sentinel-override",
]
}
node {
policy = "write"
}
agent {
policy = "write"
}
operator {
policy = "write"
}
quota {
policy = "write"
}
host_volume "*" {
policy = "write"
}
plugin {
policy = "read"
}
# ポリシーの作成
nomad acl policy apply developer-policy developer-policy.hcl
nomad acl policy apply admin-policy admin-policy.hcl
# トークンの作成
nomad acl token create \
-name="Developer Token" \
-policy=developer-policy \
-type=client
# ロールの作成
nomad acl role create \
-name="developer-role" \
-policy=developer-policy \
-description="Role for developers"
# トークンにロールを割り当て
nomad acl token create \
-name="Developer Token with Role" \
-role=developer-role \
-type=client
13.4 OIDC 認証
# OIDC 認証メソッドの設定
acl {
enabled = true
}
# OIDC 認証メソッドの作成
nomad acl auth-method create \
-name="okta" \
-type="OIDC" \
-max-token-ttl="8h" \
-config @- <<EOF
{
"OIDCDiscoveryURL": "https://mycompany.okta.com",
"OIDCClientID": "client-id",
"OIDCClientSecret": "client-secret",
"BoundAudiences": ["client-id"],
"AllowedRedirectURIs": [
"http://localhost:4649/oidc/callback",
"https://nomad.example.com:4646/ui/settings/tokens"
],
"ClaimMappings": {
"email": "email"
},
"ListClaimMappings": {
"groups": "groups"
}
}
EOF
# バインディングルールの作成
nomad acl binding-rule create \
-auth-method="okta" \
-bind-type="role" \
-bind-name="developer-role" \
-selector='list.groups contains "Engineering"'
nomad acl binding-rule create \
-auth-method="okta" \
-bind-type="role" \
-bind-name="admin-role" \
-selector='list.groups contains "SRE"'
14. 名前空間とリソースクォータ
14.1 名前空間
# 名前空間の作成
nomad namespace apply -description "Production environment" production
nomad namespace apply -description "Staging environment" staging
nomad namespace apply -description "Development environment" development
# 名前空間の一覧
nomad namespace list
# 名前空間を指定したジョブ操作
nomad job run -namespace=production web-api.nomad
nomad job status -namespace=production web-api
# ジョブ内での名前空間指定
job "web-api" {
namespace = "production"
datacenters = ["us-east-1"]
type = "service"
# ...
}
14.2 リソースクォータ(Enterprise)
# クォータ仕様の定義
quota "production-quota" {
description = "Production environment quota"
limit {
region = "us-east"
region_limit {
cpu = 100000 # 100 GHz
memory = 204800 # 200 GB
}
}
limit {
region = "eu-west"
region_limit {
cpu = 50000 # 50 GHz
memory = 102400 # 100 GB
}
}
}
# クォータの適用
nomad quota apply production-quota.hcl
# 名前空間にクォータを割り当て
nomad namespace apply \
-description "Production environment" \
-quota "production-quota" \
production
# クォータの使用状況確認
nomad quota inspect production-quota
15. 監視とオブザーバビリティ
15.1 Prometheus メトリクス
# Nomad のテレメトリ設定
telemetry {
publish_allocation_metrics = true
publish_node_metrics = true
# Prometheus エンドポイント
prometheus_metrics = true
# StatsD
statsd_address = "statsd.service.consul:8125"
# Datadog
datadog_address = "localhost:8125"
datadog_tags = ["env:production", "service:nomad"]
# メトリクス収集間隔
collection_interval = "10s"
# メトリクスプレフィックス
disable_hostname = true
}
15.2 Prometheus スクレイプ設定
# prometheus.yml
scrape_configs:
# Nomad サーバーメトリクス
- job_name: 'nomad-server'
metrics_path: '/v1/metrics'
params:
format: ['prometheus']
consul_sd_configs:
- server: 'consul.service.consul:8500'
services: ['nomad']
tags: ['server']
relabel_configs:
- source_labels: ['__meta_consul_tags']
regex: '.*,server,.*'
action: keep
# Nomad クライアントメトリクス
- job_name: 'nomad-client'
metrics_path: '/v1/metrics'
params:
format: ['prometheus']
consul_sd_configs:
- server: 'consul.service.consul:8500'
services: ['nomad-client']
relabel_configs:
- source_labels: ['__meta_consul_service']
target_label: 'instance'
15.3 主要メトリクス
| メトリクス | 説明 | アラート閾値例 |
|---|---|---|
nomad.nomad.broker.total_blocked | ブロックされた評価の数 | > 10 |
nomad.nomad.plan.submit | プランの処理時間 | p99 > 5s |
nomad.nomad.worker.invoke_scheduler | スケジューラの呼び出し時間 | p99 > 10s |
nomad.client.allocs.cpu.total_percent | アロケーションの CPU 使用率 | > 90% |
nomad.client.allocs.memory.usage | アロケーションのメモリ使用量 | > 90% |
nomad.client.allocs.oom_killed | OOM キルされたアロケーション | > 0 |
nomad.raft.commitTime | Raft コミット時間 | p99 > 500ms |
nomad.raft.leader.lastContact | リーダーとの最後のコンタクト | > 500ms |
nomad.runtime.num_goroutines | Goroutine 数 | > 10000 |
15.4 ログ管理
# Nomad エージェントのログ設定
log_level = "INFO"
log_file = "/var/log/nomad/nomad.log"
log_rotate_bytes = 104857600 # 100MB
log_rotate_duration = "24h"
log_rotate_max_files = 10
log_json = true
# syslog 出力
enable_syslog = true
syslog_facility = "LOCAL0"
# ジョブのログ確認
nomad alloc logs <alloc-id>
nomad alloc logs -stderr <alloc-id>
nomad alloc logs -f <alloc-id> # ストリーミング
nomad alloc logs -tail -n 100 <alloc-id> # 末尾100行
# 特定のタスクのログ
nomad alloc logs -task web-server <alloc-id>
15.5 Grafana ダッシュボード
主要なパネル構成例:
- クラスタ概要: サーバー/クライアントの数、リーダーの情報
- リソース使用率: CPU/メモリの割り当て率と使用率
- ジョブステータス: Running/Pending/Dead の内訳
- スケジューリング: 評価キュー、ブロック数
- Raft: コミット時間、リーダーコンタクト
- ネットワーク: RPC リクエスト数、レイテンシ
16. 運用
16.1 クラスタの構築
systemd ユニットファイル
# /etc/systemd/system/nomad.service
[Unit]
Description=Nomad
Documentation=https://www.nomadproject.io/docs
Wants=network-online.target
After=network-online.target
ConditionFileNotEmpty=/etc/nomad.d/nomad.hcl
[Service]
ExecReload=/bin/kill -HUP $MAINPID
ExecStart=/usr/local/bin/nomad agent -config /etc/nomad.d/
KillMode=process
KillSignal=SIGINT
LimitNOFILE=65536
LimitNPROC=infinity
Restart=on-failure
RestartSec=2
StartLimitBurst=3
StartLimitIntervalSec=10
TasksMax=infinity
OOMScoreAdjust=-1000
[Install]
WantedBy=multi-user.target
基本設定ファイル
# /etc/nomad.d/nomad.hcl(共通設定)
datacenter = "us-east-1"
data_dir = "/opt/nomad/data"
log_level = "INFO"
log_json = true
bind_addr = "0.0.0.0"
addresses {
http = "0.0.0.0"
rpc = "{{ GetPrivateInterfaces | attr \"address\" }}"
serf = "{{ GetPrivateInterfaces | attr \"address\" }}"
}
advertise {
http = "{{ GetPrivateInterfaces | attr \"address\" }}"
rpc = "{{ GetPrivateInterfaces | attr \"address\" }}"
serf = "{{ GetPrivateInterfaces | attr \"address\" }}"
}
# Consul 連携
consul {
address = "127.0.0.1:8500"
server_service_name = "nomad"
client_service_name = "nomad-client"
auto_advertise = true
server_auto_join = true
client_auto_join = true
# Consul トークン
token = "consul-token-for-nomad"
tags = ["production"]
}
# Vault 連携
vault {
enabled = true
address = "https://vault.service.consul:8200"
}
# TLS
tls {
http = true
rpc = true
ca_file = "/opt/nomad/tls/ca.pem"
cert_file = "/opt/nomad/tls/nomad.pem"
key_file = "/opt/nomad/tls/nomad-key.pem"
verify_server_hostname = true
verify_https_client = false
}
# ACL
acl {
enabled = true
}
# テレメトリ
telemetry {
publish_allocation_metrics = true
publish_node_metrics = true
prometheus_metrics = true
}
16.2 アップグレード手順
# 1. 新バージョンのダウンロード
curl -o nomad_new.zip https://releases.hashicorp.com/nomad/1.7.0/nomad_1.7.0_linux_amd64.zip
unzip nomad_new.zip
# 2. クライアントのドレイン(計画的な退避)
nomad node drain -enable -deadline 5m <node-id>
# 3. サービスの停止
sudo systemctl stop nomad
# 4. バイナリの置き換え
sudo mv nomad /usr/local/bin/nomad
# 5. サービスの再起動
sudo systemctl start nomad
# 6. ドレインの解除
nomad node drain -disable <node-id>
# 7. ヘルスチェック
nomad server members
nomad node status
16.3 バックアップとリストア
# スナップショットの取得
nomad operator snapshot save backup.snap
# 自動バックアップ設定(Enterprise)
nomad operator snapshot agent \
-interval 1h \
-retain 24 \
-path /opt/nomad/snapshots/
# スナップショットのリストア
nomad operator snapshot restore backup.snap
16.4 トラブルシューティング
# サーバーメンバーの確認
nomad server members
# ノードステータスの確認
nomad node status
nomad node status -verbose <node-id>
# ジョブの評価確認
nomad eval status <eval-id>
# アロケーションの詳細確認
nomad alloc status <alloc-id>
# アロケーション内のファイルシステム確認
nomad alloc fs <alloc-id> /
# アロケーションへのシェルアクセス
nomad alloc exec -task web-server <alloc-id> /bin/sh
# デバッグバンドルの生成
nomad operator debug -duration 5m -interval 30s
# Raft ピアの確認
nomad operator raft list-peers
# ガベージコレクション
nomad system gc
# 強制的な評価
nomad eval trigger -job web-api
17. Terraform による Nomad 管理
17.1 Nomad プロバイダー
# provider.tf
terraform {
required_providers {
nomad = {
source = "hashicorp/nomad"
version = "~> 2.0"
}
}
}
provider "nomad" {
address = "https://nomad.example.com:4646"
region = "us-east"
secret_id = var.nomad_token
# TLS 設定
ca_file = "/path/to/ca.pem"
cert_file = "/path/to/client.pem"
key_file = "/path/to/client-key.pem"
}
17.2 ジョブの管理
# ジョブの登録
resource "nomad_job" "web_api" {
jobspec = file("${path.module}/jobs/web-api.nomad.hcl")
hcl2 {
enabled = true
vars = {
image_tag = var.web_api_image_tag
replicas = var.web_api_replicas
environment = var.environment
}
}
# デタッチモード(デプロイ完了を待たない)
detach = false
}
# 名前空間の管理
resource "nomad_namespace" "production" {
name = "production"
description = "Production workloads"
quota = nomad_quota_specification.production.name
capabilities {
enabled_task_drivers = ["docker", "exec"]
disabled_task_drivers = ["raw_exec"]
}
meta = {
owner = "platform-team"
env = "production"
}
}
# ACL ポリシー
resource "nomad_acl_policy" "developer" {
name = "developer"
description = "Developer access policy"
rules_hcl = <<-EOF
namespace "development" {
policy = "write"
}
namespace "production" {
policy = "read"
}
node {
policy = "read"
}
EOF
}
# ACL トークン
resource "nomad_acl_token" "developer" {
name = "developer-token"
type = "client"
policies = [nomad_acl_policy.developer.name]
}
# CSI ボリューム
resource "nomad_csi_volume" "postgres" {
plugin_id = "aws-ebs"
volume_id = "postgres-data"
name = "postgres-data"
external_id = aws_ebs_volume.postgres.id
capability {
access_mode = "single-node-writer"
attachment_mode = "file-system"
}
mount_options {
fs_type = "ext4"
mount_flags = ["noatime"]
}
}
# スケジューラ設定
resource "nomad_scheduler_config" "config" {
scheduler_algorithm = "spread"
memory_oversubscription_enabled = true
preemption_config {
batch_scheduler_enabled = true
system_scheduler_enabled = true
service_scheduler_enabled = true
sysbatch_scheduler_enabled = true
}
}
18. CI/CD パイプラインとの統合
18.1 GitHub Actions によるデプロイ
# .github/workflows/deploy.yml
name: Deploy to Nomad
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and Push Docker Image
run: |
docker build -t registry.example.com/web-api:${{ github.sha }} .
docker push registry.example.com/web-api:${{ github.sha }}
- name: Setup Nomad
uses: hashicorp/setup-nomad@main
with:
version: '1.7.0'
- name: Deploy to Nomad
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
NOMAD_CACERT: ${{ secrets.NOMAD_CACERT }}
run: |
nomad job run \
-var="image_tag=${{ github.sha }}" \
-var="replicas=6" \
jobs/web-api.nomad.hcl
- name: Wait for Deployment
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
run: |
DEPLOY_ID=$(nomad job deployments -latest -json web-api | jq -r '.[0].ID')
nomad deployment status -monitor $DEPLOY_ID
18.2 Nomad Pack
Nomad Pack は、Nomad ジョブのパッケージ管理ツールである(Helm に相当)。 CI/CD からの利用に限れば、要点は「レンダリングと投入を分離できること」と 「レジストリの ref でテンプレートのバージョンを固定できること」の2点である。
# 静的検査(Nomad クラスタ不要)
nomad-pack fmt -check -recursive ./packs
nomad-pack render ./packs/webservice -f vars/prod/api.hcl | tail -n +3 > /tmp/out.nomad
nomad job validate /tmp/out.nomad
# 差分の確認(終了コード: 0=差分なし / 1=差分あり / 255=エラー)
nomad-pack plan webservice --registry=prod --ref=v1.4.0 -f vars/prod/api.hcl
# 投入
nomad-pack run webservice --registry=prod --ref=v1.4.0 -f vars/prod/api.hcl
パックの作り方、テンプレート言語、sprig 関数、レジストリの運用、デプロイのライフサイクルは第19章で詳しく扱う。
19. Nomad Pack と Nomad Pack Registry — 徹底解説
本章は Nomad Pack を単独で読み通せる独立した解説である。Nomad Pack という言葉を今日初めて聞いた人が、最初のパックを動かし、自前のレジストリを立て、テンプレート言語と sprig 関数を使いこなし、CI に組み込めるところまでを扱う。
掲載したコマンドと出力は、すべて実機で実行して確認したものである。
$ nomad-pack --version
Nomad Pack v0.4.2 (cd4d2a4)
$ nomad version
Nomad v1.10.5
環境は macOS(Apple Silicon / arm64)である。エラー出力も、実際に踏んだものをそのまま載せている。検証できなかった事柄は第19.16節に列挙した。
19.1 Nomad Pack とは何か
19.1.1 解決している問題
第3章で見たように、Nomad のジョブ仕様は HCL で書く。1つのアプリケーションなら、それで十分である。
問題はアプリケーションが増えたときに起きる。
jobs/
├── api.nomad ← 220行
├── worker.nomad ← 210行(api.nomad との差は15行)
├── scheduler.nomad ← 205行(api.nomad との差は12行)
├── api-staging.nomad ← 220行(api.nomad との差は datacenters と count だけ)
└── …
差分が10行なのにファイルが200行コピーされる。 そして次のような事故が起きる。
| 事故 | 起きること |
|---|---|
restart ブロックの方針を変えたい | 全ファイルを手で直す。1つ忘れる |
新しい service タグの規約を追加したい | 同じく全ファイル。書式が微妙にずれる |
| staging と production を見比べたい | 200行の diff の中から意味のある12行を探す |
| 新しいアプリを追加したい | どのファイルをコピー元にすべきか誰も知らない |
Nomad Pack は「ジョブ仕様をテンプレート化して、差分だけを変数で与える」ための道具である。
packs/webservice/ ← テンプレートは1つ
├── templates/
│ └── webservice.nomad.tpl
└── variables.hcl
vars/
├── api.hcl ← 15行
├── worker.hcl ← 12行
├── scheduler.hcl ← 10行
└── api-staging.hcl ← 4行
これで「方針を変える」作業がテンプレート1箇所の修正になる。
19.1.2 Helm との対応関係
Kubernetes を知っている読者には、Helm との対応で理解するのが最短である。
| Helm | Nomad Pack | 備考 |
|---|---|---|
| Chart | Pack | テンプレートと変数定義の入れ物 |
Chart.yaml | metadata.hcl | 名前・バージョン・依存 |
values.yaml | variables.hcl | ただし型宣言を持つ点が違う |
templates/*.yaml | templates/*.tpl | Go テンプレート |
templates/_helpers.tpl | templates/_helpers.tpl | 命名規約まで同じ |
NOTES.txt | outputs.tpl | デプロイ後に表示される案内 |
charts/(サブチャート) | deps/ | 依存パック |
| Chart Repository | Pack Registry | ただし実体は git リポジトリ |
helm install -f values.yaml | nomad-pack run -f vars.hcl | |
helm template | nomad-pack render | |
helm diff (plugin) | nomad-pack plan(標準装備) | |
| Release | Deployment(--name) | |
| Helm の Secret にある state | ジョブの meta に埋まる | 第19.10.1節。ここが最も違う |
{{ }} | [[ ]] | 第19.5.1節 |
| sprig 関数群 | sprig 関数群 | ほぼ同じ。差は第19.6.12節 |
違いのうち2つが本質的である。
① 変数に型がある。 Helm の values.yaml は素の YAML なので、replicaCount: "3" と replicaCount: 3 の区別はテンプレート側の書き方に委ねられる。Nomad Pack の variables.hcl は Terraform と同じ HCL の型制約を持ち、レンダリング前に型で弾く。
$ nomad-pack render . --var count=abc
! Failed To Process Pack
! Error: This variable value is not compatible with the variable's type constraint: a number is required.
② 状態管理の仕組みを持たない。 Helm はリリース情報を Kubernetes の Secret に保存する。Nomad Pack は独自の状態を一切持たず、Nomad のジョブに埋め込んだ meta を唯一の情報源とする(第19.10.1節)。これが良くも悪くも Nomad Pack の性格を決めている。
19.1.3 3つの登場人物
用語が3つあり、混ざると読めなくなるので先に固定する。
① パック(Pack)
テンプレートと変数定義のディレクトリ。ソースコードに相当。
② レジストリ(Registry)
パックを複数入れた git リポジトリ。配布単位。
ローカルキャッシュに ref(タグ/SHA)ごとに展開される。
③ デプロイ(Deployment)
「このパックのこの変数値でクラスタに入れたもの」の識別名。
--name で指定する。既定はパック名。
②と③の関係が最初は掴みにくい。 例を1つ挙げる。
レジストリ "prod-registry"(git リポジトリ)
└─ ref = v1.4.0
└─ パック "webservice"
├─ デプロイ "api" → Nomad ジョブ "api"
├─ デプロイ "worker" → Nomad ジョブ "worker"
└─ デプロイ "api-canary" → Nomad ジョブ "api-canary"
1つのパックから、変数を変えて何個でもデプロイを作れる。 そして同じレジストリの別の ref を同時にキャッシュできるので、「api だけ v1.4.0、worker は v1.3.0」という状態も表現できる(第19.9.4節)。
19.1.4 何ができないか
先に期待値を下げておく。
| できないこと | 代わりに何を使うか |
|---|---|
| 状態ファイルによる差分管理 | Terraform でラップする(第19.13節) |
| ロールバック | 古い ref で run し直す。または nomad job revert |
| 秘密情報の管理 | Vault(第9章)。パックの変数に秘密を入れてはいけない |
| 依存パックの ref 固定 | v0.4.2 では壊れている(第19.11.6節) |
| パックの署名検証 | 無い。git のタグ保護で代替する |
| Nomad 以外への適用 | Nomad 専用 |
「ロールバックが無い」点は運用設計に影響する。 nomad-pack run は毎回テンプレートを再レンダリングして投入するだけなので、「前の状態」を知っているのは Nomad 側のジョブバージョンだけである。
19.2 導入
19.2.1 インストール
Homebrew(推奨):
$ brew install hashicorp/tap/nomad-pack
tap を明示する必要がある。 素の brew install nomad-pack は存在しない。
バイナリを直接置く方法も知っておくと役に立つ。CI のコンテナや、Homebrew を使えない環境で必要になる。
$ V=0.4.2
$ curl -fsSL -o np.zip \
"https://releases.hashicorp.com/nomad-pack/${V}/nomad-pack_${V}_darwin_arm64.zip"
$ unzip -o np.zip -d /usr/local/bin
$ chmod +x /usr/local/bin/nomad-pack
$ nomad-pack --version
Nomad Pack v0.4.2 (cd4d2a4)
アーキテクチャの文字列に注意する。 Apple Silicon は darwin_arm64、Intel Mac は darwin_amd64、CI の Linux は linux_amd64 である。
go installは失敗する。go install github.com/hashicorp/nomad-pack@latestは、nomad-packのgo.modがreplaceディレクティブを含むため通らない(実測)。The go.mod file for the module providing named packages contains one or more replace directivesソースから入れたい場合は
git cloneしてからmake devを使う。
nomad 本体も必要である。
$ brew install hashicorp/tap/nomad
$ nomad version
Nomad v1.10.5
nomad-pack は単体では plan も run もできない。 ジョブ仕様の構文検査を Nomad サーバの /v1/jobs/parse API に投げるためである。render だけはサーバ不要で動く。
19.2.2 接続先の指定
nomad-pack は nomad と同じ環境変数を読む。
$ export NOMAD_ADDR=https://nomad.example.com:4646
$ export NOMAD_TOKEN=<ACL トークン>
$ export NOMAD_REGION=us-east-1
$ export NOMAD_NAMESPACE=default
| 変数 | 用途 |
|---|---|
NOMAD_ADDR | API のエンドポイント。既定 http://127.0.0.1:4646 |
NOMAD_TOKEN | ACL トークン(第13章) |
NOMAD_REGION / NOMAD_NAMESPACE | 対象の region / namespace |
NOMAD_CACERT / NOMAD_CLIENT_CERT / NOMAD_CLIENT_KEY | mTLS |
NOMAD_PACK_CACHE_PATH | パックキャッシュの場所(第19.9.3節) |
NOMAD_PACK_ALLOW_UNSET_VARS | --allow-unset-vars と同等 |
19.2.3 検証用クラスタを立てる
手元で試すなら開発モードのエージェントが最短である。
$ nomad agent -dev -bind=127.0.0.1 -data-dir=/tmp/nomaddata
別の端末で確認する。
$ export NOMAD_ADDR=http://127.0.0.1:4646
$ nomad node status
ID Node Pool DC Name Class Drain Eligibility Status
95429d92 default dc1 mymac <none> false eligible ready
データセンタ名は dc1、ノードプールは default である。 パックの datacenters 変数をこれに合わせる必要がある。
Apple Silicon の落とし穴 — CPU が 56 MHz と認識される
ここで多くの人が最初に詰まる。 開発エージェントを立てて何かをデプロイすると、こうなる。
$ nomad-pack plan ./demo
warning: TaskGroup "app" (failed to place 0 allocation):
warning: * Resources exhausted on 1 nodes
warning: * Dimension "cpu" exhausted on 1 nodes
ノードの状態を見ると理由が分かる。
$ nomad node status -self
Allocated Resources
CPU Memory Disk Alloc Count
0/56 MHz 0 B/64 GiB 0 B/695 GiB 0
ノード全体の CPU が 56 MHz である。 Nomad は CPU の総処理能力を「コア数 × クロック周波数」で見積もるが、Apple Silicon ではクロック周波数の取得に失敗して極端に小さい値になる。したがって resources { cpu = 100 } を要求するタスクはどこにも置けない。
対処はクライアント設定で総量を明示することである。
# agent.hcl
client {
cpu_total_compute = 8000
}
$ nomad agent -dev -bind=127.0.0.1 -data-dir=/tmp/nomaddata -config=agent.hcl
$ nomad node status -self
Allocated Resources
CPU Memory Disk Alloc Count
0/8000 MHz 0 B/64 GiB 0 B/695 GiB 0
これは Nomad Pack の問題ではなく Nomad の CPU フィンガープリントの問題である。 本番の Linux サーバでは通常起きない。手元の Mac で試すときだけ必要になる。
Docker が無い場合
開発エージェントが使えるドライバは起動時に分かる。
$ nomad node status -self | grep 'Driver Status'
Driver Status = java,raw_exec
Docker Desktop が動いていないと docker ドライバが出てこない。 本章の実行例は Docker に依存しないよう raw_exec を使っている。raw_exec は隔離のないドライバなので、手元の検証にだけ使い、本番クラスタでは有効にしない。
19.2.4 コマンドの全体像
$ nomad-pack --help
Common commands
plan Dry-run a pack update to determine its effects
render Render the templates within a pack
run Run a new pack or update an existing pack
destroy Delete an existing pack
fmt Format pack template and HCL files
info Get information on a pack
status Get information on deployed packs
Other commands
deps Manage dependencies for pack.
generate Generate a sample nomad-pack registry, pack, or variable overrides file for a pack.
list List packs available in the local environment.
registry Add, delete, list, or update registries and packs in the local environment.
stop Stop a running pack
version Prints the version of Nomad Pack
役割で分けると4群になる。
| 群 | コマンド | Nomad サーバが必要か |
|---|---|---|
| 作る | generate pack / generate registry / generate var-file / fmt | 不要 |
| 見る | render / info / list | 不要 |
| 配る | registry add / list / delete / update / deps vendor | 不要 |
| 動かす | plan / run / status / stop / destroy | 必要 |
「見る」までがサーバ不要である点が重要で、CI の静的検査は Nomad クラスタに触らずに書ける(第19.12.2節)。ただし plan は Nomad の parse API を使うので必要である。
19.3 最初のパック — 5分で動かす
ここは手を動かす節である。 一切の説明を後回しにして、動くところまで進む。
19.3.1 サンプル付きのレジストリを生成する
$ nomad-pack generate registry my-registry --with-sample-pack -y
何ができたかを見る。
$ find my-registry -type f | sort
my-registry/CHANGELOG.md
my-registry/README.md
my-registry/packs/hello_world/CHANGELOG.md
my-registry/packs/hello_world/README.md
my-registry/packs/hello_world/metadata.hcl
my-registry/packs/hello_world/outputs.tpl
my-registry/packs/hello_world/templates/_helpers.tpl
my-registry/packs/hello_world/templates/hello_world.nomad.tpl
my-registry/packs/hello_world/variables.hcl
packs/ の下に1パック1ディレクトリで並ぶのがレジストリの形である。これだけ覚えれば、あとは第19.9節で詳しく見る。
--with-sample-packを付けないとpacks/は空になる。-yは確認プロンプトの自動承諾である。
19.3.2 レンダリングする
$ cd my-registry/packs/hello_world
$ nomad-pack render .
hello_world/hello_world.nomad:
job "hello_world" {
datacenters = ["*"]
type = "service"
group "app" {
count = 2
network {
port "http" {
to = 8000
}
}
service {
name = "webapp"
tags = ["urlprefix-/", "traefik.enable=true", "traefik.http.routers.http.rule=Path(`/`)"]
provider = "nomad"
port = "http"
check {
name = "alive"
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
restart {
attempts = 2
interval = "30m"
delay = "15s"
mode = "fail"
}
task "server" {
driver = "docker"
config {
image = "mnomitch/hello_world_server"
ports = ["http"]
}
env {
MESSAGE = "Hello World!"
}
}
}
}
render は Nomad クラスタに一切触らない。 テンプレートを展開して標準出力に出すだけである。最初に覚えるべきコマンドはこれである — 何かがおかしいとき、まず render して目で見る。
出力の1行目に注目したい。
hello_world/hello_world.nomad:
これは「パック名/出力ファイル名」の見出しで、2行目は空行、3行目からが jobspec 本体である。ジョブ仕様だけを取り出してパイプに流したい場合はこの2行を落とす。
$ nomad-pack render . | tail -n +3 > hello_world.nomad
$ nomad job validate hello_world.nomad
Job validation successful
この tail -n +3 は実務で頻出する。 nomad-pack render を他のツールに繋ぐときの定型句である。
19.3.3 変数を変えてみる
$ nomad-pack render . --var count=5 --var message="こんにちは"
count = 5
...
env {
MESSAGE = "こんにちは"
}
変数の一覧は info で見られる。
$ nomad-pack info .
Pack Name hello_world
Description
Application URL
Pack "hello_world" Variables:
- "service_tags" (list(string): optional) - The service tags for the hello_world application
default: ["urlprefix-/", "traefik.enable=true", "traefik.http.routers.http.rule=Path(`/`)"]
- "job_name" (string: optional) - The name to use as the job name which overrides using the pack name
default: ""
- "region" (string: optional) - The region where jobs will be deployed
default: ""
- "datacenters" (list(string): optional) - A list of datacenters in the region which are eligible for task placement
default: ["*"]
- "count" (number: optional) - The number of app instances to deploy
default: 2
...
info は「このパックに何を渡せるか」を答える。 他人が書いたパックを使うとき最初に叩くコマンドである。
infoの変数の並び順は実行ごとに変わる。 Go のマップ反復順がそのまま出るためで、宣言順でもアルファベット順でもない。実測した3回の並びは次のとおりである。run1: message register_service service_name service_tags job_name region datacenters count run2: service_tags job_name region datacenters count message register_service service_name run3: service_tags job_name region datacenters count message register_service service_nameしたがって
infoの出力を CI で diff してはいけない。 変数一覧を安定した順序で得たい場合はgenerate var-file(第19.7.4節)を使う。こちらはアルファベット順に整列される。
19.3.4 実際に動くパックを作る
サンプルは Docker イメージを引くので、Docker が無い環境では動かない。raw_exec で確実に動くパックをゼロから作る。 これを以降の節で使い回す。
$ mkdir -p demo/templates
demo/metadata.hcl:
app {
url = "https://example.internal/demo"
author = "Platform Team"
}
pack {
name = "demo"
description = "A minimal pack that actually runs on a dev agent"
version = "0.1.0"
}
demo/variables.hcl:
variable "job_name" {
description = "Nomad job name. Empty means use the pack name."
type = string
default = ""
}
variable "datacenters" {
description = "Target datacenters"
type = list(string)
default = ["dc1"]
}
variable "count" {
description = "Number of instances"
type = number
default = 1
}
variable "message" {
description = "Message the task echoes"
type = string
default = "hello from pack"
}
variable "resources" {
description = "CPU and memory for the task"
type = object({
cpu = number
memory = number
})
default = {
cpu = 100
memory = 64
}
}
variable "env_vars" {
description = "Extra environment variables"
type = map(string)
default = {}
}
demo/templates/_helpers.tpl:
[[- define "job_name" -]]
[[ coalesce (var "job_name" .) (meta "pack.name" .) | quote ]]
[[- end -]]
[[- define "env_block" -]]
[[- $env := var "env_vars" . -]]
[[- if $env ]]
env {
[[- range $k, $v := $env ]]
[[ $k ]] = [[ $v | quote ]]
[[- end ]]
}
[[- end -]]
[[- end -]]
demo/templates/demo.nomad.tpl:
job [[ template "job_name" . ]] {
datacenters = [[ var "datacenters" . | toStringList ]]
type = "service"
group "app" {
count = [[ var "count" . ]]
task "echo" {
driver = "raw_exec"
config {
command = "/bin/sh"
args = ["-c", "while true; do echo [[ var "message" . ]]; sleep 10; done"]
}
[[ template "env_block" . ]]
resources {
cpu = [[ var "resources.cpu" . ]]
memory = [[ var "resources.memory" . ]]
}
}
}
}
demo/outputs.tpl:
Deployed job: [[ coalesce (var "job_name" .) (meta "pack.name" .) ]]
Instances: [[ var "count" . ]]
Message: [[ var "message" . ]]
この時点で3つの新しい記法が出てきた。
| 記法 | 意味 | 詳細 |
|---|---|---|
var "resources.cpu" . | オブジェクト型変数の中を . で辿る | 第19.5.2節 |
template "job_name" . | define した部品を呼ぶ | 第19.5.2節 |
toStringList | Go のリストを HCL のリテラルにする | 第19.6.2節 |
19.3.5 レンダリングして検証する
$ nomad-pack render ./demo --render-output-template
demo/demo.nomad:
job "demo" {
datacenters = ["dc1"]
type = "service"
group "app" {
count = 1
task "echo" {
driver = "raw_exec"
config {
command = "/bin/sh"
args = ["-c", "while true; do echo hello from pack; sleep 10; done"]
}
resources {
cpu = 100
memory = 64
}
}
}
}
outputs.tpl:
Deployed job: demo
Instances: 1
Message: hello from pack
--render-output-template を付けないと outputs.tpl は表示されない。 render の既定では出力テンプレートを無視する。run では自動的に表示される(第19.10.2節)。
env ブロックが消えている点に注目したい。env_vars の既定が空マップなので、_helpers.tpl の [[- if $env ]] が偽になってブロックごと消えた。**「値が無ければブロックを出さない」**のがテンプレートで最もよく書くパターンである。
jobspec として正しいかを Nomad に確認させる。
$ nomad-pack render ./demo | tail -n +3 > /tmp/demo.nomad
$ nomad job validate /tmp/demo.nomad
Driver configuration not validated since connection to Nomad agent couldn't be established.
Job validation successful
19.3.6 デプロイする
$ export NOMAD_ADDR=http://127.0.0.1:4646
$ nomad-pack run ./demo
Evaluation ID: 7c4ff486-ae17-ee64-f681-616abffe4a6e
Job 'demo' in pack deployment 'demo' registered successfully
2026-08-20T21:27:01+09:00: Monitoring evaluation "7c4ff486"
2026-08-20T21:27:01+09:00: Evaluation triggered by job "demo"
2026-08-20T21:27:02+09:00: Evaluation within deployment: "f3013652"
2026-08-20T21:27:02+09:00: Allocation "8a674a34" created: node "e765027e", group "app"
2026-08-20T21:27:02+09:00: Evaluation status changed: "pending" -> "complete"
2026-08-20T21:27:02+09:00: Evaluation "7c4ff486" finished with status "complete"
2026-08-20T21:27:02+09:00: Monitoring deployment "f3013652"
2026-08-20T21:27:13+09:00: Deployment "f3013652" successful
ID = f3013652
Job ID = demo
Job Version = 0
Status = successful
Description = Deployment completed successfully
Deployed
Task Group Desired Placed Healthy Unhealthy Progress Deadline
app 1 1 1 0 2026-08-20T21:37:11+09:00
Pack successfully deployed. Use /tmp/npwork/demo to manage this deployed instance with plan, stop, destroy, or info
Deployed job: demo
Instances: 1
Message: hello from pack
run は投入して終わりではなく、デプロイの完了を監視する。 そして最後に outputs.tpl を表示する。
状態を確認する。
$ nomad-pack status
PACK NAME | REGISTRY NAME
-----------+------------------
demo | <<local folder>>
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
demo | <<local folder>> | demo | demo | running
REGISTRY NAME が <<local folder>> になっている。 ローカルのディレクトリを直接指定して実行したためである。レジストリ経由なら登録名が入る(第19.9節)。
片付ける。
$ nomad-pack destroy ./demo
Job "demo" destroyed
Pack "demo" destroyed
ここまでが最小の一周である。 以降は、いま使った要素を1つずつ深く見ていく。
19.4 パックの解剖
19.4.1 ディレクトリ構造
パックは6種類のファイルからなる。 必須は2つだけである。
mypack/
├── metadata.hcl ← 必須。パックの身分証明
├── variables.hcl ← 実質必須。入力の型宣言
├── templates/
│ ├── mypack.nomad.tpl ← 必須。出力されるジョブ仕様
│ └── _helpers.tpl ← 任意。_ で始まるものは出力されない
├── outputs.tpl ← 任意。デプロイ後に表示される案内
├── README.md ← 任意。人間向け
├── CHANGELOG.md ← 任意。人間向け
└── deps/ ← 依存パックの置き場(deps vendor が作る)
└── <alias>/
| ファイル | 必須 | 役割 |
|---|---|---|
metadata.hcl | ○ | 名前・バージョン・依存宣言 |
variables.hcl | △ | 入力変数の型と既定値。無くても動くが実用性がない |
templates/*.tpl | ○ | レンダリング対象。1つも無いとエラー |
templates/_*.tpl | × | ヘルパ。単独では出力されない |
outputs.tpl | × | run の最後に表示される |
deps/<alias>/ | × | 依存パックの実体 |
テンプレートが1つも無いとエラーになる。 中身が空のテンプレートも同じである。
$ nomad-pack render ./probe
! No Templates Rendered
! Error: no templates were rendered by the renderer process run
このエラーは「テンプレートファイルが無い」ときと「レンダリング結果が空」のときの両方で出る。 前者を疑って ls しても見つからず、実は [[ if ... ]] が全て偽だった、という詰まり方をしやすい。
19.4.2 metadata.hcl
最小形はこれである。
app {
url = ""
}
pack {
name = "hello_world"
description = ""
version = ""
}
実務で書くべき形:
app {
url = "https://github.example.com/team/myapp"
author = "Platform Team"
}
pack {
name = "webservice"
description = "Standard HTTP service with Consul registration and rolling updates"
version = "1.4.0"
}
| ブロック / 属性 | 意味 | テンプレートからの参照 |
|---|---|---|
app.url | アプリの参照先 URL。info に出る | — |
app.author | 作者 | — |
pack.name | パック名。ジョブ名の既定値として使う | meta "pack.name" . |
pack.description | 説明。info に出る | meta "pack.description" . |
pack.version | パックのバージョン | meta "pack.version" . |
pack.version について1つ重要な注意がある。
この値はレジストリの ref(タグ)とは無関係である。 nomad-pack list の「METADATA VERSION」列に出るのはこの値で、レジストリの ref は別の列に出る。
$ nomad-pack list
PACK NAME | METADATA VERSION | REGISTRY NAME
----------------------------+------------------+-----------------------------
demo | 0.1.0 | d2p@demo1.0.0 (d1f19aa3)
demo | 0.2.0 | d2p@demo1.1.0 (6db73df8)
metadata.hcl の version を更新し忘れても、ref が違えば別のパックとして扱われる。 逆に、version を上げても git のタグを打たなければ配布されない。2つは人間が同期させる責任を負う。 実務では「タグ名 = pack.version」の規約を決めてしまうのが安全である。
dependency ブロック
生成されるひな型にはコメントアウトされた例が入っている。
# dependency "demo_dep" {
# alias = "demo_dep"
# source = "git://source.git/packs/demo_dep"
# }
詳細は第19.11節で扱う。
19.4.3 variables.hcl と型システム
Terraform の variable ブロックとほぼ同じである。
variable "count" {
description = "The number of app instances to deploy"
type = number
default = 2
}
| 属性 | 必須 | 意味 |
|---|---|---|
type | 実質必須 | HCL の型制約 |
default | × | 無い場合は必須変数になる |
description | × | info と generate var-file に出る |
使える型
すべて実測で確認した。
variable "s" {
type = string
default = "x"
}
variable "n" {
type = number
default = 1
}
variable "b" {
type = bool
default = true
}
variable "ls" {
type = list(string)
default = ["a", "b"]
}
variable "ms" {
type = map(string)
default = { a = "1", b = "2" }
}
variable "obj" {
type = object({
cpu = number
memory = number
})
default = {
cpu = 100
memory = 64
}
}
variable "lobj" {
type = list(object({
label = string
count = number
}))
default = []
}
list(object({...})) が実務で最も強力である。 「タスクグループの配列」「サービスの配列」といった構造をまるごと変数にできる。
variable "task_groups" {
description = "Task groups to render"
type = list(object({
label = string
count = number
resources = object({ cpu = number, memory = number })
env_vars = map(string)
}))
default = [
{
label = "web"
count = 3
resources = { cpu = 500, memory = 512 }
env_vars = { ROLE = "web" }
},
{
label = "worker"
count = 1
resources = { cpu = 200, memory = 256 }
env_vars = { ROLE = "worker" }
}
]
}
そしてテンプレート側で range する。
[[- range $tg := var "task_groups" . ]]
group [[ $tg.label ]] count=[[ $tg.count ]] cpu=[[ $tg.resources.cpu ]] role=[[ $tg.env_vars.ROLE ]]
[[- end ]]
$ nomad-pack render ./t2 --no-format --var image_tag=v1.2.3
group web count=3 cpu=500 role=web
group worker count=1 cpu=200 role=worker
$tg.resources.cpu と $tg.env_vars.ROLE のように、入れ子を素直に辿れる。 range で束縛した変数には var 関数を使わず、Go テンプレートのフィールド参照をそのまま使う点に注意する。
これが「1つのパックで N 個のタスクグループを生む」書き方である。 大規模構成のパックはほぼ例外なくこの形になる。
型の厳格さ — 実測
型に合わない値は render 前に弾かれる。
$ nomad-pack render . --var count=abc
! Failed To Process Pack
! Error: This variable value is not compatible with the variable's type constraint: a number is required.
! Context:
! HCL Range:
! Registry Name: <<local folder>>
! Pack Name: hello_world
! Pack Ref: <<none>>
! Pack Path: /tmp/npwork/my-registry/packs/hello_world
HCL Rangeが空である。--var由来のエラーでは位置情報が取れないため、どの--varが原因かは自分で切り分ける必要がある。変数ファイル(-f)経由なら位置が出る。$ nomad-pack render . -f ov.hcl ! Error: There is no variable named "nosuchvar". ... ! HCL Range: /tmp/npwork/ov.hcl:3,1-14変数の数が多い場合は
--varを並べるより変数ファイルに書いたほうがデバッグしやすい。
必須変数
default を書かなければ必須になる。
variable "image_tag" {
description = "Required: no default"
type = string
}
値を与えないと失敗する。
$ nomad-pack render ./t2
! Failed To Process Pack
! Error: missing required variable: "image_tag" (Required: no default)
! Context:
! Registry Name: <<local folder>>
! Pack Name: t2
! Pack Ref: <<none>>
! Pack Path: /tmp/npwork/t2
description の文字列がエラーメッセージに含まれる。 したがって必須変数の description は「何を渡すべきか」を書く場所であり、エラーを読む人への唯一の手がかりになる。
variable "image_tag" {
description = "コンテナイメージのタグ。例: v1.4.0。CI が --var で渡す"
type = string
}
--allow-unset-vars(または NOMAD_PACK_ALLOW_UNSET_VARS=1)で抑制できる。 値は空になる。
$ nomad-pack render ./t2 --no-format --allow-unset-vars
tag =
抑制すべきではない。 「タグを渡し忘れたら止まる」ことが必須変数の目的であり、フラグで無効化するとその目的が消える。上の出力のように空文字列が静かに埋まるため、壊れたジョブがそのまま投入されうる。CI で --allow-unset-vars を常用しているなら、その変数に既定値を書くべきである。
variables.hcl の書式の罠
1行ブロックが書けない。
variable "nums" { type = list(number) default = [3, 1, 2] }
! Failed To Process Pack
! Error: A single-line block definition must end with a closing brace immediately after its single argument definition.
! Context:
! HCL Range: /tmp/npwork/probe/variables.hcl:1,40-47
HCL では1行ブロックに属性を2つ以上書けない。 必ず複数行にする。
variable "nums" {
type = list(number)
default = [3, 1, 2]
}
これは HCL 一般の制約で、Nomad Pack 固有ではない。しかし書き始めた最初の5分で確実に踏むので先に挙げた。
19.4.4 templates/ の命名規則
規則は1つだけである — アンダースコアで始まるファイルは出力されない。
| ファイル名 | 出力されるか | 用途 |
|---|---|---|
webservice.nomad.tpl | される | ジョブ仕様 |
_helpers.tpl | されない | define の置き場 |
_serenity_task.tpl | されない | 大きな部品を切り出す |
outputs.tpl | 特別扱い | デプロイ後の案内 |
.nomad.tpl という拡張子は慣習であり、強制ではない。 レンダリング時は .tpl が落ちるだけである。foo.nomad.tpl → foo.nomad。
出力ファイルが複数あってもよい。 1つのパックから複数の Nomad ジョブを生むことができる。
templates/
├── api.nomad.tpl → job "m-api"
├── worker.nomad.tpl → job "m-worker"
└── _shared.tpl
$ nomad-pack render ./multi
multi/api.nomad:
job "m-api" {
multi/worker.nomad:
job "m-worker" {
run は両方のジョブを投入し、status は2行返す。
$ nomad-pack status multi
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
multi | <<local folder>> | multi | m-api | running
multi | <<local folder>> | multi | m-worker | running
destroy も両方を消す。
$ nomad-pack destroy ./multi
Job "m-api" destroyed
Job "m-worker" destroyed
Pack "multi" destroyed
これは強力だが危険でもある。 「まとめて生き死にするジョブ群」を1パックにするのが正しい使い方で、独立して運用したいジョブを同居させてはいけない。 片方だけ止めたくなったときに手段がなくなる(nomad job stop を直接使えばできるが、status の表示と実態がずれる)。
19.4.5 outputs.tpl
run の完了後に表示される。 Helm の NOTES.txt と同じ役割で、テンプレート言語がそのまま使える。
Deployed job: [[ coalesce (var "job_name" .) (meta "pack.name" .) ]]
Instances: [[ var "count" . ]]
Message: [[ var "message" . ]]
Deployed job: demo
Instances: 1
Message: hello from pack
render では既定で表示されない — --render-output-template が必要である。
実務での使い方: 次に何をすべきかを書く。
デプロイ完了: [[ var "job_name" . ]]
ログの確認:
nomad alloc logs -job [[ var "job_name" . ]]
ヘルスチェック:
curl https://[[ var "service_name" . ]].service.consul:[[ var "port" . ]]/health
[[ if not (var "enable_monitoring" .) -]]
⚠ 監視が無効になっています。本番では enable_monitoring = true にしてください。
[[- end ]]
条件付きの警告を出せる点が有用で、「危険な設定でデプロイした」ことを人間に伝えられる。
19.4.6 CHANGELOG.md と README.md
どちらも nomad-pack は読まない。 人間向けである。ただしレジストリを共有する場合は実質必須になる。
generate が作るひな型はこうなっている。
## Version v0.0.1 (Unreleased)
Initial Release
README.md に書くべきことは決まっている。
# webservice
## 用途
標準的な HTTP サービス用のパック。Consul 登録とローリング更新を含む。
## 必須変数
| 変数 | 型 | 説明 |
|---|---|---|
| `image_tag` | string | コンテナイメージのタグ |
## 使い方
nomad-pack run webservice --registry=prod --ref=v1.4.0 -f vars/api.hcl
## 破壊的変更
- v1.0.0 → v1.1.0: テンプレート記法を v1 から v2 へ変更(第19.5.6節)
「破壊的変更」の節が最も重要である。 レジストリの ref を上げる人が最初に読む場所になる。
19.5 テンプレート言語
19.5.1 なぜ [[ ]] なのか
Nomad Pack のテンプレートは Go の text/template である。 ただし区切り文字が {{ }} ではなく [[ ]] に変更されている。
理由は Nomad のジョブ仕様が {{ }} を既に使っているためである。
Nomad の template ブロックは consul-template の構文を使い、そこで {{ }} が予約されている。
template {
data = <<EOH
{{ with secret "secret/data/myapp" }}
PASSWORD={{ .Data.data.password }}
{{ end }}
EOH
destination = "secrets/env"
env = true
}
もし Nomad Pack が {{ }} を使っていたら、この部分をパックのテンプレートに書けなくなる。 区切りをずらすことで、2つのテンプレート層が同じファイルに共存できる。
[[ /* ここは nomad-pack が処理する(パック生成時) */ ]]
template {
data = <<EOH
{{ /* ここは consul-template が処理する(タスク起動時) */ }}
DB_HOST=[[ var "db_host" . ]]
PASSWORD={{ with secret "secret/data/[[ var "app" . ]]" }}{{ .Data.data.password }}{{ end }}
EOH
}
この二層構造は Nomad Pack を使う最大の理由の1つであると同時に、最も混乱を招く箇所でもある。読むときは常に「これはどちらの層か」を意識する。
| 記法 | 処理するもの | いつ処理されるか |
|---|---|---|
[[ ... ]] | nomad-pack | render / run の時点(デプロイ前) |
{{ ... }} | consul-template(Nomad クライアント内) | タスク起動時と、以降の再レンダリング時 |
${ ... } | Nomad(HCL2 と実行時補間) | 第19.8.3節 |
3種類ある。 これを取り違えると「変数が展開されない」という症状で延々と詰まる。
19.5.2 3つの組み込み関数
nomad-pack が追加した関数は3つだけである。 残りは Go の組み込みと sprig(第19.6節)である。
var — 変数を読む
[[ var "count" . ]]
第2引数の . がコンテキストである。 これを省略できない点が Go テンプレートに慣れた人には奇妙に見えるが、依存パックを扱うために必要な設計である(第19.5.4節)。
オブジェクト型はドットで辿れる。
[[ var "resources.cpu" . ]]
[[ var "resources.memory" . ]]
実測で確認した。
$ nomad-pack render ./demo
resources {
cpu = 100
memory = 64
}
マップも同じ書き方で引けるが、キーにピリオドが含まれる場合は辿れない。
variable "sysctl" {
type = map(string)
default = {
"net.ipv4.tcp_keepalive_time" = "60"
}
}
3つの書き方を実測で比べた。
dotted-direct = |[[ var "sysctl.net.ipv4.tcp_keepalive_time" . ]]|
dotted-index = |[[ index (var "sysctl" .) "net.ipv4.tcp_keepalive_time" ]]|
dotted-get = |[[ get (var "sysctl" .) "net.ipv4.tcp_keepalive_time" ]]|
$ nomad-pack render ./t2 --no-format --var image_tag=v1.2.3
dotted-direct = ||
dotted-index = |60|
dotted-get = |60|
var のドット記法は空文字列を返し、エラーにならない。 var はドットをパスの区切りとして分解するため、sysctl の下に net というキーを探して見つからず、静かに空になる。
したがってピリオドを含むキーは index か get で引く。 あるいはマップ全体を range する。
sysctl = {
[[- range $k, $v := var "sysctl" . ]]
"[[ $k ]]" = "[[ $v ]]"
[[- end ]]
}
キーを " で囲む点も必要である。HCL ではピリオドを含む属性名を裸で書けない。
meta — メタデータを読む
[[ meta "pack.name" . ]]
[[ meta "pack.version" . ]]
[[ meta "pack.description" . ]]
metadata.hcl の値を読む。 最も頻出するのは「ジョブ名の既定値としてパック名を使う」パターンである。
[[- define "job_name" -]]
[[ coalesce (var "job_name" .) (meta "pack.name" .) | quote ]]
[[- end -]]
coalesce は「最初の非空値を返す」sprig 関数である。 job_name 変数が空文字列ならパック名を使う。この3行が Nomad Pack のイディオムとして最も広く使われている。
template — 部品を呼ぶ
[[ template "job_name" . ]]
Go テンプレートの template アクションそのままである。define で定義した名前を呼ぶ。
第2引数がコンテキストである点が重要で、依存パックのヘルパを呼ぶときは渡すものを変える必要がある(第19.11.4節)。
19.5.3 空白の制御
[[- と -]] はテンプレート前後の空白を削る。 Go テンプレートの標準機能である。
| 記法 | 効果 |
|---|---|
[[- x ]] | 直前の空白(改行を含む)を削る |
[[ x -]] | 直後の空白を削る |
[[- x -]] | 両方 |
これを理解しないと出力が空行だらけになる。 実例で見る。
制御しない場合:
[[ if var "register_service" . ]]
service {
name = "[[ var "service_name" . ]]"
}
[[ end ]]
service {
name = "webapp"
}
if と end の行が空行として残る。 第19.3.2節のサンプルの出力に空行が多かったのはこれが原因である。
制御した場合:
[[- if var "register_service" . ]]
service {
name = "[[ var "service_name" . ]]"
}
[[- end ]]
service {
name = "webapp"
}
実務での指針は単純である。
制御構文(if / range / define / end)の行には [[- ... -]] または [[- ... ]] を使う
値を埋める箇所([[ var ... ]])には使わない
そして深追いしないことも指針である。 Nomad Pack はレンダリング結果を HCL フォーマッタに通すため(第19.8.1節)、余分な空行と字下げは自動的に整えられる。 出力が HCL として妥当なら、空白の見た目に神経を使う必要は薄い。
range の中で使う代表形を挙げる。
env {
[[- range $k, $v := var "env_vars" . ]]
[[ $k ]] = [[ $v | quote ]]
[[- end ]]
}
env {
APP = "child"
LOG_LEVEL = "info"
TZ = "UTC"
}
range がマップを回すとき、Go はキーをソートしてから回す。 したがって環境変数の並びは決定的である。リストの range は宣言順のままである。
19.5.4 コンテキストの実体
. の中身を実際に覗いてみる。 これが分かると var "x" . の設計理由が理解できる。
toJson . = [[ toJson . ]]
依存パック defaults を1つ持つパックで実行した結果を、構造だけ取り出すとこうなっている。
{
"_self": {
"Pack": {
"Path": "/tmp/npwork/child",
"Metadata": {
"App": { "URL": "https://example.internal/child", "Author": "" },
"Pack": { "Name": "child", "Version": "0.1.0", "Description": "..." },
"Dependencies": [
{ "Name": "defaults", "Alias": "defaults",
"Source": "git::/tmp/npwork/deprepo//packs/defaults", "Enabled": true }
]
},
"TemplateFiles": [ { "Name": "...", "Path": "...", "Content": "<base64>" } ],
"RootVariableFile": { "Name": "variables.hcl", "Content": "<base64>" }
}
},
"defaults": {
"_self": {
"Pack": {
"Path": "/tmp/npwork/child/deps/defaults",
"Metadata": { "Pack": { "Name": "defaults", "Version": "1.0.0" } },
"TemplateFiles": [ ... ],
"RootVariableFile": { ... }
}
}
}
}
構造は2階層である。
. ← PackTemplateContext
├── "_self" ← いま処理しているパック
└── "<依存のエイリアス>" ← 依存パックごとに1つ
└── "_self"
したがって var "x" . は「. が指すパックの変数 x」を意味する。 ルートテンプレートでは . の _self が自分自身なので、var "x" . は自分の変数を引く。依存パックの変数を引くには . の代わりに .<エイリアス> を渡す。
[[ var "base_env" .defaults ]]
[[ var "base_resources.cpu" .defaults ]]
[[ var "job_name" ._self ]]
実測結果:
A = map[LOG_LEVEL:info TZ:UTC]
B = 200
C = child
var "defaults.base_env" . は動かない。 名前にエイリアスを含める形ではなく、コンテキストを切り替える形である。
B defaults.base_env =
C defaults.base_res.cpu=
空文字列が返るだけでエラーにならない。 これが最も気づきにくい失敗の形である。**「値が空になったら参照形式を疑う」**のが切り分けの第一手になる。
19.5.5 デバッグ手法
toJson . でコンテキストを覗く
[[ toJson . ]]
最強のデバッグ手段である。 ただし2つ注意がある。
① keys . は使えない。
$ nomad-pack render .
! Failed To Process Pack
! Error: wrong type for value; expected map[string]interface {}; got parser.PackTemplateContext
コンテキストは Go の map[string]interface{} ではなく専用の型である。 sprig のマップ関数はそのままでは使えない。toJson は通る。
② テンプレートと変数ファイルの中身が base64 で全部出る。 RootVariableFile.Content と TemplateFiles[].Content に、ファイルの内容がそのまま base64 で入っている。変数ファイルに秘密情報を書いていた場合、toJson . の出力に載る。 デバッグ出力をチケットに貼る前に確認する。
値を1つずつ確認する
構造が複雑なときは、jobspec の形を捨てて値だけを並べるのが速い。
A extra_env = [[ var "extra_env" . ]]
B base_env = [[ var "base_env" .defaults ]]
C cpu = [[ var "resources.cpu" . ]]
$ nomad-pack render . --no-format
child/child.nomad:
A extra_env = map[APP:child]
B base_env = map[LOG_LEVEL:info TZ:UTC]
C cpu = 100
--no-format を付ける。 付けないと HCL フォーマッタが非 HCL の出力を壊す(第19.8.1節)。
エラーメッセージの読み方
nomad-pack のエラーは位置情報が付く。
! Failed To Process Pack
! Error: wrong type for value; expected map[string]interface {}; got string
! Context:
! Filename: child/templates/child.nomad.tpl
! Position: 12,70
Position: 12,70 は「12行目70桁」である。 テンプレートの行番号なので、そのまま該当行を見ればよい。
未定義関数の場合は行番号だけになる。
! Error: template: probe/templates/probe.nomad.tpl:64: function "required" not defined
19.5.6 v1 記法と v2 記法
Nomad Pack はテンプレート記法を一度非互換に変えている。 古いパックを読むとき、また移行を担当するときに必要な知識である。
| v1 記法 | v2 記法(現行) | |
|---|---|---|
| 自分の変数 | [[ .my.message ]] | [[ var "message" . ]] |
| パック名 | [[ .nomad_pack.pack.name ]] | [[ meta "pack.name" . ]] |
| 依存の変数 | [[ .<alias>.<var> ]] | [[ var "<var>" .<alias> ]] |
| 有効化 | --parser-v1 | 既定 |
v1 記法を既定のパーサに通すと、親切なエラーが出る。
$ nomad-pack render ./v1pack
! Failed To Process Pack
! Error: pack "my" not found when accessing ".my.message"
! Details: The referenced pack was not found in the template context.
! Suggestions: The legacy ".my.message" syntax should be updated to use `var
! "message" .`. You can run legacy packs unmodified by using the
! `--parser-v1` flag
! Context:
! Filename: v1pack/templates/v1pack.nomad.tpl
! Position: 1,18
.my が「my という名前の依存パック」として解釈されている点に注目したい。v2 のコンテキスト構造(第19.5.4節)を踏まえると、なぜこのエラー文になるのかが分かる。
--parser-v1 を付ければ v1 記法は動く。
$ nomad-pack render ./v1pack --parser-v1 --no-format
v1pack/v1pack.nomad:
message = "v1 style"
count = 2
packname = "v1pack"
2つの記法は共存できない
ここが移行計画に直結する。 --parser-v1 を付けた状態で v2 記法を使うとエラーになる。
$ nomad-pack render ./v1pack --parser-v1
! Failed To Process Pack
! Error: var is not implemented for nomad-pack's v1 syntax
! Suggestions: Verify that the `--parser-v1` flag is not set when running this
! pack.
したがって「パックを少しずつ v2 に移す」ことはできない。 1つのパック内で記法を混在させられないため、パック単位で一括変換するしかない。
移行の手順
大規模な環境で実際に使われている手順を一般化すると、次の4段になる。
Step 1. すべての実行環境の nomad-pack を、--parser-v1 を理解するバージョンへ上げる
Step 2. パック側の記法を v1 → v2 に一括変換し、新しいタグで公開する
Step 3. 利用側(変数ファイル / CI / IaC)が参照する ref を、新しいタグへ順次上げる
Step 4. --parser-v1 を渡す分岐を消す
Step 3 が「順次」である点が要点である。 レジストリは ref ごとに独立してキャッシュされる(第19.9.4節)ので、「アプリ A は新記法のタグ、アプリ B はまだ旧記法のタグ」という状態を安全に保てる。 これが移行を段階的に進められる理由である。
呼び出し側では、ref の名前からパーサを選ぶ分岐を書くことになる。
# パックのバージョン文字列から v1 パーサの要否を決める例
if [[ "$PACK_VERSION" == v0.* ]]; then
PARSER="--parser-v1"
else
PARSER=""
fi
nomad-pack render "$PACK_NAME" $PARSER --registry=myreg --ref "$PACK_VERSION" -f vars.hcl
この分岐は Step 4 で消すために存在する。 消す前提のコードだと分かるようにコメントを残すのが実務的である。
19.6 sprig 関数リファレンス
19.6.1 sprig とは何か
Go の text/template は組み込み関数を19個しか持たない。
and call html index slice js len not or
print printf println urlquery
eq ge gt le lt ne
upper も default も join も無い。 文字列を大文字にすることすらできない。
sprig はこの不足を埋める関数ライブラリである。 Helm が採用したことで事実上の標準になり、Nomad Pack も同じものを組み込んでいる。したがって Helm のテンプレートを書いたことがあれば、知識はほぼそのまま使える。
「sprig コマンド」ではない。 sprig は CLI ツールではなく Go のライブラリであり、
nomad-packのバイナリに静的にリンクされている。単体でインストールすることも、単体で実行することもできない。テンプレートの中で関数として呼ぶだけである。
19.6.2 どういう名前で使えるのか
実測で確認した — プレフィックスは付かない。
[[ upper "abc" ]] ← 使える
[[ sprig_upper "abc" ]] ← 使えない
$ nomad-pack render ./probe
! Failed To Process Pack
! Error: template: probe/templates/probe.nomad.tpl:13: function "sprig_upper" not defined
一部のツール(Nomad の consul-template 層など)は sprig 関数に接頭辞を付けるが、Nomad Pack は付けない。 そのまま裸の名前で呼ぶ。
nomad-pack が追加した関数
sprig と Go 組み込みのほかに、Nomad Pack 固有の関数が4つある。
| 関数 | 用途 |
|---|---|
var "<名前>" <ctx> | 変数を読む(第19.5.2節) |
meta "<キー>" <ctx> | metadata.hcl を読む(第19.5.2節) |
toStringList | Go のリストを HCL のリスト literal にする |
fileContents / spec 等 | 補助的なもの(本章では扱わない) |
toStringList を知らないと必ず詰まる。
datacenters = [[ var "datacenters" . ]]
datacenters = [[ var "datacenters" . | toStringList ]]
datacenters = [dc1 dc2] ← HCL として不正
datacenters = ["dc1", "dc2"] ← 正しい
Go のリストをそのまま出すと [dc1 dc2] になる(Go の fmt の既定書式)。HCL のリストにするには toStringList を通す。文字列のリスト型変数を出力するときは常に付けると覚えてよい。
toStringList を数値のリストに使うと壊れる
これは静かに壊れるので特に注意が必要である。
variable "nums" {
type = list(number)
default = [1, 2]
}
$ nomad-pack render ./p3v --no-format
A=['\x01', '\x02']
数値が制御文字として解釈されている。 toStringList は各要素を文字列に変換するのではなくバイト値/ルーンとして扱うため、1 が \x01 になる。エラーにならず、HCL として不正な文字列が出る。
数値やその他のリストには toJson を使う。
$ nomad-pack render ./p3v --no-format
A=[1,2]
ports = [[ var "ports" . | toJson ]]
JSON の配列リテラルは HCL のリストリテラルとしてそのまま妥当である。 これが最も簡潔な解である。
range で組み立てる方法も使える。区切りの制御が必要な場合に有用である。
[[ range $i, $p := var "nums" . ]][[ if $i ]], [[ end ]][[ $p ]][[ end ]]
1, 2
まとめると次のようになる。
| 変数の型 | 出力に使うもの |
|---|---|
list(string) | toStringList |
list(number) / list(bool) | toJson |
map(string) | range でキーと値を並べる |
| ネストした構造 | toJson、または range で組み立てる |
19.6.3 文字列
すべて実測した出力である。
| 関数 | 例 | 結果 |
|---|---|---|
upper | [[ upper "abc" ]] | ABC |
lower | [[ lower "ABC" ]] | abc |
title | [[ title "hello world" ]] | Hello World |
trunc | [[ trunc 3 "abcdef" ]] | abc |
trim | [[ trim " x " ]] | x |
replace | [[ "a-b-c" | replace "-" "_" ]] | a_b_c |
trimSuffix | [[ "app.jar" | trimSuffix ".jar" ]] | app |
trimPrefix | [[ "v1.2.3" | trimPrefix "v" ]] | 1.2.3 |
contains | [[ contains "use1" "prod-use1-broker" ]] | true |
hasPrefix | [[ hasPrefix "prod" "prod-use1" ]] | true |
hasSuffix | [[ hasSuffix "-dr" "api-dr" ]] | true |
splitList | [[ splitList "," "a,b,c" ]] | [a b c] |
join | [[ list "a" "b" | join "," ]] | a,b |
printf | [[ printf "%s-%d" "x" 3 ]] | x-3 |
quote | [[ quote "hi" ]] | "hi" |
squote | [[ squote "hi" ]] | 'hi' |
indent | [[ indent 4 "x" ]] | x |
nindent | [[ nindent 4 "x" ]] | 改行 + x |
引数の順序に注意する
sprig の関数は「パイプで渡しやすい順序」で引数を取る。 これが直感と逆になることがある。
[[ contains "use1" "prod-use1-broker" ]] → true
「use1 を含むか? prod-use1-broker が」という順序である。 つまり contains <探す文字列> <対象> である。パイプで書くと自然になる。
[[ var "job_name" . | contains "use1" ]]
trunc / indent / replace も同じで、対象が最後に来る。 迷ったらパイプで書くのが安全である。
quote の使いどころ
HCL の文字列を出力するときは quote を使う。
MESSAGE = [[ var "message" . | quote ]]
MESSAGE = "Hello World!"
自分で " を書くと二重引用の事故が起きる。 実際に踏んだ例を挙げる。
args = ["-c", "echo [[ var "message" . | quote ]]"]
args = ["-c", "while true; do echo " hello from pack "; sleep 10; done"]
すでに " の中にいる場所で quote を使ってはいけない。 上の例では quote を外すのが正解である。
args = ["-c", "echo [[ var "message" . ]]"]
判断基準: 出力する場所が「HCL の値そのもの」なら quote、「すでに文字列リテラルの内側」なら素で出す。
indent と nindent
複数行の値を字下げして埋めるときに使う。
template {
data = <<EOH
[[ var "config_body" . | indent 10 ]]
EOH
}
| 関数 | 挙動 |
|---|---|
indent N s | すべての行の先頭に N 個の空白を付ける |
nindent N s | 先頭に改行を1つ入れてから indent N する |
実測:
indent = | x|
nindent = |
x|
nindent は「キーの直後に改行して字下げ済みの塊を置く」ときに便利である。
env {[[ var "env_block" . | nindent 8 ]]
}
19.6.4 既定値と存在確認
Nomad Pack で最も出番が多い群である。
| 関数 | 例 | 結果 | 意味 |
|---|---|---|---|
default | [[ "" | default "fallback" ]] | fallback | 空なら既定値 |
coalesce | [[ coalesce "" "" "third" ]] | third | 最初の非空値 |
empty | [[ empty "" ]] | true | 空か |
ternary | [[ ternary "yes" "no" true ]] | yes | 3項演算 |
default の「空」の定義に注意する
sprig の default は「ゼロ値なら既定値」であり、「未定義なら」ではない。
| 値 | default "X" の結果 |
|---|---|
"" | X |
0 | X |
false | X |
[] / {} | X |
"0" | 0(文字列なので非空) |
0 と false が既定値に置き換わるのが事故の元である。
count = [[ var "count" . | default 3 ]]
count = 0 を意図的に渡しても 3 になる。 「スケールを 0 に落とす」操作が効かない。
対策は variables.hcl の側で既定値を持つことである。
variable "count" {
type = number
default = 3
}
count = [[ var "count" . ]]
Nomad Pack では variables.hcl に型と既定値を書けるので、テンプレート側で default を使う必要はほとんど無い。 default が必要になるのは「オブジェクトの一部のフィールドが空かもしれない」場合に限られる。
coalesce の定番用法
[[- define "job_name" -]]
[[ coalesce (var "job_name" .) (meta "pack.name" .) | quote ]]
[[- end -]]
「変数が空ならパック名を使う」というイディオムである。 variables.hcl 側で default = "" としておき、テンプレートで coalesce する。なぜ default ではなく coalesce なのか — default は「既定値」を第1引数に取るためパイプの向きが逆になり、meta の呼び出しを括弧で囲む必要が出て読みにくくなる。慣習として coalesce が使われている。
19.6.5 型変換
| 関数 | 例 | 結果 |
|---|---|---|
toString | [[ 42 | toString | printf "%q" ]] | "42" |
atoi | [[ atoi "42" ]] | 42 |
int64 | [[ int64 "7" ]] | 7 |
float64 | [[ float64 "1.5" ]] | 1.5 |
toJson | [[ var "m" . | toJson ]] | {"a":"1","b":"2"} |
toPrettyJson | [[ list 1 2 | toPrettyJson ]] | 整形された JSON |
toStringList | [[ list "a" "b" | toStringList ]] | ["a", "b"] |
toJson は JSON が欲しいときだけでなく、デバッグでも多用する(第19.5.5節)。
19.6.6 リスト
| 関数 | 例 | 結果 |
|---|---|---|
list | [[ list "a" "b" ]] | [a b] |
sortAlpha | [[ list "c" "a" "b" | sortAlpha ]] | [a b c] |
uniq | [[ list "a" "a" "b" | uniq ]] | [a b] |
compact | [[ list "a" "" "b" | compact ]] | [a b] |
first | [[ list "a" "b" | first ]] | a |
last | [[ list "a" "b" | last ]] | b |
rest | [[ list "a" "b" "c" | rest ]] | [b c] |
reverse | [[ list "a" "b" | reverse ]] | [b a] |
has | [[ has "b" (list "a" "b") ]] | true |
concat | [[ concat (list "a") (list "b") ]] | [a b] |
without | [[ without (list "a" "b") "a" ]] | [b] |
len | [[ len (list "a" "b" "c") ]] | 3 |
タグの合成 — 実務での定番
サービスタグを複数の層から集めて重複を消すという処理は、どの環境でも書くことになる。
tags = [[ concat (var "common_tags" .) (var "group_tags" .) | uniq | sortAlpha | toStringList ]]
tags = ["appname:api", "traffic_profile:int", "traffic_profile:pci"]
uniq → sortAlpha → toStringList の順が重要である。
| 順序 | 理由 |
|---|---|
concat 最初 | 全部集める |
uniq 次 | 重複を消す |
sortAlpha 次 | 並びを決定的にする |
toStringList 最後 | HCL のリテラルにする |
sortAlpha を入れる理由が重要である。 並びが不定だと、内容が同じでも nomad-pack plan が「差分あり」と報告し続ける。決定的な出力は Nomad Pack を CI で使うための前提条件である(第19.6.11節)。
concat は重複を消さない
[[ concat (list "a") (list "a") ]] → [a a]
extra_hosts のようなリストを複数層から concat すると、同じエントリが2回入る。 コンテナの /etc/hosts に同じ行が2つ並んでも動作はするが、意図しない状態である。uniq を通す習慣を付ける。
19.6.7 辞書 — 継承の中核
この群が Nomad Pack で最も重要である。 「既定値をパックが持ち、利用側が一部だけ上書きする」という継承構造を、この3関数で実装する。
| 関数 | 例 | 結果 |
|---|---|---|
dict | [[ dict "k" "v" | toJson ]] | {"k":"v"} |
keys | [[ var "m" . | keys | sortAlpha ]] | [a b] |
values | [[ var "m" . | values | sortAlpha ]] | [1 2] |
hasKey | [[ hasKey (var "m" .) "a" ]] | true |
get | [[ get (var "m" .) "a" ]] | 1 |
dig | [[ dig "zz" "MISS" (var "m" .) ]] | MISS |
set | [[ set (var "m" . | deepCopy) "c" "3" | toJson ]] | {"a":"1","b":"2","c":"3"} |
unset | [[ unset (var "m" . | deepCopy) "a" | toJson ]] | {"b":"2"} |
pick | [[ pick (var "m" .) "a" | toJson ]] | {"a":"1"} |
omit | [[ omit (var "m" .) "a" | toJson ]] | {"b":"2"} |
merge | [[ merge (dict "a" "X") (var "m" .) | toJson ]] | {"a":"X","b":"2"} |
mergeOverwrite | [[ mergeOverwrite (var "m" . | deepCopy) (dict "a" "X") | toJson ]] | {"a":"X","b":"2"} |
deepCopy | [[ var "m" . | deepCopy | toJson ]] | {"a":"1","b":"2"} |
merge と mergeOverwrite の違い
この2つを取り違えると継承の向きが逆になる。
merge dst src... → dst に「無いキーだけ」を src から埋める(dst が勝つ)
mergeOverwrite dst src... → src の値で dst を「上書きする」(src が勝つ)
実測で並べる。var "m" . は {"a":"1","b":"2"} である。
merge (dict "a" "X") (var "m" .) → {"a":"X","b":"2"}
mergeOverwrite (var "m" . | deepCopy) (dict "a" "X") → {"a":"X","b":"2"}
どちらも同じ結果に見えるが、勝っている側が違う。
| 第1引数 | 追加の引数 | どちらが勝つか | |
|---|---|---|---|
merge | {"a":"X"} | {"a":"1","b":"2"} | 第1引数(a は X のまま) |
mergeOverwrite | {"a":"1","b":"2"} | {"a":"X"} | 後の引数(a が X に変わる) |
継承を書くときの選び方:
mergeOverwrite <弱い層> <強い層> ... ← 引数の順が「弱→強」。読みやすい
merge <強い層> <弱い層> ... ← 引数の順が「強→弱」。読みにくい
mergeOverwrite を使い、引数を弱い順に並べるのが推奨である。「後に書いたものが勝つ」という直感に一致する。
deepCopy が必須である理由
merge と mergeOverwrite は第1引数を破壊的に変更する。
[[- $a := mergeOverwrite (var "defaults" .) (var "override1" .) ]]
[[- $b := mergeOverwrite (var "defaults" .) (var "override2" .) ]]
この2行を書くと $b が壊れる。 1行目で var "defaults" . の中身が override1 で書き換えられてしまい、2行目はその汚染された値を出発点にする。
正しい書き方:
[[- $a := mergeOverwrite (deepCopy (var "defaults" .)) (var "override1" .) ]]
[[- $b := mergeOverwrite (deepCopy (var "defaults" .)) (var "override2" .) ]]
deepCopy を必ず挟む。 これが Nomad Pack のテンプレートで deepCopy が頻出する理由である。
破壊性の実証
言葉だけでは信じにくいので、実際に汚染される様子を示す。 var "m" . の既定値は {"a":"1","b":"2"} である。
A=[[ mergeOverwrite (var "m" .) (dict "a" "FIRST") | toJson ]]
B=[[ mergeOverwrite (var "m" .) (dict "b" "SECOND") | toJson ]]
$ nomad-pack render ./p3v --no-format
A={"a":"FIRST","b":"2"}
B={"a":"FIRST","b":"SECOND"}
B の a が FIRST になっている。 B は a に触れていないのに、A 行の副作用が残っている。deepCopy を入れると期待どおりになる。
A=[[ mergeOverwrite (deepCopy (var "m" .)) (dict "a" "FIRST") | toJson ]]
B=[[ mergeOverwrite (deepCopy (var "m" .)) (dict "b" "SECOND") | toJson ]]
A={"a":"FIRST","b":"2"}
B={"a":"1","b":"SECOND"}
この汚染は「テンプレートの上の方を書き換えたら下の方の出力が変わった」という形で現れる。 原因を推測するのが難しい種類の不具合なので、merge / mergeOverwrite / set / unset の第1引数には常に deepCopy を通すという機械的な規則にしてしまうのが安全である。
実務で最もよく見る形はこれである。
[[- $env := mergeOverwrite (deepCopy (var "base_env" .defaults)) (var "extra_env" ._self) -]]
env {
[[- range $k, $v := $env ]]
[[ $k ]] = [[ $v | quote ]]
[[- end ]]
}
実際に動かした結果:
env {
APP = "child"
LOG_LEVEL = "info"
TZ = "UTC"
}
base_env は {LOG_LEVEL: info, TZ: UTC}、extra_env は {APP: child} である。 依存パックの既定値に、利用側の追加分が重ねられている。
mergeOverwrite は深いマージである
mergeOverwrite は入れ子も再帰的にマージする。 実測で確認した。
弱: { resources = { cpu = 100, memory = 64 }, count = 1 }
強: { resources = { memory = 512 } }
deep=[[ mergeOverwrite (deepCopy (var "weak" .)) (dict "resources" (dict "memory" 512)) | toJson ]]
$ nomad-pack render ./p3v --no-format
deep={"count":1,"resources":{"cpu":100,"memory":512}}
cpu が残り、count も残る。 これが「深いマージ」の意味である。浅い上書きなら resources ごと置き換わって cpu が消える。
この性質が「既定値の一部だけを変える」という運用を可能にしている。 利用側は変えたいフィールドだけを書けばよい。
dig — 深い階層から安全に取り出す
[[ dig "a" "MISS" (var "m" .) ]] → 1
[[ dig "zz" "MISS" (var "m" .) ]] → MISS
dig <キー>... <既定値> <辞書> である。 キーを何個でも並べられ、途中で見つからなければ既定値を返す。
[[ dig "tasks" "web" "resources" "cpu" 100 (var "config" .) ]]
「あれば使う、無ければ既定値」を1行で書ける。 hasKey を入れ子で書く必要がない。実測で多段の動作を確認した。
dig3 = [[ dig "resources" "cpu" 999 (var "weak" .) ]]
dig3miss = [[ dig "resources" "nope" 999 (var "weak" .) ]]
dig3=100
dig3miss=999
引数の順序が特殊である点に注意する。キーを並べ、既定値が最後から2番目、辞書が最後である。
19.6.8 数値
| 関数 | 例 | 結果 |
|---|---|---|
add | [[ add 1 2 3 ]] | 6 |
sub | [[ sub 5 2 ]] | 3 |
mul | [[ mul 2 3 ]] | 6 |
div | [[ div 7 2 ]] | 3 |
mod | [[ mod 7 2 ]] | 1 |
max | [[ max 1 9 3 ]] | 9 |
min | [[ min 1 9 3 ]] | 1 |
ceil | [[ ceil 1.2 ]] | 2 |
floor | [[ floor 1.8 ]] | 1 |
round | [[ round 1.5 0 ]] | 2 |
div は整数除算である。 div 7 2 は 3 になる。小数が必要なら divf を使う。
実務での用途はリソース計算である。
resources {
cpu = [[ var "base_cpu" . ]]
memory = [[ mul (var "base_memory" .) (var "memory_multiplier" .) ]]
}
ただし計算をテンプレートに埋めるのは慎重にしたい。 レンダリング結果を見るまで値が分からなくなるため、nomad-pack render での確認が前提になる。
19.6.9 符号化とハッシュ
| 関数 | 例 | 結果 |
|---|---|---|
b64enc | [[ b64enc "hi" ]] | aGk= |
b64dec | [[ b64dec "aGk=" ]] | hi |
sha256sum | [[ sha256sum "hi" ]] | 8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4 |
sha1sum | [[ sha1sum "hi" ]] | (40桁の16進) |
adler32sum | [[ adler32sum "hi" ]] | (数値) |
sha256sum の実用的な使い道が1つある — 設定内容のハッシュを meta に入れて、内容が変わったときだけ再デプロイを起こす。
meta {
config_hash = "[[ var "app_config" . | toJson | sha256sum ]]"
}
meta の値が変わると Nomad はジョブの更新と見なす。 設定ファイルを外部から配る構成で「配る中身は変わったがジョブ仕様は変わらない」ときに、これで再起動を誘発できる。Kubernetes で ConfigMap のハッシュを Pod の annotation に入れるのと同じ手法である。
秘密情報のハッシュを入れてはいけない。 meta は Nomad の API とダッシュボードから誰でも読める。
19.6.10 正規表現
| 関数 | 例 | 結果 |
|---|---|---|
regexMatch | [[ regexMatch "^prod-" "prod-use1" ]] | true |
regexReplaceAll | [[ regexReplaceAll "[0-9]+" "prod-use1" "N" ]] | prod-useN |
regexFindAll | [[ regexFindAll "[0-9]+" "a1b22" -1 ]] | [1 22] |
regexSplit | [[ regexSplit "-" "a-b-c" -1 ]] | [a b c] |
引数の順序が regexReplaceAll <正規表現> <対象> <置換文字列> である点に注意する。対象が2番目に来るため、他の sprig 関数と並びが違う。
実務では contains / hasPrefix で足りることが多い。 ジョブ名から環境やリージョンを判別するような処理は、正規表現よりも単純な文字列関数の方が読みやすい。
[[- if var "job_name" . | contains "-use1" ]]
spread {
attribute = "${node.datacenter}"
target "use1-az1" { percent = 50 }
target "use1-az2" { percent = 50 }
}
[[- end ]]
命名規約への暗黙の依存が生まれる点は意識しておきたい。上の例は「ジョブ名に
-use1が含まれる」ことを前提にしている。命名規約を変えると、エラーにならずspreadブロックが静かに消える。 この種の条件分岐を書くなら、規約を README に明記し、可能なら専用の変数(var "region_key" .)に切り出す方が安全である。
19.6.11 使ってはいけない関数
sprig には Nomad Pack で使うべきでない関数が含まれている。 使えてしまうので、意識して避ける必要がある。
① 非決定的な関数
実測で確認した — すべて動く。
now = 2026-08-20T21:09:31+09:00
uuidv4 = 3ab50610-14d9-4d88-ba35-4c76a6118963
randAlpha = Wwk450Lx
同じテンプレートを2回レンダリングすると値が変わる。
1回目: uuidv4 = 3ab50610-14d9-4d88-ba35-4c76a6118963
2回目: uuidv4 = fb4b57cb-e04f-4e77-8e91-729c74b19739
| 関数 | 何が起きるか |
|---|---|
now | レンダリング時刻が jobspec に埋まる |
uuidv4 | 毎回違う UUID |
randAlphaNum / randAlpha / randNumeric / randAscii | 毎回違う文字列 |
これらを使うと nomad-pack plan が常に「差分あり」を返す。
$ nomad-pack plan ./demo
+/- Job: "demo"
CI で「差分が無ければスキップ」という判断ができなくなる。 さらに悪いことに、何も変えていないのに run するたびにアロケーションが置き換わる。 本番で 100 アロケーションが理由なく再起動する事故になる。
代替:
| やりたいこと | 正しい方法 |
|---|---|
| 一意な ID が欲しい | Nomad の実行時変数 ${NOMAD_ALLOC_ID} を使う |
| デプロイ時刻を記録したい | CI が --var deployed_at=... で渡す |
| ランダムな値が欲しい | Vault の動的シークレット(第9章) |
| 再デプロイを強制したい | meta に固定の連番を置き、人間が上げる |
最後の「再デプロイを強制する」パターンは実務でよく使われる。
variable "bump_deployment" {
description = "値を変えると強制的に再デプロイされる。内容に意味はない"
type = string
default = "1"
}
uuidv4 を使えば毎回再デプロイできるが、それは「制御できない」ということである。 明示的な変数にすることで、再デプロイのタイミングを人間が握れる。
② env と expandenv
レンダリングを実行したホストの環境変数が読める。
$ export MY_PROBE_VAR=leaked-secret
$ nomad-pack render ./probe --no-format
env = leaked-secret
expandenv = v=leaked-secret
3つの問題がある。
| 問題 | 内容 |
|---|---|
| 秘密の漏洩 | CI ランナーの環境変数(トークン等)が jobspec に焼き込まれる |
| 再現性の喪失 | 手元と CI で結果が変わる。同じコミットから同じ jobspec が出ない |
| 監査不能 | 「この値はどこから来たのか」がテンプレートを読んでも分からない |
入力は必ず変数として明示的に受け取る。
DB_HOST = [[ var "db_host" . | quote ]]
$ nomad-pack run ./mypack --var db_host="$DB_HOST"
CI 側で環境変数を --var に写す。 これで「何が入力なのか」が variables.hcl を見れば分かる。
秘密情報については、そもそも jobspec に入れない。 Nomad の template ブロックと Vault を使い、タスク起動時にクライアント上で解決させる(第9章)。
③ ホストの外部状態に触る関数
getHostByName が使える。 実測で確認した。
A=[[ getHostByName "localhost" ]]
A=127.0.0.1
レンダリング時に DNS を引いて、その結果を jobspec に焼き込む。 env と同じ問題を持つ。
| 問題 | 内容 |
|---|---|
| 再現性 | DNS の応答が変われば同じコミットから別の jobspec が出る |
| 実行環境依存 | CI ランナーと本番クラスタで名前解決の結果が違いうる |
| 障害の伝播 | DNS が落ちるとレンダリングが失敗する(またはもっと悪く、古い値が入る) |
名前解決は Nomad と Consul の仕事である。 サービスディスカバリ(第8章)を使い、jobspec に IP を埋め込まない。IP を固定で渡す必要があるなら変数として明示的に受け取る。
readFile は登録されていない。
$ nomad-pack render ./p3v
! Error: template: p3v/templates/p3v.nomad.tpl:1: function "readFile" not defined
これは幸いである。 パックの外のファイルに依存すると再現性が失われる。パック外のファイルを取り込む必要があるなら、変数として渡す方法を検討する。
判定のための道具
「この値の型は何か」を調べる関数は使ってよい。 デバッグ専用に留める限り副作用がない。
kindOf = [[ kindOf (var "m" .) ]] → map
typeOf = [[ typeOf (var "m" .) ]] → map[string]interface {}
バージョン比較も使える。
semver = [[ semver "1.2.3" ]] → 1.2.3
semverCompare = [[ semverCompare ">=1.0.0" "1.2.3" ]] → true
semverCompare は「パックのバージョンに応じて出力を変える」用途に使えるが、慎重にしたい。バージョンによって挙動が変わるテンプレートは読み解きが難しくなる。互換性の分岐はパックを分ける方が明快である。
19.6.12 sprig に「ない」関数
Helm のテンプレートからコピーしてくると、必ずここで詰まる。
$ nomad-pack render ./probe
! Failed To Process Pack
! Error: template: probe/templates/probe.nomad.tpl:64: function "required" not defined
required は sprig の関数ではなく Helm が独自に追加したものである。 Nomad Pack には無い。
| Helm にあるが Nomad Pack に無い関数 | 代替 |
|---|---|
required "msg" <値> | variables.hcl で default を書かない(第19.4.3節) |
include "name" . | template "name" .(戻り値をパイプに流せない点が違う) |
lookup | 無い。クラスタの状態を読む機能は持たない |
.Files.Get / readFile | 無い |
.Capabilities.* | 無い |
.Release.Name | --name の値を参照する手段は無い。変数で渡す |
実測で確認した「無い」もの:
! Error: template: p3v/templates/p3v.nomad.tpl:1: function "include" not defined
! Error: template: p3v/templates/p3v.nomad.tpl:1: function "readFile" not defined
! Error: template: p3v/templates/p3v.nomad.tpl:1: function "lookup" not defined
tplは使える。 Helm のtplに相当する「文字列をテンプレートとして評価する」関数は存在し、区切りは[[ ]]である。A=[[ tpl "hello [[ 1 ]]" . ]] → A=hello 1 A=[[ tpl "{{ 1 }}" . ]] → A={{ 1 }}
{{ }}は評価されずそのまま残るため、consul-template 用の文字列を誤って壊す心配はない。ただし「変数の値をテンプレートとして評価する」設計は追跡が難しくなるので、必要でなければ使わない方がよい。
required の代替が最も重要である。 Helm では
image: {{ required "image tag is required" .Values.imageTag }}
と書くところを、Nomad Pack では variables.hcl に既定値を書かないことで実現する。
variable "image_tag" {
description = "コンテナイメージのタグ。CI が --var で渡す"
type = string
}
エラーメッセージは description から作られるため、Helm の required の第1引数に相当するものは description に書く。
! Error: missing required variable: "image_tag" (コンテナイメージのタグ。CI が --var で渡す)
include が無い点も実務で効いてくる。 Helm では include の戻り値を nindent に流すイディオムが定番だが、Nomad Pack の template はテキストを直接出力するだけでパイプに繋げない。字下げは define した側で持たせる必要がある。
[[- define "resources" -]]
resources {
cpu = [[ var "cpu" . ]]
memory = [[ var "memory" . ]]
}
[[- end -]]
define の中に最終的な字下げを書き込んでおくのが Nomad Pack の作法である。生成されるサンプルパックの _helpers.tpl も、すべてこの形で書かれている。
19.7 変数の与え方
19.7.1 4つの経路と優先順位
変数の値は4箇所から来る。
① variables.hcl の default 最も弱い
↑ 上書き
② --var-file / -f で渡すファイル(複数指定可。後のファイルが勝つ)
↑ 上書き
③ --var で渡す個別の値
↑
④ (必須変数は①が無いので②か③が必要)
同じ変数を -f と --var の両方で指定した場合、--var が勝つ。 「ファイルで一括、CI が個別に上書き」という運用に合っている。実測で確認した。
f1.hcl は count = 11 と message = "from f1"、f2.hcl は count = 22 だけを持つ。
$ nomad-pack render . -f f1.hcl -f f2.hcl --no-format
count = 22
MESSAGE = "from f1"
count は後のファイルが勝ち、message は前のファイルの値が残る。 変数ファイルは置き換えではなくキー単位のマージである。
$ nomad-pack render . -f f1.hcl --var count=99 --no-format
count = 99
MESSAGE = "from f1"
--var が変数ファイルより強い。
$ nomad-pack run ./mypack -f base.hcl -f prod.hcl --var image_tag=v1.4.2
この形が実務での定番である — 共通設定を base.hcl、環境差分を prod.hcl、ビルドごとに変わる値を --var で渡す。
19.7.2 --var の書式
すべて実測で確認した書式である。
| 型 | 書式 |
|---|---|
| string | --var message=hello |
| string(空白入り) | --var 'message=hello world' |
| number | --var count=3 |
| bool | --var register_service=true |
| list(string) | --var 'service_tags=["a","b"]' |
| map(string) | --var 'env_vars={FOO="bar",BAZ="qux"}' |
| object | --var 'resources={cpu=999,memory=256}' |
シェルのクォートに注意する。 リストやオブジェクトは中に " や { を含むので、全体をシングルクォートで囲む。
$ nomad-pack render . --var 'service_tags=["a","b"]'
tags = ["a", "b"]
値の部分は HCL の式として解釈される。 したがって --var 'count=1+2' のような式も書けるが、可読性のために避けるべきである。
オブジェクトの一部だけを変えることはできない
$ nomad-pack render . --var defaults.base_resources.cpu=999
! Failed To Process Pack
! Error: There is no variable named "defaults.base_resources.cpu". ...
--var の左辺は変数名そのものでなければならない。 ドットでフィールドを指定することはできない。オブジェクト全体を渡す。
$ nomad-pack render . --var 'defaults.base_resources={cpu=999,memory=256}'
B = 999
ここに一貫性の欠如がある。 テンプレート側では
var "resources.cpu" .とドットで辿れるのに、--varの左辺ではドットが使えない。**「読むときは辿れるが、書くときは辿れない」**と覚える。これは実務上の制約になる。オブジェクト型の変数はフィールドが増えるたびに
--varが長くなるため、フィールド数が3を超えるようなオブジェクトは変数ファイルで渡す方が現実的である。
19.7.3 変数ファイル
HCL で書く。拡張子は .hcl が慣習である。
# vars/api.hcl
job_name = "api"
count = 5
datacenters = ["dc1", "dc2"]
message = "from var-file"
resources = {
cpu = 1000
memory = 2048
}
env_vars = {
ROLE = "api"
LOG_LEVEL = "info"
}
$ nomad-pack run ./mypack -f vars/api.hcl
依存パックの変数は「フラットなドット記法」で書く
これが最も間違えやすい箇所である。 依存パックの変数を変数ファイルで指定する場合、ブロックではなくドット付きの属性名を使う。
正しい形:
defaults.base_resources = {
cpu = 777
memory = 333
}
$ nomad-pack render . -f o1.hcl --no-format
B = 777
間違った形:
defaults {
base_resources = { cpu = 777, memory = 333 }
}
$ nomad-pack render . -f o2.hcl
! Failed To Process Pack
! Error: Expected an equals sign ("=") to mark the beginning of the attribute value.
! Context:
! HCL Range: /tmp/npwork/o2.hcl:1,10-11
変数ファイルは「属性の並び」であり、ブロックを含められない。 エラーメッセージが HCL の一般的な構文エラーになるため、「依存パックの書き方が違う」とは気づきにくい。
まとめると次のようになる。
| 対象 | --var | 変数ファイル |
|---|---|---|
| 自分の変数 | --var count=3 | count = 3 |
| 依存の変数 | --var 'defaults.base_resources={cpu=1}' | defaults.base_resources = { cpu = 1 } |
| オブジェクトの1フィールド | 不可 | 不可(全体を書く) |
19.7.4 generate var-file — ひな型を作る
他人のパックを使うとき、まずこれを叩く。
$ nomad-pack generate var-file .
# variable "count"
# description: The number of app instances to deploy
# type: number
# default: 2
#
# count=2
# variable "datacenters"
# description: A list of datacenters in the region which are eligible for task
# placement
# type: list(string)
# default: ["*"]
#
# datacenters=["*"]
# variable "job_name"
# description: The name to use as the job name which overrides using the pack name
# type: string
# default: ""
#
# job_name=""
...
すべてコメントアウトされた状態で出力される。 必要な行だけコメントを外して使う。
ファイルに書き出す:
$ nomad-pack generate var-file . --to-file vars/api.hcl
既存ファイルを上書きするには --auto-approve が必要である。
$ nomad-pack generate var-file . --to-file vars/api.hcl --auto-approve
レジストリのパックにも使える。
$ nomad-pack generate var-file webservice --registry=prod --ref=v1.4.0 --to-file vars/api.hcl
info との違いは並び順である。 generate var-file はアルファベット順に整列する(実測: count, datacenters, job_name, message, region, register_service, service_name, service_tags)。info は非決定的である(第19.3.3節)。
したがって「パックのバージョンを上げたら変数が増えたか」を CI で検出したい場合は、generate var-file の出力を diff する。
$ nomad-pack generate var-file mypack --registry=prod --ref=v1.4.0 > /tmp/v140.txt
$ nomad-pack generate var-file mypack --registry=prod --ref=v1.5.0 > /tmp/v150.txt
$ diff /tmp/v140.txt /tmp/v150.txt
これがパックのアップグレード前に「何が変わるか」を知る最も確実な方法である。
19.7.5 エラーの読み方
型が合わない
$ nomad-pack render . --var count=abc
! Failed To Process Pack
! Error: This variable value is not compatible with the variable's type constraint: a number is required.
! Context:
! HCL Range:
HCL Range が空である。 どの --var が原因かは自分で切り分ける。変数の数が多いなら変数ファイルに移す。
存在しない変数
$ nomad-pack render . --var nosuchvar=1
! Failed To Process Pack
! Error: There is no variable named "nosuchvar". An override file can only override a variable that was already declared in a primary configuration file.
! Context:
! HCL Range: <value for var nosuchvar from arguments>:1,1-1
変数ファイル経由なら位置が出る。
$ nomad-pack render . -f ov.hcl
! Error: There is no variable named "nosuchvar". ...
! HCL Range: /tmp/npwork/ov.hcl:3,1-14
ov.hcl の3行目1〜14桁である。 エディタで直接飛べる。
--ignore-missing-varsは v0.4.2 では効かない。 ヘルプにはこう書かれている。--ignore-missing-vars Determines whether override variables not present in the pack should be ignored or produce an error. Defaults to false.しかし実測ではフラグの有無で挙動が変わらなかった。
--var経由でも変数ファイル経由でも、同じエラーで終了コード1になる。$ nomad-pack render . -f ov.hcl --ignore-missing-vars ! Error: There is no variable named "nosuchvar". ... ! (EXIT=1)したがって「共通の変数ファイルを複数のパックで使い回す」という運用はできない。 パックごとに変数ファイルを分けるか、共通部分を依存パックとして切り出す(第19.11節)。
変数名のタイポは必ず止まる
この挙動は不便に見えて、実は安全側である。 Helm の values.yaml は未知のキーを黙って無視するため、replicaCount を replicaCounts と書いても気づけない。Nomad Pack は必ずエラーにする。
ただし逆方向のタイポは検出できない。 テンプレート側で var "coont" . と書いた場合はエラーになるが、var "count" . と書くべき箇所を書き忘れた場合は何も起きない。レンダリング結果を目で見る習慣が必要である。
19.8 レンダリングの落とし穴
19.8.1 出力は HCL フォーマッタを通る
これを知らないと原因不明の症状に悩まされる。
nomad-pack render は、テンプレートを展開したあと HCL のフォーマッタに通す。 ジョブ仕様は HCL なので、通常は「整形されて読みやすくなる」だけである。
しかしテンプレートの出力が HCL として妥当でない場合、フォーマッタが内容を壊す。
実測で並べる。 同じテンプレートを --no-format の有無で比較した。
| 値 | --no-format(真の値) | 既定(フォーマッタ通過後) |
|---|---|---|
[[ list "a" "b" | join "," ]] | a,b | a, b |
[[ squote "hi" ]] | 'hi' | ' hi ' |
[[ b64enc "hi" ]] | aGk= | aGk = |
[[ sha256sum "hi" ]] | 8f434346648f... | 8 f434346648f... |
[[ indent 4 "x" ]] | x | x |
[[ var "m" . | toJson ]] | {"a":"1","b":"2"} | { "a" : "1", "b" : "2" } |
[ 文字列 ](見出しの文字列) | [ 文字列 ] | [文字列] |
壊れ方に規則がある。
| 現象 | 原因 |
|---|---|
aGk= → aGk = | フォーマッタが = を属性の代入演算子と見て前後に空白を入れる |
8f43… → 8 f43… | 8f43 を数値 8 と識別子 f43 の2トークンに分解する |
a,b → a, b | , をリストの区切りと見て後に空白を入れる |
{"a":"1"} → { "a" : "1" } | オブジェクトのリテラルとして整形する |
すべて「HCL のトークンとして読んで整形し直す」ことの帰結である。
実務への影響
① jobspec を出しているなら問題は起きない。 出力が妥当な HCL であれば、整形は望ましい動作である。
② デバッグ出力を作るときは --no-format を付ける。 値を確認するためのテンプレートは HCL ではないので、必ず壊れる。
$ nomad-pack render . --no-format
③ HCL の文字列リテラルの中は安全である。 " で囲まれた内側はフォーマッタが触らない。
env {
HASH = "[[ sha256sum "hi" ]]"
B64 = "[[ b64enc "hi" ]]"
JOINED = "[[ list "a" "b" | join "," ]]"
}
フォーマッタを通した(--no-format なしの)出力:
env {
HASH = "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4"
B64 = "aGk="
JOINED = "a,b"
}
すべて無傷である。 base64 やハッシュ、区切り文字を含む値は、必ず引用符の中に置く。
④ ヒアドキュメントの中も安全である。
template {
data = <<EOH
raw-hash=[[ sha256sum "hi" ]]
raw-b64=[[ b64enc "hi" ]]
raw-join=[[ list "a" "b" | join "," ]]
EOH
destination = "local/x"
}
template {
data = <<EOH
raw-hash=8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4
raw-b64=aGk=
raw-join=a,b
EOH
destination = "local/x"
}
<<EOH 〜 EOH の中身はフォーマッタが整形しない。 設定ファイルをまるごと埋め込むときはこれを使う。data の = の位置だけが揃えられている点に注目したい — ブロックの外側は整形され、ヒアドキュメントの中身は保護される。
19.8.2 引用符の二重化
第19.6.3節でも触れたが、独立した落とし穴なので再掲する。
args = ["-c", "while true; do echo [[ var "message" . | quote ]]; sleep 10; done"]
args = ["-c", "while true; do echo " hello from pack "; sleep 10; done"]
文字列が途中で切れて、HCL として不正になっている。 さらにフォーマッタが空白を入れたことで見た目も崩れた。
判断基準を整理する。
出力する場所が「HCL の値そのもの」 → | quote を付ける
出力する場所が「すでに文字列の内側」 → 素で出す
出力する場所が「シェルコマンドの引数」 → 素で出し、シェルのクォートを自分で書く
シェルコマンドに値を埋める場合、シェルインジェクションにも注意する。
args = ["-c", "echo [[ var "message" . ]]"]
message に ; rm -rf / が入っていれば、そのまま実行される。変数の値が信頼できない経路から来る場合は、args を配列で分けて渡す。
command = "/bin/echo"
args = [[ list (var "message" .) | toStringList ]]
exec 形式なのでシェルを経由しない。 これが安全な書き方である。
19.8.3 ドル記号のエスケープ — 3段の畳み込み
Nomad Pack で最も難解な部分である。 実測で完全に確定させた。
3つの層
① Go テンプレート(nomad-pack) [[ ]] を処理する。$ には触らない
② HCL2 パース(Nomad) $$ → $ に畳む
③ 実行時補間(Nomad クライアント) $$ → $ に畳み、${VAR} を値に置換
実測
テンプレートに3種類書いた。
env {
ONE = "${NOMAD_JOB_NAME}"
TWO = "$${NOMAD_JOB_NAME}"
THREE = "$$${NOMAD_JOB_NAME}"
}
① レンダリング直後(nomad-pack render --no-format):
ONE = "${NOMAD_JOB_NAME}"
TWO = "$${NOMAD_JOB_NAME}"
THREE = "$$${NOMAD_JOB_NAME}"
Go テンプレートの段では $ は一切変化しない。
② Nomad が保存したジョブ(nomad job inspect):
ONE = '${NOMAD_JOB_NAME}'
TWO = '${NOMAD_JOB_NAME}'
THREE = '$${NOMAD_JOB_NAME}'
$$ が $ に畳まれた。 TWO が ONE と同じになり、THREE は $$ が1つ残った。
③ プロセスが実際に受け取った値:
$ cat /tmp/esc-env.txt
THREE=$esc
ONE=esc
TWO=esc
(ジョブ名は esc である)
表にまとめる
| テンプレートに書く | ② HCL2 パース後 | ③ プロセスが受け取る |
|---|---|---|
${NOMAD_JOB_NAME} | ${NOMAD_JOB_NAME} | esc |
$${NOMAD_JOB_NAME} | ${NOMAD_JOB_NAME} | esc |
$$${NOMAD_JOB_NAME} | $${NOMAD_JOB_NAME} | $esc |
規則は単純である — HCL のパースを1回通るたびに $$ が $ に減る。
何段通るかを数える
「$ を何個書くべきか」は「値が HCL のパースを何回通るか」で決まる。
単純な構成(テンプレートに直接書く):
テンプレート → nomad-pack render → Nomad の HCL2 パース → 実行時
HCL パースは実質2回(HCL2 パースと実行時補間)なので、実行時に ${NOMAD_JOB_NAME} を解決させたいなら、テンプレートには $${NOMAD_JOB_NAME} と書くのが定石である。
${...}と$${...}のどちらでも同じ結果になった(上の表のONEとTWO)。これは Nomad の HCL2 パーサが未知の${...}を補間せずそのまま通すためである。$${...}と書く方が意図が明確で、Nomad の変数と HCL2 の変数を混同しないので推奨される。
多段の構成(外部ツールから変数として渡す):
Terraform や CI から値を渡す場合、段数が増える。
① Terragrunt / CI が HCL をパース
② JSON に変換
③ nomad-pack の変数ファイルとして HCL をパース
④ Nomad が jobspec を HCL2 でパース
⑤ Nomad クライアントが実行時に補間
この場合 $ を4個並べる必要がある。 実際に稼働している構成でそのような記述が現れるのは、この段数の帰結である。
TE_OTELCOL_METRICS_ENDPOINT = "$$$${NOMAD_ADDR_otel_metrics}"
実務での正しい対処:
| 状況 | どうするか |
|---|---|
| 既に動いている行がある | その行の $ の数をコピーする |
| 新しく追加する | 同じファイル内の既存の行に数を合わせる |
| 数が分からない | --no-format で render して1段目を確認し、nomad job inspect で2段目を確認する |
推測で書いてはいけない。 上の実測手順で1段ずつ確認するのが唯一確実な方法である。
19.8.4 render の出力形式
出力の先頭2行はヘッダである。
hello_world/hello_world.nomad:
← 空行
job "hello_world" {
jobspec だけを取り出すには tail -n +3 を使う。
$ nomad-pack render ./demo | tail -n +3 > demo.nomad
$ nomad job validate demo.nomad
Job validation successful
複数のテンプレートがある場合、それぞれにヘッダが付くので tail -n +3 では足りない。
multi/api.nomad:
job "m-api" {
...
}
multi/worker.nomad:
job "m-worker" {
...
}
この場合は --to-dir でファイルに書き出す。
$ nomad-pack render ./multi --to-dir ./out
$ find out -type f
out/multi/api.nomad
out/multi/worker.nomad
出力先は <指定したディレクトリ>/<パック名>/<ファイル名> になる。 パック名のサブディレクトリが1段挟まる点に注意する。
--to-dir を付けても標準出力への表示は止まらない。 ヘルプの「in addition to standard output」がそのまま意味である。
2回目は失敗する。
$ nomad-pack render ./multi --to-dir ./out
! Failed To Render To File
! Error: destination file exists and overwrite is unset
上書きするには --auto-approve を使う。
$ nomad-pack render ./multi --to-dir ./out --auto-approve
-oは--overwriteではなく--to-dirの短縮形である。$ nomad-pack render ./multi --to-dir ./out -o ! Error Parsing Args Or Flags ! Error: flag needs an argument: 'o' in -o
nomad-pack generate packでは-f, --overwriteと-o, --to-dirの両方があるため、コマンドをまたぐと混同しやすい。renderの上書きは--auto-approveである。
主なオプションを整理する。
| オプション | 効果 |
|---|---|
--no-format | HCL 整形をしない(第19.8.1節) |
--render-output-template | outputs.tpl も表示する |
-o, --to-dir=<path> | 標準出力に加えてファイルとして書き出す |
--auto-approve | --to-dir の出力先が既存でも上書きする |
--parser-v1 | v1 記法を使う(第19.5.6節) |
--registry / --ref | レジストリのパックを対象にする |
19.9 Nomad Pack Registry
19.9.1 レジストリとは何か
レジストリは「パックを複数入れた git リポジトリ」である。
専用のサーバは存在しない。 Helm の Chart Repository のように HTTP でインデックスを配る仕組みではなく、git リポジトリをそのままレジストリとして使う。 これが Nomad Pack の設計上の大きな特徴である。
myregistry/ ← git リポジトリのルート
├── README.md
├── CHANGELOG.md
└── packs/ ← この名前は固定
├── webservice/
│ ├── metadata.hcl
│ ├── variables.hcl
│ └── templates/
├── batch/
└── defaults/
規約は1つだけである — packs/ ディレクトリの直下に、1パック1ディレクトリで並べる。
この設計の利点:
| 項目 | 内容 |
|---|---|
| 配信基盤が不要 | 既にある git ホスティングをそのまま使える |
| アクセス制御が既存のまま | git のリポジトリ権限がそのままパックの権限になる |
| バージョン管理が git のタグ | 別途バージョン体系を作る必要がない |
| レビューが PR | パックの変更が通常のコードレビューに乗る |
| 監査が git log | 誰がいつ何を変えたかが残る |
欠点も明確である:
| 項目 | 内容 |
|---|---|
| 署名検証が無い | パックの真正性を検証する仕組みが無い。git のタグ保護で代替する |
| 依存解決が素朴 | バージョン制約の記述ができない(第19.11.6節) |
| 検索性が無い | 中央のインデックスが無いので「どこにどのパックがあるか」は人間が管理する |
19.9.2 registry add
書式:
$ nomad-pack registry add <登録名> <ソース> [--ref=<git ref>] [--target=<パック名>]
公式のコミュニティレジストリを追加してみる。
$ nomad-pack registry add community github.com/hashicorp/nomad-pack-community-registry
debug: go-getter URL is github.com/hashicorp/nomad-pack-community-registry?depth=1
debug: Registry successfully cloned at /Users/shogo/Library/Caches/nomad/packs/nomad-pack-tmp
debug: Processing pack entries at /Users/shogo/Library/Caches/nomad/packs/nomad-pack-tmp
debug: found pack entry alertmanager
debug: Processing pack alertmanager@latest
debug: Writing pack to /Users/shogo/Library/Caches/nomad/packs/community/latest/alertmanager@latest
debug: calculating SHA for latest
debug: SHA 7a9eee6c26cbcfa7907b1bf170662e85dcb98e1c downloaded at UTC 2026-08-20 12:30:11 +0000 UTC
...
出力から仕組みが読み取れる。
① go-getter で clone する(depth=1 の浅いクローン)
② 一時ディレクトリ nomad-pack-tmp に展開する
③ packs/ 配下の各エントリを走査する
④ パックごとにキャッシュへコピーする
⑤ ref の SHA を記録する
depth=1 の浅いクローンである点は運用上重要で、大きなレジストリでも取得が速い。
追加できたか確認する。
$ nomad-pack registry list
REGISTRY NAME | REF | LOCAL REF | REGISTRY URL
---------------+--------+-----------+----------------------------------------------------
community | latest | 7a9eee6c | github.com/hashicorp/nomad-pack-community-registry
| 列 | 意味 |
|---|---|
REGISTRY NAME | ローカルでの登録名(--registry に渡す名前) |
REF | 指定した git ref。省略時は latest |
LOCAL REF | ref が解決された実際の SHA |
REGISTRY URL | ソース |
LOCAL REF が最も重要な列である。 「いま手元にあるのはどのコミットか」を答える。
パックの一覧を見る。
$ nomad-pack list
PACK NAME | METADATA VERSION | REGISTRY NAME
----------------------------+------------------+-----------------------------
alertmanager | 0.2.1 | community@latest (7a9eee6c)
aws_ebs_csi | 0.2.1 | community@latest (7a9eee6c)
aws_efs_csi | 0.2.1 | community@latest (7a9eee6c)
backstage | 0.2.1 | community@latest (7a9eee6c)
boundary | 0.2.1 | community@latest (7a9eee6c)
caddy | 0.2.1 | community@latest (7a9eee6c)
...
コミュニティレジストリには47のパックが入っている(この時点の実測)。traefik、prometheus、grafana、各種 CSI プラグインなどが揃っている。自分でパックを書く前に、ここに既にあるかを確認する価値がある。
19.9.3 キャッシュの構造
場所は OS ごとに違う。
| OS | 既定のキャッシュパス |
|---|---|
| macOS | ~/Library/Caches/nomad/packs |
| Linux | ~/.cache/nomad/packs(XDG に従う) |
NOMAD_PACK_CACHE_PATH で変更できる。 CI ではこれを明示してキャッシュ対象にする(第19.12.2節)。
構造を実測で確認した。
$ find ~/Library/Caches/nomad/packs/d2p -maxdepth 2 -type d | sort
/Users/shogo/Library/Caches/nomad/packs/d2p
/Users/shogo/Library/Caches/nomad/packs/d2p/demo1.0.0
/Users/shogo/Library/Caches/nomad/packs/d2p/demo1.0.0/demo@demo1.0.0
/Users/shogo/Library/Caches/nomad/packs/d2p/demo1.1.0
/Users/shogo/Library/Caches/nomad/packs/d2p/demo1.1.0/demo@demo1.1.0
規則はこうである。
<キャッシュルート>/<登録名>/<ref>/<パック名>@<ref>/
ref がディレクトリ名に2回現れるのが特徴で、同じレジストリの複数の ref を並べて保持できる理由がここにある。
パック1つの中身:
$ find ~/Library/Caches/nomad/packs/community/latest/traefik@latest | sort
.../traefik@latest
.../traefik@latest/CHANGELOG.md
.../traefik@latest/README.md
.../traefik@latest/examples
.../traefik@latest/examples/traefik.hcl
.../traefik@latest/latest.log
.../traefik@latest/metadata.hcl
.../traefik@latest/outputs.tpl
.../traefik@latest/templates
.../traefik@latest/templates/_helpers.tpl
.../traefik@latest/templates/traefik.nomad.tpl
.../traefik@latest/variables.hcl
latest.log が nomad-pack が付け加えたファイルである。 ref の SHA と取得時刻を記録している。
キャッシュは単なるディレクトリなので、直接読める。 「このパックのテンプレートは実際どう書かれているのか」を知りたいときは、キャッシュを開くのが最短である。
$ cat ~/Library/Caches/nomad/packs/community/latest/traefik@latest/templates/_helpers.tpl
キャッシュを消せば作り直される。 壊れた状態を疑うなら、該当レジストリのディレクトリを消して registry add し直す。
19.9.4 --ref によるバージョン固定
ここが Nomad Pack Registry の中核機能である。
自前のレジストリで2つのタグを用意して実測した。
$ cd myreg-src && git tag
demo1.0.0
demo1.1.0
$ git rev-parse --short=8 demo1.0.0 demo1.1.0
d1f19aa3
6db73df8
demo1.0.0 では message の既定値が "hello from pack"、demo1.1.0 では "hello from pack V2" になっている。metadata.hcl の version も 0.1.0 と 0.2.0 である。
両方を追加する。
$ nomad-pack registry add d2p /path/to/myreg-src --ref=demo1.0.0
$ nomad-pack registry add d2p /path/to/myreg-src --ref=demo1.1.0
$ nomad-pack list
PACK NAME | METADATA VERSION | REGISTRY NAME
----------------------------+------------------+-----------------------------
demo | 0.1.0 | d2p@demo1.0.0 (d1f19aa3)
demo | 0.2.0 | d2p@demo1.1.0 (6db73df8)
同じ登録名 d2p の下に2つの ref が共存している。 そして ref ごとに正しい内容が入っている。
使うときは --ref で選ぶ。
$ nomad-pack render demo --registry=d2p --ref=demo1.0.0 --no-format
args = ["-c", "while true; do echo hello from pack; sleep 10; done"]
$ nomad-pack render demo --registry=d2p --ref=demo1.1.0 --no-format
args = ["-c", "while true; do echo hello from pack V2; sleep 10; done"]
これが「アプリごとにパックのバージョンを固定する」運用の基盤である。
アプリ A → --ref=v1.4.0 ← 検証済み。動かさない
アプリ B → --ref=v1.5.0 ← 新機能を試している
アプリ C → --ref=v1.4.0
テンプレートに破壊的な変更を入れても、利用側が ref を上げるまで影響しない。 第19.5.6節で述べたパーサ移行が段階的に進められるのは、この仕組みがあるからである。
infoも--refを反映する。 実測で確認した。$ nomad-pack info demo --registry=d2p --ref=demo1.0.0 | grep -A1 '"message"' - "message" (string: optional) - Message the task echoes default: "hello from pack" $ nomad-pack info demo --registry=d2p --ref=demo1.1.0 | grep -A1 '"message"' - "message" (string: optional) - Message the task echoes default: "hello from pack V2"ref を上げる前に
infoで変数の差を確認できる。
19.9.5 latest の危険性
--ref を省略すると latest が使われる。
$ nomad-pack render demo --registry=d2p
! Failed To Find Pack
! Error: stat /Users/shogo/Library/Caches/nomad/packs/d2p/latest/demo@latest: no such file or directory
! Context:
! Registry Name: d2p
! Pack Name: demo
! Pack Ref: latest
latest としてキャッシュしていなければ失敗する。 エラーメッセージにキャッシュパスが出るので原因は分かりやすい。
問題は latest をキャッシュしている場合である。 ヘルプが警告している。
--ref=<string>
... Running "nomad registry add" multiple times for the same ref is
idempotent, however running "nomad-pack registry add" without specifying a
ref, or when specifying @latest, is destructive, and will overwrite
current @latest in the global cache.
latest は破壊的に上書きされる。 つまり:
月曜: registry add myreg <url> → latest = コミット A
run mypack --registry=myreg → A の内容がデプロイされる
金曜: registry add myreg <url> → latest = コミット B(上書き)
run mypack --registry=myreg → B の内容がデプロイされる
同じコマンドが違う結果を生む。 そして「何が変わったか」を知る手段が LOCAL REF の SHA を目で比べることしかない。
本番運用では --ref を必ず明示する。 これは強い推奨である。
| 用途 | ref の指定 |
|---|---|
| 本番デプロイ | タグを明示(--ref=v1.4.0) |
| ステージング | タグを明示(1つ先のタグ) |
| パックの開発中 | ローカルのディレクトリを直接指定(nomad-pack run ./mypack) |
| 「最新を試す」 | --ref=latest を明示的に書く(省略しない) |
「省略しない」ことに意味がある。 コマンドライン上に latest の文字があれば、レビューする人が危険性に気づける。
19.9.6 ソースの書き方
実測で動く形と動かない形を確定させた。
| ソースの書き方 | 結果 |
|---|---|
github.com/org/repo | 動く(go-getter が GitHub と認識する) |
/absolute/local/path | 動く(ローカルの git リポジトリ) |
file:///absolute/local/path | 動く |
git::file:///path | 失敗 |
git::/path | 失敗 |
git:: 接頭辞を付けたローカルパスは失敗する。
$ nomad-pack registry add d2p "git::file:///tmp/npwork/myreg-src" --ref=demo1.0.0
! Could Not Install Registry
! Error: error downloading 'git::file:///tmp/npwork/myreg-src?ref=demo1.0.0': /usr/bin/git exited with 128: Cloning into '...'
git: 'remote-git' is not a git command. See 'git --help'.
The most similar command is
remote-ext
fatal: remote helper 'git' aborted session
go-getter が git:: を git:// プロトコルに変換してしまい、git のリモートヘルパが見つからない。 ローカルパスは接頭辞なしで書く。
リモートの private リポジトリを指定する形:
# SSH
$ nomad-pack registry add prod "git::ssh://git@github.example.com/team/pack-registry.git" --ref=v1.4.0
# HTTPS + トークン
$ nomad-pack registry add prod \
"git::https://${GITHUB_TOKEN}@github.example.com/team/pack-registry.git" --ref=v1.4.0
HTTPS にトークンを埋める形は、シェル履歴とプロセス一覧に残る。 CI では環境変数から展開されるので許容されることが多いが、手元では SSH を使う方が安全である。
サブディレクトリを指定する // の形も使える(依存パックで必須になる。第19.11節)。
git::https://github.example.com/team/monorepo.git//infra/pack-registry
// の後ろがリポジトリ内のパスである。 モノレポの一部をレジストリにできる。
19.9.7 --target — パック1つだけを取る
大きなレジストリから1パックだけ欲しい場合に使う。
$ nomad-pack registry add community github.com/hashicorp/nomad-pack-community-registry --target=traefik
47パックすべてを展開する代わりに traefik だけをキャッシュする。 CI での取得時間とディスクを節約できる。
注意: clone 自体は全体に対して行われる(depth=1 なので影響は小さい)。節約されるのは展開とディスクの部分である。
19.9.8 registry list / delete / update
list
$ nomad-pack registry list
REGISTRY NAME | REF | LOCAL REF | REGISTRY URL
---------------+-----------+-----------+------------------------------
community | latest | 7a9eee6c | github.com/hashicorp/...
d2p | demo1.0.0 | d1f19aa3 | /tmp/npwork/myreg-src
d2p | demo1.1.0 | 6db73df8 | /tmp/npwork/myreg-src
「いま手元にどの ref があるか」を答える。 障害対応で「デプロイしたのはどのバージョンか」を確認するとき、ここと Nomad のジョブ meta(第19.10.1節)の両方を見る。
delete
3つの粒度がある。実測で挙動を確定させた。
① レジストリ全体を消す(--target も --ref も付けない):
$ nomad-pack registry delete d2p
registry d2p deleted
キャッシュの d2p ディレクトリごと消える。 すべての ref が対象である。
$ ls ~/Library/Caches/nomad/packs/d2p
(ディレクトリ自体が無い)
$ nomad-pack registry list
REGISTRY NAME | REF | LOCAL REF | REGISTRY URL
---------------+--------+-----------+--------------------------------
community | latest | 7a9eee6c | github.com/hashicorp/...
② 特定の ref を消す:
$ nomad-pack registry delete d2p --ref=demo1.0.0
registry d2p at ref demo1.0.0 deleted
③ 特定のパックの特定の ref を消す:
$ nomad-pack registry delete d2p --target=demo --ref=demo1.1.0
registry d2p pack demo at ref demo1.1.0 deleted
registry list は削除後も ref を表示し続ける
②と③には注意すべき挙動がある。 キャッシュの中身を追跡して確認した。
削除前:
d2p/demo1.0.0/demo@demo1.0.0 ← パックの実体
d2p/demo1.0.0/metadata.json ← レジストリのメタ情報
d2p/demo1.1.0/demo@demo1.1.0
d2p/demo1.1.0/metadata.json
registry delete d2p --ref=demo1.0.0 の後:
d2p/demo1.0.0/metadata.json ← パックは消えたが metadata.json が残る
d2p/demo1.1.0/demo@demo1.1.0
d2p/demo1.1.0/metadata.json
registry list はこの metadata.json を読むため、ref がまだ存在すると報告する。
$ nomad-pack registry list
REGISTRY NAME | REF | LOCAL REF | REGISTRY URL
---------------+-----------+-----------+----------------------
d2p | demo1.0.0 | d1f19aa3 | /tmp/npwork/myreg-src ← 実体は無い
d2p | demo1.1.0 | 6db73df8 | /tmp/npwork/myreg-src
しかし実際には使えない。
$ nomad-pack render demo --registry=d2p --ref=demo1.0.0
(EXIT=1)
$ nomad-pack render demo --registry=d2p --ref=demo1.1.0
(EXIT=0)
nomad-pack list は正しく答える。
$ nomad-pack list
PACK NAME | METADATA VERSION | REGISTRY NAME
----------------------------+------------------+-----------------------------
demo | 0.2.0 | d2p@demo1.1.0 (6db73df8)
したがって「何が実際に使えるか」を知りたいときは nomad-pack list を見る。 registry list は「どのレジストリをどの ref で登録したか」の台帳であり、パックの実在は保証しない。
| コマンド | 答える質問 | 削除後の正確性 |
|---|---|---|
nomad-pack registry list | どのレジストリを登録したか | 古い ref が残る |
nomad-pack list | どのパックが実際に使えるか | 正確 |
キャッシュを確実にきれいにしたいなら、ディレクトリを直接消すのが最も確実である。
$ rm -rf ~/Library/Caches/nomad/packs/d2p
update
$ nomad-pack registry update d2p
登録済みのレジストリを再取得する。 ただし latest の再取得は破壊的である(第19.9.5節)。タグ固定で運用している場合、update の出番はほとんど無い — 新しいタグを使いたいなら registry add --ref=<新しいタグ> で追加する方が明示的である。
19.9.9 自前レジストリの作り方
手順は4つである。実測した流れをそのまま示す。
① ひな型を作る
$ nomad-pack generate registry my-registry --with-sample-pack -y
$ cd my-registry
② パックを追加する
$ nomad-pack generate pack webservice --to-dir ./packs
$ find packs -maxdepth 1 -type d
packs
packs/hello_world
packs/webservice
generate pack で1パックのひな型ができる。 --to-dir ./packs を忘れるとカレントディレクトリに作られてレジストリの規約から外れる。
③ git リポジトリにしてタグを打つ
$ git init -b main
$ git add -A
$ git commit -m "webservice 1.0.0"
$ git tag webservice1.0.0
$ git remote add origin git@github.example.com:team/pack-registry.git
$ git push origin main --tags
タグの命名を決める。 選択肢は2つある。
| 方式 | 例 | 利点 | 欠点 |
|---|---|---|---|
| レジストリ全体で1つのバージョン | v1.4.0 | 単純。全パックが同時に上がる | 1パックの修正で全パックの ref が変わる |
| パックごとのバージョン | webservice1.4.0 | パックを独立に上げられる | タグが増える。どのタグがどのパックかを規約で示す必要がある |
パックの数が増えるなら後者が現実的である。 タグ名にパック名を含めれば、nomad-pack registry add myreg <url> --ref=webservice1.4.0 を見ただけで対象が分かる。
metadata.hcl の pack.version とタグ名を一致させる規約も併せて決めておく(第19.4.2節)。
④ 登録して使う
$ nomad-pack registry add prod \
"git::ssh://git@github.example.com/team/pack-registry.git" --ref=webservice1.0.0
$ nomad-pack list
PACK NAME | METADATA VERSION | REGISTRY NAME
----------------------------+------------------+----------------------------------
webservice | 1.0.0 | prod@webservice1.0.0 (abc12345)
$ nomad-pack info webservice --registry=prod --ref=webservice1.0.0
$ nomad-pack run webservice --registry=prod --ref=webservice1.0.0 -f vars/api.hcl
レビューの仕組みを付ける
レジストリが git リポジトリであることの最大の利点はここにある。
① main ブランチを保護する(直接 push 禁止)
② タグの作成を保護する(特定のチームのみ)
③ packs/ 配下に CODEOWNERS を置く
④ PR で nomad-pack render の差分を出す CI を回す(第19.12.2節)
⑤ タグを打つのはリリース手順の一部にする
④が特に有用である。 「テンプレートを1行変えたら、全パックの全変数パターンで出力がどう変わるか」を PR に貼れば、破壊的変更を投入前に見つけられる。
19.10 デプロイのライフサイクル
19.10.1 状態はどこにあるのか
Nomad Pack は状態ファイルを持たない。 これが Helm との最大の違いである。
では nomad-pack status は何を見ているのか。 実測で確定させた。
$ nomad job inspect demo | jq '.Job.Meta'
{
"pack.name": "demo",
"pack.path": "/tmp/npwork/demo",
"pack.registry": "<<local folder>>",
"pack.version": "<<none>>",
"pack.deployment_name": "demo",
"pack.job": "demo"
}
Nomad のジョブの meta に6つのキーが埋め込まれている。
| キー | 内容 | 例 |
|---|---|---|
pack.name | パック名 | demo |
pack.path | レンダリング時のパックのパス | /tmp/npwork/demo |
pack.registry | レジストリの登録名 | <<local folder>> |
pack.version | レジストリの ref | <<none>> |
pack.deployment_name | --name の値 | demo |
pack.job | このジョブの名前 | demo |
nomad-pack status / plan / stop / destroy は、すべて Nomad のジョブ一覧をこの meta で絞り込んで動く。
この設計の帰結
① 状態が Nomad の中にあるので、手元の環境を作り直しても失われない。 Helm の state を消した、というような事故が起きない。別のマシンからでも nomad-pack status が同じ結果を返す。
② meta を書き換えれば管理から外れる。 逆に言えば、meta を手で消すと nomad-pack から見えなくなる。
③ pack.version は「レジストリの ref」であり metadata.hcl の version ではない。 ローカルディレクトリから実行すると <<none>> になる。「いまデプロイされているパックのバージョンは?」に答えるのはこのキーである。
# レジストリ経由でデプロイした場合
"pack.version": "v1.4.0"
"pack.registry": "prod"
障害対応でまずこれを見る。 「どのパックのどのバージョンから生まれたジョブか」が Nomad の API だけで分かる。
④ pack.path はレンダリングした人のマシンのパスである。 CI から実行すればランナーの作業ディレクトリが入る。情報として役に立たないことが多く、環境によっては内部のディレクトリ構造が漏れる点は意識しておきたい。
自分でも meta を使う
パックのテンプレートに独自の meta を追加できる。 運用情報を埋めるのに有用である。
meta {
owner = [[ var "owner" . | quote ]]
config_hash = "[[ var "app_config" . | toJson | sha256sum ]]"
deployed_by = [[ var "deployed_by" . | quote ]]
source_commit = [[ var "source_commit" . | quote ]]
}
CI が --var source_commit=$GIT_SHA を渡せば、「このジョブはどのコミットから来たか」が Nomad 上で追える。 pack.* は nomad-pack が管理する領域なので、独自のキーは別の名前を使う。
秘密情報を meta に入れてはいけない。 API とダッシュボードから誰でも読める。
19.10.2 run
$ nomad-pack run ./demo
やっていることは4段である。
① パックをレンダリングする
② Nomad の /v1/jobs/parse で構文を検査する
③ 既存ジョブとの所有権を検査する(第19.10.7節)
④ ジョブを投入し、デプロイの完了まで監視する
⑤ outputs.tpl を表示する
render と違い outputs.tpl が自動的に表示される。
主なオプション
| オプション | 効果 |
|---|---|
--name=<string> | デプロイ名(第19.10.8節) |
-f / --var | 変数 |
--check-index=<int> | 楽観ロック。指定した index と一致しなければ失敗する |
--var-file | -f の別名 |
--parser-v1 | v1 記法 |
--registry / --ref | レジストリ指定 |
--verbose | 監視中の詳細表示 |
--check-index — 競合を防ぐ
plan の出力が教えてくれる。
$ nomad-pack plan ./demo
To submit the job with version verification run:
nomad-pack run demo --check-index=11 [options]
When running the job with the check-index flag, the job will only be run if the
job modify index given matches the server-side version. If the index has
changed, another user has modified the job and the plan's results are
potentially invalid.
plan してから run するまでの間に他人が変更していたら止まる。 CI で「plan の結果を人間が承認してから apply する」構成を作るなら、--check-index は必須である。 これが無いと、承認した plan とは違う内容が適用されうる。
# CI での典型的な使い方
INDEX=$(nomad-pack plan ./mypack 2>&1 | grep -o 'check-index=[0-9]*' | cut -d= -f2)
# … 人間の承認を待つ …
nomad-pack run ./mypack --check-index="$INDEX"
19.10.3 plan と終了コード
nomad-pack plan は Nomad の nomad job plan と同じ終了コード規約を持つ。実測で確定させた。
| 終了コード | 意味 |
|---|---|
| 0 | 差分なし |
| 1 | 差分あり |
| 255 | エラー |
差分なし
$ nomad-pack plan /tmp/npwork/demo
Job: "demo"
Task Group: "app" (1 ignore)
Task: "echo"
» Scheduler dry-run:
- All tasks successfully allocated.
...
Plan succeeded
$ echo $?
0
(1 ignore) が「変更なし」の表示である。
差分あり
$ nomad-pack plan /tmp/npwork/demo --var count=3
+/- Job: "demo"
+/- Task Group: "app" (2 create, 1 in-place update)
+/- Count: "1" => "3" (forces create)
Task: "echo"
» Scheduler dry-run:
- All tasks successfully allocated.
...
Plan succeeded
$ echo $?
1
設定内容の変更も差分として出る。
$ nomad-pack plan /tmp/npwork/demo --var message=changed
+/- Job: "demo"
+/- Task Group: "app" (1 create/destroy update)
+/- Task: "echo" (forces create/destroy update)
+/- Config {
args[0]: "-c"
+/- args[1]: "while true; do echo hello from pack; sleep 10; done" => "while true; do echo changed; sleep 10; done"
command: "/bin/sh"
}
$ echo $?
1
create/destroy update と in-place update の区別が読める。 count の変更はアロケーションの作成、args の変更はタスクの再作成を引き起こす。「この変更でアプリが再起動するか」を投入前に判断できる。
エラー
$ nomad-pack plan /tmp/npwork/demo --var count=abc
! Failed To Process Pack
! Error: This variable value is not compatible with the variable's type constraint: a number is required.
$ echo $?
255
CI での使い方
この3値があるので、次のような制御が書ける。
set +e
nomad-pack plan ./mypack -f vars/prod.hcl
rc=$?
set -e
case $rc in
0) echo "差分なし。デプロイをスキップします" ; exit 0 ;;
1) echo "差分あり。承認後に run します" ;;
255) echo "パックの処理に失敗しました" ; exit 1 ;;
*) echo "予期しない終了コード: $rc" ; exit 1 ;;
esac
set -e の下では終了コード1で即座に落ちるため、明示的に外す必要がある。これを忘れると「差分があるときだけ CI が失敗する」という奇妙な症状になる。
注意: パイプを通すと終了コードが取れない。
nomad-pack plan ./mypack | tee plan.txt # ← $? は tee の終了コード
echo $? # ← 常に 0
PIPESTATUS を使うか、リダイレクトにする。
nomad-pack plan ./mypack > plan.txt 2>&1
rc=$?
第19.6.11節で述べた「非決定的な関数を使うな」がここに繋がる。 uuidv4 を1つ入れるだけで plan が常に1を返し、この制御が全部無意味になる。
19.10.4 status
# デプロイされているパックの一覧
$ nomad-pack status
PACK NAME | REGISTRY NAME
-----------+------------------
demo | <<local folder>>
# あるパックのデプロイ一覧
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
demo | <<local folder>> | demo | demo | running
demo | <<local folder>> | demo2 | demo-two | running
引数なしだと「パック単位」、パック名を付けると「デプロイ単位」になる。
STATUS は Nomad のジョブの状態である。 stop した後は dead になる(第19.10.5節)。
複数のジョブを生むパックでは1デプロイが複数行になる。
$ nomad-pack status multi
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
multi | <<local folder>> | multi | m-api | running
multi | <<local folder>> | multi | m-worker | running
19.10.5 stop と destroy の違い
実測で確定させた。
| コマンド | Nomad で起きること | ジョブの状態 | status での見え方 |
|---|---|---|---|
stop | nomad job stop 相当 | dead (stopped)。ジョブ定義は残る | dead として表示される |
destroy | nomad job stop -purge 相当 | 完全に削除される | 表示されなくなる |
stop
$ nomad-pack stop /tmp/npwork/demo --name=demo2 --var job_name=demo-two
Job "demo-two" stopped
Pack "demo" stopped
$ nomad job status
ID Type Priority Status Submit Date
demo service 50 running 2026-08-20T21:27:01+09:00
demo-two service 50 dead (stopped) 2026-08-20T21:29:43+09:00
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+--------
demo | <<local folder>> | demo | demo | running
demo | <<local folder>> | demo2 | demo-two | dead
ジョブ定義が残るので nomad job inspect で内容を読める。 「止めたが後で戻すかもしれない」場合に使う。
destroy
$ nomad-pack destroy /tmp/npwork/demo --name=demo2 --var job_name=demo-two
Job "demo-two" destroyed
Pack "demo" destroyed
$ nomad job status demo-two
No job(s) with prefix or ID "demo-two" found
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
demo | <<local folder>> | demo | demo | running
痕跡が消える。 ジョブのバージョン履歴も消えるので、destroy の後に「前は何だったか」を Nomad から知る方法は無い。
使い分け
一時的に止める、または調査したい → stop
リソースを本当に解放する、名前を空ける → destroy
本番で迷ったら → stop
19.10.6 stop / destroy が再レンダリングする罠
これは実務で必ず踏む落とし穴である。
stop と destroy は、パックを再レンダリングしてジョブ名を求める。 したがって**run のときと同じ変数を渡さないと、対象が見つからない。**
実測した失敗:
$ nomad-pack run /tmp/npwork/demo --name=demo2 --var job_name=demo-two
(成功。ジョブ名 demo-two ができる)
$ nomad-pack stop /tmp/npwork/demo --name=demo2
! Failed To Find Jobs For Pack
! Error: pack "demo" running but not in deployment "demo2". Run "nomad-pack status demo" for more information
! Context:
! Registry Name: <<local folder>>
! Pack Name: demo
! Pack Ref: <<none>>
! Pack Path: /tmp/npwork/demo
! Deployment Name: demo2
しかし status はデプロイ demo2 の存在を知っている。
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
demo | <<local folder>> | demo2 | demo-two | running
矛盾しているように見えるが、理由は単純である。
stop は job_name の既定値("")でレンダリングする
→ coalesce で パック名 "demo" になる
→ ジョブ "demo" を探す
→ ジョブ "demo" はデプロイ "demo" に属している
→ 「デプロイ demo2 に無い」というエラー
同じ変数を渡せば成功する。
$ nomad-pack stop /tmp/npwork/demo --name=demo2 --var job_name=demo-two
Job "demo-two" stopped
Pack "demo" stopped
運用上の対策
この挙動は「変数ファイルを必ず保存する」ことを要求する。
| やり方 | 評価 |
|---|---|
--var をコマンドラインに直接書く | 危険。 後で同じ値を再現できない |
| 変数ファイルをリポジトリに置く | 推奨。 -f vars/api.hcl で常に同じ結果 |
| ジョブ名を変数にしない | 有効。 パック名 = ジョブ名にすれば --var 不要 |
--name を使わない(1パック1デプロイ) | 最も単純 |
推奨は「変数ファイルをリポジトリに置き、すべての操作で -f を付ける」ことである。
$ nomad-pack run mypack --registry=prod --ref=v1.4.0 -f vars/api.hcl
$ nomad-pack plan mypack --registry=prod --ref=v1.4.0 -f vars/api.hcl
$ nomad-pack destroy mypack --registry=prod --ref=v1.4.0 -f vars/api.hcl
4つのコマンドで引数が完全に同じになる。 これが Nomad Pack の正しい使い方である。
destroyにも--refが必要である点を見落としやすい。ref が違えばテンプレートが違い、ジョブ名が違いうる。runに渡したすべての引数をdestroyにも渡す。
19.10.7 所有権の検査
nomad-pack は他人のジョブを勝手に上書きしない。実測で2種類の拒否を確認した。
① 別のデプロイが所有しているジョブ
$ nomad-pack run /tmp/npwork/demo --name=intruder --var job_name=demo
! Failed Job Conflict Validation
! Error: job with id "demo" already exists and is part of deployment "demo"
! Context:
! Template Name: demo/templates/demo.nomad.tpl
ジョブ demo はデプロイ demo のものなので、デプロイ intruder から触れない。
② nomad-pack が管理していないジョブ
$ nomad job run plain.nomad # 素の nomad で投入
$ nomad-pack run /tmp/npwork/demo --name=takeover --var job_name=plainjob
! Failed Job Conflict Validation
! Error: job with id "plainjob" already exists and is not managed by nomad pack
! Context:
! Template Name: demo/templates/demo.nomad.tpl
pack.* の meta を持たないジョブは奪えない。
この安全性の根拠と限界
根拠は meta である(第19.10.1節)。ジョブに pack.deployment_name が無ければ「管理外」、あって値が違えば「他人のもの」と判定する。
限界も meta にある。 手で meta を書き足せば所有権を偽装できる。悪意への防御ではなく、事故への防御である。
「既存のジョブを nomad-pack 管理に移す」正しい手順:
① 現行のジョブ仕様を取得する
nomad job inspect -json myjob > current.json
② 同じ内容を生むパックを書く
③ nomad-pack render で出力を比較する
nomad-pack render ./mypack | tail -n +3 > new.nomad
④ 元のジョブを destroy する(またはジョブ名を変える)
nomad job stop -purge myjob
⑤ nomad-pack run で投入する
④でダウンタイムが発生する。 これを避けたい場合は、新しいジョブ名で並行稼働させてからトラフィックを切り替える(第10章のデプロイ戦略を参照)。「無停止で管理に移す」直接的な手段は nomad-pack には無い。
19.10.8 --name による複数インスタンス
1つのパックから複数のデプロイを作れる。
$ nomad-pack run ./demo # デプロイ名 demo
$ nomad-pack run ./demo --name=demo2 --var job_name=demo-two # デプロイ名 demo2
$ nomad-pack status demo
PACK NAME | REGISTRY NAME | DEPLOYMENT NAME | JOB NAME | STATUS
-----------+------------------+-----------------+----------+---------
demo | <<local folder>> | demo | demo | running
demo | <<local folder>> | demo2 | demo-two | running
重要な制約: ジョブ名も変えなければならない。 --name だけを変えて job_name を変えないと、第19.10.7節の所有権エラーになる。Nomad のジョブ名はクラスタ内で一意である必要があるためである。
したがってパックの設計として、ジョブ名を変数にしておく。
variable "job_name" {
description = "Nomad job name. Empty means use the pack name."
type = string
default = ""
}
job [[ template "job_name" . ]] {
--name と job_name を毎回両方渡すのは煩雑である。 実務では変数ファイルにデプロイ名とジョブ名の対応を書き、ファイル名を揃える。
vars/
├── api.hcl → job_name = "api" → --name=api
├── worker.hcl → job_name = "worker" → --name=worker
└── api-canary.hcl → job_name = "api-canary" → --name=api-canary
for v in vars/*.hcl; do
name=$(basename "$v" .hcl)
nomad-pack run mypack --registry=prod --ref=v1.4.0 --name="$name" -f "$v"
done
ファイル名 = デプロイ名 = ジョブ名にするのが、第19.10.6節の罠を避ける最も単純な規約である。
19.11 依存パック
「複数のパックで共通する既定値やヘルパを1箇所にまとめる」ための仕組みである。 Helm のサブチャートに相当する。
19.11.1 何が共有されるのか
依存パックから継承されるものは2つある。
| 継承されるもの | 参照方法 |
|---|---|
| 変数と既定値 | var "<名前>" .<エイリアス> |
define したテンプレート | template "<名前>" .<エイリアス> |
継承されないもの:
| されないもの | 理由 |
|---|---|
出力テンプレート(*.nomad.tpl) | 依存パックのジョブは生成されない |
outputs.tpl | 親のものだけが使われる |
つまり依存パックは「部品の library」であり、それ自体はジョブを生まない。 純粋な共通化のための入れ物として使う。
19.11.2 dependency ブロック
親の metadata.hcl に書く。
app { url = "https://example.internal/child" }
pack {
name = "child"
description = "Pack that inherits from the defaults pack"
version = "0.1.0"
}
dependency "defaults" {
alias = "defaults"
source = "git::/path/to/deprepo//packs/defaults"
}
| 属性 | 意味 |
|---|---|
| ブロックのラベル | 依存の名前 |
alias | テンプレートから参照するときの名前。省略時はラベル |
source | go-getter の URL |
enabled | 真偽値。偽にすると読み込まれない |
source は go-getter の書式である。 リポジトリのサブディレクトリを指すには // を使う。
git::ssh://git@github.example.com/team/pack-registry.git//packs/defaults
// の前がリポジトリ、後ろがリポジトリ内のパスである。 これを忘れると失敗する。
$ nomad-pack deps vendor
! Failed To Vendor Dependencies
! Error: error downloading dependency: error downloading 'file:///tmp/npwork/deprepo/packs/defaults?depth=1': /usr/bin/git exited with 128
fatal: '/tmp/npwork/deprepo/packs/defaults' does not appear to be a git repository
「git リポジトリではない」というエラーは、ほぼ // の書き忘れである。
19.11.3 deps vendor
依存はダウンロードしなければ使えない。
$ nomad-pack render ./child
! Failed To Process Pack
! Error: failed to load pack dependency: failed to load dependent pack: stat /tmp/npwork/child/deps/defaults: no such file or directory
! Context:
! Registry Name: <<local folder>>
! Pack Name: child
エラーメッセージが期待するパスを教えてくれるので、原因はすぐ分かる。
$ cd child
$ nomad-pack deps vendor
downloading defaults pack to deps/defaults...
...success!
$ find deps
deps
deps/defaults
deps/defaults/metadata.hcl
deps/defaults/templates
deps/defaults/templates/_defaults.tpl
deps/defaults/variables.hcl
deps/<エイリアス>/ に展開される。
| オプション | 効果 |
|---|---|
--path=<string> | パックのパス。省略時はカレントディレクトリ |
--timeout=<int> | ダウンロードのタイムアウト秒数。既定30 |
deps/ をコミットするかどうか
2つの方針があり、どちらも実務で使われる。
| 方針 | 利点 | 欠点 |
|---|---|---|
| コミットする(vendoring) | レンダリングが再現的。ネットワーク不要。監査しやすい | リポジトリが膨らむ。更新を人間が行う |
| コミットしない | 常に最新。リポジトリが軽い | CI で deps vendor が必要。ref を固定できない(第19.11.6節) |
Nomad Pack では「コミットする」を推奨する。 理由は第19.11.6節で述べる ref 固定の不具合である。依存の ref を固定できないため、コミットしないと「いつ内容が変わるか分からない」状態になる。
開発中は symlink が便利である。 metadata.hcl の生成ひな型にもこう書かれている。
# Dependencies in active development can by symlinked in
# the ./deps directory
$ ln -s /path/to/defaults-pack deps/defaults
依存パックとそれを使うパックを同時に開発するときに有用である。
19.11.4 依存変数の参照とコンテキスト
第19.5.4節のコンテキスト構造がここで効いてくる。
.
├── "_self" ← child
└── "defaults" ← 依存パック
正しい参照:
A = [[ var "base_env" .defaults ]]
B = [[ var "base_resources.cpu" .defaults ]]
C = [[ var "job_name" ._self ]]
A = map[LOG_LEVEL:info TZ:UTC]
B = 200
C = child
間違った参照(静かに空になる):
B = [[ var "defaults.base_env" . ]]
B =
ヘルパのコンテキストが最も間違えやすい
実際に踏んだ例を示す。 依存パック側のヘルパをこう書いた。
[[- define "defaults.resources" -]]
resources {
cpu = [[ var "defaults.base_resources.cpu" . ]]
memory = [[ var "defaults.base_resources.memory" . ]]
}
[[- end -]]
親から . を渡して呼んだ。
[[ template "defaults.resources" . ]]
結果:
resources {
cpu =
memory =
}
値が空になり、HCL としても不正になった。 しかもエラーにならない。
原因は2つある。
① 名前に "defaults." を含めている(第19.11.4節の間違った参照)
② 呼び出しのコンテキストが親の "." になっている
正しい形は、ヘルパ側を「自分自身のコンテキスト」前提で書き、呼び出し側でエイリアスを渡すことである。
依存パック側:
[[- /*
defaults.resources — 呼び出し側は「このパック自身のコンテキスト」を渡すこと。
例: [[ template "defaults.resources" .defaults ]]
*/ -]]
[[- define "defaults.resources" -]]
resources {
cpu = [[ var "base_resources.cpu" . ]]
memory = [[ var "base_resources.memory" . ]]
}
[[- end -]]
親側:
[[ template "defaults.resources" .defaults ]]
結果:
resources {
cpu = 200
memory = 128
}
規則としてまとめる。
依存パックのヘルパは、var "<名前>" . で自分の変数を引く
呼び出し側は、template "<名前>" .<エイリアス> でエイリアスのコンテキストを渡す
そしてヘルパのコメントに「どのコンテキストで呼ぶか」を必ず書く。 これが無いと利用者が必ず間違える。呼び出し例をコメントに書くのが最も親切である。
実務での完成形
依存の既定値に親の追加分を重ねるという定番の書き方を、実際に動かしたものを示す。
親のテンプレート:
[[- $env := mergeOverwrite (deepCopy (var "base_env" .defaults)) (var "extra_env" ._self) -]]
job "[[ var "job_name" ._self ]]" {
datacenters = ["dc1"]
group "app" {
task "t" {
driver = "raw_exec"
config {
command = "/bin/sh"
args = ["-c", "sleep 3600"]
}
env {
[[- range $k, $v := $env ]]
[[ $k ]] = [[ $v | quote ]]
[[- end ]]
}
[[ template "defaults.resources" .defaults ]]
}
}
}
レンダリング結果:
job "child" {
datacenters = ["dc1"]
group "app" {
task "t" {
driver = "raw_exec"
config {
command = "/bin/sh"
args = ["-c", "sleep 3600"]
}
env {
APP = "child"
LOG_LEVEL = "info"
TZ = "UTC"
}
resources {
cpu = 200
memory = 128
}
}
}
}
実際にデプロイして、Nomad 側の値も確認した。
$ nomad-pack run ./child
Deployment "..." successful
$ nomad job inspect child | jq '.Job.TaskGroups[0].Tasks[0] | {Env, CPU: .Resources.CPU, Mem: .Resources.MemoryMB}'
{
"Env": { "APP": "child", "LOG_LEVEL": "info", "TZ": "UTC" },
"CPU": 200,
"Mem": 128
}
依存パックの既定値(LOG_LEVEL, TZ, cpu=200, memory=128)と親の追加分(APP)が正しく合成されて Nomad に届いている。
19.11.5 依存変数の上書き
親から依存パックの変数を上書きできる。 書式は第19.7節のとおりである。
--var(オブジェクト全体を渡す):
$ nomad-pack render . --var 'defaults.base_resources={cpu=999,memory=256}' --no-format
B = 999
変数ファイル(フラットなドット記法):
defaults.base_resources = {
cpu = 777
memory = 333
}
$ nomad-pack render . -f o1.hcl --no-format
B = 777
フィールド1つだけの上書きはできない。
$ nomad-pack render . --var defaults.base_resources.cpu=999
! Failed To Process Pack
! Error: There is no variable named "defaults.base_resources.cpu". ...
設計上の含意
「オブジェクト全体を渡さなければならない」制約は、依存パックの設計に影響する。
避けるべき形 — 大きなオブジェクト1つ:
variable "config" {
type = object({
cpu = number
memory = number
count = number
image = string
ports = list(string)
env = map(string)
})
default = { ... }
}
cpu だけ変えたい利用者が、6フィールド全部を書かなければならない。
推奨される形 — 用途ごとに分ける:
variable "resources" {
type = object({ cpu = number, memory = number })
default = { cpu = 200, memory = 128 }
}
variable "count" {
type = number
default = 1
}
variable "base_env" {
type = map(string)
default = { LOG_LEVEL = "info" }
}
マップ型(map(string))はテンプレート側で mergeOverwrite すれば部分的な追加ができるので、「追加したいだけ」のものはマップにする。オブジェクト型は「まとめて置き換わってよい単位」に留めるのが指針である。
19.11.6 v0.4.2 の既知の不具合 — 依存の ref を固定できない
依存パックの source にクエリ文字列を書くと URL が壊れる。実測で確認した。
dependency "defaults" {
alias = "defaults"
source = "git::/tmp/npwork/deprepo//packs/defaults?ref=defaults1.0.0"
}
$ nomad-pack deps vendor
! Failed To Vendor Dependencies
! Error: error downloading dependency: error downloading
'file:///tmp/npwork/deprepo?ref=defaults1.0.0%!F(MISSING)depth%!D(MISSING)1':
/usr/bin/git exited with 1: error: pathspec 'defaults1.0.0?depth=1' did not match any file(s) known to git
%!F(MISSING) と %!D(MISSING) は Go の fmt の書式指定エラーの痕跡である。 nomad-pack が ?depth=1 を素朴に連結しており、既にクエリ文字列がある URL を壊している。
metadata.hcl の dependency ブロックには ref 属性も無い。 したがって v0.4.2 では依存パックのバージョンを固定する手段が存在しない。
回避策
選択肢は3つある。
| 回避策 | 内容 | 評価 |
|---|---|---|
deps/ をコミットする | vendoring した内容をリポジトリに入れる | 推奨 |
| ブランチを固定用に使う | source の URL を、ref ではなくブランチ名を含むリポジトリに向ける | 限定的 |
| 依存を使わない | 共通部分をコピーする、またはレジストリの ref で管理する | 状況次第 |
deps/ をコミットするのが現実的な唯一の解である。
$ nomad-pack deps vendor
$ git add deps/
$ git commit -m "vendor defaults pack at defaults1.1.0"
コミットメッセージに元の ref を書き残すことで、後から「どのバージョンを取り込んだか」が追える。仕組みで固定できないため、規約で補う。
依存を使わない選択も検討に値する。 共通化したいものが「変数の既定値」だけなら、共通の変数ファイルを1つ置いて -f common.hcl -f app.hcl の順で渡す方が単純である。
$ nomad-pack run mypack --registry=prod --ref=v1.4.0 -f vars/common.hcl -f vars/api.hcl
第19.7.1節で確認したとおり、変数ファイルは後のものが勝つキー単位のマージである。 依存パックを持ち込まずに継承と同じ効果が得られる。
ただしヘルパテンプレートの共有はこの方法では代替できない。 define を共有したい場合は依存パックが必要になる。
19.12 fmt と CI
19.12.1 nomad-pack fmt
パックの .hcl と .tpl を HCL の規則で整形する。
$ nomad-pack fmt --help
Format pack files (.tpl templates and .hcl files) using HCL formatting rules.
Template files preserve the [[ ]] syntax, while .hcl files are formatted directly.
Format Options:
-check Check if files are formatted without modifying them.
Returns exit code 1 if formatting is needed.
-list List files that would be modified (default: true).
-write Write formatted content back to files (default: true).
-recursive Process directories recursively.
[[ ]] を保ったまま整形する点が重要で、terraform fmt では壊れてしまうテンプレートを扱える。
パスの指定が必要である
実測で確認した挙動:
$ nomad-pack fmt /tmp/npwork/fmtt
Formatted:/tmp/npwork/fmtt/metadata.hcl
Formatted:/tmp/npwork/fmtt/variables.hcl
templates/ の中は -recursive が必要である。
$ nomad-pack fmt -recursive /tmp/npwork/fmtt
Formatted:/tmp/npwork/fmtt/templates/fmtt.nomad.tpl
整形前:
pack {
name="fmtt"
description = "fmt test"
version="0.0.1"
}
整形後:
pack {
name = "fmtt"
description = "fmt test"
version = "0.0.1"
}
テンプレートも整形される。[[ ]] は保たれる。
job "[[ var "a" . ]]" {
group "g" {
task "t" {
driver = "raw_exec"
}
}
}
実務では常に -recursive を付け、パスを明示する。
$ nomad-pack fmt -recursive ./packs
-check で CI に組み込む
$ nomad-pack fmt -check -recursive ./packs
整形が必要なら終了コード1になる。 terraform fmt -check と同じ使い方である。
19.12.2 CI パイプラインの組み方
第19.2.4節で見たように、plan 以外は Nomad クラスタを必要としない。 これを利用して2段構成にする。
第1段: クラスタ不要の静的検査
name: pack-validate
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nomad-pack and nomad
run: |
set -euo pipefail
NP=0.4.2
curl -fsSL -o np.zip \
"https://releases.hashicorp.com/nomad-pack/${NP}/nomad-pack_${NP}_linux_amd64.zip"
sudo unzip -o np.zip -d /usr/local/bin
# nomad は job validate のために入れる
curl -fsSL -o nomad.zip \
"https://releases.hashicorp.com/nomad/1.10.5/nomad_1.10.5_linux_amd64.zip"
sudo unzip -o nomad.zip -d /usr/local/bin
- name: 整形の確認
run: nomad-pack fmt -check -recursive ./packs
- name: 全パックをレンダリングして jobspec を検証
run: |
set -euo pipefail
for pack in packs/*/; do
name=$(basename "$pack")
for vars in vars/"$name"/*.hcl; do
[ -e "$vars" ] || continue
echo "=== $name / $(basename "$vars") ==="
nomad-pack render "./$pack" -f "$vars" | tail -n +3 > /tmp/out.nomad
nomad job validate /tmp/out.nomad
done
done
nomad job validate は Nomad サーバなしで動く(ドライバ設定の検証だけがスキップされる)。
$ nomad job validate demo.nomad
Driver configuration not validated since connection to Nomad agent couldn't be established.
Job validation successful
レンダリング結果の差分を PR に出す
パックのテンプレートを変えたときに最も知りたいのは「出力がどう変わるか」である。
- name: レンダリング結果の差分
run: |
set -euo pipefail
mkdir -p /tmp/base /tmp/head
git fetch origin "${{ github.base_ref }}"
git checkout FETCH_HEAD -- packs/
for vars in vars/*/*.hcl; do
pack=$(basename "$(dirname "$vars")")
nomad-pack render "./packs/$pack" -f "$vars" \
> "/tmp/base/$pack-$(basename "$vars").txt" || true
done
git checkout HEAD -- packs/
for vars in vars/*/*.hcl; do
pack=$(basename "$(dirname "$vars")")
nomad-pack render "./packs/$pack" -f "$vars" \
> "/tmp/head/$pack-$(basename "$vars").txt"
done
diff -ru /tmp/base /tmp/head > /tmp/render.diff || true
cat /tmp/render.diff
「テンプレートの1行を変えたら、全アプリの jobspec がこう変わる」が PR に出る。 これが Nomad Pack を安全に運用する最も効果的な手段である。第19.6.11節で「非決定的な関数を使うな」と述べた理由もここにある — uuidv4 が1つあると、この差分がノイズで埋まる。
変数の増減を検出する
パックのバージョンを上げるとき、変数が増えたかどうかを知りたい。
nomad-pack generate var-file mypack --registry=prod --ref="$OLD_REF" > /tmp/old.txt
nomad-pack generate var-file mypack --registry=prod --ref="$NEW_REF" > /tmp/new.txt
diff /tmp/old.txt /tmp/new.txt || true
generate var-file はアルファベット順に整列するので diff が安定する(第19.7.4節)。info は順序が非決定的なので使えない。
第2段: クラスタが必要な plan
plan:
needs: validate
runs-on: self-hosted # Nomad に到達できるランナー
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
NOMAD_PACK_CACHE_PATH: ${{ github.workspace }}/.nomad-pack-cache
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.nomad-pack-cache
key: nomad-pack-${{ hashFiles('registry.ref') }}
- name: レジストリを追加
run: |
nomad-pack registry add prod \
"git::https://${{ secrets.GIT_TOKEN }}@github.example.com/team/pack-registry.git" \
--ref="$(cat registry.ref)"
- name: plan
run: |
set +e
nomad-pack plan mypack --registry=prod --ref="$(cat registry.ref)" \
-f vars/prod.hcl > /tmp/plan.txt 2>&1
rc=$?
set -e
cat /tmp/plan.txt
case $rc in
0) echo "差分なし" ;;
1) echo "差分あり(承認が必要)" ;;
255) echo "エラー"; exit 1 ;;
*) echo "予期しない終了コード: $rc"; exit 1 ;;
esac
ポイントを整理する。
| 項目 | なぜそうするか |
|---|---|
NOMAD_PACK_CACHE_PATH をワークスペース内に置く | actions/cache でキャッシュできる |
ref を registry.ref ファイルで管理する | ref の変更が PR の diff に出る |
set +e で plan を囲む | 終了コード1で CI が落ちるのを防ぐ |
| パイプを使わない | パイプ越しでは終了コードが取れない(第19.10.3節) |
registry.ref ファイルを置く発想が重要である。 ref をワークフローの YAML に埋め込むと変更が見えにくい。1行のファイルにすれば「パックのバージョンを上げる PR」が1行の diff になり、レビューの対象として明快になる。
第3段: apply
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production # 承認ゲート
runs-on: self-hosted
steps:
- name: run
run: |
nomad-pack run mypack --registry=prod --ref="$(cat registry.ref)" \
-f vars/prod.hcl --check-index="$(cat /tmp/index.txt)"
--check-index を渡すことで、plan と apply の間に他人が変更していたら止まる(第19.10.2節)。承認ゲートを挟む構成では必須である。
19.13 発展 — 外部ツールから nomad-pack を呼ぶ
大規模な環境では、nomad-pack を単体で使うのではなく Terraform や独自のツールから呼び出す構成が採られることがある。 ここでは代表的なパターンと、そのときに問題になる点を扱う。
19.13.1 なぜラップするのか
Nomad Pack に足りないものを補うためである。
| Nomad Pack に無いもの | ラップして得られるもの |
|---|---|
| 状態ファイルによる差分管理 | Terraform の state |
| 設定の階層化・DRY | Terragrunt / Terraform のモジュールと変数 |
| 他のリソースとの依存関係 | Terraform のグラフ(Consul の設定、DNS など) |
| 承認フロー | Terraform を扱う既存の CI 基盤 |
「アプリの数が数十を超え、環境が複数ある」段階でラップの必要が出てくる。 それ以下なら変数ファイルと CI で足りる。
19.13.2 Terraform から呼ぶ構成
external データソースで nomad-pack render を実行し、結果を nomad_job に渡すのが定番である。
data "external" "rendered" {
for_each = local.deployments
program = ["bash", "${path.module}/scripts/render-pack.sh"]
query = {
pack_name = each.value.pack_name
pack_version = each.value.pack_version
pack_vars = jsonencode(each.value.pack_vars)
}
}
resource "nomad_job" "app" {
for_each = data.external.rendered
hcl2 { enabled = true }
deregister_on_destroy = false
jobspec = each.value.result.rendered_jobspec
lifecycle {
ignore_changes = [allocation_ids, modify_index, region, type]
}
}
重要な設定を3つ挙げる。
| 設定 | 意味 |
|---|---|
hcl2 { enabled = true } | jobspec を HCL2 として解釈する。$$ の畳み込みが起きる段(第19.8.3節) |
deregister_on_destroy = false | Terraform からリソースを消してもジョブを止めない。 誤操作の安全装置 |
ignore_changes = [allocation_ids, modify_index, ...] | Nomad が動的に変える値を差分にしない。これが無いと毎回 apply が走る |
19.13.3 JSON と HCL の不整合
この構成には構造的な問題が1つある。
Terraform の external プロバイダは JSON でしか値を渡せない
nomad-pack の -f は HCL の変数ファイルを要求する
両者を繋ぐには JSON → HCL の変換が必要になる。 ラッパスクリプトでこう書かれることが多い。
# JSON でエンコードされた pack_vars を HCL に変換する
CONVERTED_PACK_VARS=$(echo "$PACK_VARS" | jq . \
| sed -r 's/\"(.*)\": /\1 = /;s/^( [^ ].*),$/\1/' \
| tail -n +2 | "${HEAD}" -n -1 \
| terraform fmt -)
echo "$CONVERTED_PACK_VARS" > "$VARS_PATH"
nomad-pack render "$PACK_NAME" --registry=myreg --ref "$PACK_VERSION" -f "$VARS_PATH" | tail -n +3
各処理の役割:
| 処理 | 目的 |
|---|---|
jq . | JSON を1行1キーに整形する |
sed 's/\"(.*)\": /\1 = /' | "key": を key = に置換(JSON → HCL) |
sed 's/^( [^ ].*),$/\1/' | 行末のカンマを削除 |
tail -n +2 / head -n -1 | 最外の { } を落とす |
terraform fmt - | HCL として整形する(構文検証も兼ねる) |
tail -n +3 | nomad-pack render のヘッダ2行を落とす(第19.8.4節) |
この変換は脆い。 実際に問題になる箇所を挙げる。
① キーにピリオドが含まれる場合。 sysctl の設定などがこれに当たる。
{ "net.ipv4.tcp_keepalive_time": "60" }
素朴な変換だと net.ipv4.tcp_keepalive_time = "60" になり、HCL として不正である。キーを引用符で囲む追加処理が必要になる。
| sed 's/^[ \t]*\(.*net\.ipv[^ ]*\)/"\1"/g'
② macOS と Linux で head の挙動が違う。 macOS の head は -n -1(末尾1行を落とす)に対応しない。
[[ $(uname) != "Linux" ]] && HEAD="ghead" # coreutils が必要
[[ $(uname) == "Linux" ]] && HEAD="head"
手元でこの構成を動かすには brew install coreutils が必要になる。
③ ドル記号の段数が増える。 第19.8.3節で述べたとおり、HCL のパースを通る回数が増えるため、${...} を最終段まで残すには $ を4個並べる必要が出る。
この脆さは Terraform の external プロバイダが構造化データを渡せないことに起因する。 上流の制約であり、ラッパ側で完全に解決する方法は無い。
19.13.4 ラッパを書くときの指針
実際に運用されている構成から読み取れる、有効な工夫をまとめる。
① 依存の存在確認を先に行う
[[ ! -x $(command -v nomad-pack) ]] && { echo "nomad-pack is not installed" >&2; exit 1; }
[[ ! -x $(command -v nomad) ]] && { echo "nomad is not installed" >&2; exit 1; }
[[ ! -x $(command -v jq) ]] && { echo "jq is not installed" >&2; exit 1; }
[[ -z ${GIT_TOKEN} ]] && { echo "GIT_TOKEN must be set" >&2; exit 1; }
「何が足りないか」を1行で伝える。 Terraform の中で失敗すると原因が埋もれるため、入口で止めるのが有効である。
② パックのバージョンの存在確認を Nomad に触る前に行う
nomad-pack info "${PACK_NAME}" --registry=myreg --ref="${PACK_VERSION}" > /dev/null \
|| { echo "failed to pull version ${PACK_VERSION} of the ${PACK_NAME} pack" >&2; exit 1; }
pack_version のタイプミスがここで止まる。 info はクラスタに触らないので安全に実行できる。
③ デバッグ用の出力を持たせる
Terraform の output で中間状態を見せる。
output "rendered_pack_vars" {
description = "デバッグ用: パックに渡された変数"
value = { for k, v in local.deployments : k => v.pack_vars }
}
output "nomad_plans" {
description = "デバッグ用: 投入前の nomad plan"
value = { for k, v in data.external.rendered : k => v.result.plan }
}
「変数を足したのに反映されない」という調査で、変数の合成結果が期待どおりかをここで確定できる。 多段の変換を目で追う必要がなくなる。
plan の出力を得るには、ラッパスクリプトの中で nomad plan も実行する。
if [[ "$PACK_DEBUG" == "true" ]]; then
PLAN_OUTPUT=$(nomad plan -address="$NOMAD_ADDR" -token="$NOMAD_TOKEN" <(echo "$JOBSPEC"))
fi
jq -n --arg jobspec "$JOBSPEC" --arg plan "$PLAN_OUTPUT" \
'{"rendered_jobspec":$jobspec,"plan":$plan}'
実行時間が増えるので、フラグで切り替えられるようにする。 本番では有効にして apply 前に差分を確認する、という運用が採られる。
④ 変換を挟む段数を減らす
最も効果的な改善は「変換の段数を減らすこと」である。
| 案 | 内容 |
|---|---|
| 変数ファイルを直接生成する | Terraform の local_file で HCL を書き出し、external に渡すのはパスだけにする |
templatefile を使う | JSON → HCL の変換をテンプレートで明示的に行う |
nomad-pack を使わない | Terraform の templatefile で jobspec を直接生成する |
3番目は極論だが検討に値する。 パックの機能(依存、レジストリのバージョン固定、sprig)を使っていないなら、Terraform の templatefile で jobspec を作る方が段数が少なく、state 管理も自然である。「Nomad Pack を使う理由」を確認する価値がある。
| Nomad Pack を使う理由 | あるか |
|---|---|
| レジストリによるバージョン固定が必要 | → 使う価値がある |
| 複数チームがテンプレートを共有する | → 使う価値がある |
| sprig の深いマージによる継承が必要 | → 使う価値がある |
| 単に変数を埋めたいだけ | → templatefile で足りる |
19.14 設計指針
実機で一通り触った上での、実務向けの指針をまとめる。
19.14.1 パックを作るときの指針
| 指針 | 理由 |
|---|---|
| 変数には必ず型を書く | レンダリング前に弾ける。info と generate var-file が読める |
必須変数の description に「何を渡すか」を書く | エラーメッセージに出る唯一の手がかり(第19.4.3節) |
既定値は variables.hcl に置き、テンプレートで default を使わない | 0 と false の事故を避ける(第19.6.4節) |
リストは toStringList、数値のリストは toJson | 静かに壊れるのを避ける(第19.6.2節) |
merge 系の第1引数には必ず deepCopy | 破壊的変更による汚染を避ける(第19.6.7節) |
タグやリストは uniq | sortAlpha で決定的にする | plan のノイズを消す(第19.6.6節) |
| 非決定的な関数を使わない | plan が常に差分を報告する(第19.6.11節) |
env / expandenv / getHostByName を使わない | 再現性と秘密の漏洩(第19.6.11節) |
| ジョブ名を変数にする | 複数デプロイができるようになる(第19.10.8節) |
define のコメントに呼び出し例を書く | コンテキストの取り違えを防ぐ(第19.11.4節) |
| 1パックにまとめるのは「同時に生き死にするジョブ」だけ | destroy が全部消す(第19.4.4節) |
| base64 / ハッシュは引用符の中に置く | フォーマッタに壊される(第19.8.1節) |
README.md に「破壊的変更」の節を作る | ref を上げる人が最初に読む |
19.14.2 パックを使うときの指針
| 指針 | 理由 |
|---|---|
--ref を必ず明示する | latest は破壊的に上書きされる(第19.9.5節) |
| 変数はファイルに書き、リポジトリに置く | stop / destroy で同じ引数が必要(第19.10.6節) |
run / plan / destroy で引数を完全に揃える | 同上 |
| ref をファイル1行で管理する | バージョン変更が PR の diff になる(第19.12.2節) |
ref を上げる前に generate var-file の diff を見る | 変数の増減が分かる(第19.7.4節) |
plan の終了コードで制御する | 0/1/255 の3値(第19.10.3節) |
--check-index を渡す | plan と apply の間の競合を防ぐ(第19.10.2節) |
迷ったら stop、destroy は慎重に | destroy は痕跡を消す(第19.10.5節) |
--allow-unset-vars を常用しない | 空文字列が静かに埋まる(第19.4.3節) |
19.14.3 リポジトリの構成例
「パックのレジストリ」と「変数」を分けるのが基本である。
pack-registry/ ← レジストリのリポジトリ
├── README.md
├── CHANGELOG.md
└── packs/
├── defaults/ ← 依存パック(共通の既定値とヘルパ)
├── webservice/
│ ├── metadata.hcl
│ ├── variables.hcl
│ ├── deps/defaults/ ← vendoring してコミット(第19.11.6節)
│ ├── templates/
│ ├── outputs.tpl
│ ├── README.md
│ └── CHANGELOG.md
└── batch/
app-deployments/ ← 変数のリポジトリ
├── registry.ref ← "webservice1.4.0" の1行
├── vars/
│ ├── common.hcl
│ ├── prod/
│ │ ├── api.hcl
│ │ └── worker.hcl
│ └── staging/
│ ├── api.hcl
│ └── worker.hcl
└── .github/workflows/deploy.yml
分ける理由:
| 項目 | 理由 |
|---|---|
| 変更の頻度が違う | パックは月単位、変数は日単位で変わる |
| レビュアーが違う | パックはプラットフォームチーム、変数はアプリチーム |
| 権限を分けられる | タグの作成権限とデプロイ権限を分離できる |
| ref がゲートになる | パックを変えてもタグを打つまで本番に影響しない |
小規模なら1リポジトリでもよい。 その場合はレジストリを使わず、nomad-pack run ./packs/webservice -f vars/api.hcl と直接パスで指定する。レジストリの ref によるバージョン固定が不要なら、その方が単純である。
19.14.4 Helm から移ってきた人向けの注意点
| Helm の習慣 | Nomad Pack では |
|---|---|
values.yaml に何でも書く | 型を書く。未知のキーはエラーになる |
required で必須を表現 | default を書かない(第19.6.12節) |
include + nindent | define の中に字下げを書く(第19.6.12節) |
helm rollback | 無い。 古い ref で run し直す |
helm upgrade --install | run が両方を兼ねる |
helm list | nomad-pack status |
--set a.b.c=1 | 不可。 オブジェクト全体を渡す(第19.7.2節) |
| Chart Repository | git リポジトリ + タグ(第19.9節) |
.Release.Name | 参照できない。 変数で渡す |
{{ }} | [[ ]](第19.5.1節) |
最も戸惑うのは「ロールバックが無い」点である。 Nomad Pack は宣言的な適用しか行わないため、前の状態を知らない。戻すには「前の ref と前の変数ファイル」で run し直す。 したがって**「デプロイした ref と変数を記録しておく」ことが実質的なロールバック手段になる。**
記録の場所は2つある。
① Nomad のジョブ meta(pack.version / pack.registry) ← 自動
② 変数リポジトリの git 履歴 ← 自分で管理
両方が揃って初めて「あの時の状態」を再現できる。 ①だけでは変数が分からず、②だけでは ref が分からない。registry.ref ファイルを変数リポジトリに置く構成(第19.12.2節)が、この2つを1つの git 履歴にまとめる。
19.15 トラブルシューティング逆引き
症状からの逆引き表である。すべて本章で実際に踏んだものである。
レンダリングが失敗する
| 症状 | 原因 | 対処 |
|---|---|---|
no templates were rendered | テンプレートが無い、または全ての if が偽 | ls templates/、条件を確認 |
A single-line block definition must end with... | HCL の1行ブロックに属性を2つ書いた | 複数行にする(第19.4.3節) |
missing required variable: "x" (説明) | 必須変数を渡していない | --var x=... または -f |
This variable value is not compatible with the variable's type constraint | 型が合わない | info で型を確認 |
There is no variable named "x" | 変数名のタイポ、またはパックに無い変数 | generate var-file で一覧を出す |
function "required" not defined | Helm の関数を使った | variables.hcl で必須にする(第19.6.12節) |
function "include" not defined | 同上 | template を使う |
pack "my" not found when accessing ".my.x" | v1 記法を v2 パーサで実行 | --parser-v1 を付ける(第19.5.6節) |
var is not implemented for nomad-pack's v1 syntax | v2 記法に --parser-v1 を付けた | フラグを外す |
wrong type for value; expected map[string]interface {}; got parser.PackTemplateContext | コンテキストに sprig のマップ関数を使った | toJson を使う(第19.5.5節) |
failed to load pack dependency: ... deps/<alias>: no such file | 依存を vendoring していない | nomad-pack deps vendor |
does not appear to be a git repository | 依存の source に // が無い | git::<repo>//<path> にする(第19.11.2節) |
%!F(MISSING) を含む URL エラー | 依存の source にクエリ文字列を書いた | v0.4.2 の不具合。 ref を書かない(第19.11.6節) |
値が期待どおりにならない
| 症状 | 原因 | 対処 |
|---|---|---|
| 値が空になる(エラーは出ない) | 依存変数の参照形式が違う | var "x" .<alias> にする(第19.11.4節) |
| 値が空になる(ドット付きキー) | マップのキーにピリオドがある | index / get を使う(第19.5.2節) |
0 や false が既定値に化ける | default のゼロ値判定 | variables.hcl 側に既定値を置く(第19.6.4節) |
リストが [a b] と出る | toStringList を忘れた | 付ける(第19.6.2節) |
数値のリストが ['\x01'] になる | 数値に toStringList を使った | toJson を使う(第19.6.2節) |
base64 が aGk = になる | フォーマッタが = を代入と解釈した | 引用符の中に置く(第19.8.1節) |
ハッシュが 8 f43... になる | 同上(数値 + 識別子に分解) | 同上 |
| 文字列が途中で切れる | 引用符の中で quote を使った | quote を外す(第19.8.2節) |
| 上のブロックを直したら下のブロックが変わった | merge 系の破壊的変更 | deepCopy を挟む(第19.6.7節) |
${VAR} が展開されない / $ が余る | ドル記号の段数 | 1段ずつ実測する(第19.8.3節) |
デプロイに失敗する
| 症状 | 原因 | 対処 |
|---|---|---|
job with id "x" already exists and is part of deployment "y" | 別のデプロイが所有している | --name と job_name を揃える(第19.10.7節) |
job with id "x" already exists and is not managed by nomad pack | 素の nomad job run で作ったジョブ | 移行手順を踏む(第19.10.7節) |
pack "x" running but not in deployment "y" | stop / destroy に変数を渡していない | run と同じ引数を渡す(第19.10.6節) |
Resources exhausted / Dimension "cpu" exhausted | Apple Silicon の CPU 誤認識 | cpu_total_compute を設定(第19.2.3節) |
dial tcp ...: connect: ... | NOMAD_ADDR が違う、または到達できない | nomad node status で確認 |
stat .../<registry>/latest/<pack>@latest: no such file | --ref を省略し、latest が未キャッシュ | --ref を明示(第19.9.5節) |
destination file exists and overwrite is unset | --to-dir の出力先が既存 | --auto-approve(第19.8.4節) |
flag needs an argument: 'o' in -o | -o を --overwrite と誤解 | -o は --to-dir の短縮形(第19.8.4節) |
レジストリ関連
| 症状 | 原因 | 対処 |
|---|---|---|
remote helper 'git' aborted session | ローカルパスに git:: を付けた | 接頭辞なしで書く(第19.9.6節) |
registry list に消したはずの ref が残る | v0.4.2 の挙動。metadata.json が残る | nomad-pack list を見る(第19.9.8節) |
| 「同じコマンドなのに結果が違う」 | latest が上書きされた | --ref でタグ固定(第19.9.5節) |
--ignore-missing-vars が効かない | v0.4.2 では機能しない | パックごとに変数ファイルを分ける(第19.7.5節) |
調査の順序
「変数を変えたのに反映されない」という最も多い症状に対する切り分け手順:
① render で出力を目視する
nomad-pack render <pack> -f <vars> --no-format
→ ここで期待どおりなら、問題はパックの外にある
② 値だけを出すデバッグテンプレートで確認する
A = [[ var "x" . ]]
→ 空なら参照形式を疑う(第19.11.4節)
③ 変数が届いているか確認する
nomad-pack generate var-file <pack> ← 変数名の綴りを確認
nomad-pack info <pack> --ref=<ref> ← 型と既定値を確認
④ plan で差分を確認する
nomad-pack plan <pack> -f <vars>
→ 差分が無ければ、投入済みの内容と同じ
⑤ Nomad 側の実体を確認する
nomad job inspect <job> | jq '.Job.Meta' ← どの ref から来たか
nomad job inspect <job> | jq '.Job.TaskGroups[0]' ← 実際の値
⑥ 実行中のプロセスが受け取った値を確認する
nomad alloc exec <alloc> env ← ドライバが対応していれば
①と②の間が最も見落とされやすい区間である。 「render の出力が期待と違う」ことに気づかず、Nomad 側を疑って時間を使うことが多い。まず render を目で見る。
⑤の pack.version が特に有用である。 「本番で動いているのはどのパックのどのバージョンか」に、Nomad の API だけで答えられる。
19.16 検証範囲と限界
本章の記述の根拠を明示する。
実機で確認した事柄
バージョン: nomad-pack v0.4.2 (cd4d2a4) / nomad v1.10.5
環境: macOS(Apple Silicon / arm64)
クラスタ: nomad agent -dev(単一ノード、raw_exec ドライバ)
| 分類 | 確認した内容 |
|---|---|
| CLI | 全サブコマンドのヘルプ、generate 3種、fmt、list |
| パックの構造 | metadata.hcl / variables.hcl / templates/ / outputs.tpl の役割、_ 接頭辞、複数出力 |
| 型 | string / number / bool / list(string) / map(string) / object / list(object)、必須変数、型エラー |
| テンプレート | var / meta / template、ドット参照、空白制御、コンテキスト構造(toJson .)、v1/v2 パーサ |
| sprig | 本章の表に挙げた全関数を実行して出力を確認。required / include / readFile / lookup の不在、tpl / getHostByName の存在 |
| 変数 | --var の全書式、変数ファイル、優先順位、依存変数の上書き形式 |
| レンダリング | HCL フォーマッタによる改変(--no-format との比較)、引用符とヒアドキュメントの保護、--to-dir |
| エスケープ | ${} / $${} / $$${} の3段の畳み込みを、テンプレート → jobspec → プロセスの3点で実測 |
| レジストリ | add / list / delete(3粒度)、キャッシュ構造、--ref による複数バージョンの共存、コミュニティレジストリ(47パック) |
| ライフサイクル | run / plan(終了コード0/1/255)/ status / stop / destroy、ジョブ meta の6キー、所有権の検査2種、--name による複数インスタンス、stop の再レンダリング問題 |
| 依存 | dependency ブロック、deps vendor、コンテキストの受け渡し、実デプロイでの合成結果 |
確認できなかった事柄
推測で埋めず、明示しておく。
| 項目 | 理由 |
|---|---|
| Docker ドライバでの動作 | 検証環境で Docker が動いていなかった。raw_exec で代替した |
| マルチノードクラスタでの挙動 | nomad agent -dev の単一ノードで検証した。spread / constraint の実際の配置は未確認 |
| ACL 有効環境での挙動 | -dev は ACL 無効。トークンの権限不足時のエラーは未確認 |
| namespace / region をまたぐ操作 | 未検証 |
registry update の詳細な挙動 | 追加と削除は検証したが、update の差分更新の挙動は未確認 |
| Nomad Pack Enterprise 相当の機能 | 存在しない(Nomad Pack は OSS のみ) |
--check-index の実際の競合検出 | メッセージの確認のみ。実際に index をずらして失敗させる検証は未実施 |
| v0.5 以降の挙動 | v0.4.2 のみで検証した。本章で挙げた不具合は将来直りうる |
v0.4.2 で確認した不具合・不整合
将来のバージョンで直る可能性があるため、バージョンを添えて記録する。
| 項目 | 内容 |
|---|---|
--ignore-missing-vars | ヘルプに記載があるが機能しない。 --var 経由も変数ファイル経由も同じエラーで終了コード1 |
依存の source にクエリ文字列 | URL が壊れる(%!F(MISSING))。依存の ref を固定する手段が無い |
registry delete --ref | パックは消えるが metadata.json が残り、registry list が古い ref を報告し続ける |
info の変数の並び順 | 非決定的(Go のマップ反復順)。diff に使えない |
git:: 付きのローカルパス | 失敗する(remote-git is not a git command)。接頭辞なしなら動く |
fmt の引数なし実行 | カレントディレクトリを対象にすると「No formattable files」になる場合がある。パスを明示すれば動く |
これらは「使い方を変えれば回避できる」ものばかりである。 致命的な障害には至らないが、知らないと原因の分からない時間を使うことになる。
参考
| 対象 | 場所 |
|---|---|
| Nomad Pack のリポジトリ | github.com/hashicorp/nomad-pack |
| コミュニティレジストリ | github.com/hashicorp/nomad-pack-community-registry — 47パック。実装例として最も有用 |
| sprig の関数一覧 | masterminds.github.io/sprig/ |
Go の text/template | pkg.go.dev/text/template — 組み込み19関数と制御構文 |
| go-getter の URL 書式 | github.com/hashicorp/go-getter — source と --registry の書式 |
| バイナリの配布 | releases.hashicorp.com/nomad-pack/ |
学習の順序として最も効率が良いのは、コミュニティレジストリのパックを読むことである。
$ nomad-pack registry add community github.com/hashicorp/nomad-pack-community-registry
$ ls ~/Library/Caches/nomad/packs/community/latest/
$ cat ~/Library/Caches/nomad/packs/community/latest/traefik@latest/templates/_helpers.tpl
キャッシュは単なるディレクトリなので、47パックのテンプレートをそのまま読める。 本章で扱った記法が実際にどう使われているかが分かる。
20. セキュリティベストプラクティス
20.1 TLS の完全な設定
# CA 証明書の生成(cfssl を使用)
cat > ca-config.json <<EOF
{
"signing": {
"default": {
"expiry": "87600h"
},
"profiles": {
"nomad": {
"usages": [
"signing", "key encipherment",
"server auth", "client auth"
],
"expiry": "87600h"
}
}
}
}
EOF
cat > ca-csr.json <<EOF
{
"CN": "Nomad CA",
"key": { "algo": "ecdsa", "size": 256 },
"names": [
{
"O": "HashiCorp",
"OU": "Nomad"
}
]
}
EOF
cfssl geninitca ca-csr.json | cfssljson -bare ca
# サーバー証明書の生成
cat > server-csr.json <<EOF
{
"CN": "server.us-east.nomad",
"hosts": [
"server.us-east.nomad",
"localhost",
"127.0.0.1",
"*.us-east.nomad"
],
"key": { "algo": "ecdsa", "size": 256 },
"names": [
{
"O": "HashiCorp",
"OU": "Nomad"
}
]
}
EOF
cfssl gencert -ca=ca.pem -ca-key=ca-key.pem \
-config=ca-config.json -profile=nomad \
server-csr.json | cfssljson -bare server
20.2 Gossip 暗号化
# 暗号化キーの生成
nomad operator gossip keyring generate
# 設定ファイルに追加
server {
encrypt = "cg8StVXbQJ0gPvMd9o7yrg=="
}
20.3 セキュリティチェックリスト
- TLS の有効化: サーバー間、クライアント-サーバー間の全通信を暗号化
- ACL の有効化: 最小権限の原則に基づくアクセス制御
- Gossip 暗号化: Serf 通信の暗号化
- Vault 連携: シークレットの外部管理
- 名前空間の分離: ワークロードの論理的な分離
- Docker の制限:
privilegedモードの制限、raw_execドライバーの無効化 - ネットワークポリシー: Consul Connect によるサービス間のアクセス制御
- 監査ログ: Enterprise 機能による操作ログの記録
- Sentinel ポリシー: Enterprise 機能によるポリシーの強制
- 定期的な証明書ローテーション: 自動化された証明書更新
21. Enterprise 機能
21.1 主要な Enterprise 機能
| 機能 | 説明 |
|---|---|
| 名前空間 | ワークロードの論理的な分離(OSS でも利用可能、ただし機能制限あり) |
| リソースクォータ | 名前空間ごとのリソース制限 |
| Sentinel ポリシー | Policy as Code |
| マルチリージョンデプロイ | リージョン間のジョブ管理 |
| 監査ログ | 操作の監査証跡 |
| 自動スナップショット | 定期的なクラスタバックアップ |
| SSO / OIDC | シングルサインオン |
| ライセンス管理 | 自動ライセンスリロード |
| レプリケーション | クロスリージョンの ACL レプリケーション |
21.2 Sentinel ポリシー(Enterprise)
# 本番環境では privileged コンテナを禁止
import "tfplan"
main = rule {
all job.task_groups as tg {
all tg.tasks as task {
task.config.privileged is not true
}
}
}
# 最小リソース要件の強制
main = rule {
all job.task_groups as tg {
all tg.tasks as task {
task.resources.cpu >= 100 and
task.resources.memory >= 64
}
}
}
22. 実践的な構成例
22.1 マイクロサービスアプリケーション
# フロントエンド + バックエンド + データベースの構成例
job "ecommerce-platform" {
datacenters = ["us-east-1", "us-east-2"]
namespace = "production"
type = "service"
priority = 70
# アップデート戦略
update {
max_parallel = 2
canary = 1
min_healthy_time = "30s"
healthy_deadline = "5m"
auto_revert = true
auto_promote = true
}
# フロントエンド
group "frontend" {
count = 4
spread {
attribute = "${meta.zone}"
}
network {
mode = "bridge"
port "http" { to = 3000 }
}
service {
name = "ecommerce-frontend"
port = "3000"
connect {
sidecar_service {
proxy {
upstreams {
destination_name = "ecommerce-api"
local_bind_port = 8080
}
}
}
}
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "3s"
}
}
task "frontend" {
driver = "docker"
config {
image = "registry.example.com/ecommerce-frontend:v3.2.1"
}
env {
API_URL = "http://localhost:8080"
NODE_ENV = "production"
}
resources {
cpu = 500
memory = 256
}
}
}
# バックエンド API
group "api" {
count = 6
spread {
attribute = "${meta.zone}"
}
network {
mode = "bridge"
port "http" { to = 8080 }
port "grpc" { to = 9090 }
}
service {
name = "ecommerce-api"
port = "8080"
connect {
sidecar_service {
proxy {
upstreams {
destination_name = "postgres"
local_bind_port = 5432
}
upstreams {
destination_name = "redis"
local_bind_port = 6379
}
upstreams {
destination_name = "elasticsearch"
local_bind_port = 9200
}
}
}
}
check {
type = "http"
path = "/api/health"
interval = "10s"
timeout = "3s"
}
}
task "api" {
driver = "docker"
config {
image = "registry.example.com/ecommerce-api:v3.2.1"
}
vault {
policies = ["ecommerce-api"]
}
template {
data = <<-EOF
{{- with secret "secret/data/ecommerce/api" }}
JWT_SECRET={{ .Data.data.jwt_secret }}
STRIPE_KEY={{ .Data.data.stripe_key }}
{{- end }}
{{- with secret "database/creds/ecommerce-api" }}
DB_URL=postgresql://{{ .Data.username }}:{{ .Data.password }}@localhost:5432/ecommerce
{{- end }}
REDIS_URL=redis://localhost:6379
ELASTICSEARCH_URL=http://localhost:9200
EOF
destination = "secrets/env.txt"
env = true
}
resources {
cpu = 1000
memory = 512
}
# オートスケーリング
scaling "cpu" {
enabled = true
min = 4
max = 20
policy {
check "cpu" {
source = "prometheus"
query = "avg(nomad_client_allocs_cpu_total_percent{task='api'})"
strategy "target-value" {
target = 70
}
}
}
}
}
}
}
22.2 バッチ処理パイプライン
job "data-pipeline" {
datacenters = ["us-east-1"]
type = "batch"
# パラメータ化されたジョブ
parameterized {
payload = "optional"
meta_required = ["source_table", "target_table"]
meta_optional = ["batch_size", "parallelism"]
}
group "etl" {
count = 1
# リスタートポリシー
restart {
attempts = 3
interval = "30m"
delay = "15s"
mode = "fail"
}
ephemeral_disk {
size = 10000
}
task "extract" {
driver = "docker"
lifecycle {
hook = "prestart"
sidecar = false
}
config {
image = "registry.example.com/etl-extract:v1.0"
command = "/app/extract"
args = [
"--source", "${NOMAD_META_source_table}",
"--output", "${NOMAD_ALLOC_DIR}/data/extracted.parquet",
]
}
resources {
cpu = 2000
memory = 4096
}
}
task "transform-load" {
driver = "docker"
config {
image = "registry.example.com/etl-transform:v1.0"
command = "/app/transform-load"
args = [
"--input", "${NOMAD_ALLOC_DIR}/data/extracted.parquet",
"--target", "${NOMAD_META_target_table}",
"--batch-size", "${NOMAD_META_batch_size}",
]
}
resources {
cpu = 4000
memory = 8192
}
vault {
policies = ["etl-pipeline"]
}
template {
data = <<-EOF
{{- with secret "database/creds/etl-role" }}
DB_USER={{ .Data.username }}
DB_PASS={{ .Data.password }}
{{- end }}
EOF
destination = "secrets/db.env"
env = true
}
}
}
}
# パラメータ化ジョブのディスパッチ
nomad job dispatch \
-meta source_table="raw_events" \
-meta target_table="processed_events" \
-meta batch_size="10000" \
data-pipeline
23. まとめ
23.1 Nomad の強み
- シンプルさ: 単一バイナリで動作し、学習コストが低い
- 柔軟性: コンテナだけでなく、あらゆるワークロードに対応
- スケーラビリティ: 数万ノード規模のクラスタをサポート
- HashiCorp エコシステム: Consul、Vault、Terraform との緊密な連携
- マルチリージョン: ネイティブなマルチリージョンフェデレーション
- 運用の容易さ: アップグレード、バックアップ、トラブルシューティングが容易
23.2 Nomad の適用が適しているケース
- HashiCorp スタックを既に利用している組織
- コンテナとレガシーアプリケーションの混在環境
- 小〜中規模の運用チーム
- マルチリージョン・マルチクラウドの要件がある場合
- Kubernetes の複雑さを避けたい場合
- 段階的にオーケストレーションを導入したい場合
23.3 今後の展望
Nomad は継続的に進化を続けており、以下のような方向性が期待される。
- ネイティブサービスメッシュの強化: Consul Connect のさらなる統合深化
- エッジコンピューティング: 軽量なアーキテクチャを活かしたエッジデプロイ
- AI/ML ワークロード: GPU スケジューリングの強化
- Kubernetes との相互運用: ハイブリッドオーケストレーション
- セキュリティの強化: ゼロトラストアーキテクチャへの対応
付録A: 主要 CLI コマンドリファレンス
| コマンド | 説明 |
|---|---|
nomad agent | エージェントの起動 |
nomad job run <file> | ジョブの登録/更新 |
nomad job plan <file> | ジョブの変更プレビュー |
nomad job stop <name> | ジョブの停止 |
nomad job status <name> | ジョブのステータス確認 |
nomad job deployments <name> | デプロイメント一覧 |
nomad job history <name> | ジョブの変更履歴 |
nomad job dispatch <name> | パラメータ化ジョブのディスパッチ |
nomad job scale <name> <group> <count> | スケーリング |
nomad alloc status <id> | アロケーションのステータス |
nomad alloc logs <id> | アロケーションのログ |
nomad alloc exec <id> <cmd> | アロケーション内でコマンド実行 |
nomad alloc fs <id> <path> | ファイルシステムの確認 |
nomad node status | ノード一覧 |
nomad node drain -enable <id> | ノードのドレイン |
nomad server members | サーバーメンバー一覧 |
nomad operator raft list-peers | Raft ピア一覧 |
nomad operator snapshot save <file> | スナップショット取得 |
nomad system gc | ガベージコレクション |
nomad deployment promote <id> | カナリアプロモーション |
nomad deployment fail <id> | デプロイメント失敗(ロールバック) |
nomad var put <path> <k=v> | 変数の設定 |
nomad var get <path> | 変数の取得 |
nomad acl bootstrap | ACL ブートストラップ |
nomad acl token create | トークン作成 |
nomad acl policy apply <name> <file> | ポリシー適用 |
付録B: 環境変数リファレンス
| 変数 | 説明 |
|---|---|
NOMAD_ALLOC_ID | アロケーション ID |
NOMAD_ALLOC_NAME | アロケーション名 |
NOMAD_ALLOC_INDEX | アロケーションインデックス |
NOMAD_ALLOC_DIR | アロケーションディレクトリ |
NOMAD_TASK_DIR | タスクディレクトリ |
NOMAD_SECRETS_DIR | シークレットディレクトリ |
NOMAD_JOB_NAME | ジョブ名 |
NOMAD_JOB_ID | ジョブ ID |
NOMAD_GROUP_NAME | グループ名 |
NOMAD_TASK_NAME | タスク名 |
NOMAD_NAMESPACE | 名前空間 |
NOMAD_REGION | リージョン |
NOMAD_DC | データセンター |
NOMAD_IP_<label> | ポートラベルの IP |
NOMAD_PORT_<label> | ポートラベルのポート番号 |
NOMAD_ADDR_<label> | ポートラベルの IP:ポート |
NOMAD_HOST_PORT_<label> | ホスト側のポート番号 |
NOMAD_CPU_LIMIT | CPU 制限(MHz) |
NOMAD_MEMORY_LIMIT | メモリ制限(MB) |
NOMAD_UPSTREAM_ADDR_<name> | Connect Upstream のアドレス |
NOMAD_META_<key> | メタデータの値 |