Nomad

HashiCorp Nomad 完全ガイド — ワークロードオーケストレーションの全容

1. はじめに

1.1 Nomad とは何か

HashiCorp Nomad は、コンテナ化されたアプリケーションだけでなく、レガシーなバイナリ、Java アプリケーション、仮想マシンなど、あらゆる種類のワークロードをデプロイ・管理するための汎用ワークロードオーケストレーターである。Kubernetes がコンテナオーケストレーションに特化しているのに対し、Nomad は「あらゆるワークロードをあらゆるインフラストラクチャ上で実行する」という設計哲学を持つ。

Nomad は単一バイナリで動作し、軽量でありながらも大規模環境(数万ノード規模)に対応できるスケーラビリティを備えている。HashiCorp のエコシステム(Consul、Vault、Terraform)との緊密な統合により、サービスディスカバリ、シークレット管理、インフラストラクチャプロビジョニングをシームレスに実現できる。

1.2 Nomad が解決する課題

現代のインフラストラクチャ運用において、以下のような課題が存在する。

  1. 多様なワークロードの統一管理: コンテナだけでなく、レガシーアプリケーション、バッチジョブ、システムデーモンなどを単一のプラットフォームで管理する必要性
  2. マルチリージョン・マルチクラウド対応: 複数のデータセンターやクラウドプロバイダーにまたがるデプロイメント
  3. 運用の複雑さの軽減: Kubernetes のような重厚なオーケストレーターの学習コストと運用負荷
  4. 段階的な導入: 既存インフラへの影響を最小限に抑えた段階的なオーケストレーション導入

1.3 Nomad の主要な特徴

特徴説明
単一バイナリ外部依存なし、軽量デプロイ
マルチワークロードコンテナ、VM、バイナリ、Java など
マルチリージョンネイティブなマルチリージョンフェデレーション
宣言的ジョブ仕様HCL によるジョブ定義
Bin Packing効率的なリソース配置
ローリングアップデートゼロダウンタイムデプロイ
サービスディスカバリConsul との統合
シークレット管理Vault との統合
ACLきめ細かなアクセス制御
Web UI組み込みダッシュボード

1.4 Nomad と Kubernetes の比較

Nomad と Kubernetes はしばしば比較されるが、それぞれ異なる設計思想に基づいている。

観点NomadKubernetes
アーキテクチャ単一バイナリ、軽量多数のコンポーネント、複雑
対応ワークロードコンテナ、VM、バイナリ等主にコンテナ
学習コスト比較的低い高い
スケーラビリティ数万ノード(単一クラスタ)数千ノード(単一クラスタ)
エコシステムHashiCorp スタック中心CNCF エコシステム、巨大
サービスメッシュConsul ConnectIstio, Linkerd 等
パッケージ管理Nomad PackHelm
セットアップ難易度低い高い
コミュニティ規模中程度非常に大きい

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 は以下のポートを使用する。

ポートプロトコル用途
4646HTTPAPI、UI
4647RPCサーバー間、クライアント-サーバー間通信
4648Serf (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)
  1. 評価(Evaluation): ジョブの状態変更(登録、更新、ノード障害など)をトリガーに生成される
  2. プランニング: スケジューラがノードの選定、リソースの割り当てを計算
  3. アロケーション: 実際にタスクをノードに割り当てて実行
┌──────────┐    ┌────────────┐    ┌──────────┐    ┌────────────┐
│ Job      │───►│ Evaluation │───►│ Plan     │───►│ Allocation │
│ Submit   │    │ Queue      │    │ Queue    │    │ Execute    │
└──────────┘    └────────────┘    └──────────┘    └────────────┘
                      │                                  │
                      ▼                                  ▼
               ┌──────────────┐                  ┌──────────────┐
               │ Scheduler    │                  │ Task Driver  │
               │ (bin-pack /  │                  │ (docker,     │
               │  spread)     │                  │  exec, etc.) │
               └──────────────┘                  └──────────────┘

4.2 スケジューラの種類

スケジューラジョブタイプ説明
ServiceserviceBin packing とスプレッドアルゴリズムを使用
Batchbatch未使用リソースを効率的に活用
Systemsystem, 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 は複数のタスクドライバーを組み込みで提供する。

ドライバー説明ユースケース
dockerDocker コンテナコンテナ化されたアプリケーション
execネイティブバイナリLinux バイナリの直接実行
javaJava アプリケーションJAR ファイルの実行
raw_exec権限なしバイナリ実行分離不要なスクリプト
qemuQEMU 仮想マシン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 外部(コミュニティ)タスクドライバー

プラグインシステムにより、以下のような外部ドライバーも利用可能。

ドライバー説明
podmanPodman コンテナ
containerdcontainerd ランタイム
firecrackerFirecracker microVM
nspawnsystemd-nspawn コンテナ
windows_iisWindows IIS
lxcLXC コンテナ
potFreeBSD jail (pot)
rookoutRookout デバッグ
SingularityHPC コンテナ
ECSAWS 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 VolumeContainer 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_killedOOM キルされたアロケーション> 0
nomad.raft.commitTimeRaft コミット時間p99 > 500ms
nomad.raft.leader.lastContactリーダーとの最後のコンタクト> 500ms
nomad.runtime.num_goroutinesGoroutine 数> 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 ダッシュボード

主要なパネル構成例:

  1. クラスタ概要: サーバー/クライアントの数、リーダーの情報
  2. リソース使用率: CPU/メモリの割り当て率と使用率
  3. ジョブステータス: Running/Pending/Dead の内訳
  4. スケジューリング: 評価キュー、ブロック数
  5. Raft: コミット時間、リーダーコンタクト
  6. ネットワーク: 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 との対応で理解するのが最短である。

HelmNomad Pack備考
ChartPackテンプレートと変数定義の入れ物
Chart.yamlmetadata.hcl名前・バージョン・依存
values.yamlvariables.hclただし型宣言を持つ点が違う
templates/*.yamltemplates/*.tplGo テンプレート
templates/_helpers.tpltemplates/_helpers.tpl命名規約まで同じ
NOTES.txtoutputs.tplデプロイ後に表示される案内
charts/(サブチャート)deps/依存パック
Chart RepositoryPack Registryただし実体は git リポジトリ
helm install -f values.yamlnomad-pack run -f vars.hcl
helm templatenomad-pack render
helm diff (plugin)nomad-pack plan(標準装備)
ReleaseDeployment(--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_ADDRAPI のエンドポイント。既定 http://127.0.0.1:4646
NOMAD_TOKENACL トークン(第13章)
NOMAD_REGION / NOMAD_NAMESPACE対象の region / namespace
NOMAD_CACERT / NOMAD_CLIENT_CERT / NOMAD_CLIENT_KEYmTLS
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節
toStringListGo のリストを 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-packrender / 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節)
toStringListGo のリストを 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 ]]yes3項演算
default の「空」の定義に注意する

sprig の default は「ゼロ値なら既定値」であり、「未定義なら」ではない。

値default "X" の結果
""X
0X
falseX
[] / {}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=3count = 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,ba, b
[[ squote "hi" ]]'hi'' hi '
[[ b64enc "hi" ]]aGk=aGk =
[[ sha256sum "hi" ]]8f434346648f...8 f434346648f...
[[ indent 4 "x" ]] xx
[[ 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-formatHCL 整形をしない(第19.8.1節)
--render-output-templateoutputs.tpl も表示する
-o, --to-dir=<path>標準出力に加えてファイルとして書き出す
--auto-approve--to-dir の出力先が既存でも上書きする
--parser-v1v1 記法を使う(第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 REFref が解決された実際の 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-v1v1 記法
--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 での見え方
stopnomad job stop 相当dead (stopped)。ジョブ定義は残るdead として表示される
destroynomad 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テンプレートから参照するときの名前。省略時はラベル
sourcego-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
設定の階層化・DRYTerragrunt / 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 = falseTerraform からリソースを消してもジョブを止めない。 誤操作の安全装置
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 +3nomad-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 + nindentdefine の中に字下げを書く(第19.6.12節)
helm rollback無い。 古い ref で run し直す
helm upgrade --installrun が両方を兼ねる
helm listnomad-pack status
--set a.b.c=1不可。 オブジェクト全体を渡す(第19.7.2節)
Chart Repositorygit リポジトリ + タグ(第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 definedHelm の関数を使った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 syntaxv2 記法に --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" exhaustedApple 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/templatepkg.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 セキュリティチェックリスト

  1. TLS の有効化: サーバー間、クライアント-サーバー間の全通信を暗号化
  2. ACL の有効化: 最小権限の原則に基づくアクセス制御
  3. Gossip 暗号化: Serf 通信の暗号化
  4. Vault 連携: シークレットの外部管理
  5. 名前空間の分離: ワークロードの論理的な分離
  6. Docker の制限: privileged モードの制限、raw_exec ドライバーの無効化
  7. ネットワークポリシー: Consul Connect によるサービス間のアクセス制御
  8. 監査ログ: Enterprise 機能による操作ログの記録
  9. Sentinel ポリシー: Enterprise 機能によるポリシーの強制
  10. 定期的な証明書ローテーション: 自動化された証明書更新

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 の強み

  1. シンプルさ: 単一バイナリで動作し、学習コストが低い
  2. 柔軟性: コンテナだけでなく、あらゆるワークロードに対応
  3. スケーラビリティ: 数万ノード規模のクラスタをサポート
  4. HashiCorp エコシステム: Consul、Vault、Terraform との緊密な連携
  5. マルチリージョン: ネイティブなマルチリージョンフェデレーション
  6. 運用の容易さ: アップグレード、バックアップ、トラブルシューティングが容易

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-peersRaft ピア一覧
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 bootstrapACL ブートストラップ
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_LIMITCPU 制限(MHz)
NOMAD_MEMORY_LIMITメモリ制限(MB)
NOMAD_UPSTREAM_ADDR_<name>Connect Upstream のアドレス
NOMAD_META_<key>メタデータの値

付録C: 参考リソース