Consul

HashiCorp Consul 徹底解説 — サービスメッシュとサービスディスカバリの全容

1. はじめに

1.1 Consul とは何か

HashiCorp Consul は、分散システムにおける サービスディスカバリサービスメッシュKey-Value ストアネットワークインフラの自動化 を統合的に提供するプラットフォームである。2014 年に HashiCorp によって初めてリリースされ、以降マイクロサービスアーキテクチャやクラウドネイティブ環境の普及とともに広く採用されてきた。

従来の静的なインフラストラクチャでは、サービス間の接続情報(IP アドレスやポート番号)は設定ファイルやロードバランサーに静的に記述されていた。しかし、コンテナやオーケストレーションプラットフォーム(Kubernetes、Nomad など)の登場により、サービスインスタンスは動的に生成・破棄されるようになった。この環境下では、サービス間の接続を動的に解決し、安全に通信を行うための仕組みが不可欠である。Consul はまさにこの課題を解決するために設計されている。

1.2 Consul が解決する課題

Consul が対処する主要な課題は以下のとおりである。

課題従来のアプローチConsul によるアプローチ
サービスディスカバリDNS の静的レコード、ロードバランサー動的なサービスカタログと DNS/HTTP API
サービス間通信の安全性VPN、ファイアウォールルールmTLS による自動暗号化(サービスメッシュ)
アクセス制御IP ベースのファイアウォールサービスアイデンティティに基づく Intention
設定の一元管理各サーバーの設定ファイル配布Key-Value ストアと Watch 機能
マルチデータセンター個別管理、手動フェデレーションWAN Gossip による自動フェデレーション
ヘルスチェックNagios、Zabbix 等の外部ツール組み込みヘルスチェック機構

1.3 Consul のコア機能一覧

Consul は以下の主要な機能を提供する。

  1. サービスディスカバリ(Service Discovery): サービスの登録と検出を DNS または HTTP API で行う
  2. サービスメッシュ(Service Mesh / Consul Connect): サイドカープロキシを用いた mTLS 通信とトラフィック管理
  3. Key-Value ストア: 設定情報やフィーチャーフラグの動的管理
  4. ヘルスチェック: サービスとノードの健全性監視
  5. マルチデータセンター対応: 複数データセンターにまたがるサービスメッシュとディスカバリ
  6. ネットワークインフラの自動化: Consul-Terraform-Sync(CTS)によるネットワーク機器設定の動的更新
  7. アクセスコントロール(ACL): きめ細かなアクセス制御ポリシー
  8. Intention(意図ベースのアクセス制御): サービス間の通信許可/拒否をサービス名で定義

1.4 Consul のエディション

Consul は以下のエディションで提供されている。

  • Consul OSS(Community Edition): オープンソース版。基本的なサービスディスカバリ、KV ストア、サービスメッシュ機能を含む
  • Consul Enterprise: OSS に加え、名前空間(Namespace)、管理パーティション(Admin Partition)、冗長化機能、Sentinel ポリシーなどエンタープライズ機能を追加
  • HCP Consul(HashiCorp Cloud Platform): HashiCorp が管理するマネージドサービス版

本記事では、OSS 版の機能を中心に解説しつつ、Enterprise 固有の機能についても適宜言及する。


2. アーキテクチャ概要

2.1 全体構成

Consul のアーキテクチャは、以下の主要コンポーネントで構成される。

┌─────────────────────────────────────────────────────────────┐
│                      データセンター 1                         │
│                                                             │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │ Server 1 │──│ Server 2 │──│ Server 3 │  ← Raft Consensus│
│  │ (Leader) │  │(Follower)│  │(Follower)│                   │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘                  │
│       │              │              │                        │
│       └──────────────┼──────────────┘                       │
│              LAN Gossip (Serf)                              │
│       ┌──────────────┼──────────────┐                       │
│       │              │              │                        │
│  ┌────┴─────┐  ┌────┴─────┐  ┌────┴─────┐                  │
│  │ Client 1 │  │ Client 2 │  │ Client 3 │  ← Agent         │
│  │┌────────┐│  │┌────────┐│  │┌────────┐│                   │
│  ││Service ││  ││Service ││  ││Service ││                   │
│  ││  A     ││  ││  B     ││  ││  C     ││                   │
│  │└────────┘│  │└────────┘│  │└────────┘│                   │
│  └──────────┘  └──────────┘  └──────────┘                   │
│                                                             │
└──────────────────────┬──────────────────────────────────────┘
                       │ WAN Gossip
┌──────────────────────┴──────────────────────────────────────┐
│                      データセンター 2                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │ Server 1 │──│ Server 2 │──│ Server 3 │                   │
│  └──────────┘  └──────────┘  └──────────┘                   │
│       ...(同様の構成)...                                    │
└─────────────────────────────────────────────────────────────┘

2.2 Agent(エージェント)

Consul の中心的なコンポーネントは Agent であり、クラスタの各ノードで実行されるデーモンプロセスである。Agent には 2 つのモードがある。

Server モード

  • Raft コンセンサスプロトコル に参加し、クラスタの状態を管理する
  • サービスカタログ、KV ストア、ACL ポリシーなどのデータを保持する
  • リーダー選出を行い、書き込み操作はリーダーが処理する
  • 通常、1 データセンターあたり 3 台または 5 台の奇数構成が推奨される
  • クォーラム(過半数)が維持されている限り、障害に耐えられる

Client モード

  • ステートレスで、Server に RPC リクエストを転送する
  • サービスの登録、ヘルスチェックの実行、DNS クエリの処理を行う
  • 各アプリケーションノードに 1 つずつ配置されるのが標準的な構成
  • リソース消費が少なく、軽量に動作する

2.3 コンセンサスプロトコル(Raft)

Consul Server は Raft コンセンサスプロトコル を使用して、クラスタ全体でデータの一貫性を保証する。

Raft プロトコルの主要な特性は以下のとおりである。

  • リーダー選出: リーダーが応答しなくなると、Follower がタイムアウト後に選挙を開始する
  • ログレプリケーション: 書き込み操作はまずリーダーのログに記録され、Follower に複製される
  • コミット: 過半数の Server がログエントリを受け入れると、そのエントリはコミット済みとなる
  • 一貫性保証: デフォルトではリーダーが最新のコミット済みデータを返す(default 一貫性モード)

一貫性モードは 3 種類から選択できる。

モード説明用途
defaultリーダーが最新のコミットデータを返す一般的な読み取り
consistentリーダーがクォーラムを確認してから応答強い一貫性が必要な場合
stale任意の Server が応答(古いデータの可能性あり)高スループットが必要な場合

2.4 Gossip プロトコル(Serf)

Consul は Serf ライブラリを使用して Gossip プロトコルを実装している。Gossip プロトコルはメンバーシップ管理と障害検出に使用される。

LAN Gossip

  • 同一データセンター内の全 Agent(Server + Client)が参加する
  • メンバーシップの管理、障害検出、イベント配信を行う
  • UDP ベースで効率的に動作する(デフォルトポート: 8301)

WAN Gossip

  • 異なるデータセンターの Server 間で使用される
  • データセンター間の接続状態を監視する
  • クロスデータセンターのサービスディスカバリを可能にする(デフォルトポート: 8302)

2.5 ネットワークポート一覧

ポートプロトコル用途
8300TCPServer RPC(Agent 間の RPC 通信)
8301TCP/UDPLAN Serf Gossip
8302TCP/UDPWAN Serf Gossip(Server のみ)
8500TCPHTTP API
8501TCPHTTPS API(TLS 有効時)
8502TCPgRPC API(xDS、サービスメッシュ用)
8503TCPgRPC TLS API
8600TCP/UDPDNS インターフェース

ポートを開ける際の考え方を整理しておく。

区分ポート誰から誰へ
クラスタ内部8300 / 8301 / 8302Agent 間。外部に公開してはいけない
アプリ向け8500 / 8501 / 8600同一ノードのアプリから localhost へ
サービスメッシュ8502 / 8503サイドカー(Envoy)から Consul へ

8301 と 8302 は TCP と UDP の両方が必要である。 Gossip は通常 UDP を使うが、大きなメッセージや UDP が通らない環境では TCP にフォールバックする。片方だけ開けると「たまにメンバーシップが不安定になる」という切り分けの難しい症状になる。

8500(HTTP API)を 0.0.0.0 で待ち受けるのは危険である。 ACL を有効にしていない Consul の HTTP API は、KV の読み書きとサービス登録を誰にでも許す。既定では 127.0.0.1 にバインドし、アプリは同一ノードの Agent 経由で使うのが基本形である。これが「各ノードに Client Agent を1つ置く」構成の理由でもある。


3. サービスディスカバリ

サービスディスカバリとは「サービス名から、いま生きているインスタンスの接続先を得る仕組み」である。

従来は接続先を静的に書いていた。

① 設定ファイルに IP を直書きする         → インスタンスが増減すると全台を書き換える
② ロードバランサの VIP を1つ書く         → LB のメンバー登録を人間か別の仕組みが行う
③ DNS に A レコードを静的に登録する       → TTL の間は落ちたホストに流れ続ける

Consul はこれを「登録」と「検索」の2つの動作に分解する。

登録: インスタンス自身(またはオーケストレータ)が
      「私は web というサービスで、10.0.1.10:8080 で、この URL でヘルスチェックできる」
      と自分のノードの Agent に伝える
        ↓
      Agent が Server に転送し、サービスカタログに載る
        ↓
      Agent が定期的にヘルスチェックを実行し、結果をカタログに反映する

検索: 利用側が「web の健全なインスタンスを教えろ」と問い合わせる
        ↓ DNS か HTTP API
      健全なものだけが返る

ここで重要なのは「ヘルスチェックがディスカバリと統合されている」点である。 落ちたインスタンスはカタログから自動的に外れるため、利用側は「返ってきたものは生きている」と仮定できる。 これが静的 DNS との決定的な違いである。

3.1 サービスの登録

登録の方法は3つあり、使い分けは「誰がライフサイクルを持つか」で決まる。

方法誰が登録するか向いている場面
設定ファイルAgent が起動時に読むノードに常駐する固定的なサービス
HTTP APIアプリ自身か、起動スクリプト動的に増減するプロセス
オーケストレータ連携Nomad / Kubernetes が代行コンテナ環境(最も一般的)

設定ファイル方式は Agent の再起動(または consul reload)で反映されるため、宣言的で確実だが即時性はない。HTTP API 方式は即座に反映されるが、プロセスが異常終了すると登録が残るderegister_critical_service_after で回収する)。

設定ファイルによる登録

// /etc/consul.d/web-service.json
{
  "service": {
    "name": "web",
    "tags": ["production", "v1.2.3"],
    "port": 8080,
    "meta": {
      "version": "1.2.3",
      "environment": "production"
    },
    "check": {
      "http": "http://localhost:8080/health",
      "interval": "10s",
      "timeout": "5s",
      "deregister_critical_service_after": "90s"
    }
  }
}

各フィールドの意味と設計上の意味合いを押さえる。

フィールド意味設計上の注意
nameサービス名。 検索のキーになる同じ役割のインスタンスは必ず同じ名前にする。名前が違うと別サービスになる
idインスタンスの一意な ID省略すると name が使われる。1ノードに複数インスタンスを置くなら必須
tags任意のラベル検索の絞り込みに使えるproduction.web.service.consul)。系統・世代・カナリアの表現に使う
portポート
addressアドレス省略するとノードのアドレスが使われる。 コンテナ環境では明示が必要
meta任意のキーバリュー検索の絞り込みには使えないが、Service Resolver の Filter やテンプレートで参照できる
checkヘルスチェック定義第4章
deregister_critical_service_aftercritical が続いたら登録を自動削除する落ちたプロセスの登録が残り続けるのを防ぐ

tagsmeta の違いは実務でよく迷う。

tags → DNS でもフィルタできる。値を持たないラベル("production", "v1", "canary")
meta → DNS ではフィルタできない。キーと値のペア(version=1.2.3, zone=az1)

**「絞り込みに使うなら tags、情報として持ちたいなら meta」**が指針である。ただしタグを増やしすぎるとカタログが読みにくくなるため、タグはルーティングに影響するものだけに絞るのが健全である。

HTTP API による登録

curl -X PUT http://localhost:8500/v1/agent/service/register \
  -d '{
    "Name": "web",
    "ID": "web-1",
    "Tags": ["production", "v1.2.3"],
    "Address": "10.0.1.10",
    "Port": 8080,
    "Meta": { "version": "1.2.3" },
    "Check": {
      "HTTP": "http://10.0.1.10:8080/health",
      "Interval": "10s"
    }
  }'

3.2 サービスの検索

検索には2つのインターフェースがあり、性質が大きく違う。

DNSHTTP API
アプリの改造不要(既存のクライアントがそのまま使える)必要
得られる情報IP(A)、IP + ポート(SRV)すべて(タグ、メタ、ヘルス詳細、インデックス)
絞り込みタグのみタグ、メタ、状態、データセンター
キャッシュOS/ライブラリがキャッシュする(TTL の影響を受ける)制御できる
変化の追従次のクエリまで気づかないブロッキングクエリで即座に気づける

DNS の最大の利点は「既存のアプリを一切変更せずに使える」ことである。 接続先を web.service.consul に書き換えるだけで、動的なディスカバリが手に入る。

一方で DNS には構造的な限界がある。 A レコードはポートを返せないため、ポートが動的な環境では SRV レコードが必要になるが、SRV を解釈できるクライアントは限られる。 また DNS キャッシュにより、インスタンスが落ちてから切り替わるまで遅延が生じる。

したがって実務では次のように使い分ける。

既存のミドルウェア・レガシーアプリ  → DNS(ポート固定なら A レコードで十分)
新規に書くアプリ                    → HTTP API か、サービスメッシュ(第5章)
ロードバランサの設定生成            → consul-template(第10章)← 最も確実

DNS インターフェース

# 基本的なサービスルックアップ
dig @127.0.0.1 -p 8600 web.service.consul

# SRV レコードでポート情報を含むクエリ
dig @127.0.0.1 -p 8600 web.service.consul SRV

# タグでフィルタリング
dig @127.0.0.1 -p 8600 production.web.service.consul

# 別のデータセンターのサービスを検索
dig @127.0.0.1 -p 8600 web.service.dc2.consul

DNS フォーマット: [tag.]<service>.service[.datacenter].consul

この書式を分解して覚える。

   production  .  web  .  service  .  dc2  .  consul
   └─ タグ         └─ サービス名   └─ 固定  └─ DC(省略可)  └─ ドメイン(設定で変更可)
      (省略可)                     ↑
                             ここが "node" だとノード検索になる
検索対象書式
サービス[tag.]<service>.service[.<dc>].consul
ノード<node>.node[.<dc>].consul
Connect 対応サービス<service>.connect.consul
Prepared Query<name>.query.consul

A レコードと SRV レコードの違いが実用上重要である。

dig @127.0.0.1 -p 8600 web.service.consul        # → IP のみ。ポートは分からない
dig @127.0.0.1 -p 8600 web.service.consul SRV    # → IP + ポート + 優先度

ポートが動的なコンテナ環境では A レコードだけでは足りない。 SRV を使えない場合は、サービス登録時にポートを固定するか、consul-template で設定ファイルを生成する(第10章)。

HTTP API

curl http://localhost:8500/v1/catalog/services
curl http://localhost:8500/v1/health/service/web?passing=true
curl http://localhost:8500/v1/health/service/web?passing=true&tag=production

Prepared Queries(準備済みクエリ)

Prepared Query は「複雑な検索条件を Consul 側に名前を付けて保存し、DNS からも呼べるようにする」仕組みである。

解決したい問題は明確である。 DNS ではタグしか絞り込めず、フェイルオーバーも表現できない。しかし「既存アプリは DNS しか使えない」。この2つを両立させるのが Prepared Query である。

curl -X POST http://localhost:8500/v1/query \
  -d '{
    "Name": "web-nearest",
    "Service": {
      "Service": "web",
      "Tags": ["production"],
      "Failover": { "NearestN": 3, "Datacenters": ["dc2", "dc3"] },
      "OnlyPassing": true
    },
    "DNS": { "TTL": "10s" }
  }'

登録すると web-nearest.query.consul で引けるようになる。 アプリ側は普通の DNS 名として扱える。

フィールド意味
Nameクエリ名。<name>.query.consul で参照する
Service.Service対象のサービス名
Service.Tagsタグの絞り込み
Failover.NearestNローカル DC に健全なインスタンスが無い場合、近い N 個の DC を試す
Failover.Datacenters明示的なフェイルオーバー先の順序
OnlyPassingpassing のみを返す(warning を除外する)
DNS.TTL返す DNS レコードの TTL

Failover が Prepared Query の主な価値である。 通常の DNS クエリはローカル DC しか見ないため、DC 全体が落ちると名前解決が失敗する。Prepared Query なら自動で別の DC にフォールバックする。

ただし注意点がある。 クエリの定義は Consul 側に保存されるため、Git 管理下の設定ファイルには現れない。 「誰がいつ作ったクエリか」が分からなくなりやすい。サービスメッシュ(第5章)の Service Resolver で同じことが宣言的に書けるため、新規に組むなら Resolver を優先する。

3.3 DNS フォワーディングの設定

Consul の DNS は 8600 番ポートで待ち受ける。 しかし通常のアプリケーションは 53 番ポートの OS のリゾルバに問い合わせる。この隙間を埋めるのがフォワーディングの設定である。

アプリ  →  /etc/resolv.conf  →  ローカルの DNS(53)
                                      │
                    ┌─────────────────┴──────────────────┐
                    │ .consul で終わる名前だけ           │ それ以外
                    ▼                                    ▼
              Consul(127.0.0.1:8600)            通常の DNS サーバ

**「.consul ドメインだけを Consul に転送し、残りは既存の DNS に流す」**のが基本形である。これを実現する手段が環境によって違う。

環境使うもの
Linux(従来)dnsmasq
Linux(systemd)systemd-resolved
KubernetesCoreDNS
macOS/etc/resolver/consul

dnsmasq

# /etc/dnsmasq.d/consul.conf
server=/consul/127.0.0.1#8600

CoreDNS(Kubernetes 環境)

consul {
    forward . 10.0.0.1:8600 10.0.0.2:8600 10.0.0.3:8600 {
        policy round_robin
    }
}

4. ヘルスチェック

ヘルスチェックは Consul の中で最も地味だが、最も効果が大きい機能である。

第3章で述べたとおり、Consul のディスカバリは「健全なものだけを返す」。その判定を行うのがヘルスチェックであり、ここが正しく設定されていなければディスカバリ全体が信用できなくなる。

3つの状態

passing   → 健全。ディスカバリの結果に含まれる
warning   → 警告。既定では含まれる(OnlyPassing で除外できる)
critical  → 異常。ディスカバリの結果から除外される

warning の扱いに注意が必要である。 DNS と ?passing=true を付けない HTTP API は warning を「使える」と扱う。「負荷が高いが応答はできる」といった状態を表現するのに使えるが、意図せず warning を返すチェックを書くと、劣化したインスタンスに流れ続ける。

誰がチェックを実行するのか

チェックはサービスが載っているノードの Agent が実行する。 Server が集中してチェックするのではない。

Client Agent(10.0.1.10)
   │ 10秒ごとに http://localhost:8080/health を叩く
   ▼
   結果(passing/warning/critical)を Server に報告
   ▼
   サービスカタログに反映される

この分散設計の利点は2つある。 ①チェックの負荷が各ノードに分散する、②localhost に対してチェックできるのでネットワーク経路の影響を受けにくい。

逆に欠点もある。 ノードごと落ちた場合、そのノードの Agent はチェック結果を報告できない。これは Gossip による障害検出(第2.4節)が補い、ノードが serfHealth で critical になることでサービスも除外される。

4.1 ヘルスチェックの種類

Consul は多様なヘルスチェック方式をサポートしている。選択の基準は「何を確認したいか」である。

種類確認できること向いている対象
HTTPアプリが応答し、正しいステータスを返すことWeb / API サービス(最も推奨
TCPポートが開いていることデータベース、TCP のみのサービス
スクリプト任意の条件ディスク残量、レプリカ遅延など
TTLアプリが自分で生きていると申告することバッチ、非同期ワーカー
gRPCgRPC のヘルスチェックプロトコルgRPC サービス
Dockerコンテナ内でコマンドを実行コンテナ
H2PingHTTP/2 の接続性HTTP/2 サービス

HTTP チェックが基本形である。 TCP チェックは「ポートは開いているがアプリは壊れている」状態を検出できない。「listen しているか」ではなく「仕事ができるか」を確認すべきである。

HTTP チェック

{
  "check": {
    "id": "web-health",
    "name": "Web Health Check",
    "http": "http://localhost:8080/health",
    "method": "GET",
    "interval": "10s",
    "timeout": "5s"
  }
}

TCP チェック

{
  "check": {
    "id": "db-tcp",
    "name": "Database TCP Check",
    "tcp": "localhost:5432",
    "interval": "10s",
    "timeout": "3s"
  }
}

スクリプトチェック

{
  "check": {
    "id": "mem-check",
    "name": "Memory Check",
    "args": ["/usr/local/bin/check_mem.sh", "-limit", "256MB"],
    "interval": "30s",
    "timeout": "10s"
  }
}

終了コード: 0=passing, 1=warning, その他=critical

スクリプトチェックは強力だが、既定では無効化されている。 任意のコマンドを実行できるため、enable_local_script_checks を明示的に有効にする必要がある。

enable_local_script_checks = true    # 設定ファイル由来のチェックのみ許可(推奨)
enable_script_checks       = true    # HTTP API 経由の登録も許可(危険)

enable_script_checks を有効にすると、HTTP API を叩ける者が任意のコマンドを実行できる。 ACL 無効の環境では実質的にリモートコード実行になるため、enable_local_script_checks を使う。

TTL チェック

{ "check": { "id": "app-ttl", "name": "Application TTL", "ttl": "30s" } }
curl -X PUT http://localhost:8500/v1/agent/check/pass/app-ttl \
  -d '{"Output": "Application healthy, 150 requests/sec"}'

TTL チェックは「向き」が逆である。 他のチェックは Consul がアプリを叩くが、TTL はアプリが定期的に Consul に「生きている」と申告する。 期限内に申告が来なければ自動的に critical になる。

通常のチェック: Consul ──[叩く]──▶ アプリ
TTL チェック:   アプリ ──[申告]──▶ Consul

向いているのは「外から叩けないもの」である。

① HTTP エンドポイントを持たないバッチ処理・ワーカー
② 「処理が進んでいること」を確認したい場合(キューを消化しているか)
③ ファイアウォール越しで Consul から到達できないプロセス

注意点が2つある。 ①アプリ側に申告のコードを書く必要がある(=Consul に依存する)、②申告のロジックが「本当に仕事ができているか」を反映していなければ意味がない。 単に pass を送るループを回すだけでは、プロセスが生きていることしか分からない。

gRPC / Docker / H2 Ping チェック

{ "check": { "grpc": "localhost:50051", "grpc_use_tls": true, "interval": "10s" } }
{ "check": { "docker_container_id": "abc123", "args": ["/health.sh"], "interval": "15s" } }
{ "check": { "h2ping": "localhost:443", "h2ping_use_tls": true, "interval": "10s" } }

4.2 ベストプラクティス

  1. チェック間隔はサービスの重要度に応じて設定(重要: 5-10秒、低優先度: 30-60秒)
  2. タイムアウトは間隔より短く設定
  3. deregister_critical_service_after で長時間異常なサービスを自動登録解除
  4. TTL/スクリプトチェックで診断情報を出力

それぞれの理由を補う。

① 間隔はトレードオフである。 短くすると障害検出が速くなるが、チェックの負荷が増える。1000インスタンス × 5秒間隔 = 毎秒200リクエストがヘルスチェックだけで発生する。

② タイムアウトが間隔より長いとチェックが重なる。 間隔10秒・タイムアウト15秒だと、前のチェックが終わる前に次が始まり、アプリへの負荷が積み上がる。タイムアウトは間隔の半分以下を目安にする。

deregister_critical_service_after は「掃除」の仕組みである。 プロセスが異常終了すると HTTP API で登録した情報が残り、カタログに critical のインスタンスが溜まり続ける。この設定があれば自動的に消える。ただし短すぎると、一時的な障害からの復旧時に再登録が必要になる。 数分〜数十分が現実的である。

④ 診断情報は障害対応の時間を大きく変える。 チェックの Output フィールドは consul monitor や UI で見えるため、「なぜ critical なのか」をここに書いておく。

加えて2点挙げる。

⑤ ヘルスチェックのエンドポイントを依存先の確認に使いすぎない。 /health がデータベース接続まで確認していると、DB の一時的な不調で全アプリインスタンスが同時に critical になり、サービス全体がカタログから消える。 「自分が仕事できるか」と「依存先が生きているか」は分けるのが定石である(/health/health/deep を分ける)。

⑥ チェックの数を必要以上に増やさない。 1つのサービスに複数のチェックを付けると、すべてが passing でなければサービスは passing にならない。 集約された .Status は最も悪い状態になる。


5. サービスメッシュ(Consul Connect)

5.1 サービスメッシュの概念

サービスメッシュとは、マイクロサービス間の通信を管理するインフラストラクチャレイヤーである。Consul Connect は以下を提供する。

  • mTLS(相互 TLS): サービス間通信の暗号化と相互認証
  • Intention: サービスレベルでの通信許可/拒否
  • トラフィック管理: ルーティング、スプリッティング、リゾルバー
  • 可観測性: メトリクス、トレーシング、ログ

第3章のディスカバリと何が違うのか

サービスディスカバリは「接続先を教える」だけである。 実際の接続はアプリが自分で行い、暗号化も認証もアプリの責任になる。

【ディスカバリだけの場合】
  web  ──[ Consul に問い合わせ ]──▶ 「api は 10.0.2.10:9090 だ」
  web  ──[ 平文の HTTP で直接接続 ]──▶ api
         ↑ 暗号化なし。相手が本当に api かも分からない

サービスメッシュは通信そのものを引き受ける。

【サービスメッシュの場合】
  web  ──[ localhost:9191 に接続 ]──▶ web のサイドカー
                                          │ mTLS(双方が証明書で相互認証)
                                          ▼
                                       api のサイドカー ──▶ api(localhost)

アプリは常に localhost に接続する。 暗号化、相互認証、リトライ、タイムアウト、ロードバランシング、メトリクス収集はすべてサイドカーが行う。

何が嬉しいのか

課題ディスカバリのみサービスメッシュ
通信の暗号化アプリが TLS を実装するサイドカーが自動で mTLS
相手の認証実質できない(IP を信じる)証明書のサービス名で認証
アクセス制御ファイアウォール(IP ベース)Intention(サービス名ベース)
証明書のローテーション人間が更新する自動(既定 72時間)
カナリアデプロイLB の設定を書き換えるSplitter で宣言的に
メトリクスアプリが計装するサイドカーが自動収集

最も価値が大きいのは「IP ベースの制御からサービス名ベースの制御へ移れる」ことである。 コンテナ環境では IP が頻繁に変わるため、ファイアウォールのルールが維持できない。サービスメッシュは「どの IP か」ではなく「どのサービスか」で判断する。

代償

無料ではない。 導入前に理解しておくべきコストがある。

① プロセス数が倍になる          → 各サービスに Envoy が1つ付く(メモリ 50〜100MB/個)
② レイテンシが増える            → 通信が2ホップ増える(通常 1ms 未満だが 0 ではない)
③ 障害の切り分けが複雑になる    → アプリ / サイドカー / Consul のどこが原因か
④ 学習コストが高い              → Envoy の設定、Config Entry の体系、証明書の仕組み

「ディスカバリだけで足りるなら、メッシュを入れない」判断も正当である。 ゼロトラストが要件、PCI DSS のような監査要件がある、カナリアを頻繁に行う、といった具体的な動機があるときに導入する。

5.2 サイドカープロキシ(Envoy)

Consul は自前のプロキシを書かず、Envoy を使う。 Consul の役割は「Envoy に設定を配ること」であり、実際のトラフィック処理は Envoy が行う。

Consul Server ──[ xDS(gRPC / 8502番) ]──▶ Envoy サイドカー
                   設定を動的に配布            │
                                              ▼
                                        実際のトラフィック処理

この分担が重要である。 Consul が落ちても、すでに設定を受け取った Envoy はトラフィックを流し続ける。 新しい設定(新しいインスタンスの追加など)は反映されなくなるが、既存の通信は継続する。

{
  "service": {
    "name": "web",
    "port": 8080,
    "connect": {
      "sidecar_service": {
        "proxy": {
          "upstreams": [
            { "destination_name": "api", "local_bind_port": 9191 },
            { "destination_name": "database", "local_bind_port": 5432 }
          ]
        }
      }
    }
  }
}
consul connect envoy -sidecar-for web -admin-bind 0.0.0.0:19000

upstreams が設定の核心である。

{ "destination_name": "api", "local_bind_port": 9191 }

これは「localhost:9191 に来た通信を、メッシュ経由で api サービスに届ける」という宣言である。 アプリ側のコードは次のように変わる。

変更前: http://api.internal:9090/v1/users
変更後: http://localhost:9191/v1/users

アプリは相手の場所を知らなくなる。 どのインスタンスに振るか、暗号化するか、リトライするかはすべてサイドカーが決める。

逆に言えば、上流を追加するたびに設定を書く必要がある。 これを不要にするのが Transparent Proxy(第5.7節)である。

-admin-bind で指定する管理ポート(19000)はデバッグの入口である。

curl localhost:19000/clusters      # 現在のバックエンド一覧と健全性
curl localhost:19000/config_dump   # Envoy に配られた設定の全体
curl localhost:19000/stats         # 統計

メッシュのトラブルシューティングは、ほぼこの3つで行う。 「Consul のカタログには居るのに繋がらない」場合、/clusters を見れば Envoy が認識しているかが分かる。管理ポートは認証が無いため、127.0.0.1 にのみバインドするのが原則である(上の例は説明のため 0.0.0.0 にしている)。

5.3 証明書管理(CA)

mTLS を成立させるには、全サービスに証明書を配り、期限が来たら更新し続ける必要がある。 人手では回らないため、Consul が CA として自動化する。

Root CA(Consul 内蔵 または Vault)
   └─ Intermediate CA
        ├─ web の Leaf 証明書   (SAN に spiffe://<trust-domain>/ns/default/dc/dc1/svc/web)
        ├─ api の Leaf 証明書
        └─ ...

証明書の SAN(Subject Alternative Name)にサービス名が入る点が本質である。これを SPIFFE ID と呼び、「証明書を見ればどのサービスか分かる」ため、サービス名ベースの認証が成立する。

**Leaf 証明書の TTL が短い(既定 72時間)**のは意図的である。漏洩しても影響時間が限られ、失効リストの管理が不要になる。Consul が自動でローテーションする。

内蔵 CA

connect {
  enabled = true
  ca_config {
    leaf_cert_ttl         = "72h"
    root_cert_ttl         = "87600h"
    intermediate_cert_ttl = "8760h"
    private_key_type      = "ec"
    private_key_bits      = 256
  }
}

Vault CA プロバイダー

connect {
  enabled = true
  ca_provider = "vault"
  ca_config {
    address               = "https://vault.example.com:8200"
    root_pki_path         = "pki-root"
    intermediate_pki_path = "pki-intermediate"
    leaf_cert_ttl         = "72h"
  }
}

5.4 Intention(意図ベースのアクセス制御)

Intention は「どのサービスがどのサービスを呼べるか」をサービス名で宣言する仕組みである。

従来:  「10.0.1.0/24 から 10.0.2.10:9090 への TCP を許可」   ← IP が変わると壊れる
Intention: 「web は api を呼べる」                            ← IP に依存しない

判定はサイドカーが証明書の SPIFFE ID を見て行う。 ネットワーク層のファイアウォールではなく、TLS 接続の確立時にアプリケーション層で判定する。

既定の挙動が重要である

Intention の既定は ACL の default_policy に従う。

default_policyIntention を書かないとき
allowすべて許可(開発環境の既定)
denyすべて拒否(本番の推奨)

本番では default_policy = "deny" にし、必要な通信だけを明示的に許可するのがゼロトラストの基本形である(第16.1節)。ただし移行時は既存の通信が全部止まるため、まず allow で運用しながらメトリクスで通信を把握し、Intention を書き終えてから deny に切り替える。

consul intention create -allow web api
consul intention create -deny web database
consul intention list

L7 Intention

L4(TCP レベル)の Intention は「呼べる/呼べない」しか表現できない。 L7 Intention は HTTP のパスやメソッドで細かく制御する。

使う条件がある — 対象サービスの Protocolhttp / http2 / grpc に設定されていなければならない(Service Defaults、第5.5節)。TCP のままでは L7 のルールは効かない。

Kind = "service-intentions"
Name = "api"
Sources = [
  {
    Name = "web"
    Permissions = [
      {
        Action = "allow"
        HTTP { PathPrefix = "/api/v1/"; Methods = ["GET", "POST"] }
      },
      {
        Action = "deny"
        HTTP { PathPrefix = "/api/admin/" }
      }
    ]
  },
  { Name = "monitoring", Action = "allow" },
  { Name = "*", Action = "deny" }
]

5.5 トラフィック管理

ここが Consul の Config Entry で最も理解しにくい部分である。 3つの仕組みが決まった順序で連鎖することを押さえると整理できる。

リクエストが api 宛に来る
      │
      ▼
① Service Router     「パス /v2/ なら v2 サブセットへ」        ← 条件で振り分ける
      │
      ▼
② Service Splitter   「stable に 90%、canary に 10%」           ← 比率で分ける
      │
      ▼
③ Service Resolver   「stable とは meta.version==1.0.0 のこと」 ← サブセットを定義する
      │
      ▼
   実際のインスタンス群

必ず Router → Splitter → Resolver の順である。 そして**「サブセット」を定義するのは Resolver だけ**なので、Router や Splitter で ServiceSubset を使うなら、先に Resolver でその名前を定義しておく必要がある。

Config Entry役割例えるなら
Routerリクエストの内容(パス、ヘッダ、メソッド)で行き先を選ぶswitch
Splitter複数の行き先に比率で分配する重み付き抽選
Resolver「サブセット」の定義とフェイルオーバー先変数の定義
Service Defaults1サービスの既定(プロトコル、上流の制限)サービス単位の設定
Proxy Defaultsメッシュ全体の既定グローバル設定

単純なカナリアなら Resolver + Splitter の2つで済む。 Router が必要になるのは「パスやヘッダで分ける」場合だけである。

Service Router

Kind = "service-router"
Name = "api"
Routes = [
  {
    Match { HTTP { PathPrefix = "/api/v2/" } }
    Destination { Service = "api"; ServiceSubset = "v2" }
  }
]

Service Splitter

Kind = "service-splitter"
Name = "api"
Splits = [
  { Weight = 90, ServiceSubset = "stable" },
  { Weight = 10, ServiceSubset = "canary" }
]

Service Resolver

Kind = "service-resolver"
Name = "api"
DefaultSubset = "stable"
Subsets = {
  stable = { Filter = "Service.Meta.version == 1.0.0" }
  canary = { Filter = "Service.Meta.version == 2.0.0" }
}
Failover = { "*" = { Datacenters = ["dc2", "dc3"] } }
ConnectTimeout = "5s"
RequestTimeout = "10s"
LoadBalancer = {
  Policy = "ring_hash"
  HashPolicies = [{ Field = "header", FieldValue = "X-User-Id" }]
}

Resolver には4つの独立した役割が詰まっている。

設定役割
Subsetsサブセットの定義。 Filter 式でインスタンスを絞る
DefaultSubsetサブセット名を指定されなかったときの既定
Failoverローカルに健全なインスタンスが無いときの退避先
LoadBalancer分散方式

Filter はサービス登録時の meta を参照する。 つまりカナリアを実現するには、まずインスタンスに meta を付けて登録する必要がある。

登録時:  meta = { version: "2.0.0" }
Resolver: canary = { Filter = "Service.Meta.version == 2.0.0" }
Splitter: canary に 10%

LoadBalancer.Policy = "ring_hash" はセッションアフィニティに使う。 HashPolicies で指定したヘッダの値が同じリクエストは、同じインスタンスに送られる。ステートフルなアプリをメッシュに載せるときに必要になる。

Policy挙動
round_robin順番に(既定)
least_requestリクエスト数が最も少ないインスタンスへ
ring_hashハッシュで固定(アフィニティ)
maglev同じくハッシュだが、メンバー変動時の再配置が少ない
randomランダム

Service Defaults

Kind = "service-defaults"
Name = "api"
Protocol = "http"
UpstreamConfig {
  Defaults {
    ConnectTimeoutMs = 5000
    Limits { MaxConnections = 512; MaxPendingRequests = 512; MaxConcurrentRequests = 512 }
    PassiveHealthCheck { Interval = "30s"; MaxFailures = 10 }
  }
}

Protocol の設定が最も重要である。 既定は tcp で、この場合 Consul は L7 の機能(パスによるルーティング、L7 Intention、HTTP メトリクス)を一切使えない。

Protocol = "tcp"   → L4 のみ。Router のパス条件も L7 Intention も効かない
Protocol = "http"  → L7 が有効になる

「Router を書いたのに効かない」の原因はほぼこれである。 HTTP サービスには必ず Protocol = "http" を設定する。

UpstreamConfigLimits はサーキットブレーカである。 上流が詰まったときに、無制限に接続を積み上げて自分も倒れるのを防ぐ。

設定意味
MaxConnections上流への同時接続数の上限
MaxPendingRequests接続待ちのキューの上限
MaxConcurrentRequests同時リクエスト数の上限(HTTP/2)
PassiveHealthCheckエラーを返したインスタンスを一時的に除外する(Outlier Detection)

PassiveHealthCheck は Consul のヘルスチェックとは別物である。 ヘルスチェックは能動的に叩くが、これは実際のリクエストの失敗を見て自動的に切り離す。 「ヘルスチェックは通るが実リクエストは失敗する」状態に効く。

Proxy Defaults

Proxy Defaults はメッシュ全体の既定値である。 Name = "global" 固定で、1つしか作れない。

Kind = "proxy-defaults"
Name = "global"
Config { protocol = "http"; envoy_prometheus_bind_addr = "0.0.0.0:9102" }
MeshGateway { Mode = "local" }
Expose { Checks = true }
TransparentProxy = { OutboundListenerPort = 15001 }

5.6 Mesh Gateway

データセンター間のサービスメッシュトラフィックを仲介するプロキシ。

解決する問題は「DC 間で全サイドカーが互いに到達できる必要がある」という要求である。

【Mesh Gateway なし】
  dc1 の全サイドカー ⇄ dc2 の全サイドカー     ← 全対全の到達性が必要
     数百 × 数百 のファイアウォール穴、重複する IP レンジの問題

【Mesh Gateway あり】
  dc1 の全サイドカー ──▶ dc1 の Mesh GW ──▶ dc2 の Mesh GW ──▶ dc2 のサイドカー
     DC 間に開ける穴は Gateway 間の1本だけ

Mesh Gateway は TLS を終端しない。 SNI を見て転送するだけなので、エンドツーエンドの mTLS は維持される。 Gateway は中身を読めない。これが「暗号化を保ったまま経路を集約できる」理由である。

IP レンジが重複していても動く点も実用上大きい。Gateway が中継するため、DC 間で直接ルーティングできる必要がない。

consul connect envoy -gateway=mesh -register \
  -service "mesh-gateway" \
  -address "10.0.0.1:8443" \
  -wan-address "198.51.100.1:8443"

モード: local(ローカル GW 経由)、remote(リモート GW に直接)、none(直接接続)

5.7 Transparent Proxy

アプリケーションコード変更なしでサービスメッシュに参加。iptables でトラフィックをサイドカーにリダイレクト。

第5.2節の upstreams 方式の欠点を解消する仕組みである。

【upstreams 方式】
  アプリのコードを http://localhost:9191 に書き換える
  上流を追加するたびに設定に upstream を追記する

【Transparent Proxy】
  アプリは http://api.virtual.consul に接続する(=本来の宛先のまま)
  iptables が出ていく通信をサイドカーに吸い込む
  アプリのコードも設定も変えない

Kubernetes では既定で有効にするのが一般的である。 アプリの改造が要らないため導入の障壁が大きく下がる。

代償が2つある。NET_ADMIN 権限(iptables を書き換えるための初期化コンテナ)が必要、②通信が黙ってサイドカー経由になるため、繋がらないときの切り分けが難しくなる。 「アプリからは普通に接続しているつもりなのに失敗する」という形で現れる。

Kind = "proxy-defaults"
Name = "global"
TransparentProxy = true

6. Key-Value ストア

Consul の KV は「小さな設定値を、クラスタ全体で一貫して共有する」ための領域である。

用途に向くもの                      向かないもの
─────────────────────────────      ─────────────────────────────
フィーチャーフラグ                  大きなファイル(512KB の上限あり)
接続先やタイムアウトの設定値         高頻度に書き換わるデータ
リーダー選出のためのロック           時系列データ、メトリクス
分散ロック / セマフォ                アプリケーションのデータベース代わり
デプロイ中のバージョン番号           バイナリの配布

設計上の性質を押さえておく。

性質内容
値のサイズ上限既定 512KB。大きなものは向かない
型が無いすべてバイト列。数値も真偽値も文字列として読み出される(第10.4.2節)
階層は擬似的config/myapp/db_host/ は単なる文字。ディレクトリは存在しない
Raft を通る書き込みはリーダー経由で全 Server に複製される。強い一貫性がある代わりに書き込みは重い
変更通知があるブロッキングクエリで「変わったら教える」ことができる。これが Watch と consul-template の基盤

「階層が擬似的」である点は実務で効いてくる。 config/myapp/ を消すという操作は無く、-recurse で「その文字列で始まるキー全部」を消す。プレフィックスの付け方がそのまま名前空間の設計になる。

推奨される構造
  config/<service>/<環境>/<キー>      ← 環境を含めるかは運用次第
  service/<service>/leader             ← リーダー選出用
  locks/<resource>                     ← ロック用

6.1 基本操作

# 書き込み/読み取り
consul kv put config/database/host 10.0.1.50
consul kv get config/database/host
consul kv get -recurse config/database/
consul kv delete -recurse config/database/

# HTTP API
curl -X PUT http://localhost:8500/v1/kv/config/database/host -d '10.0.1.50'
curl "http://localhost:8500/v1/kv/config/database/?recurse"

# CAS(Compare-And-Swap)
curl -X PUT "http://localhost:8500/v1/kv/config/database/host?cas=12345" -d '10.0.1.51'

CAS(Compare-And-Swap)は「読んだときから変わっていなければ書く」操作である。

① キーを読む → ModifyIndex = 12345 が返る
② 値を加工する
③ cas=12345 を付けて書く
     → まだ 12345 なら成功
     → 誰かが先に書いていたら失敗(false が返る)

これが無いと「読んで、加工して、書く」処理で更新が失われる。

プロセスA: 読む(値=5) ─────────── 6 を書く      ← A の変更が消える
プロセスB:      読む(値=5) ── 7 を書く

CAS を使えば片方が失敗するので、再読み込みしてやり直せる。 カウンタのインクリメント、リストへの追記、設定のマージなど、読み書きが分かれる操作では必須である。

ModifyIndexconsul kv get -detailed で確認できる。

consul kv get -detailed config/database/host

6.2 高度な機能

トランザクション

複数の KV 操作をまとめて「全部成功か、全部失敗」にする。 CAS が1キーの原子性を保証するのに対し、トランザクションは複数キーの原子性を保証する。

Verb で操作を指定する。

Verb意味
set書き込む
casCAS 付きで書き込む
get読む(結果が返る)
check-index指定したインデックスであることを確認する(書き込まない)
delete / delete-tree削除
lock / unlockセッションによるロック

check-index は「前提条件」を表現する。 「設定バージョンが 12345 のままなら、これらのキーを更新する」という書き方ができる。

Value は Base64 でエンコードする必要がある点に注意する(例の MTAuMC4xLjUw10.0.1.50)。

curl -X PUT http://localhost:8500/v1/txn \
  -d '[
    { "KV": { "Verb": "set", "Key": "config/database/host", "Value": "MTAuMC4xLjUw" } },
    { "KV": { "Verb": "check-index", "Key": "config/database/version", "Index": 12345 } }
  ]'

Watch

Watch は「変化したらコマンドを実行する」仕組みである。 ポーリングではなくブロッキングクエリを使うため、変化は即座に伝わる。

consul watch -type=keyprefix -prefix=config/database/ /usr/local/bin/update-config.sh

-type で監視対象を選ぶ。

type監視するもの
key単一のキー
keyprefixプレフィックス配下のキー群
serviceサービスのインスタンスとヘルス
servicesサービス一覧
nodesノード一覧
checksヘルスチェックの状態
eventユーザーイベント

変化した内容は標準入力に JSON で渡されるため、スクリプト側で jq などで読む。

Watch と consul-template の使い分けは明確である。

consul watchconsul-template
やることコマンドを実行するファイルを生成してからコマンドを実行する
テンプレート無い(自分でスクリプトを書く)ある
向いている用途通知、独自処理の起動設定ファイルの生成(第10章)

設定ファイルを作りたいなら consul-template を使う。 consul watch でスクリプトを書くと、結局テンプレート処理を自作することになる。

Consul Template

KV とサービスカタログを「設定ファイル」に変換して配る仕組みである。 Consul を知らないミドルウェア(nginx、HAProxy など)を動的な環境に載せるための標準的な手段になっている。

# haproxy.cfg.tpl
backend servers
    balance roundrobin
{{- range service "web" }}
    server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}

range service "web" は既定で「健全なインスタンスだけ」を返すため、落ちたインスタンスは自動的に設定から消え、LB がリロードされる。

テンプレートの文法、関数の一覧、運用上の注意は第10章で詳しく扱う。

セッションとロック

セッションは「クライアントの生存」を表すオブジェクトで、これを使って分散ロックを実装する。

① セッションを作る(TTL 付き)
② KV のキーに acquire=セッションID で書き込む
     → 成功すればロック取得
     → 既に誰かが持っていれば失敗
③ 仕事をする(TTL を更新し続ける)
④ release で解放する。または落ちれば TTL 切れで自動解放

「落ちても自動で解放される」ことが分散ロックの要件であり、それを TTL が担う。

パラメータ意味
TTLこの時間内に更新が無ければセッションを無効化する
LockDelayセッションが異常終了した後、他者がロックを取れるまでの待ち時間(既定15秒)
Behaviorrelease(ロックを解放)または delete(キーを削除)

LockDelay の存在理由が重要である。 ネットワーク分断でセッションが失効したように見えても、元の保持者がまだ動いている可能性がある。 即座に別のプロセスにロックを渡すと、2つが同時に動く(スプリットブレイン)。LockDelay はその窓を塞ぐための安全マージンである。

実務では consul lock コマンドが簡単である。

consul lock locks/my-job "/usr/local/bin/batch.sh"

ロックを取れたときだけコマンドを実行し、終了時に解放する。 「複数ノードに同じバッチを配置し、1つだけ動かす」構成がこれで作れる。

SESSION_ID=$(curl -s -X PUT http://localhost:8500/v1/session/create \
  -d '{"Name":"my-lock","TTL":"30s","LockDelay":"15s","Behavior":"release"}' | jq -r '.ID')
curl -X PUT "http://localhost:8500/v1/kv/locks/my-resource?acquire=$SESSION_ID"
curl -X PUT "http://localhost:8500/v1/kv/locks/my-resource?release=$SESSION_ID"

7. ACL(アクセスコントロールリスト)

ACL を有効にしていない Consul は、API に到達できる者すべてに全権限を与える。 KV の読み書き、サービスの登録と削除、他ノードの情報の閲覧がすべて可能である。本番環境で ACL を無効にしてはならない。

構成要素の関係

トークン(Token)          ← クライアントが提示する秘密。UUID
   │ 紐付く
   ├─ ポリシー(Policy)    ← 「何にどう触れるか」の規則。HCL で書く
   ├─ ロール(Role)        ← ポリシーの束。複数トークンで共有する
   └─ サービスアイデンティティ ← サービス用の定型ポリシーを自動生成する近道

ポリシーは「リソースの種類 × 対象 × 権限」で書く。

service "web"          { policy = "write" }   ← 完全一致
service_prefix ""      { policy = "read" }    ← 前方一致("" は全部)
key_prefix "config/web/" { policy = "read" }
node_prefix ""         { policy = "read" }
リソース種別対象
service / service_prefixサービスの登録・参照
key / key_prefixKV
node / node_prefixノード情報
session / session_prefixセッション
agent / agent_prefixAgent の管理 API
query / query_prefixPrepared Query
event / event_prefixユーザーイベント
aclACL 自体の管理
operatorクラスタ運用(Raft、スナップショット)
meshメッシュ全体の設定

権限は3段階である。

read   → 参照のみ
write  → 参照 + 変更(read を含む)
deny   → 明示的な拒否(他の許可より強い)

deny は最優先である。 「全部 read だが、特定のキーだけは絶対に読ませない」という表現ができる。

7.1 ACL ブートストラップ

ACL を有効にした直後は、まだトークンが1つも存在しない。 そこで最初の管理トークンを発行するのがブートストラップである。

consul acl bootstrap

出力される SecretID が全権限を持つ初期管理トークンである。

AccessorID:   xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
SecretID:     xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   ← これを保管する
Description:  Initial Management Token
Policies:     global-management

3点に注意する。

① 1度しか実行できない。 2度目は ACL bootstrap no longer allowed になる。忘れた場合はリセット手順(acl-bootstrap-reset ファイルの作成)が必要になる。

② この場で必ず保管する。 再表示する方法は無い。Vault などの秘密管理に入れる。

③ このトークンを日常運用に使ってはいけない。 全権限を持つため、用途ごとに絞ったトークンを作り、初期管理トークンは緊急時用に封印する。

ブートストラップ直後は default_policy = "allow" で起動し、トークンを配り終えてから deny に切り替えるのが安全な移行手順である。いきなり deny にすると、Agent 自身が Server と通信できず、クラスタが機能しなくなる。

7.2 ポリシーの作成

# policies/web-service.hcl
service "web" { policy = "write" }
service "web-sidecar-proxy" { policy = "write" }
service_prefix "" { policy = "read" }
node_prefix "" { policy = "read" }
key_prefix "config/web/" { policy = "read" }
consul acl policy create -name "web-service-policy" -rules @policies/web-service.hcl
consul acl token create -description "Web service token" -policy-name "web-service-policy"

7.3 ロールとサービスアイデンティティ

サービスアイデンティティは「サービス用のポリシーを毎回書くのが面倒」という問題への解答である。

サービスがメッシュに参加するために必要な権限は、実はほぼ定型である。

自分自身のサービスを登録する権限          service "web"              write
自分のサイドカーを登録する権限            service "web-sidecar-proxy" write
他のサービスを発見する権限                service_prefix ""          read
ノードを参照する権限                      node_prefix ""             read

-service-identity "web:dc1" と書くと、Consul がこの定型ポリシーを自動生成する。 ポリシーを手で書く必要がなくなり、書き忘れによる事故も防げる。

consul acl token create -description "Web service token" -service-identity "web:dc1"

ロールは「ポリシーの束に名前を付ける」仕組みである。

【ロールなし】 トークンA ─┬─ ポリシー1
                          ├─ ポリシー2
                          └─ ポリシー3
               トークンB ─┬─ ポリシー1     ← 同じ組み合わせを毎回指定する
                          ├─ ポリシー2
                          └─ ポリシー3

【ロールあり】 トークンA ─── ロールX ─┬─ ポリシー1
               トークンB ─── ロールX  ├─ ポリシー2
                                      └─ ポリシー3   ← 変更は1箇所

トークンを再発行せずに権限を変えられる点がロールの実用的な価値である。

consul acl role create -name "web-role" -policy-name "web-service-policy" -service-identity "web:dc1,dc2"
consul acl token create -description "Web service token" -service-identity "web:dc1"

7.4 ACL 設定

acl {
  enabled = true
  default_policy = "deny"
  enable_token_persistence = true
  tokens {
    initial_management = "bootstrap-token-uuid"
    agent = "agent-token-uuid"
  }
}

各設定の意味を押さえる。

設定意味推奨
enabledACL を有効にする本番では必ず true
default_policyポリシーで明示されていない操作の扱いdenyallow は移行期間だけ)
down_policyServer に到達できないときの挙動後述
enable_token_persistenceトークンをディスクに保存し、再起動後も保持するtrue
tokens.agentAgent 自身が使うトークン(ノード登録、チェック報告)必須
tokens.defaultトークン未指定の API 呼び出しで使われる用途次第

down_policy が運用上重要である。

extend-cache(既定) → キャッシュしたトークンを期限切れ後も使い続ける
deny                 → Server に繋がらなければすべて拒否
allow                → Server に繋がらなければすべて許可(危険)
async-cache          → キャッシュを返しつつ、裏で更新を試みる

extend-cache が既定である理由は「Server 障害時にサービスが止まらないこと」を優先しているためである。 ACL のチェックのために可用性を落とすべきではない、という判断である。

tokens.agent を設定しないと、ACL を deny にした瞬間に Agent がノード登録できなくなる。 「ACL を有効にしたらクラスタが壊れた」の典型的な原因がこれである。


8. マルチデータセンター

Consul のマルチデータセンターは「疎結合」である。 これが設計を理解する鍵になる。

【重要な原則】
  各データセンターは独立した Raft クラスタを持つ
  KV とサービスカタログは DC 間で複製されない
  DC 間で共有されるのは「メンバーシップ情報」だけ

つまり dc1 の KV に書いた値は dc2 からは見えない。 サービスカタログも同様で、dc2 のサービスを知るにはクエリのたびに dc2 に問い合わせる(Consul が代わりに転送する)。

項目DC 間で複製されるか
サービスカタログされない(クエリ時に転送される)
KVされない(Enterprise の複製機能を除く)
ACL のポリシーとトークンされる(Primary DC から複製)
Connect の CAされる(Primary DC が Root CA を持つ)
メンバーシップされる(WAN Gossip)

この設計の利点は「DC 障害の影響が閉じる」ことである。 dc2 が全滅しても dc1 の Raft は無関係に動き続ける。逆に欠点は「DC をまたぐデータの共有が自前になる」ことである。

primary_datacenter の指定が重要である。 ACL と Connect の CA は Primary DC が権威を持つため、Primary DC が落ちると新しいトークンの発行や証明書の発行ができなくなる(既存のものは動き続ける)。

8.1 WAN Federation

# DC1 (Primary)
datacenter = "dc1"
primary_datacenter = "dc1"
server = true
bootstrap_expect = 3
retry_join_wan = ["dc2-server1.example.com"]
# DC2 (Secondary)
datacenter = "dc2"
primary_datacenter = "dc1"
server = true
bootstrap_expect = 3
retry_join_wan = ["dc1-server1.example.com"]

retry_join_wan が WAN Gossip への参加方法である。 LAN の retry_join とは別のプールで、Server だけが参加する。 Client は WAN Gossip に参加しない。

Mesh Gateway WAN Federation

従来の WAN Federation は「全 Server が互いに 8302 で到達できる」ことを要求した。 これはクラウドをまたぐ構成やネットワークが分離された環境では成立しにくい。

connect {
  enabled = true
  enable_mesh_gateway_wan_federation = true
}

この設定を入れると、WAN Gossip も Mesh Gateway 経由になる。 DC 間に開ける穴が Gateway 間の1本だけになり、Server を直接公開しなくて済む。

Kubernetes をまたぐ構成では実質必須である。 Pod の IP は外部から到達できないため、Gateway を経由する以外の選択肢がない。

8.2 クロスデータセンターのサービスディスカバリ

dig @127.0.0.1 -p 8600 web.service.dc2.consul
curl "http://localhost:8500/v1/health/service/web?dc=dc2&passing=true"

8.3 Admin Partition(Enterprise のみ)

Admin Partition は「1つの Consul クラスタを、複数の独立した論理クラスタに分割する」Enterprise 機能である。

Namespace(Enterprise)      → 同じクラスタ内で名前空間を分ける。サービス名の衝突を防ぐ
Admin Partition(Enterprise)→ さらに強い分離。ネットワークもカタログも別扱いになる

用途は「複数チームが1つの Consul を共有するが、互いに干渉させたくない」場合である。 既定では Partition をまたぐ通信はできず、exported-services で明示的に公開したサービスだけが見える。

OSS でこれを実現するには、クラスタ自体を分けるしかない。

Kind = "exported-services"
Name = "default"
Partition = "team-a"
Services = [{ Name = "api", Consumers = [{ Partition = "team-b" }] }]

9. Consul と Kubernetes

Kubernetes 上の Consul は、これまでの章で見た仕組みを「Kubernetes のやり方」で包んだものである。 新しい概念はほとんど無く、操作のインターフェースが変わる。

これまでKubernetes では
設定ファイル(HCL / JSON)Helm の values.yaml
サービス登録Pod の annotation(自動で登録される)
サイドカーの起動Mutating Webhook が自動注入
Config Entry(consul config writeCRD(kubectl apply
ACL トークンの配布manageSystemACLs が自動生成して Secret に置く

Kubernetes 特有の論点が1つある。 Kubernetes 自身がサービスディスカバリ(Service、CoreDNS)を持っているため、「Consul と Kubernetes のどちらでディスカバリするか」という選択が生じる。

① Kubernetes 内部だけ           → Kubernetes Service で十分。Consul は不要
② Kubernetes 外とも通信する      → Consul が価値を持つ(VM 上のサービスと相互接続)
③ ゼロトラストが要件            → Consul Connect(mTLS + Intention)
④ 複数クラスタをまたぐ          → Consul + Mesh Gateway

「Kubernetes だけで完結するなら Consul を入れる必要は薄い」というのが正直な整理である。Consul の価値はKubernetes の外(VM、ベアメタル、別クラウド)と繋ぐときに最も大きくなる。

9.1 Helm Chart によるデプロイ

global:
  name: consul
  datacenter: dc1
  tls: { enabled: true, enableAutoEncrypt: true }
  acls: { manageSystemACLs: true }
  gossipEncryption: { autoGenerate: true }
server:
  replicas: 3
  storage: 10Gi
connectInject:
  enabled: true
  transparentProxy: { defaultEnabled: true }
  metrics: { defaultEnabled: true, defaultPrometheusScrapePort: 20200 }
syncCatalog:
  enabled: true
  toConsul: true
  toK8S: true
meshGateway:
  enabled: true
  replicas: 2
ui:
  enabled: true
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install consul hashicorp/consul --namespace consul --create-namespace --values values.yaml

values.yaml の主要ブロックの意味を押さえる。

ブロック役割注意点
global.tlsAgent 間の TLS。enableAutoEncrypt で Client の証明書を自動配布本番では必ず有効にする
global.acls.manageSystemACLsACL のブートストラップとトークン配布を自動化有効にすると Secret にトークンが作られる
global.gossipEncryptionGossip の暗号化autoGenerate: true が手軽
server.replicasServer の数3 または 5(奇数)。1 は本番不可
server.storageRaft のデータ用 PVCStatefulSet なので永続ボリュームが必要
connectInjectサイドカーの自動注入メッシュを使うなら必須
syncCatalogConsul と Kubernetes のサービスを相互同期後述
meshGatewayDC / クラスタ間の中継単一クラスタなら不要

syncCatalog は誤解されやすい。

toConsul: true  → Kubernetes の Service を Consul のカタログに登録する
                   (VM 上のアプリが Kubernetes のサービスを DNS で引ける)
toK8S:    true  → Consul のサービスを Kubernetes の Service(ExternalName)として作る
                   (Pod が VM 上のサービスを Kubernetes の名前で引ける)

これは「メッシュ」とは別の機能である。 mTLS もアクセス制御も伴わず、単に名前解決を橋渡しするだけである。「VM と Kubernetes の間で段階的に移行する」場面で有用である。

9.2 Connect Inject(サイドカー注入)

Mutating Admission Webhook が Pod の生成を横取りし、サイドカーコンテナを差し込む。 アプリの Deployment には annotation を1行足すだけである。

kubectl apply
   │
   ▼
Kubernetes API Server
   │ Pod を作る直前に Webhook を呼ぶ
   ▼
consul-connect-injector
   │ annotation を見て、Pod の spec を書き換える
   ▼
Pod(アプリ + Envoy サイドカー + 初期化コンテナ)が起動する

annotation の意味を整理する。

annotation意味
connect-inject: "true"サイドカーを注入する(これが無ければ何も起きない)
connect-service-upstreams上流の指定(api:9191 = localhost:9191api になる)
transparent-proxy: "true"iptables 方式にする(upstreams の指定が不要になる)
enable-metrics: "true"Envoy のメトリクスを公開する
service-meta-*サービスの meta を設定する(Resolver の Filter で使う)

transparent-proxy を有効にすると connect-service-upstreams は不要になる。 両方書いても害はないが、混在は混乱を招くためどちらかに統一するのが良い。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  template:
    metadata:
      annotations:
        consul.hashicorp.com/connect-inject: "true"
        consul.hashicorp.com/connect-service-upstreams: "api:9191,database:5432"
        consul.hashicorp.com/transparent-proxy: "true"
        consul.hashicorp.com/enable-metrics: "true"
    spec:
      containers:
        - name: web
          image: myapp/web:1.2.3
          env:
            - name: API_URL
              value: "http://localhost:9191"

9.3 CRD による Config Entry 管理

第5章で HCL で書いた Config Entry を、Kubernetes のリソースとして宣言する。 対応は素直である。

HCL の KindKubernetes の kind
service-intentionsServiceIntentions
service-routerServiceRouter
service-splitterServiceSplitter
service-resolverServiceResolver
service-defaultsServiceDefaults
proxy-defaultsProxyDefaults
ingress-gatewayIngressGateway
terminating-gatewayTerminatingGateway
exported-servicesExportedServices

CRD 方式の利点は「GitOps に乗る」ことである。 consul config write は命令的な操作なので Git と状態が乖離しうるが、CRD なら Argo CD や Flux が差分を検知して収束させる。

注意点が1つある。 CRD と consul config write を混在させると、どちらが正なのか分からなくなる。 CRD で管理し始めたら、CLI での直接変更はやめる。

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceIntentions
metadata:
  name: api-intentions
spec:
  destination: { name: api }
  sources:
    - name: web
      action: allow
    - name: "*"
      action: deny
---
apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceSplitter
metadata:
  name: api
spec:
  splits:
    - weight: 90
      serviceSubset: stable
    - weight: 10
      serviceSubset: canary

9.4 Ingress / Terminating Gateway

メッシュには「境界」があり、その内外をつなぐのが Gateway である。3種類あり、役割が明確に分かれている。

       メッシュの外(インターネット / クライアント)
                    │
                    ▼
          ┌──────────────────┐
          │ Ingress Gateway  │  外 → メッシュ内へ入れる
          └──────────────────┘
                    │
        ┌───────────┴────────────┐
        │      メッシュの中       │
        │   web ⇄ api ⇄ ...      │
        └───────────┬────────────┘
                    │
          ┌──────────────────────┐
          │ Terminating Gateway  │  メッシュ内 → 外へ出す
          └──────────────────────┘
                    │
                    ▼
      メッシュの外(既存 DB、SaaS、外部 API)

     ※ Mesh Gateway(第5.6節)は「メッシュ内 ⇄ メッシュ内」の DC 間中継
Gateway方向用途
Ingress外 → 内メッシュ内のサービスを外部に公開する
Terminating内 → 外メッシュに参加できない外部リソースへ接続する
Mesh内 ⇄ 内(DC 間)データセンター / クラスタ間の中継

Terminating Gateway が実務で重要である。 マネージド RDS や外部 SaaS にはサイドカーを入れられないため、そのままではメッシュから接続できない。Terminating Gateway が「メッシュ内では mTLS、外向きには通常の TLS」に変換する。

api(サイドカー) ──[ mTLS ]──▶ Terminating GW ──[ 通常の TLS ]──▶ RDS

これにより外部リソースへの通信にも Intention を適用できる。 「どのサービスが DB に触れるか」をサービス名で制御できるようになる。

# Ingress Gateway
apiVersion: consul.hashicorp.com/v1alpha1
kind: IngressGateway
metadata:
  name: ingress-gateway
spec:
  listeners:
    - port: 8080
      protocol: http
      services:
        - name: web
          hosts: ["web.example.com"]
# Terminating Gateway
apiVersion: consul.hashicorp.com/v1alpha1
kind: TerminatingGateway
metadata:
  name: terminating-gateway
spec:
  services:
    - name: external-database
      caFile: /etc/ssl/certs/ca-certificates.crt

10. Consul Template 完全ガイド

10.1 Consul Template とは何か — なぜ必要なのか

Consul にサービスを登録し、KV に設定を入れるところまでは前章までで扱った。しかし 既存のミドルウェアの大半は Consul を知らない。nginx は nginx.conf を読み、HAProxy は haproxy.cfg を読み、PostgreSQL は postgresql.conf を読む。これらに「Consul のカタログを見て動的にバックエンドを決めろ」と言うことはできない。

Consul Template はこの断絶を埋めるためのツールである。 やることは3つしかない。

① Consul(と Vault)を監視する
② 変化があったらテンプレートを再レンダリングして、ファイルに書き出す
③ 書き出したあとに任意のコマンドを実行する(例: nginx -s reload)

つまり 「Consul のデータを、Consul を知らないソフトウェアが読める設定ファイルに変換し続ける常駐プロセス」 である。

位置づけを図で押さえる

   ┌──────────────┐   サービス登録・KV 書き込み
   │  Consul      │◀──────────────────────────── アプリ / CI / 運用者
   │  カタログ/KV  │
   └──────┬───────┘
          │ ブロッキングクエリで「変化」を待つ(ポーリングではない)
          ▼
   ┌──────────────────┐
   │ consul-template  │  テンプレート(.tpl)を評価
   └──────┬───────────┘
          │ ① ファイルに書き出す
          ▼
   /etc/nginx/conf.d/upstream.conf
          │ ② コマンドを実行
          ▼
   nginx -s reload            ← nginx は Consul を一切知らない

この図の要点は「nginx は Consul を知らない」ことである。 Consul 対応の改造をせずに、既存のソフトウェアを動的な環境に載せられる。これが consul-template が今も広く使われている理由である。

何に使われるか

用途生成するもの実行するコマンド
ロードバランサのバックエンド更新nginx.conf / haproxy.cfgnginx -s reload
アプリの設定ファイル生成application.properties / config.yamlアプリの再起動やシグナル
環境変数ファイルの生成.env / systemdEnvironmentFilesystemctl restart
Vault の動的シークレット配布DB 接続情報を含む設定アプリのリロード
監視設定の自動生成Prometheus の targets.jsonPrometheus のリロード(不要な場合も)

5行目のように、書き出すだけでコマンドが不要なケースもある。 Prometheus の file_sd はファイルの変更を自分で検知するため、consul-template はファイルを置くだけでよい。

envconsul との違い

同じ HashiCorp のツールで、目的が1点だけ違う。

consul-templateenvconsul
出力先ファイル環境変数
使い方常駐してファイルを更新し続けるプロセスを起動し、その環境変数に注入する
変化への追従ファイルを書き換えて command を実行プロセスに signal を送る/再起動する
テンプレート自由に書けるテンプレートは書かない(KV のパスがそのまま変数名になる)
# envconsul: config/myapp/ 配下のキーを環境変数にして myapp を起動する
envconsul -prefix config/myapp/ myapp

「設定ファイルを読むソフトウェア」には consul-template、「環境変数を読むアプリ」には envconsul と覚えればよい。12-Factor App 的な作りのアプリなら envconsul の方が素直である。

本章の記述は実機で検証したものである。

$ consul version
Consul v1.21.1
$ consul-template -v
consul-template v0.40.0 (781ce19)

macOS(Apple Silicon / arm64)上で consul agent -dev を動かし、掲載したテンプレートを実際にレンダリングして出力を確認している。エラーメッセージも実際に踏んだものである。


10.2 5分で動かす

理屈より先に動かす。 以降の文法解説は、この環境を前提にすると理解しやすい。

準備

# インストール
brew install hashicorp/tap/consul hashicorp/tap/consul-template

# 開発用の Consul を起動(別ターミナルで開いたままにする)
consul agent -dev

データを入れる

consul kv put config/myapp/db_host 10.0.1.50
consul kv put config/myapp/db_port 5432
consul kv put config/myapp/log_level info

サービスも3つ登録しておく。

cat > /tmp/web1.json <<'EOF'
{"service":{"id":"web-1","name":"web","address":"10.0.1.10","port":8080,
            "tags":["primary","v1"],"meta":{"version":"1.4.0"}}}
EOF
consul services register /tmp/web1.json

同様に web-2(10.0.1.11)、web-3(10.0.1.12、タグ secondary v2)を登録する。

最初のテンプレート

cat > /tmp/first.tpl <<'EOF'
db_host = {{ key "config/myapp/db_host" }}
db_port = {{ key "config/myapp/db_port" }}

upstream web {
{{- range service "web" }}
    server {{ .Address }}:{{ .Port }};
{{- end }}
}
EOF

レンダリングする

consul-template -once -template "/tmp/first.tpl:/tmp/first.out"
cat /tmp/first.out

実際の出力:

db_host = 10.0.1.50
db_port = 5432

upstream web {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

-once は「1回レンダリングして終了する」モードである。 これを外すと常駐して変化を待ち続ける。テンプレートを開発している間は必ず -once を使う。

常駐させて変化に追従させる

consul-template \
  -template "/tmp/first.tpl:/tmp/first.out:echo '=== 再生成された ==='"

別のターミナルでサービスを1つ落とすと、即座に再レンダリングされる。

consul services deregister -id web-3

ポーリングではなくブロッキングクエリなので、反映は秒未満である。 「何秒間隔で見に行くか」という設定は存在しない。


10.3 テンプレート言語の基礎 — Go text/template

consul-template のテンプレートは Go の text/template である。 独自言語ではない。したがって Go のテンプレートの規則がそのまま当てはまる。

3種類の記述しかない

{{ 値を出力する }}        ← アクション。評価結果がその場に出力される
{{ if ... }}...{{ end }}  ← 制御構文
{{/* コメント */}}        ← 出力されない

すべて {{}} で囲む。 これ以外の文字はそのまま出力される(リテラル)。

server_name example.com;          ← そのまま出る
port {{ key "config/port" }};     ← key の結果に置き換わる

パイプ(|)が読みやすさの鍵

左の結果を右の関数の「最後の引数」として渡す。

{{ key "config/myapp/log_level" | toUpper }}

これは toUpper (key "config/myapp/log_level") と同じで、結果は INFO になる。

パイプは何段でも繋げられる。

{{ key "config/myapp/db_port" | parseInt | printf "port=%d" }}

括弧で囲む書き方も同じ意味である。 入れ子が深くなるならパイプ、引数が複数あるなら括弧、と使い分ける。

{{ printf "%s:%d" (key "config/myapp/db_host") (key "config/myapp/db_port" | parseInt) }}

実際の出力:

10.0.1.50:5432

ドット(.)は「現在のコンテキスト」

. はいまスコープに入っている値を指す。 トップレベルでは意味を持たないが、rangewith の中では「その要素」になる。

{{ range service "web" }}
  {{ .Address }}      ← この . は「いま回している1つのサービス」
{{ end }}

入れ子になると . が上書きされるため、外側の値を使いたいときは変数に退避する(第10.6節)。

変数

$名前 := 値 で定義する。 一度定義した変数はそのブロック内で使える。

{{ $level := key "config/myapp/log_level" }}
{{ $count := service "web" | len }}
level={{ $level }} count={{ $count }}

実際の出力:

level=info count=3

変数を使う理由は3つある。 ①同じ関数を何度も呼ばない(=Consul への問い合わせを増やさない)、②. が上書きされる場所で外側の値を持ち回る、③長い式に名前を付けて読みやすくする。


10.4 Consul からデータを取る関数

ここが consul-template 固有の部分である。 関数は大きく「KV を読む」「サービスを読む」「その他のカタログを読む」に分かれる。

10.4.1 KV を読む — key / keyOrDefault / keyExists

関数戻り値キーが無いとき
key "パス"値(文字列)ブロックして待ち続ける
keyOrDefault "パス" "既定値"値、または既定値既定値を返す
keyExists "パス"true / falsefalse を返す
[A] key         = {{ key "config/myapp/db_host" }}
[B] keyOrDefault= {{ keyOrDefault "config/myapp/nope" "fallback" }}
[C] keyExists   = {{ keyExists "config/myapp/db_host" }} / {{ keyExists "config/myapp/nope" }}

実際の出力:

[A] key         = 10.0.1.50
[B] keyOrDefault= fallback
[C] keyExists   = true / false
⚠ 最初に踏む罠 — key は存在しないキーで止まる

key に存在しないキーを渡すと、consul-template はそのキーが作られるまで待ち続ける。 -once を付けていても終了しない。

存在しないキー: [{{ key "config/myapp/does_not_exist" }}]
$ consul-template -once -template "/tmp/missing.tpl:/tmp/missing.out"
(何も出力せず、終了しない)

これは仕様である。 consul-template の役目は「Consul の状態を反映し続ける」ことなので、「まだ無いキー」は「これから作られるキー」として待つ。

対処は明確である。

必ず存在すると保証できるキー  → key
無いかもしれないキー          → keyOrDefault(既定値を必ず書く)
有無で分岐したいキー          → keyExists で if する

実務では keyOrDefault を既定にしてよい。 テンプレートが固まるまでは key を使わない方が事故が少ない。

10.4.2 KV の値は必ず文字列である

Consul の KV に型は無い。 すべてバイト列として保存され、テンプレートには文字列として届く。

[D] {{ key "config/myapp/db_port" }} (型={{ printf "%T" (key "config/myapp/db_port") }})
[E] parseInt  = {{ key "config/myapp/db_port" | parseInt }} (型={{ printf "%T" (key "config/myapp/db_port" | parseInt) }})
[F] parseBool = {{ key "config/myapp/enabled" | parseBool }} (型={{ printf "%T" (key "config/myapp/enabled" | parseBool) }})

実際の出力:

[D] 5432 (型=string)
[E] parseInt  = 5432 (型=int64)
[F] parseBool = true (型=bool)

これが効いてくるのは比較のときである。

{{ if gt (key "config/myapp/replicas") 2 }}...{{ end }}     ← 文字列と数値の比較でエラー
{{ if gt (key "config/myapp/replicas" | parseInt) 2 }}...{{ end }}   ← 正しい

数値として扱うなら必ず parseInt を通す。 これを忘れると incompatible types for comparison で落ちる。

構造化データを入れておく手もある

JSON や YAML を値として入れ、テンプレート側で展開できる。

consul kv put config/myapp/pool '{"min":5,"max":20,"tags":["a","b"]}'
{{ with key "config/myapp/pool" | parseJSON }}min={{ .min }} max={{ .max }} tags={{ .tags }}{{ end }}

実際の出力:

min=5 max=20 tags=[a b]

parseYAML も同様に使える。 「キーを細かく分けるか、1つのキーに構造を入れるか」は設計判断になる。

方式利点欠点
キーを細かく分ける一部だけ更新できる。tree で一覧できるキー数が増える。まとまりが見えにくい
1キーに JSON を入れる構造が明確。一貫性のある更新(1回の書き込み)一部だけの更新ができない。差分が読みにくい

10.4.3 KV を範囲で読む — ls / tree

関数範囲
ls "prefix"直下のキーのみ。 サブフォルダの中は含まない
tree "prefix"再帰的に全部

次のデータで比較する。

config/myapp/db_host           = 10.0.1.50
config/myapp/db_port           = 5432
config/myapp/enabled           = true
config/myapp/log_level         = info
config/myapp/replicas          = 3
config/myapp/feature/beta      = false          ← サブフォルダ
config/myapp/feature/dark_mode = true           ← サブフォルダ
[A] ls
{{- range ls "config/myapp" }}
  {{ .Key }} = {{ .Value }}
{{- end }}

[B] tree
{{- range tree "config/myapp" }}
  {{ .Key }} = {{ .Value }}
{{- end }}

実際の出力(一部を省略):

[A] ls
  db_host = 10.0.1.50
  db_port = 5432
  enabled = true
  log_level = info
  replicas = 3

[B] tree
  db_host = 10.0.1.50
  db_port = 5432
  enabled = true
  feature/beta = false
  feature/dark_mode = true
  log_level = info
  replicas = 3

lsfeature/* が現れない点が両者の違いである。

.Key.Path の違い

ls / tree が返すのは KV ペアの構造体で、次のフィールドを持つ。 実際の値をそのまま示す。

{"Path":"config/myapp/db_host","Key":"db_host","Value":"10.0.1.50",
 "CreateIndex":19,"ModifyIndex":19,"LockIndex":0,"Flags":0,"Session":""}
フィールド内容
.Keyprefix を除いた相対キーdb_host
.Path完全なパスconfig/myapp/db_host
.Value値(文字列)
.CreateIndex / .ModifyIndexConsul の内部インデックス。CAS 操作で使う

.Key が相対であることを知らないと、.env を作るときに prefix が消えて混乱する。完全なパスが欲しければ .Path を使う。

safeLs / safeTree — 空の prefix で暴走しない版
{{ safeTree "config/myapp" | len }}

safeLs / safeTree は、prefix が存在しないときにエラーにせず空を返す。 さらに、tree が「存在しない prefix でブロックする」挙動を避けたい場合にも使える。prefix が動的に決まる場合は safe 版を選ぶ。

10.4.4 サービスを読む — service

最も使う関数である。 書式は service "[tag.]名前[|状態]" で、状態は第2引数でも指定できる。

[A] service "web" — 既定は健全(passing)なものだけ
{{- range service "web" }}
  {{ .ID }} {{ .Address }}:{{ .Port }} status={{ .Status }} tags={{ .Tags }}
{{- end }}

[B] service "web" "any" — 状態を問わない
{{- range service "web" "any" }}
  {{ .ID }} status={{ .Status }}
{{- end }}

[C] タグで絞る — service "primary.web"
{{- range service "primary.web" }}
  {{ .ID }} tags={{ .Tags }}
{{- end }}

[D] 件数 — web={{ service "web" | len }}  api={{ service "api" | len }}

実際の出力:

[A] service "web" — 既定は健全(passing)なものだけ
  web-1 10.0.1.10:8080 status=passing tags=[canary primary v1]
  web-2 10.0.1.11:8080 status=passing tags=[primary v1]
  web-3 10.0.1.12:8080 status=passing tags=[secondary v2]

[B] service "web" "any" — 状態を問わない
  web-1 status=passing
  web-2 status=passing
  web-3 status=passing

[C] タグで絞る — service "primary.web"
  web-1 tags=[canary primary v1]
  web-2 tags=[primary v1]

[D] 件数 — web=3  api=2

3つの要点がある。

① 既定で「健全なものだけ」が返る。 これが consul-template の最も価値ある性質である。ヘルスチェックが落ちたインスタンスは自動的に消え、command が走って LB が更新される。「障害時に自動で切り離す」仕組みを自分で書かなくてよい。

② タグはドットで前置する。 primary.web は「primary タグを持つ web」である。web.primary ではない点に注意する。

③ 状態の指定は5種類ある。

指定意味
省略passing のみ(既定)
"any"すべて
"passing"健全
"warning"警告
"critical"異常
{{ range service "web" "passing,warning" }}   ← カンマで複数指定できる
サービスから取れるフィールドの全一覧

実機で構造体を丸ごと出して確認した。 使えるのはこれだけである。

{"Node":"myhost","NodeID":"e74c77fb-...","NodeAddress":"127.0.0.1",
 "NodeTaggedAddresses":{"lan":"127.0.0.1","wan":"127.0.0.1"},
 "NodeMeta":{"consul-version":"1.21.1"},
 "ServiceMeta":{"version":"1.4.0","zone":"az1"},
 "Address":"10.0.1.10",
 "ServiceTaggedAddresses":{"lan_ipv4":{"Address":"10.0.1.10","Port":8080}},
 "ID":"web-1","Name":"web","Tags":["canary","primary","v1"],
 "Checks":[...],"Status":"passing","Port":8080,
 "Weights":{"Passing":1,"Warning":1}}
フィールド内容
.IDサービスインスタンスの ID(web-1
.Nameサービス名(web
.Addressサービスのアドレス。 未指定ならノードのアドレスが入る
.Portポート
.Tagsタグの配列
.Status集約されたヘルス状態passing / warning / critical
.Nodeノード名
.NodeAddressノードのアドレス
.ServiceMetaサービスのメタデータ(マップ)
.NodeMetaノードのメタデータ
.Checksチェックの配列
.Weights重み付け(Passing / Warning

.NodeDatacenter は存在しない。 実機で試すと次のエラーになる。

executing "" at <.NodeDatacenter>: can't evaluate field NodeDatacenter in type *dependency.HealthService

データセンター名が欲しい場合は datacenters 関数を使うか、テンプレートの外から渡す。「ありそうなフィールド」を推測で書くとこの形で落ちるので、迷ったら {{ . | toJSON }} で中身を出して確かめるのが速い(第10.9節)。

.Address.NodeAddress を取り違えない

サービス登録時に address を指定しなければ、.Address にはノードのアドレスが入る。 コンテナ環境では「サービスは 172.17.0.5、ノードは 10.0.1.10」のように別物になるため、どちらを LB のバックエンドに書くかで到達性が変わる。

server {{ .Address }}:{{ .Port }};        ← 通常はこちら
server {{ .NodeAddress }}:{{ .Port }};    ← ホストのポートに転送している場合

10.4.5 その他のカタログ関数

実機で動作を確認したものを挙げる。

関数出力例用途
datacenters["dc1"]全データセンター名
nodes1len で数えた)全ノード
nodemyhost services=7自分のノードの情報とサービス一覧
services[{"Name":"api","Tags":["primary"]},...]サービス名とタグの一覧(インスタンスは含まない)
{{ datacenters | toJSON }}
{{ nodes | len }}
{{ with node }}{{ .Node.Node }} services={{ .Services | len }}{{ end }}
{{ services | toJSON }}

services は「どんなサービスがあるか」の一覧で、インスタンスの情報は含まない。 インスタンスが欲しければ service を使う。


10.5 制御構文

10.5.1 if / else if / else

{{ if eq (key "config/myapp/log_level") "debug" }}DEBUG モード
{{ else if eq (key "config/myapp/log_level") "info" }}通常モード
{{ else }}その他
{{ end }}

実際の出力(log_level=info のとき):

通常モード

比較は関数で書く。 ==> のような演算子は使えない。

関数意味
eq a ba == b
ne a ba != b
lt a ba < b
le a ba <= b
gt a ba > b
ge a ba >= b
{{ $r := key "config/myapp/replicas" | parseInt }}
replicas={{ $r }} / gt 2 → {{ gt $r 2 }} / le 3 → {{ le $r 3 }}

実際の出力:

replicas=3 / gt 2 → true / le 3 → true

and / or / not も関数である。

{{ if and (keyExists "config/myapp/db_host") (gt $r 0) }}両方成立{{ end }}
{{ if or (eq $lvl "debug") (eq $lvl "trace") }}詳細ログ{{ end }}
{{ if not (keyExists "config/myapp/disabled") }}有効{{ end }}
何が「偽」なのか

Go テンプレートの if は、次を偽と扱う。

false / 0 / 空文字列 "" / 長さ0の配列・マップ / nil

したがって {{ if service "web" }} は「健全なインスタンスが1つ以上ある」を意味する。 これは実用的で、次のように書ける。

{{ if service "web" }}バックエンドあり{{ else }}バックエンドなし{{ end }}

10.5.2 with — 空を安全に扱う

with は「値が偽でなければ、それをコンテキスト(.)にしてブロックを実行する」構文である。

{{ with key "config/myapp/db_host" }}db_host が設定されている: {{ . }}{{ end }}
{{ with keyOrDefault "config/myapp/nope" "" }}これは出ない{{ end }}(空なので何も出ない)

実際の出力:

db_host が設定されている: 10.0.1.50
(空なので何も出ない)

with の価値は2つある。

① 値が無いときにブロックごと消える。 if + 変数の2行が1行になる。

. が短くなる。 深い構造にアクセスするとき、with で入っておくと読みやすい。

{{ with key "config/myapp/pool" | parseJSON }}
  min={{ .min }} max={{ .max }}
{{ end }}

10.5.3 range — 繰り返し

3つの書き方がある。

{{ range service "web" }}{{ .ID }}{{ end }}                    ← 要素が . になる
{{ range $s := service "web" }}{{ $s.ID }}{{ end }}             ← 要素を変数に取る
{{ range $i, $s := service "web" }}{{ $i }}:{{ $s.ID }}{{ end }} ← インデックスも取る

マップを回すとキーと値になる。

{{ range $k, $v := key "config/myapp/pool" | parseJSON }}{{ $k }}={{ $v }} {{ end }}
区切り文字を入れるイディオム

「カンマ区切りで並べたいが、末尾にカンマを付けたくない」は頻出する。

{{ range $i, $s := service "web" }}{{ if $i }}, {{ end }}{{ $s.Address }}{{ end }}

実際の出力:

10.0.1.10, 10.0.1.11, 10.0.1.12

{{ if $i }} は「インデックスが0でなければ」=「2件目以降なら」を意味する。 0 は偽なので、最初の要素の前には区切りが入らない。

rangeelse — フォールバックを書く

要素が0件のときに実行されるブロックを書ける。 これが実務で極めて重要である。

{{- range service "nosuchservice" }}
  {{ .ID }}
{{- else }}
  健全なメンバーが無い → フォールバックを出す
{{- end }}

実際の出力:

  健全なメンバーが無い → フォールバックを出す

なぜ重要か。 全インスタンスが落ちると range が空になり、upstream ブロックが空の設定ファイルが生成される。nginx は空の upstream を構文エラーとして拒否し、リロードに失敗する。 つまり「全部落ちた」ときに「設定ファイルが壊れる」という二次障害が起きる。

upstream web_backend {
{{- range service "web" }}
    server {{ .Address }}:{{ .Port }};
{{- else }}
    server 127.0.0.1:65535;   # 必ず失敗するダミー。502 を返させる
{{- end }}
}

ダミーのバックエンドを置くのが定石である。 設定は妥当なまま、クライアントには 502 が返る。「壊れた設定でリロード失敗」より「502 を返す」方が復旧しやすい。


10.6 空白の制御 — 出力を整える

テンプレートで最初に戸惑うのがこれである。 {{ }} の前後の改行や空白がそのまま出力に残る。

制御しない場合

{{ range service "api" }}
server {{ .Address }}
{{ end }}

実際の出力(空行が入る):


server 10.0.2.10

server 10.0.2.11

{{ range }} の行の改行と、{{ end }} の行の改行が残っている。

{{--}} でトリムする

記法効果
{{- x }}直前の空白(改行を含む)を削る
{{ x -}}直後の空白を削る
{{- x -}}両方
{{- range service "api" }}
server {{ .Address }}
{{- end }}

実際の出力:

server 10.0.2.10
server 10.0.2.11

実務での指針

制御構文の行(range / if / with / end)→ 行頭側に {{- を付ける
値を出す {{ }}                          → 何も付けない

この規則だけで、ほとんどの場合きれいに出る。 迷ったら -once でレンダリングして目で見るのが速い。

行末に -}} を付けるのは、次の行と繋げたいときに限る。 付けすぎると全部が1行になって読めなくなる。


10.7 関数リファレンス(実測)

consul-template v0.40.0 で実際に呼び出して確認した結果である。 出力はすべて実測値。

10.7.1 文字列

関数結果
toUpper{{ "abc" | toUpper }}ABC
toLower{{ "ABC" | toLower }}abc
toTitle{{ "hello world" | toTitle }}Hello World
trimSpace[{{ " x " | trimSpace }}][x]
replaceAll{{ "a-b-c" | replaceAll "-" "_" }}a_b_c
regexReplaceAll{{ "prod-use1" | regexReplaceAll "[0-9]+" "N" }}prod-useN
regexMatch{{ "prod-use1" | regexMatch "^prod-" }}true
split{{ "a,b,c" | split "," }}[a b c]
join{{ "a,b,c" | split "," | join "-" }}a-b-c
contains{{ contains "use1" "prod-use1" }}true
in{{ in (split "," "a,b,c") "b" }}true
indent{{ "a\nb" | indent 4 }}a 改行 b

title は存在しない。 {{ "x" | title }}function "title" not defined になる。toTitle を使う。 同様に nindent も無い(sprig 側にはある。第10.7.5節)。

contains の引数順に注意
{{ contains "use1" "prod-use1" }}     → true

use1 を含むか? — prod-use1 が」という順序である。 つまり contains <探す文字列> <対象>。パイプで書くと自然になる。

{{ .Name | contains "web" }}
containsAll / containsAny / containsNone は使いにくい

存在はするが、[]interface{} を要求する。 split の戻り値は []string なので、そのまま渡すとエラーになる。

$ consul-template -once ...
executing "" at <"a,b">: wrong type for value; expected []interface {}; got []string

実務では inor で並べるか、regexMatch で済ませる方が確実である。

10.7.2 型変換とシリアライズ

関数結果
parseInt{{ "42" | parseInt }}42(int64)
parseFloat{{ "1.5" | parseFloat }}1.5
parseBool{{ "true" | parseBool }}true
parseJSON{{ with "{\"a\":1}" | parseJSON }}{{ .a }}{{ end }}1
parseYAML{{ with "a: 1" | parseYAML }}{{ .a }}{{ end }}1
toJSON{{ split "," "a,b" | toJSON }}["a","b"]
toJSONPretty{{ split "," "a,b" | toJSONPretty }}整形された JSON
toYAML{{ split "," "a,b" | toYAML }}- a 改行 - b

toJSON は文字列を正しくエスケープするため、JSON を生成するときは必ず使う。 手で " を書くと値に " が含まれた瞬間に壊れる。

{"upstreams": {{ service "web" | toJSON }}}      ← 安全
{"name": "{{ .Name }}"}                           ← .Name に " が入ると壊れる
{"name": {{ .Name | toJSON }}}                    ← 安全

10.7.3 数値 — ⚠ 引数の順序が逆である

これは必ず知っておく必要がある。 実測結果を示す。

subtract 1 5 = 4
divide 2 7   = 3
modulo 2 7   = 1
add 1 2      = 3

subtract a bb - a である。 divide a bb / amodulo a bb % a

関数実際の計算
add a ba + b(可換なので気づかない)
subtract a bb - a
multiply a ba × b(可換)
divide a bb ÷ a
modulo a bb % a
minimum a b / maximum a b小さい方 / 大きい方

なぜこうなっているか — パイプで書いたときに自然になるよう設計されている。

{{ 100 | subtract 30 }}    → 70   (100 から 30 を引く、と読める)
{{ 100 | divide 4 }}       → 25   (100 を 4 で割る、と読める)

括弧で書くと直感と逆になる。 divide は整数除算である点にも注意する(divide 2 73)。

10.7.4 グループ化と変換

サービスや KV を「並べ替える」ための関数群である。

byTag — タグごとにまとめる
{{ range $tag, $svcs := service "web" | byTag }}
[{{ $tag }}] {{ $svcs | len }} 件
{{- end }}

web-1canary,primary,v1)、web-2primary,v1)、web-3secondary,v2)のとき、canaryprimarysecondaryv1v2 の5グループになる。1つのサービスが複数のタグを持てば、複数のグループに現れる。

byMeta — メタデータの値ごとにまとめる
{{ range $ver, $svcs := service "web" | byMeta "version" }}
version {{ $ver }}: {{ $svcs | len }} 件
{{- end }}

byMeta はキーのドットをアンダースコアに変換する。 version=1.4.01_4_0 というキーになる(実測)。バージョン番号でグループ化すると、そのまま表示すると 1_4_0 と出る。表示に使うなら元の値を .ServiceMeta.version から取る。

byKey — KV を最初の階層でまとめる
{{- range $app, $kvs := tree "apps" | byKey }}
[{{ $app }}]
{{- range $kvs }}
  {{ .Key }} = {{ .Value }}
{{- end }}
{{- end }}

apps/web/portapps/web/replicasapps/api/portapps/api/replicas に対する実際の出力:

[api]
  port = 9090
  replicas = 2
[web]
  port = 8080
  replicas = 3

「アプリごとに設定ブロックを作る」テンプレートがこれ1つで書ける。 キーはアルファベット順に並ぶ。

explode — KV ツリーをネストしたマップにする
{{ tree "config/myapp" | explode | toJSON }}

実際の出力:

{"db_host":"10.0.1.50","db_port":"5432","enabled":"true",
 "feature":{"beta":"false","dark_mode":"true"},
 "log_level":"info","replicas":"3"}

feature/betafeature の下にネストされている。 これを toYAML に流せば、KV ツリーがそのまま YAML 設定ファイルになる。

{{ tree "config/myapp" | explode | toYAML }}

これが最も強力なイディオムの1つである。 KV の階層と設定ファイルの階層を対応させておけば、テンプレートは1行で済む。

10.7.5 sprig 関数が使える

consul-template には sprig(Helm でも使われる関数ライブラリ)が組み込まれており、sprig_ の接頭辞で呼べる。 実測で確認した。

{{ sprig_upper "a" }}                                    → A
{{ sprig_default "X" "" }}                               → X
{{ sprig_trunc 2 "abcdef" }}                             → ab
{{ sprig_quote "hi" }}                                   → "hi"
{{ sprig_b64enc "hi" }}                                  → aGk=
{{ sprig_dig "a" "MISS" (sprig_dict "a" "1") }}          → 1
{{ sprig_mergeOverwrite (sprig_deepCopy (sprig_dict "a" "1" "b" "2")) (sprig_dict "a" "X") | toJSON }}
                                                          → {"a":"X","b":"2"}

これは大きい。 consul-template 本体に無い関数(defaultdigmergeOverwritedeepCopynindent など)が sprig 側で埋まる。

やりたいことconsul-template 本体sprig
既定値keyOrDefault(KV 限定)sprig_default(任意の値に使える)
深い階層から安全に取る無いsprig_dig
マップの深いマージ無いsprig_mergeOverwrite
改行付きの字下げ無いsprig_nindent

sprig_required は存在しない。 required は sprig ではなく Helm が独自に追加した関数のため、ここには無い(実測で not defined)。値の必須チェックは if not ...keyExists で書く。

sprig の非決定的な関数(sprig_uuidv4sprig_randAlphaNumsprig_now)を使ってはいけない。 consul-template は変化があるたびに再レンダリングし、内容が変わればファイルを書き換えて command を実行する。 毎回違う値が出る関数を入れると、Consul に何の変化が無くても再レンダリングのたびに差分が生じ、nginx が延々とリロードされ続ける。

10.7.6 その他

関数結果備考
env{{ env "HOME" }}/Users/shogoプロセスの環境変数
timestamp{{ timestamp }}2026-08-20T22:00:28ZUTC の RFC3339
timestamp "unix"{{ timestamp "unix" }}1787263228エポック秒
timestamp "2006-01-02"2026-08-20Go の日付書式
loop{{ range loop 3 }}x{{ end }}xxx回数指定の繰り返し
base64Encode / base64Decode{{ "hi" | base64Encode }}aGk=
sha256Hex{{ "hi" | sha256Hex }}8f434346...
md5sum{{ "hi" | md5sum }}49f68a5c...
sockaddr{{ sockaddr "GetAllInterfaces | include \"type\" \"IPv4\" | limit 1 | attr \"address\"" }}127.0.0.1自ホストの IP を取る

timestamp も非決定的である。 生成したファイルに「生成時刻」を書き込むと、その1行のせいで毎回差分が出て command が走り続ける。 生成時刻を残したいなら、command 側で別ファイルに書く。

scratch — テンプレート内の一時変数

テンプレートをまたいで値を持ち回るための領域である。

{{ scratch.Set "k" "v" }}{{ scratch.Get "k" }}                     → v
{{ scratch.MapSet "m" "k1" "v1" }}{{ scratch.MapSet "m" "k2" "v2" }}
{{ range $k, $v := scratch.Get "m" }}{{ $k }}={{ $v }} {{ end }}    → k1=v1 k2=v2

用途は「重複を除いた一覧を作る」ことである。 Go テンプレートには集合が無いので、scratch.MapSet でマップのキーとして詰めて重複を潰す。

{{- range service "web" }}
  {{- range .Tags }}{{ scratch.MapSet "tags" . "" }}{{ end }}
{{- end }}
全タグ: {{ range $t, $_ := scratch.Get "tags" }}{{ $t }} {{ end }}

10.8 設定ファイルと運用

-template フラグで済むのは検証時までである。 本番では設定ファイルを書く。

10.8.1 設定ファイルの全体像

# /etc/consul-template/config.hcl

consul {
  address = "127.0.0.1:8500"
  token   = "s.xxxxxxxx"          # ACL トークン(環境変数 CONSUL_TOKEN でも渡せる)
  retry {
    enabled     = true
    attempts    = 12
    backoff     = "250ms"
    max_backoff = "1m"
  }
}

# 出力先が複数あれば template ブロックを並べる
template {
  source      = "/etc/consul-template/templates/nginx-upstream.conf.tpl"
  destination = "/etc/nginx/conf.d/upstream.conf"
  command     = "/usr/sbin/nginx -s reload"

  perms  = 0644                   # 生成ファイルのパーミッション
  backup = true                   # 上書き前に .bak を残す

  # 変化が続いているときに書き換えを抑える(後述)
  wait {
    min = "5s"
    max = "30s"
  }

  # テンプレートのエラー時に古いファイルを残すか
  error_on_missing_key = false
}

log_level = "info"

10.8.2 wait — リロードの嵐を防ぐ

これを設定しないと本番で事故る。

デプロイ中は、インスタンスが次々に入れ替わる。 10インスタンスのローリング更新なら、Consul のカタログは短時間に20回以上変化する。wait が無いと そのたびに nginx がリロードされる。

wait {
  min = "5s"
  max = "30s"
}
設定意味
min変化を検知してから最低これだけ待つ。待っている間に別の変化が来たらタイマーをリセットする
max変化が続いていてもこれ以上は待たない。必ず反映する

動作を言葉にすると「静かになってから5秒待って書く。ただし最大30秒で必ず書く」である。 デバウンスと呼ばれる挙動で、ローリング更新の途中の中間状態を書き出さずに済む。

値の決め方の目安:

状況minmax
LB のバックエンド更新2〜5s15〜30s
障害時の切り離しを急ぎたい1〜2s5〜10s
設定ファイル(変化が稀)5〜10s1m

min を小さくすると障害時の切り離しが速くなるが、リロード回数が増える。 トレードオフである。

10.8.3 command の注意点

command = "/usr/sbin/nginx -s reload"

3つの落とし穴がある。

command はファイルが変化したときだけ実行される。 内容が同じなら実行されない。これは望ましい挙動だが、「毎回実行される」と思って書くと期待が外れる。

command の失敗は検知しにくい。 リロードが失敗しても consul-template は動き続ける。設定の妥当性を先に確認するコマンドを繋げるのが定石である。

command = "/usr/sbin/nginx -t && /usr/sbin/nginx -s reload"

nginx -t で構文検査してから reload する。 これで「壊れた設定でリロードして落ちる」事故を防げる。第10.5.3節の rangeelse と組み合わせると、二重の防御になる。

③ 複数のテンプレートが同じコマンドを叩くと、その回数だけ実行される。 nginx のリロードのように冪等なら問題ないが、そうでない場合は1つの template ブロックにまとめる。

10.8.4 実行モードとデバッグ用フラグ

フラグ用途
-once1回だけ評価して終了。 テンプレート開発の必須フラグ
-dryファイルに書かず標準出力に出す。 本番の設定ファイルを壊さずに確認できる
-template "src:dst:cmd"設定ファイルなしで指定する
-config設定ファイル(またはディレクトリ)を指定する
-log-leveldebug にすると依存関係の解決過程が見える
-consul-addrConsul のアドレス
-vault-addrVault のアドレス

開発の手順はこれで固定してよい。

# ① まず -dry -once で出力を目で見る
consul-template -once -dry -template "app.tpl:/dev/null"

# ② 出力が正しければファイルに書く
consul-template -once -template "app.tpl:/tmp/app.out"
cat /tmp/app.out

# ③ 最後に command を付けて常駐させる
consul-template -config /etc/consul-template/config.hcl

10.8.5 systemd での常駐

# /etc/systemd/system/consul-template.service
[Unit]
Description=consul-template
Requires=network-online.target
After=network-online.target consul.service

[Service]
Type=simple
User=consul-template
ExecStart=/usr/local/bin/consul-template -config=/etc/consul-template/config.hcl
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGINT
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

After=consul.service を入れる点が重要である。Consul より先に起動すると初回のレンダリングが失敗する(retry があるので最終的には成功するが、起動が遅れる)。

command を実行するために権限が必要な場合は、User を root にするか sudoers を設定する。nginx のリロードのために consul-template を root で動かすのは避けたいので、実務では次のいずれかを採る。

① nginx を systemd の管理下に置き、command で systemctl reload を叩く(polkit で権限を付与)
② command でシグナルファイルを touch し、別の仕組みがリロードする
③ nginx のマスタープロセスに SIGHUP を送れるグループに consul-template を入れる

10.9 デバッグの手順

テンプレートが期待どおりに動かないときの切り分けである。

手順

① -once -dry でレンダリング結果を目で見る
     → ここで期待どおりなら、問題はテンプレートの外にある
② 中身が分からない構造は toJSON で丸ごと出す
③ -log-level=debug で依存関係の解決を追う
④ Consul 側のデータを consul CLI で確認する

② が最も効く — toJSON で構造を見る

「このフィールド名で合っているか」を推測で試すのは遅い。 構造を丸ごと出せばよい。

{{ with service "web" }}{{ with index . 0 }}{{ . | toJSON }}{{ end }}{{ end }}

これで使えるフィールドが全部分かる。 第10.4.4節のフィールド一覧も、この方法で得たものである。

index を直接使うと初回評価で落ちる

知らないと原因が分からないエラーがある。

{{ (index (service "web") 0).Address }}
$ consul-template -once -template "x.tpl:x.out"
executing "" at <index (service "web") 0>: error calling index: reflect: slice index out of range

サービスは3件あるのにエラーになる。 理由は consul-template の評価順序である。

① テンプレートを1回評価する
     → このとき service "web" は「まだ取得していない」ので空リストを返す
     → index で 0 番目を取ろうとして範囲外エラー
② 依存関係(service "web")を認識し、Consul に問い合わせる
③ データが届いたら、もう一度評価する

つまり「1回目の評価は必ず空で走る」。 通常の rangewith は空リストでも安全に動くので気づかないが、index は空リストで例外を出す。

対処は with で囲むことである。

{{ with service "web" }}{{ with index . 0 }}{{ .Address }}{{ end }}{{ end }}

with は空なら中身を実行しないので、1回目の評価を安全に通過する。 第10.5.2節で「with は空を安全に扱う」と述べたのは、この意味でも正しい。

規則としてまとめる。

依存関係の結果に添字でアクセスするときは、必ず with か if で空を守る

エラーメッセージの読み方

メッセージ原因対処
function "x" not defined関数名の間違い、または存在しない関数第10.7節の一覧で確認。titletoTitle など
can't evaluate field X in type ...フィールド名が存在しないtoJSON で構造を確認
slice index out of range初回評価で空のリストに index したwith で囲む
incompatible types for comparison文字列と数値を比較したparseInt を通す
wrong type for value; expected []interface {}containsAll 系に []string を渡したin で代替
(何も出力せず終了しない)key に存在しないキーを渡したkeyOrDefault にする
unexpected "{" in command{{ の対応が崩れている括弧とクォートを数える

最後から2番目が最も厄介である。 エラーも出ず、ただ終了しない。-log-level=debug を付けると、どの依存関係を待っているかが見える。


10.10 実用テンプレート集

すべて実機でレンダリングを確認したものである。

10.10.1 nginx の upstream

upstream web_backend {
    least_conn;
{{- range service "web" }}
    server {{ .Address }}:{{ .Port }} max_fails=3 fail_timeout=30s;   # {{ .ID }}
{{- else }}
    server 127.0.0.1:65535;  # フォールバック: 健全なメンバーが無い
{{- end }}
}

実際の出力:

upstream web_backend {
    least_conn;
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;   # web-1
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;   # web-2
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;   # web-3
}

# {{ .ID }} のコメントを入れておくと、生成物を見たときにどのインスタンスか分かる。 障害調査で役に立つ。

10.10.2 HAProxy — タグで系統を分ける

backend web_v1
    balance roundrobin
{{- range service "v1.web" }}
    server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}

backend web_v2
    balance roundrobin
{{- range service "v2.web" }}
    server {{ .ID }} {{ .Address }}:{{ .Port }} check
{{- end }}

実際の出力:

backend web_v1
    balance roundrobin
    server web-1 10.0.1.10:8080 check
    server web-2 10.0.1.11:8080 check

backend web_v2
    balance roundrobin
    server web-3 10.0.1.12:8080 check

タグでカナリアや世代を分けるのは最も実用的なパターンである。 サービス側は同じ名前で登録し、タグだけを変える。

10.10.3 .env ファイル

{{- range tree "config/myapp" }}
{{ .Key | replaceAll "/" "_" | toUpper }}={{ .Value }}
{{- end }}

実際の出力:

DB_HOST=10.0.1.50
DB_PORT=5432
ENABLED=true
FEATURE_BETA=false
FEATURE_DARK_MODE=true
LOG_LEVEL=info
REPLICAS=3

feature/dark_modeFEATURE_DARK_MODE になっている。 .Key が相対パスなので prefix は消え、/_ に置き換えるだけで環境変数名になる。

値に改行が含まれると .env が壊れる。 KV に複数行の値を入れていると、そのまま出力されて次の行が別の変数に見える。.env を生成するなら値を1行に制限するか、toJSON でクォートする。

{{ .Key | replaceAll "/" "_" | toUpper }}={{ .Value | toJSON }}

10.10.4 YAML 設定ファイル — explode の威力

myapp:
{{ tree "config/myapp" | explode | toYAML | indent 2 }}

実際の出力:

myapp:
  db_host: 10.0.1.50
  db_port: "5432"
  enabled: "true"
  feature:
    beta: "false"
    dark_mode: "true"
  log_level: info
  replicas: "3"

KV の階層がそのまま YAML の階層になる。 feature/betafeature: の下に入り、キーを増やしてもテンプレートを変える必要がない。

数値と真偽値がクォートされる点に注意する。KV の値は文字列なので(第10.4.2節)、5432 は文字列として YAML 化され、"5432" になる。YAML の読み取り側が型を厳格に見る場合、これが問題になる。

対処は2つある。

① 読み取り側で文字列を受け入れる(多くの設定ライブラリは変換してくれる)
② 型が必要なキーだけテンプレートで個別に書く
     db_port: {{ key "config/myapp/db_port" | parseInt }}

db_host: 10.0.1.50 がクォートされていないのは、10.0.1.50 が YAML の数値としても解釈できないためである。**「数値に見える文字列だけがクォートされる」**という YAML マーシャラの挙動である。

10.10.5 Prometheus の file_sd

JSON を生成するときは「区切りのカンマ」が問題になる。 素朴に書くと壊れる例から見る。

[
{{- range $i, $svc := services }}
  {{- if ne $svc.Name "consul" }}
  {{- if $i }},{{ end }}      ← ここが罠
  { ... }
  {{- end }}
{{- end }}
]

{{ if $i }} は「2件目以降ならカンマ」という第10.5.3節のイディオムだが、ここでは使えない。 consul をスキップしているのにインデックスは消費されるため、consul がリストの先頭(インデックス0)に来た場合、最初に出力される要素にカンマが付いて JSON が壊れる。

正しくは「実際に出力したか」を変数で覚える。

{{- $first := true -}}
[
{{- range services }}{{ if ne .Name "consul" }}
  {{- if $first }}{{ $first = false }}{{ else }},{{ end }}
  { "targets": [{{ range $j, $s := service .Name }}{{ if $j }}, {{ end }}"{{ $s.Address }}:{{ $s.Port }}"{{ end }}],
    "labels": { "job": "{{ .Name }}" } }
{{- end }}{{- end }}
]

実際の出力(JSON として妥当であることを確認済み):

[
  { "targets": ["10.0.2.10:9090", "10.0.2.11:9090"],
    "labels": { "job": "api" } },
  { "targets": ["10.0.3.10:6379"],
    "labels": { "job": "redis" } },
  { "targets": ["10.0.1.10:8080", "10.0.1.11:8080", "10.0.1.12:8080"],
    "labels": { "job": "web" } }
]

{{ $first = false }} は宣言(:=)ではなく再代入(=)である。 Go テンプレートは再代入をサポートしており、range の外で宣言した変数を中から書き換えられる。フィルタを伴う繰り返しでは、この形が唯一安全である。

Prometheus はこのファイルの変更を自分で検知するので、command は不要である。

template {
  source      = "/etc/consul-template/templates/targets.json.tpl"
  destination = "/etc/prometheus/file_sd/consul.json"
  # command は不要
}

10.10.6 Vault のシークレットを含む設定

consul-template は Vault も監視できる。 これが Serenity のような設定配布の仕組みの土台になっている。

{{ with secret "database/creds/readonly" }}
jdbc.username={{ .Data.username }}
jdbc.password={{ .Data.password }}
{{ end }}

動的シークレットはリース期限が来ると新しい値に変わる。 consul-template は期限を追跡し、更新のたびに設定ファイルを書き換えて command を実行する。

vault {
  address = "https://vault.example.com:8200"
  renew_token = true
}

template {
  source      = "/etc/consul-template/templates/db.properties.tpl"
  destination = "/app/etc/db.properties"
  command     = "systemctl reload myapp"
  perms       = 0600            # ← シークレットを含むので必須
}

perms = 0600 を忘れてはいけない。 既定の 0644 では他のユーザーがパスワードを読める。

Vault の関数は本章では実機検証していない。 検証環境に Vault を用意していないため、secret / secrets / pkiCert の挙動は未確認である。書式は公式ドキュメントに基づく。


10.11 設計の指針とアンチパターン

やるべきこと

指針理由
rangeelse でフォールバックを書く全滅時に設定ファイルが壊れるのを防ぐ(第10.5.3節)
command に構文検査を挟むnginx -t && nginx -s reload
wait を必ず設定するローリング更新でリロードが嵐になる(第10.8.2節)
keyOrDefault を既定にするkey はキーが無いと止まる(第10.4.1節)
数値比較の前に parseIntKV は必ず文字列(第10.4.2節)
with で空を守ってから index初回評価は必ず空(第10.9節)
JSON 生成には toJSON手書きのクォートは壊れる
シークレットを含むなら perms = 0600既定は 0644
-once -dry で開発する本番ファイルを壊さない

やってはいけないこと

アンチパターン何が起きるか
timestamp を出力に含める毎回差分が出て command が走り続ける
sprig_uuidv4 / sprig_randAlphaNum同上。無限リロード
env に依存するconsul-template を起動した環境に依存し、再現性が失われる
1つのテンプレートに全部詰めるどの変化がどのリロードを起こしたか分からなくなる
wait なしで LB を更新するデプロイのたびに数十回リロードされる
テンプレートにロジックを詰め込むデバッグ手段がほぼ無い。複雑な整形は生成側(KV に入れる値)で済ませる

最後の項目を補足する。 テンプレート言語にはブレークポイントもテストもない。「KV に入れる値を整形しておく」方が常に安い。 たとえば「バージョン番号からメジャーバージョンを切り出す」処理をテンプレートで書くより、KV に major_version というキーを別に入れる方が保守しやすい。


11. セキュリティ

Consul のセキュリティは4つの独立した層からなる。 どれか1つでは不十分で、すべてを有効にして初めて安全になる。

① Gossip 暗号化      → Agent 間のメンバーシップ通信を暗号化(共有鍵)
② TLS               → RPC と HTTP API を暗号化・認証(証明書)
③ ACL               → 「誰が何をできるか」を制御(トークン)
④ Connect の mTLS    → サービス間通信を暗号化・認証(第5章。①〜③とは別物)

混同しやすいので区別しておく。 ①②③は Consul 自身を守る仕組みで、④は アプリケーション間の通信を守る仕組みである。④だけを有効にしても、Consul の API が無防備なら意味がない。

無効だと何が起きるか
① Gossip 暗号化ネットワークを覗ける者がクラスタのメンバー構成を把握できる。偽の Agent を参加させられる
② TLSAPI が平文。トークンが盗まれる
③ ACLAPI に到達できる者が全権限を持つ。KV の改変、サービスの削除が可能
④ mTLSサービス間通信が平文。相手が本物か分からない

優先順位を付けるなら ③ → ② → ① → ④ である。 ACL の無効が最も危険で、影響も直接的である。

11.1 TLS 暗号化

Consul の TLS には2つの方向があり、設定項目も別である。

verify_incoming        → 自分に来る接続でクライアント証明書を検証する
verify_outgoing        → 自分から出る接続でサーバ証明書を検証する
verify_server_hostname → 接続先が本当に Consul Server か確認する(なりすまし防止)

verify_server_hostname = true が特に重要である。 これが無いと、有効な証明書を持つ Client Agent が Server になりすませてしまう。証明書の CN が server.<dc>.<domain> であることまで確認するため、Client の証明書では Server を装えなくなる。

Client 用の証明書を全ノードに配るのは運用負荷が高い。 そこで Auto-Encrypt がある。

auto_encrypt {
  allow_tls = true      # Server 側
}
auto_encrypt {
  tls = true            # Client 側
}

Client は Server から証明書を自動で受け取る。 人間が配布・更新する対象が Server の証明書だけになる。Kubernetes の Helm では enableAutoEncrypt: true がこれに対応する。

consul tls ca create
consul tls cert create -server -dc dc1
consul tls cert create -client -dc dc1
tls {
  defaults {
    ca_file = "/etc/consul.d/tls/consul-agent-ca.pem"
    cert_file = "/etc/consul.d/tls/dc1-server-consul-0.pem"
    key_file = "/etc/consul.d/tls/dc1-server-consul-0-key.pem"
    verify_incoming = true
    verify_outgoing = true
  }
  internal_rpc { verify_server_hostname = true }
}

11.2 Gossip 暗号化

Gossip は共有鍵(対称鍵)で暗号化する。 TLS とは別の仕組みで、encrypt 設定に 32 バイトの Base64 鍵を与える。

consul keygen        # 鍵を生成する

全 Agent が同じ鍵を持つ必要がある。 鍵が違う Agent はクラスタに参加できない。これが「偽の Agent を防ぐ」仕組みでもある。

consul keygen
# キーローテーション(ゼロダウンタイム)
consul keyring -install "new-key"
consul keyring -use "new-key"
consul keyring -remove "old-key"
encrypt = "pUqJrVyVRj5jsiYEkM/tFQYfWyJIv4s3XkvDwy7u5Sk="

12. 可観測性

Consul の可観測性は2種類に分かれる。 これを混同すると「何のメトリクスを見ているのか」が分からなくなる。

① Consul 自身のメトリクス     → Raft、Gossip、API のレイテンシ、KV の操作数
② メッシュを流れる通信のメトリクス → Envoy が出す HTTP のステータス、レイテンシ、リトライ

②は Envoy が出すため、Consul のメトリクスエンドポイントではなく Envoy のメトリクスエンドポイントを見る。

12.1 メトリクス

Consul 自身のメトリクスで注視すべきものを挙げる。

メトリクス意味異常の兆候
consul.raft.leader.lastContactリーダーが Follower と最後に通信した時間これが伸びるのが最も危険。 リーダー選挙が起きる前兆
consul.raft.state.candidate選挙の開始回数増え続けるならクラスタが不安定
consul.raft.applyRaft への書き込み回数急増は KV の書き込み過多
consul.raft.commitTimeコミットにかかる時間ディスク I/O のボトルネック
consul.serf.member.failedGossip で失敗と判定されたメンバー数ネットワークの問題
consul.http.*API のレイテンシクライアント側の体感に直結
consul.rpc.queryRPC のクエリ数ブロッキングクエリの数を反映する

raft.leader.lastContact を最優先で監視する。 これが数百ミリ秒を超えると、リーダーが Follower を見失いかけている。原因はほぼディスク I/O かネットワーク遅延である。

Raft はディスクに同期書き込みするため、Consul Server のディスクは速いものを使う。 ネットワークストレージや汎用の EBS では commitTime が伸びる。

telemetry {
  prometheus_retention_time = "60s"
  dogstatsd_addr = "127.0.0.1:8125"
  dogstatsd_tags = ["env:production"]
}

主要メトリクス:

メトリクス説明閾値
consul.raft.leader.lastContactリーダーが Follower に連絡した時間> 200ms
consul.raft.commitTimeコミット時間> 500ms
consul.serf.member.failedメンバー障害> 0

12.2 分散トレーシング / ログ / UI

分散トレーシングについて、期待値を正しく持つ必要がある。

Envoy はトレースの「スパン」を生成し、トレースヘッダを転送できる。 しかしアプリがヘッダを引き継がなければトレースは繋がらない。

web のサイドカー  → span を作り、x-request-id 等を付与
web のアプリ      → ★ 受け取ったヘッダを、次のリクエストにコピーする必要がある
api のサイドカー  → 引き継がれたヘッダがあれば、同じトレースの子 span になる

★の部分はアプリの責任である。 「サービスメッシュを入れれば分散トレーシングが手に入る」というのは正確ではない。サイドカーが自動で得られるのは「サービス間のメトリクス」までで、リクエスト単位のトレースはアプリの協力が必要である。

一方、次のものはアプリの改造なしに得られる。

サービス間の通信量、成功率、レイテンシの分布
どのサービスがどのサービスを呼んでいるか(依存グラフ)
リトライとサーキットブレーカの発動状況

「まず依存グラフとエラー率が見えるようになる」ことがメッシュの可観測性の実際の価値である。

トレーシング(Zipkin/Jaeger)、JSON ログ、Consul UI(Prometheus/Grafana 連携)をサポート。


13. 運用とベストプラクティス

13.1 本番環境の推奨構成

Server の台数は Raft の性質から決まる。

Server 台数耐えられる障害数備考
10本番不可。 開発用のみ
31標準的な構成
52大規模・可用性重視
73書き込みが遅くなるため推奨されない

偶数台にしてはいけない。 4台はクォーラム3で、耐えられる障害は1台のみ(3台と同じ)。台数が増えるだけで可用性は上がらず、書き込みは遅くなる。

Raft の書き込みは全 Server の過半数の同意を待つため、台数を増やすと遅くなる。 7台以上にする理由はほとんどない。可用性をさらに上げたいなら、Enterprise の Read Replica(非投票メンバー)を使う。

規模CPUメモリディスクServer 数
小(~500)2 コア4 GB50 GB SSD3
中(~5000)4 コア8 GB100 GB SSD5
大(~50000)8+ コア16+ GB200+ GB NVMe5-7

13.2 バックアップ

Consul のバックアップは「スナップショット」で行う。 Raft のログとステートマシンを1つのファイルに固める。

含まれるもの・含まれないものを理解しておく。

含まれる含まれない
KVAgent のトークンenable_token_persistence のローカル保存分)
サービスカタログ設定ファイル/etc/consul.d/
ACL のポリシー・トークン・ロールGossip の暗号鍵
Config EntryTLS の証明書と鍵
セッション
Connect の CA(内蔵 CA の場合)Vault CA プロバイダの場合は Vault 側

「スナップショットがあれば復旧できる」わけではない。 設定ファイル、Gossip 鍵、TLS 証明書は別に保管する必要がある。この点を見落とすと、復旧時にクラスタを組み直せない。

Enterprise には自動スナップショット機能(snapshot agent)があるが、OSS では cron で回す。

consul snapshot save /backup/consul-$(date +%Y%m%d-%H%M%S).snap
consul snapshot save backup-$(date +%Y%m%d-%H%M%S).snap
consul snapshot inspect backup.snap
consul snapshot restore backup.snap

13.3 アップグレード手順

Consul のアップグレードには明確な順序がある。 守らないとクラスタが分裂する。

① Client を先に上げる(Server より新しくても動く)
② Server の Follower を1台ずつ上げる
③ 最後にリーダーを上げる(または step-down させてから上げる)

注意点を挙げる。

① バージョンを飛ばさない。 1.14 → 1.18 のような飛躍は Raft のプロトコルバージョンや Config Entry の互換性で問題が出る。マイナーバージョンを1つずつ上げる。

② 上げる前にスナップショットを取る。 ロールバックはダウングレードでは不可能で、スナップショットからの復元しか手段がない。

autopilot の設定を確認する。 min_quorum を設定しておくと、アップグレード中に誤ってクォーラムを割るのを防げる。

④ マルチ DC では Primary DC を最後にする(ACL と CA の権威を持つため)。

  1. スナップショット取得
  2. Follower Server を1台ずつアップグレード
  3. Leader Server を最後にアップグレード
  4. Client をアップグレード
  5. consul members / consul operator raft list-peers で検証

13.4 トラブルシューティング

症状から原因を辿る表を示す。

症状確認すること
サービスが DNS で引けないconsul catalog services に居るか ②ヘルスチェックが passing か ③DNS フォワーディング(第3.3節)
ヘルスチェックが criticalconsul monitor でチェックの Output を見る。エンドポイントを手で叩いてみる
メッシュで繋がらないEnvoy の localhost:19000/clusters(第5.2節)→ Intention → Protocol の設定
リーダー選挙が繰り返されるraft.leader.lastContact(第12.1節)→ ディスク I/O → ネットワーク遅延
ACL 有効化後に壊れたtokens.agent を設定したか(第7.4節)
メンバーシップが不安定8301 の TCP と UDP の両方が開いているか(第2.5節)
consul-template が止まる存在しないキーを key で引いていないか(第10.4.1節)
consul members && consul members -wan
consul operator raft list-peers
consul monitor -log-level=debug
curl localhost:19000/stats  # Envoy Admin
curl localhost:19000/clusters
dig @127.0.0.1 -p 8600 consul.service.consul

14. Consul-Terraform-Sync(CTS)

CTS は「Consul のカタログの変化を Terraform の実行に変換する」ツールである。 consul-template(第10章)と発想は同じで、出力先が設定ファイルではなく Terraform である。

consul-template  → カタログの変化 → 設定ファイルを書き換える → nginx をリロード
CTS              → カタログの変化 → Terraform を実行する      → F5 / Palo Alto / A10 を更新

主な用途はネットワーク機器の自動化である。 物理ロードバランサやファイアウォールは設定ファイルを持たず、API で操作する。それらの API を叩く Terraform Provider があるなら、CTS でカタログと同期できる。

「Network Infrastructure Automation(NIA)」と呼ばれる領域で、Consul の機能の中では利用者が限られる。 ソフトウェアの LB(nginx / HAProxy / Envoy)を使っているなら、consul-template かサービスメッシュの方が素直である。

Consul のサービスカタログ変更を監視し、Terraform でネットワークインフラを自動更新するツール。

task {
  name = "firewall-update"
  module = "findkim/print/cts"
  condition "services" { names = ["web", "api"] }
}

15. 他のサービスメッシュとの比較

比較の前に、Consul の立ち位置を1文で述べる。 Consul はサービスメッシュ専用ツールではなく、ディスカバリと KV を土台に持つプラットフォームであり、メッシュはその1機能である。

この違いが選択の分かれ目になる。

Istio / Linkerd  → Kubernetes 前提。メッシュに特化
Consul           → Kubernetes / VM / ベアメタルを横断。ディスカバリと KV も含む

「Kubernetes だけで完結する」なら Istio か Linkerd の方が適合する。 「VM とコンテナが混在する」「Kubernetes の外にも広げる」なら Consul が有利になる。

機能ConsulIstioLinkerd
データプレーンEnvoyEnvoylinkerd2-proxy
サービスディスカバリ内蔵K8s APIK8s API
KV ストア内蔵なしなし
マルチプラットフォームVM + K8s + NomadK8s のみK8s のみ
マルチクラスタWAN FederationMulti-PrimaryMulti-Cluster
学習コスト
リソース消費

16. 設計パターン

16.1 ゼロトラスト

ゼロトラストとは「ネットワークの内側だからといって信用しない」という設計方針である。

従来(境界防御)      → ファイアウォールの内側は信頼する。1つ破られると全部危険
ゼロトラスト          → すべての通信で相手を認証し、明示的に許可されたものだけ通す

Consul でこれを実装する要素は3つである。

要素役割
mTLS(第5.3節)通信ごとに相手の身元を証明書で確認する
Intention(第5.4節)「誰が誰を呼べるか」をサービス名で明示する
default_policy = "deny"(第7.4節)明示されていない通信を拒否する

3つ目が本質である。 mTLS と Intention を入れても、既定が allow なら「認証はするが誰でも通す」状態にすぎない。拒否を既定にして初めてゼロトラストになる。

移行の順序が重要である。

① メッシュに載せる(mTLS が有効になる。この時点では全通信が許可)
② メトリクスで実際の通信を把握する(誰が誰を呼んでいるか)
③ Intention を書く(allow を明示していく)
④ 最後に default_policy を deny に切り替える

②を飛ばして④をやると、把握していなかった通信が全部止まる。

Kind = "service-intentions"
Name = "*"
Sources = [{ Name = "*", Action = "deny" }]

16.2 カナリアデプロイ

第5.5節の3つの Config Entry を組み合わせる。 手順は次のとおりである。

① 新バージョンを、meta.version を変えて登録する(同じサービス名で)
② Service Resolver で stable / canary のサブセットを定義する
③ Service Splitter で比率を設定する
④ メトリクスを見ながら比率を上げていく
⑤ 問題があれば Splitter の比率を戻す(=即座にロールバック)

⑤が最大の利点である。 新旧のインスタンスは両方動いたままなので、比率を戻すだけで復旧する。 デプロイのやり直しは不要である。

# リゾルバーでサブセット定義 → スプリッターで比率制御
# Weight: 5 → 10 → 25 → 50 → 100

16.3 ブルーグリーンデプロイ

カナリアとの違いは「段階的に混ぜるか、一気に切り替えるか」である。

カナリアブルーグリーン
トラフィック少しずつ移す一気に全部切り替える
実装Splitter の比率Resolver の DefaultSubset
検証本番トラフィックの一部で切り替え前に別経路で
ロールバック比率を戻すDefaultSubset を戻す

ブルーグリーンは「新旧が混在すると壊れる」場合に選ぶ。 DB スキーマの変更を伴うリリースなどで、新旧が同時に動くこと自体が問題になるケースである。

Kind = "service-resolver"
Name = "api"
DefaultSubset = "blue"
Subsets = {
  blue  = { Filter = "Service.Meta.deployment == blue" }
  green = { Filter = "Service.Meta.deployment == green" }
}
# 切り替え: DefaultSubset を "green" に変更

17. まとめ

17.1 Consul の強み

  1. マルチプラットフォーム対応: VM、コンテナ、Kubernetes、Nomad
  2. マルチデータセンター: ネイティブサポート
  3. 統合的な機能セット: ディスカバリ、KV、メッシュ、ACL
  4. HashiCorp エコシステム: Vault、Nomad、Terraform との統合
  5. 段階的な導入: ディスカバリ → メッシュと段階的に導入可能

17.2 導入の指針

  1. サービスディスカバリ → 2. ヘルスチェック → 3. KV ストア → 4. ACL → 5. サービスメッシュ → 6. トラフィック管理 → 7. マルチデータセンター

17.3 今後の展望

  • Consul Dataplane: Agent レスアーキテクチャ
  • V2 Catalog API: Kubernetes ネイティブなカタログ
  • 簡素化: 設定とデフォルト値の最適化
  • パフォーマンス向上: 大規模環境でのスケーラビリティ

付録 A: CLI コマンドリファレンス

コマンド説明
consul agent -dev開発モードで起動
consul membersクラスタメンバー一覧
consul operator raft list-peersRaft ピア一覧
consul catalog servicesサービス一覧
consul health checks <svc>ヘルスチェック状態
consul kv put/get/deleteKV 操作
consul intention create/listIntention 管理
consul config write/listConfig Entry 管理
consul acl bootstrap/token/policyACL 管理
consul snapshot save/restoreバックアップ/リストア
consul connect envoy -sidecar-for <svc>サイドカー起動
consul monitor -log-level=debugログ監視
consul tls ca createCA 証明書作成
consul keygenGossip 暗号化キー生成

付録 B: Config Entry 一覧

Kind説明
proxy-defaultsグローバルプロキシ設定
service-defaultsサービスデフォルト設定
service-routerHTTP ルーティング
service-splitterトラフィック分割
service-resolverサブセットとフェイルオーバー
service-intentionsL7 アクセス制御
ingress-gatewayIngress Gateway
terminating-gatewayTerminating Gateway
meshメッシュ全体設定
exported-servicesサービスエクスポート

付録 C: 参考リンク