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 は以下の主要な機能を提供する。
- サービスディスカバリ(Service Discovery): サービスの登録と検出を DNS または HTTP API で行う
- サービスメッシュ(Service Mesh / Consul Connect): サイドカープロキシを用いた mTLS 通信とトラフィック管理
- Key-Value ストア: 設定情報やフィーチャーフラグの動的管理
- ヘルスチェック: サービスとノードの健全性監視
- マルチデータセンター対応: 複数データセンターにまたがるサービスメッシュとディスカバリ
- ネットワークインフラの自動化: Consul-Terraform-Sync(CTS)によるネットワーク機器設定の動的更新
- アクセスコントロール(ACL): きめ細かなアクセス制御ポリシー
- 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 ネットワークポート一覧
| ポート | プロトコル | 用途 |
|---|---|---|
| 8300 | TCP | Server RPC(Agent 間の RPC 通信) |
| 8301 | TCP/UDP | LAN Serf Gossip |
| 8302 | TCP/UDP | WAN Serf Gossip(Server のみ) |
| 8500 | TCP | HTTP API |
| 8501 | TCP | HTTPS API(TLS 有効時) |
| 8502 | TCP | gRPC API(xDS、サービスメッシュ用) |
| 8503 | TCP | gRPC TLS API |
| 8600 | TCP/UDP | DNS インターフェース |
ポートを開ける際の考え方を整理しておく。
| 区分 | ポート | 誰から誰へ |
|---|---|---|
| クラスタ内部 | 8300 / 8301 / 8302 | Agent 間。外部に公開してはいけない |
| アプリ向け | 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_after | critical が続いたら登録を自動削除する | 落ちたプロセスの登録が残り続けるのを防ぐ |
tags と meta の違いは実務でよく迷う。
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つのインターフェースがあり、性質が大きく違う。
| DNS | HTTP 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 | 明示的なフェイルオーバー先の順序 |
OnlyPassing | passing のみを返す(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 |
| Kubernetes | CoreDNS |
| 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 | アプリが自分で生きていると申告すること | バッチ、非同期ワーカー |
| gRPC | gRPC のヘルスチェックプロトコル | gRPC サービス |
| Docker | コンテナ内でコマンドを実行 | コンテナ |
| H2Ping | HTTP/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 ベストプラクティス
- チェック間隔はサービスの重要度に応じて設定(重要: 5-10秒、低優先度: 30-60秒)
- タイムアウトは間隔より短く設定
deregister_critical_service_afterで長時間異常なサービスを自動登録解除- 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_policy | Intention を書かないとき |
|---|---|
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 のパスやメソッドで細かく制御する。
使う条件がある — 対象サービスの Protocol が http / 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 Defaults | 1サービスの既定(プロトコル、上流の制限) | サービス単位の設定 |
| 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" を設定する。
UpstreamConfig の Limits はサーキットブレーカである。 上流が詰まったときに、無制限に接続を積み上げて自分も倒れるのを防ぐ。
| 設定 | 意味 |
|---|---|
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 を使えば片方が失敗するので、再読み込みしてやり直せる。 カウンタのインクリメント、リストへの追記、設定のマージなど、読み書きが分かれる操作では必須である。
ModifyIndex は consul kv get -detailed で確認できる。
consul kv get -detailed config/database/host
6.2 高度な機能
トランザクション
複数の KV 操作をまとめて「全部成功か、全部失敗」にする。 CAS が1キーの原子性を保証するのに対し、トランザクションは複数キーの原子性を保証する。
Verb で操作を指定する。
| Verb | 意味 |
|---|---|
set | 書き込む |
cas | CAS 付きで書き込む |
get | 読む(結果が返る) |
check-index | 指定したインデックスであることを確認する(書き込まない) |
delete / delete-tree | 削除 |
lock / unlock | セッションによるロック |
check-index は「前提条件」を表現する。 「設定バージョンが 12345 のままなら、これらのキーを更新する」という書き方ができる。
Value は Base64 でエンコードする必要がある点に注意する(例の MTAuMC4xLjUw は 10.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 watch | consul-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秒) |
Behavior | release(ロックを解放)または 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_prefix | KV |
node / node_prefix | ノード情報 |
session / session_prefix | セッション |
agent / agent_prefix | Agent の管理 API |
query / query_prefix | Prepared Query |
event / event_prefix | ユーザーイベント |
acl | ACL 自体の管理 |
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"
}
}
各設定の意味を押さえる。
| 設定 | 意味 | 推奨 |
|---|---|---|
enabled | ACL を有効にする | 本番では必ず true |
default_policy | ポリシーで明示されていない操作の扱い | deny(allow は移行期間だけ) |
down_policy | Server に到達できないときの挙動 | 後述 |
enable_token_persistence | トークンをディスクに保存し、再起動後も保持する | true |
tokens.agent | Agent 自身が使うトークン(ノード登録、チェック報告) | 必須 |
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 write) | CRD(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.tls | Agent 間の TLS。enableAutoEncrypt で Client の証明書を自動配布 | 本番では必ず有効にする |
global.acls.manageSystemACLs | ACL のブートストラップとトークン配布を自動化 | 有効にすると Secret にトークンが作られる |
global.gossipEncryption | Gossip の暗号化 | autoGenerate: true が手軽 |
server.replicas | Server の数 | 3 または 5(奇数)。1 は本番不可 |
server.storage | Raft のデータ用 PVC | StatefulSet なので永続ボリュームが必要 |
connectInject | サイドカーの自動注入 | メッシュを使うなら必須 |
syncCatalog | Consul と Kubernetes のサービスを相互同期 | 後述 |
meshGateway | DC / クラスタ間の中継 | 単一クラスタなら不要 |
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:9191 が api になる) |
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 の Kind | Kubernetes の kind |
|---|---|
service-intentions | ServiceIntentions |
service-router | ServiceRouter |
service-splitter | ServiceSplitter |
service-resolver | ServiceResolver |
service-defaults | ServiceDefaults |
proxy-defaults | ProxyDefaults |
ingress-gateway | IngressGateway |
terminating-gateway | TerminatingGateway |
exported-services | ExportedServices |
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.cfg | nginx -s reload |
| アプリの設定ファイル生成 | application.properties / config.yaml | アプリの再起動やシグナル |
| 環境変数ファイルの生成 | .env / systemd の EnvironmentFile | systemctl restart |
| Vault の動的シークレット配布 | DB 接続情報を含む設定 | アプリのリロード |
| 監視設定の自動生成 | Prometheus の targets.json | Prometheus のリロード(不要な場合も) |
5行目のように、書き出すだけでコマンドが不要なケースもある。 Prometheus の file_sd はファイルの変更を自分で検知するため、consul-template はファイルを置くだけでよい。
envconsul との違い
同じ HashiCorp のツールで、目的が1点だけ違う。
| consul-template | envconsul | |
|---|---|---|
| 出力先 | ファイル | 環境変数 |
| 使い方 | 常駐してファイルを更新し続ける | プロセスを起動し、その環境変数に注入する |
| 変化への追従 | ファイルを書き換えて 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
ドット(.)は「現在のコンテキスト」
. はいまスコープに入っている値を指す。 トップレベルでは意味を持たないが、range や with の中では「その要素」になる。
{{ 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 / false | false を返す |
[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
ls に feature/* が現れない点が両者の違いである。
.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":""}
| フィールド | 内容 |
|---|---|
.Key | prefix を除いた相対キー(db_host) |
.Path | 完全なパス(config/myapp/db_host) |
.Value | 値(文字列) |
.CreateIndex / .ModifyIndex | Consul の内部インデックス。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"] | 全データセンター名 |
nodes | 1(len で数えた) | 全ノード |
node | myhost 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 b | a == b |
ne a b | a != b |
lt a b | a < b |
le a b | a <= b |
gt a b | a > b |
ge a b | a >= 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 は偽なので、最初の要素の前には区切りが入らない。
range の else — フォールバックを書く
要素が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
実務では in を or で並べるか、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 b は b - a である。 divide a b は b / a、modulo a b は b % a。
| 関数 | 実際の計算 |
|---|---|
add a b | a + b(可換なので気づかない) |
subtract a b | b - a |
multiply a b | a × b(可換) |
divide a b | b ÷ a |
modulo a b | b % a |
minimum a b / maximum a b | 小さい方 / 大きい方 |
なぜこうなっているか — パイプで書いたときに自然になるよう設計されている。
{{ 100 | subtract 30 }} → 70 (100 から 30 を引く、と読める)
{{ 100 | divide 4 }} → 25 (100 を 4 で割る、と読める)
括弧で書くと直感と逆になる。 divide は整数除算である点にも注意する(divide 2 7 は 3)。
10.7.4 グループ化と変換
サービスや KV を「並べ替える」ための関数群である。
byTag — タグごとにまとめる
{{ range $tag, $svcs := service "web" | byTag }}
[{{ $tag }}] {{ $svcs | len }} 件
{{- end }}
web-1(canary,primary,v1)、web-2(primary,v1)、web-3(secondary,v2)のとき、canary、primary、secondary、v1、v2 の5グループになる。1つのサービスが複数のタグを持てば、複数のグループに現れる。
byMeta — メタデータの値ごとにまとめる
{{ range $ver, $svcs := service "web" | byMeta "version" }}
version {{ $ver }}: {{ $svcs | len }} 件
{{- end }}
⚠
byMetaはキーのドットをアンダースコアに変換する。version=1.4.0は1_4_0というキーになる(実測)。バージョン番号でグループ化すると、そのまま表示すると1_4_0と出る。表示に使うなら元の値を.ServiceMeta.versionから取る。
byKey — KV を最初の階層でまとめる
{{- range $app, $kvs := tree "apps" | byKey }}
[{{ $app }}]
{{- range $kvs }}
{{ .Key }} = {{ .Value }}
{{- end }}
{{- end }}
apps/web/port、apps/web/replicas、apps/api/port、apps/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/beta が feature の下にネストされている。 これを 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 本体に無い関数(default、dig、mergeOverwrite、deepCopy、nindent など)が 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_uuidv4、sprig_randAlphaNum、sprig_now)を使ってはいけない。 consul-template は変化があるたびに再レンダリングし、内容が変わればファイルを書き換えてcommandを実行する。 毎回違う値が出る関数を入れると、Consul に何の変化が無くても再レンダリングのたびに差分が生じ、nginx が延々とリロードされ続ける。
10.7.6 その他
| 関数 | 例 | 結果 | 備考 |
|---|---|---|---|
env | {{ env "HOME" }} | /Users/shogo | プロセスの環境変数 |
timestamp | {{ timestamp }} | 2026-08-20T22:00:28Z | UTC の RFC3339 |
timestamp "unix" | {{ timestamp "unix" }} | 1787263228 | エポック秒 |
timestamp "2006-01-02" | 2026-08-20 | Go の日付書式 | |
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秒で必ず書く」である。 デバウンスと呼ばれる挙動で、ローリング更新の途中の中間状態を書き出さずに済む。
値の決め方の目安:
| 状況 | min | max |
|---|---|---|
| LB のバックエンド更新 | 2〜5s | 15〜30s |
| 障害時の切り離しを急ぎたい | 1〜2s | 5〜10s |
| 設定ファイル(変化が稀) | 5〜10s | 1m |
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節の range の else と組み合わせると、二重の防御になる。
③ 複数のテンプレートが同じコマンドを叩くと、その回数だけ実行される。 nginx のリロードのように冪等なら問題ないが、そうでない場合は1つの template ブロックにまとめる。
10.8.4 実行モードとデバッグ用フラグ
| フラグ | 用途 |
|---|---|
-once | 1回だけ評価して終了。 テンプレート開発の必須フラグ |
-dry | ファイルに書かず標準出力に出す。 本番の設定ファイルを壊さずに確認できる |
-template "src:dst:cmd" | 設定ファイルなしで指定する |
-config | 設定ファイル(またはディレクトリ)を指定する |
-log-level | debug にすると依存関係の解決過程が見える |
-consul-addr | Consul のアドレス |
-vault-addr | Vault のアドレス |
開発の手順はこれで固定してよい。
# ① まず -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回目の評価は必ず空で走る」。 通常の range や with は空リストでも安全に動くので気づかないが、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節の一覧で確認。title→toTitle など |
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_mode が FEATURE_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/beta が feature: の下に入り、キーを増やしてもテンプレートを変える必要がない。
数値と真偽値がクォートされる点に注意する。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 設計の指針とアンチパターン
やるべきこと
| 指針 | 理由 |
|---|---|
range に else でフォールバックを書く | 全滅時に設定ファイルが壊れるのを防ぐ(第10.5.3節) |
command に構文検査を挟む | nginx -t && nginx -s reload |
wait を必ず設定する | ローリング更新でリロードが嵐になる(第10.8.2節) |
keyOrDefault を既定にする | key はキーが無いと止まる(第10.4.1節) |
数値比較の前に parseInt | KV は必ず文字列(第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 を参加させられる |
| ② TLS | API が平文。トークンが盗まれる |
| ③ ACL | API に到達できる者が全権限を持つ。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.apply | Raft への書き込み回数 | 急増は KV の書き込み過多 |
consul.raft.commitTime | コミットにかかる時間 | ディスク I/O のボトルネック |
consul.serf.member.failed | Gossip で失敗と判定されたメンバー数 | ネットワークの問題 |
consul.http.* | API のレイテンシ | クライアント側の体感に直結 |
consul.rpc.query | RPC のクエリ数 | ブロッキングクエリの数を反映する |
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 台数 | 耐えられる障害数 | 備考 |
|---|---|---|
| 1 | 0 | 本番不可。 開発用のみ |
| 3 | 1 | 標準的な構成 |
| 5 | 2 | 大規模・可用性重視 |
| 7 | 3 | 書き込みが遅くなるため推奨されない |
偶数台にしてはいけない。 4台はクォーラム3で、耐えられる障害は1台のみ(3台と同じ)。台数が増えるだけで可用性は上がらず、書き込みは遅くなる。
Raft の書き込みは全 Server の過半数の同意を待つため、台数を増やすと遅くなる。 7台以上にする理由はほとんどない。可用性をさらに上げたいなら、Enterprise の Read Replica(非投票メンバー)を使う。
| 規模 | CPU | メモリ | ディスク | Server 数 |
|---|---|---|---|---|
| 小(~500) | 2 コア | 4 GB | 50 GB SSD | 3 |
| 中(~5000) | 4 コア | 8 GB | 100 GB SSD | 5 |
| 大(~50000) | 8+ コア | 16+ GB | 200+ GB NVMe | 5-7 |
13.2 バックアップ
Consul のバックアップは「スナップショット」で行う。 Raft のログとステートマシンを1つのファイルに固める。
含まれるもの・含まれないものを理解しておく。
| 含まれる | 含まれない |
|---|---|
| KV | Agent のトークン(enable_token_persistence のローカル保存分) |
| サービスカタログ | 設定ファイル(/etc/consul.d/) |
| ACL のポリシー・トークン・ロール | Gossip の暗号鍵 |
| Config Entry | TLS の証明書と鍵 |
| セッション | |
| 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 の権威を持つため)。
- スナップショット取得
- Follower Server を1台ずつアップグレード
- Leader Server を最後にアップグレード
- Client をアップグレード
consul members/consul operator raft list-peersで検証
13.4 トラブルシューティング
症状から原因を辿る表を示す。
| 症状 | 確認すること |
|---|---|
| サービスが DNS で引けない | ①consul catalog services に居るか ②ヘルスチェックが passing か ③DNS フォワーディング(第3.3節) |
| ヘルスチェックが critical | consul 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 が有利になる。
| 機能 | Consul | Istio | Linkerd |
|---|---|---|---|
| データプレーン | Envoy | Envoy | linkerd2-proxy |
| サービスディスカバリ | 内蔵 | K8s API | K8s API |
| KV ストア | 内蔵 | なし | なし |
| マルチプラットフォーム | VM + K8s + Nomad | K8s のみ | K8s のみ |
| マルチクラスタ | WAN Federation | Multi-Primary | Multi-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 の強み
- マルチプラットフォーム対応: VM、コンテナ、Kubernetes、Nomad
- マルチデータセンター: ネイティブサポート
- 統合的な機能セット: ディスカバリ、KV、メッシュ、ACL
- HashiCorp エコシステム: Vault、Nomad、Terraform との統合
- 段階的な導入: ディスカバリ → メッシュと段階的に導入可能
17.2 導入の指針
- サービスディスカバリ → 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-peers | Raft ピア一覧 |
consul catalog services | サービス一覧 |
consul health checks <svc> | ヘルスチェック状態 |
consul kv put/get/delete | KV 操作 |
consul intention create/list | Intention 管理 |
consul config write/list | Config Entry 管理 |
consul acl bootstrap/token/policy | ACL 管理 |
consul snapshot save/restore | バックアップ/リストア |
consul connect envoy -sidecar-for <svc> | サイドカー起動 |
consul monitor -log-level=debug | ログ監視 |
consul tls ca create | CA 証明書作成 |
consul keygen | Gossip 暗号化キー生成 |
付録 B: Config Entry 一覧
| Kind | 説明 |
|---|---|
proxy-defaults | グローバルプロキシ設定 |
service-defaults | サービスデフォルト設定 |
service-router | HTTP ルーティング |
service-splitter | トラフィック分割 |
service-resolver | サブセットとフェイルオーバー |
service-intentions | L7 アクセス制御 |
ingress-gateway | Ingress Gateway |
terminating-gateway | Terminating Gateway |
mesh | メッシュ全体設定 |
exported-services | サービスエクスポート |