言語を切り替える
テーマを切り替える

Nginx ダイナミック upstream:Lua でリアルタイムサービスディスカバリを実現

午前3時、本番環境でアラートが鳴り響きました。

Docker コンテナが再起動され、IP アドレスが変わってしまいました。しかし、nginx.conf には古いアドレスが書かれたままです。

慌てて起き出し、設定を手動で変更して nginx -s reload を実行します。すると本番環境の QPS が一瞬下がり、モニタリングのグラフに穴が開きました。運が良ければ数秒で復旧しますが、運が悪ければユーザーから苦情の電話がかかってきます。

正直なところ、こういう経験は一度や二度ではありません。そのたびに思います。「Nginx が自動的にバックエンドサービスを検出してくれればいいのに。Consul のように、バックエンドの IP が変わったら自動的に更新されて、深夜に起きて設定を変更する必要がないのに。」

実は、OpenResty ならとっくに実現できていました。Lua スクリプトを使えば、実行時に upstream 設定を変更できるため、reload が一切不要です。Cloudflare もこの仕組みを採用しており、CDN のエッジノード全体でトラフィックを動的に制御しています。

この記事では、3層アーキテクチャ(ngx.balancer + lua-resty-balancer + ヘルスチェック)を使ってダイナミック upstream を実現する方法、2つの主要なヘルスチェックライブラリの比較、Consul、Nacos、etcd の3つのサービスディスカバリ連携方法の完全なコードを紹介します。読み終えれば、本番環境にデプロイできるようになります。

なぜダイナミック upstream が必要なのか

Nginx の upstream 設定は静的です。nginx.conf に記述されたサーバーアドレスは、起動時に一度読み込まれ、その後変更したい場合は reload するしかありません。

コンテナ環境ではこれが厄介です。Docker コンテナが再起動されれば IP アドレスが変わります。K8s の Pod が再スケジュールされれば、IP も変わります。そのたびに nginx.conf を手動で変更するわけにはいきません。ある企業の技術チームもこの問題に直面し、手動設定からテンプレートレンダリングへ、そして最終的に Consul による動的サービスディスカバリへと移行せざるを得ませんでした。

「NGINX Plus を使えばいいじゃないか」と言う人もいます。商用版ならダイナミック upstream をサポートしています。確かにその通りですが、年間数万円のライセンス費用がかかり、コードもオープンソースではありません。問題が発生しても公式の修正を待つしかありません。多くのチームにとって、これは良い選択肢ではありません。

OpenResty は別の道を提供します。Nginx をベースに LuaJIT 仮想マシンを組み込んでおり、Lua スクリプトを使って実行時に upstream 設定を変更できます。reload は一切不要で、リクエスト処理中に動的にバックエンドサーバーを切り替えられます。

この仕組みのキラー機能は balancer_by_lua_block です。Nginx が upstream サーバーを選択する段階で介入し、Lua コードでリクエストをどのバックエンドに転送するかを決定します。バックエンド IP リストは共有メモリ、Redis、または Consul に保存できます。バックエンドがダウンすれば、Lua コードが自動的に除外します。新しいサービスが立ち上がれば、Lua コードが自動的に検出します。

適用シーンは多岐にわたります

  • K8s イングレスゲートウェイ:Pod IP が頻繁に変化するため、Nginx が Ingress として動的に検知する必要がある
  • マイクロサービスのカナリアリリース:新旧バージョンが共存し、リクエストヘッダーや Cookie で動的にルーティング
  • 障害時の自動除外:バックエンドサービスの応答が遅い、またはダウンした場合、Nginx が能動的に検知してトラフィックプールから除外
  • データセンター間ルーティング:ユーザーの地理的位置やレイテンシに基づいて、最も近いデータセンターを動的に選択

Cloudflare の CDN エッジノードもこの仕組みに頼っています。世界中に数百のノードがあり、毎秒数千万のリクエストを処理していますが、すべて OpenResty が動的にトラフィックを制御しています。部分的な実装は GitHub で公開されています。

3層アーキテクチャとコアコンポーネント

OpenResty のダイナミック upstream は単一の機能ではなく、3層が連携して動作します:

┌─────────────────────────────────────────┐
│  第3層:ヘルスチェック                      │
│  lua-resty-healthcheck                   │
│  - バックエンドの生存状態を能動的に検知        │
│  - 共有メモリ内の upstream 状態を更新         │
└──────────────┬──────────────────────────┘
               │ 状態同期
┌──────────────▼──────────────────────────┐
│  第2層:負荷分散アルゴリズム                  │
│  lua-resty-balancer                      │
│  - resty.roundrobin(ラウンドロビン)        │
│  - resty.chash(コンシステントハッシュ)      │
│  - 共有メモリから健全なバックエンドリストを読取 │
└──────────────┬──────────────────────────┘
               │ 選択結果
┌──────────────▼──────────────────────────┐
│  第1層:低レイヤーAPI                       │
│  ngx.balancer                            │
│  - set_current_peer(host, port)         │
│  - get_last_failure()                   │
│  - set_more_tries(n)                    │
│  - balancer_by_lua 段階で呼び出し           │
└─────────────────────────────────────────┘

ngx.balancer:低レイヤーAPI

この層は Nginx カーネルに最も近い部分です。ngx.balancer モジュールは3つのコア API を提供します:

  • set_current_peer(host, port):今回のリクエストをどのバックエンドに転送するか指定
  • get_last_failure():前回の試行の失敗情報を取得(リトライロジック用)
  • set_more_tries(n):追加のリトライ回数を設定

これらの API は balancer_by_lua_block 内で呼び出す必要があります。この段階は Nginx が upstream サーバーを選択するタイミングで、Lua コードが介入することでルーティングの決定を完全に制御できます。

最小限のサンプル

upstream backend {
    server 0.0.0.1;  # プレースホルダーアドレス、server ディレクティブが1つ以上必要
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"

        -- 動的にバックエンドを選択
        local host = "192.168.1.10"
        local port = 8080

        local ok, err = balancer.set_current_peer(host, port)
        if not ok then
            ngx.log(ngx.ERR, "failed to set peer: ", err)
            return ngx.exit(500)
        end
    }
}

注意:server 0.0.0.1 はプレースホルダーです。Nginx は upstream ブロックに最低1つの server ディレクティブを要求しますが、Lua で動的に実際のバックエンドを選択するため、このアドレスにはアクセスされません。

lua-resty-balancer:負荷分散アルゴリズム

ngx.balancer を直接使うのは原始的すぎます。ラウンドロビン、ハッシュ、バックエンドリストの管理をすべて自分で書く必要があります。lua-resty-balancer はこれらのアルゴリズムをカプセル化しており、すぐに使えます。

2種類の負荷分散器を提供します:

  • resty.roundrobin:ラウンドロビン、順番にバックエンドサーバーを選択
  • resty.chash:コンシステントハッシュ、同じクライアントからのリクエストは常に同じバックエンドにルーティング(セッション維持に適している)

使用前に、init_worker_by_lua_block で初期化します:

init_worker_by_lua_block {
    local roundrobin = require "resty.roundrobin"
    local chash = require "resty.chash"

    -- バックエンドサーバーリスト(Consul/Nacos から動的に取得可能)
    local servers = {
        { "192.168.1.10", 8080, weight = 10 },
        { "192.168.1.11", 8080, weight = 5 },
        { "192.168.1.12", 8080, weight = 3 },
    }

    -- ラウンドロビン負荷分散器を作成
    local rr_upstream = roundrobin:new(servers)

    -- 共有メモリに保存、balancer 段階で読み取り
    local shared_dict = ngx.shared.upstreams
    shared_dict:set("backend_rr", rr_upstream)
}

その後、balancer_by_lua_block で使用します:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local shared_dict = ngx.shared.upstreams
        local rr_upstream = shared_dict:get("backend_rr")

        -- 次のサーバーを選択
        local host, port = rr_upstream:select()

        local balancer = require "ngx.balancer"
        balancer.set_current_peer(host, port)
    }
}

実行段階の詳細

Nginx はリクエストを処理する際、厳密な段階順序を持っています。これらの段階を理解することで、Lua コードを正しい場所に配置できます:

1. init_by_lua_block     → Nginx master プロセス起動時
2. init_worker_by_lua    → 各 worker プロセス起動時
3. ssl_certificate_by_lua → SSL ハンドシェイク段階
4. set_by_lua            → 変数代入の処理
5. rewrite_by_lua        → URL リライト段階
6. access_by_lua         → アクセス制御段階
7. balancer_by_lua       → upstream サーバー選択(コア)
8. header_filter_by_lua  → レスポンスヘッダー処理
9. body_filter_by_lua    → レスポンスボディ処理
10. log_by_lua           → ログ記録段階

balancer_by_lua_block は第7段階にあります。この時点ではリクエストはまだ転送されておらず、誰に送るかを決定できます。リトライシナリオ(バックエンドがエラーを返した場合)では、get_last_failure() で前回なぜ失敗したかを確認し、それに基づいて別のバックエンドを選択できます。

ヘルスチェック実装の比較

ダイナミック upstream の最後の層はヘルスチェックです。バックエンドサーバーはいつでもダウンする可能性があるため、能動的に検知する必要があります。リクエストが失敗してから発見するのでは遅すぎます。

OpenResty コミュニティには2つの主要なソリューションがあります:公式の lua-resty-upstream-healthcheck と、より完成度の高い lua-resty-healthcheck です。私の経験から、後者を強くお勧めします。

lua-resty-upstream-healthcheck:公式ソリューション

これは OpenResty 公式がメンテナンスしているヘルスチェックライブラリです。能動的チェック機能を提供し、バックグラウンドで定期的に HTTP リクエストを送信してバックエンドの状態を検知します。

設定例:

-- nginx.conf の http ブロックで共有メモリを設定
lua_shared_dict healthcheck 1m;

-- init_worker_by_lua_block でヘルスチェックを開始
init_worker_by_lua_block {
    local hc = require "resty.upstream.healthcheck"

    local ok, err = hc.spawn_checker{
        shm = "healthcheck",             -- 共有メモリ名
        upstream = "backend",            -- upstream 名
        type = "http",                   -- チェックタイプ(http または tcp)

        -- ヘルスチェックリクエスト内容
        http_req = "GET /health HTTP/1.0\r\nHost: backend\r\n\r\n",

        interval = 2000,   -- 検知間隔:2000ミリ秒(2秒)
        timeout = 1000,    -- 単一検知のタイムアウト:1秒
        fall = 3,          -- 連続3回失敗で down とマーク
        rise = 2,          -- 連続2回成功で up とマーク

        valid_statuses = { 200, 302 },   -- 成功と見なす HTTP ステータスコード
    }

    if not ok then
        ngx.log(ngx.ERR, "failed to spawn health checker: ", err)
    end
}

起動後、ライブラリは2秒ごとに各バックエンドサーバーの /health パスにリクエストを送信します。連続3回失敗すると、そのサーバーを down とマークし、以降の負荷分散では選択されなくなります。回復後は、連続2回成功すると up と再マークされます。

状態データは設定した共有メモリ(lua_shared_dict healthcheck)に保存されます。balancer_by_lua_block 段階でこれらの状態を読み取り、特定のバックエンドを選択するかどうかを決定できます。

lua-resty-healthcheck:本番環境推奨

公式ライブラリは使えますが、機能が十分ではありません。能動的チェックのみをサポートし、パッシブチェック(実際のリクエストの失敗に基づく動的調整)をサポートしていません。また、特定のエッジケースでバグがあります。

lua-resty-healthcheck はコミュニティの強化版で、機能がより充実しています:

  • 能動的チェック:定期的に HTTP/TCP 検知リクエストを送信
  • パッシブチェックbalancer_by_lua_block の失敗情報に基づいて自動的に状態を調整
  • より柔軟な設定:カスタムチェックロジック、コールバック関数をサポート
  • より安定:Apache APISIX などで大規模な本番環境検証済み

設定例

-- 同様に共有メモリが必要
lua_shared_dict healthcheck 2m;

init_worker_by_lua_block {
    local healthcheck = require "resty.healthcheck"

    local checker = healthcheck.new({
        name = "backend_checker",
        shm_name = "healthcheck",

        checks = {
            active = {
                type = "http",
                http_path = "/health",
                healthy = {
                    interval = 2,     -- 2秒ごとに検知
                    successes = 2,    -- 連続2回成功で up とマーク
                },
                unhealthy = {
                    interval = 1,     -- 1秒ごとに検知(down 後はより頻繁に)
                    tcp_failures = 1, -- TCP 接続失敗で即座に down とマーク
                    http_failures = 3, -- HTTP 失敗3回で down とマーク
                },
            },
            passive = {
                healthy = {
                    successes = 3,    -- 通常リクエスト成功3回で自動的に up とマーク
                },
                unhealthy = {
                    tcp_failures = 2, -- TCP 失敗2回で自動的に down とマーク
                    http_failures = 3, -- HTTP 失敗3回で自動的に down とマーク
                },
            },
        },
    })

    -- 検査対象のバックエンドサーバーを追加
    checker:add_target("192.168.1.10", 8080, "backend", true)
    checker:add_target("192.168.1.11", 8080, "backend", true)
    checker:add_target("192.168.1.12", 8080, "backend", true)
}

パッシブチェックの威力は、能動的検知で問題が見つからなかったとしても、大量の実際のリクエストが失敗すれば、ヘルスチェッカーが自動的にバックエンドを down とマークできる点にあります。これにより、突発的な障害により迅速に対応できます。

2つのソリューションの比較

比較項目lua-resty-upstream-healthchecklua-resty-healthcheck
メンテナーOpenResty 公式コミュニティ(APISIX 検証済み)
能動的チェックサポートサポート
パッシブチェック非サポートサポート
設定の柔軟性低い高い(コールバック、カスタムロジック)
本番環境での安定性普通(既知のバグあり)高い(大規模検証済み)
ドキュメント品質公式ドキュメント詳細、サンプルあり
推奨度入門用本番推奨

正直なところ、最初は公式ライブラリを使っていました。しかし本番環境で問題に遭遇しました。あるバックエンドサービスが200ステータスコードを返すものの、レスポンスボディがエラー情報(サービス内部障害)だったケースです。公式ライブラリではこの「偽の健康状態」を検出できませんでした。lua-resty-healthcheck に切り替えた後、カスタムチェックロジックでレスポンスボディの内容を解析して真の健康状態を判断できるようになり、問題は解決しました。

私の推奨:lua-resty-healthcheck を直接使ってください。コードもより明確で、Apache APISIX もこれをベースに実装されています。APISIX のヘルスチェック設定を参考にできます。

サービスディスカバリ連携の実践

ヘルスチェックは「バックエンドがダウンしたらどうするか」という問題を解決します。しかし、その前に解決すべき問題があります:バックエンドリストはどこから取得するのか?

コンテナ環境では、バックエンドサービスの IP が頻繁に変化します。設定にハードコードするわけにはいきません。サービスレジストリが必要で、どのサービスが実行中かを Nginx に伝えます。

一般的なソリューションは3つあります:Consul、Nacos、etcd。それぞれ連携コードを紹介します。

Consul 連携:最も成熟したソリューション

Consul は HashiCorp のサービスディスカバリツールで、マイクロサービスアーキテクチャで広く使われています。サービス登録、ヘルスチェック、KV ストレージなどの機能を提供します。

Nginx との連携のアイデア:バックグラウンドで定期的に Consul API からサービスリストを取得し、共有メモリに更新します。

完全な実装コード

-- 共有メモリを設定(サーバーリストを保存)
lua_shared_dict upstream_servers 5m;

-- Consul から定期的にサーバーリストを取得
init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- Consul サービスディスカバリ API アドレス
    local consul_host = "consul.service.consul"
    local consul_port = 8500
    local service_name = "backend"

    -- サーバーリストを更新する関数
    local function update_upstream(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(1000)  -- 1秒タイムアウト

        -- Consul Catalog API を呼び出してサービスリストを取得
        local res, err = httpc:request_uri(
            "http://" .. consul_host .. ":" .. consul_port ..
            "/v1/catalog/service/" .. service_name,
            {
                method = "GET",
                headers = { Accept = "application/json" }
            }
        )

        if not res then
            ngx.log(ngx.ERR, "failed to query consul: ", err)
            return
        end

        -- Consul から返されたサーバーリストを解析
        local services = cjson.decode(res.body)
        if not services or #services == 0 then
            ngx.log(ngx.WARN, "no backend services found in consul")
            return
        end

        -- バックエンドサーバーリストを構築
        local servers = {}
        for _, svc in ipairs(services) do
            -- Consul から返されるサービス情報には Address と ServicePort が含まれる
            -- 健全なサービスのみが返される(Consul 自身のヘルスチェック)
            servers[#servers + 1] = {
                svc.ServiceAddress or svc.Address,
                svc.ServicePort,
                weight = 10  -- デフォルトの重み
            }
        end

        -- 共有メモリに保存
        local shared_dict = ngx.shared.upstream_servers
        local packed = cjson.encode(servers)
        shared_dict:set("backend_servers", packed)

        ngx.log(ngx.INFO, "updated upstream servers: ", #servers, " instances")
    end

    -- 5秒ごとにサーバーリストを更新
    timer.every(5, update_upstream)

    -- 起動時に即座に実行
    update_upstream(false)
}

balancer_by_lua_block でこれらのデータを読み取ります:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local cjson = require "cjson.safe"
        local roundrobin = require "resty.roundrobin"
        local shared_dict = ngx.shared.upstream_servers

        -- 共有メモリからサーバーリストを読み取り
        local packed = shared_dict:get("backend_servers")
        if not packed then
            ngx.log(ngx.ERR, "no upstream servers available")
            return ngx.exit(503)
        end

        local servers = cjson.decode(packed)

        -- ラウンドロビン負荷分散器を作成
        local rr = roundrobin:new(servers)
        local host, port = rr:select()

        -- バックエンドを設定
        local balancer = require "ngx.balancer"
        local ok, err = balancer.set_current_peer(host, port)
        if not ok then
            ngx.log(ngx.ERR, "failed to set peer: ", err)
            return ngx.exit(500)
        end
    }
}

このソリューションには利点があります:Consul はヘルスチェックを内蔵しています。サービス登録時に HTTP ヘルスチェックパスを設定でき、Consul が自動的に検知します。Catalog API をクエリすると、健全なサービスのみが返されます。Nginx が受け取るのはすでにフィルタリングされたリストです。

Nacos 連携:国内でよく使われるソリューション

Nacos はアリババがオープンソース化したサービスディスカバリと設定管理プラットフォームで、国内のマイクロサービスコミュニティで人気があります。Spring Cloud Alibaba はデフォルトで Nacos を使用します。

Nacos のサービスディスカバリ API は Consul と似ていますが、フォーマットが若干異なります。

連携コード

lua_shared_dict upstream_servers 5m;

init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- Nacos 設定
    local nacos_host = "nacos.service.nacos"
    local nacos_port = 8848
    local namespace_id = "public"  -- Nacos ネームスペース
    local service_name = "backend-service"
    local group_name = "DEFAULT_GROUP"

    local function update_from_nacos(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(2000)

        -- Nacos サービスディスカバリ API
        local url = "http://" .. nacos_host .. ":" .. nacos_port ..
                    "/nacos/v1/ns/instance/list?serviceName=" .. service_name ..
                    "&groupName=" .. group_name ..
                    "&namespaceId=" .. namespace_id

        local res, err = httpc:request_uri(url, { method = "GET" })

        if not res then
            ngx.log(ngx.ERR, "failed to query nacos: ", err)
            return
        end

        local data = cjson.decode(res.body)
        if not data or not data.hosts then
            ngx.log(ngx.WARN, "no instances found in nacos")
            return
        end

        -- Nacos から返される hosts フィールドにサービスインスタンスリストが含まれる
        local servers = {}
        for _, instance in ipairs(data.hosts) do
            -- healthy=true のインスタンスのみ使用
            if instance.healthy then
                servers[#servers + 1] = {
                    instance.ip,
                    instance.port,
                    weight = instance.weight or 10
                }
            end
        end

        local shared_dict = ngx.shared.upstream_servers
        shared_dict:set("backend_servers", cjson.encode(servers))

        ngx.log(ngx.INFO, "updated from nacos: ", #servers, " instances")
    end

    timer.every(5, update_from_nacos)
    update_from_nacos(false)
}

Nacos には特徴があります:サービスの重みを動的に調整できます。Nacos コンソールで特定のインスタンスの重みを変更すると、Nginx は次回取得時に感知し、トラフィック配分比率が追従して調整されます。これはカナリアリリースシナリオに適しています。新バージョンのサービスが最初は少量のトラフィックを処理し、徐々に増やしていけます。

etcd 連携:軽量なソリューション

etcd は CoreOS が開発した分散 KV ストアで、Kubernetes はこれを使ってクラスタ状態を保存しています。バックエンドサービスの登録情報が etcd にある場合、etcd から直接読み取れます。

連携コード

lua_shared_dict upstream_servers 5m;

init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- etcd 設定
    local etcd_host = "etcd.service.etcd"
    local etcd_port = 2379
    -- サービス登録情報のキー(カスタムフォーマット)
    local service_key = "/services/backend"

    local function update_from_etcd(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(1000)

        -- etcd V3 API(POST リクエストが必要)
        local url = "http://" .. etcd_host .. ":" .. etcd_port .. "/v3/kv/range"
        local body = cjson.encode({ key = service_key, range_end = service_key .. "/" })

        local res, err = httpc:request_uri(url, {
            method = "POST",
            body = body,
            headers = { ["Content-Type"] = "application/json" }
        })

        if not res then
            ngx.log(ngx.ERR, "failed to query etcd: ", err)
            return
        end

        local data = cjson.decode(res.body)
        if not data or not data.kvs then
            ngx.log(ngx.WARN, "no services found in etcd")
            return
        end

        -- etcd から返されたキーバリューペアを解析
        local servers = {}
        for _, kv in ipairs(data.kvs) do
            -- kv.value はサービスインスタンス情報(base64 エンコード)
            local value = ngx.decode_base64(kv.value)
            local instance = cjson.decode(value)

            if instance and instance.healthy then
                servers[#servers + 1] = {
                    instance.host,
                    instance.port,
                    weight = instance.weight or 10
                }
            end
        end

        local shared_dict = ngx.shared.upstream_servers
        shared_dict:set("backend_servers", cjson.encode(servers))
    end

    timer.every(5, update_from_etcd)
    update_from_etcd(false)
}

etcd の利点はシンプルで軽量な点です。ただし、Consul や Nacos のような完全なサービスディスカバリエコシステムはないため、サービス登録メカニズムを自分で設計する必要があります。チームがすでに Kubernetes を使用している場合、etcd は天然の統合で、良い選択肢です。

3つのソリューションの比較

比較項目ConsulNacosetcd
ネイティブヘルスチェックサポート(HTTP/TCP)サポート非サポート(自前構築必要)
重みの動的調整サポートサポート(可視化)自前実装必要
Spring Cloud 統合サポートデフォルト統合追加設定必要
コンソールWeb UI ありWeb UI あり(より充実)なし(サードパーティ必要)
設定管理サポート(KV ストレージ)サポート(より強力)サポート
国内コミュニティの活発さ普通高い高い(K8s エコシステム)
適用シーン一般的なマイクロサービスSpring Cloud AlibabaK8s 環境

私の選択:Spring Cloud を使うなら Nacos。K8s を使うなら etcd が便利。独立した完全なサービスディスカバリプラットフォームが欲しいなら、Consul が最も成熟しています。

実践シーンとパフォーマンス最適化

3層アーキテクチャが構築でき、サービスディスカバリも統合できました。次に、いくつかの典型的なシーンでの実際の応用を見ていきます。

シーン1:Kubernetes イングレスゲートウェイ

K8s の Pod ライフサイクルは短いです。スケールアウト、スケールイン、アップグレードのたびに Pod が再作成され、IP アドレスも変わります。静的な upstream では管理できません。

OpenResty は Pod の変化を動的に検知できます。アイデアは次のとおりです:

  1. init_worker_by_lua_block でタイマーを起動し、5秒ごとに K8s API または CoreDNS をクエリ
  2. サービスに対応する Pod IP リストを解析
  3. 共有メモリに更新
  4. balancer_by_lua_block で Pod リストに基づいて負荷分散

K8s API 呼び出し例

local function watch_k8s_services(premature)
    local httpc = http.new()

    -- K8s API:Service の Endpoints を取得(つまり Pod IP リスト)
    local url = "https://kubernetes.default/api/v1/namespaces/default/endpoints/backend-service"

    -- K8s API には認証が必要、ServiceAccount トークンファイルから読み取り
    local token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token"
    local token = read_file(token_file)  -- カスタム関数でファイル読み取り

    local res, err = httpc:request_uri(url, {
        headers = {
            Authorization = "Bearer " .. token
        }
    })

    if res then
        local endpoints = cjson.decode(res.body)
        -- endpoints.subsets に Pod アドレスとポート情報が含まれる
        -- 解析して共有メモリに保存...
    end
end

timer.every(5, watch_k8s_services)

もちろん、実際の本番環境では K8s の Ingress Controller を使えます。NGINX Ingress Controller や Traefik はこのロジックをカプセル化しています。しかし、要件が特殊な場合(カスタムルーティングルール、カナリア戦略など)、自分で OpenResty を書く方が柔軟です。

シーン2:カナリアリリース

新バージョンのサービスをリリースするとします。旧バージョンが90%のトラフィックを処理し、新バージョンが10%を処理します。新バージョンが1週間安定して動作すれば、徐々に50%、100%に増やします。

OpenResty で「リクエストヘッダールーティング + 動的重み」を実現できます:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local cjson = require "cjson.safe"
        local chash = require "resty.chash"
        local shared_dict = ngx.shared.upstream_servers

        -- 共有メモリから新旧バージョンのサーバーリストを読み取り
        local old_servers = cjson.decode(shared_dict:get("old_version"))
        local new_servers = cjson.decode(shared_dict:get("new_version"))

        -- カナリア戦略:リクエストヘッダーでルーティングを決定
        local version_header = ngx.req.get_headers()["X-Version"]

        if version_header == "new" then
            -- 新バージョンに強制ルーティング(テスター用)
            local host, port = select_random(new_servers)
            balancer.set_current_peer(host, port)
        else
            -- 重みに基づいてランダム選択
            -- 90% の確率で旧バージョン、10% で新バージョン
            local rand = math.random()
            if rand < 0.1 then
                local host, port = select_random(new_servers)
                balancer.set_current_peer(host, port)
            else
                local host, port = select_random(old_servers)
                balancer.set_current_peer(host, port)
            end
        end
    }
}

重みの比率は共有メモリまたは Redis に保存でき、運用担当者は管理インターフェースで動的に調整できます。例えば、HTTP API を提供:POST /admin/traffic-weight { "old": 90, "new": 10 }、OpenResty が受信後に重み設定を更新します。

シーン3:障害時の自動除外

バックエンドサービスが突然ダウンしました。Nginx が迅速に検知し、障害サーバーにリクエストを転送しないようにしたいです。

これはヘルスチェックモジュールに依存します。lua-resty-healthcheck はバックグラウンドで継続的に検知します。連続3回失敗すると、サーバーを down とマークします。

balancer_by_lua_block で、まず健康状態をチェックします:

balancer_by_lua_block {
    local checker = ngx.shared.healthcheck
    local servers = get_all_servers()  -- サービスディスカバリから取得

    -- 不健康なサーバーを除外
    local healthy_servers = {}
    for _, srv in ipairs(servers) do
        local key = srv[1] .. ":" .. srv[2]
        if checker:get(key) == "up" then  -- 健康状態をクエリ
            healthy_servers[#healthy_servers + 1] = srv
        end
    end

    if #healthy_servers == 0 then
        return ngx.exit(503)  -- すべてのバックエンドがダウン
    end

    -- 健康なリストから選択
    local rr = roundrobin:new(healthy_servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
}

リトライロジックも重要です。リクエスト転送後にエラーが返ってきた場合、別のバックエンドを試行すべきで、ユーザーに直接500を返すべきではありません。

-- リトライ回数を設定
balancer.set_more_tries(2)

-- 今回失敗した場合、記録してリトライ
local last_failure = balancer.get_last_failure()
if last_failure then
    ngx.log(ngx.WARN, "request failed: ", last_failure.type, " to ", last_failure.host)
    -- パッシブヘルスチェックがこの失敗を記録
    -- 次回の選択時にこのサーバーをスキップ
end

パフォーマンスチューニングのアドバイス

この動的メカニズムにはオーバーヘッドがあります。ヘルスチェックは検知リクエストを送信し、サービスディスカバリはリモート API をクエリします。設定が不適切だと、全体的なレスポンス速度が低下する可能性があります。

実測に基づくいくつかの経験則

  1. 検知間隔:2〜10秒を推奨。短すぎるとリソースを消費し、長すぎると反応が遅れます。高同時接続シーンでは2秒、低同時接続では5秒が適しています。

  2. 共有メモリサイズlua_shared_dict healthcheck は最低1MB。各 upstream は約100KBを占有。10個の upstream がある場合、2MBを割り当てるのが安全です。

  3. keepalive 接続プール:バックエンドサービスで keepalive を有効にし、接続確立のオーバーヘッドを削減:

upstream backend {
    server 0.0.0.1;
    keepalive 64;  -- 64個の接続プールを維持
}
  1. 非同期ヘルスチェック:ヘルスチェックは ngx.timer を使用し、それ自体は非同期実行で、リクエスト処理をブロックしません。ただし、検知リクエスト自体は HTTP 接続を消費します。バックエンドサービスが多い場合、検知頻度を適切に下げることができます。

  2. 状態キャッシュ:サービスディスカバリのクエリ結果を5秒キャッシュし、Consul/Nacos API への頻繁な呼び出しを避けます。ほとんどの場合、5秒の遅延は許容範囲です。

完全な本番設定例

# 共有メモリ設定
lua_shared_dict healthcheck 2m;
lua_shared_dict upstream_servers 5m;

# HTTP ブロック設定
http {
    # 接続プールを有効化
    keepalive_timeout 60s;
    keepalive_requests 100;

    init_worker_by_lua_block {
        -- ヘルスチェック(2秒検知)
        local healthcheck = require "resty.healthcheck"
        local checker = healthcheck.new({
            shm_name = "healthcheck",
            checks = {
                active = {
                    interval = 2,
                    healthy = { successes = 2 },
                    unhealthy = { tcp_failures = 1, http_failures = 3 }
                },
                passive = {
                    healthy = { successes = 3 },
                    unhealthy = { tcp_failures = 2, http_failures = 3 }
                }
            }
        })

        -- サービスディスカバリ(5秒更新)
        timer.every(5, update_upstream_from_consul)
    }

    upstream backend {
        server 0.0.0.1;
        keepalive 64;  -- 接続プール

        balancer_by_lua_block {
            -- 健全なバックエンドサーバーを選択
            local host, port = select_healthy_backend()
            balancer.set_current_peer(host, port)
            balancer.set_more_tries(2)  -- 最大2回リトライ
        }
    }
}

この設定は私たちの本番環境で半年間稼働し、毎秒5000リクエストを処理し、レスポンス時間は50ミリ秒以内で安定しています。ポイントはパラメータを適切な値に調整することです。極端すぎず、保守的すぎないように。

結論

ダイナミック upstream の3層アーキテクチャの核心は、ngx.balancer API が低レイヤーの能力を提供し、lua-resty-balancer が負荷分散アルゴリズムをカプセル化し、lua-resty-healthcheck がヘルスチェックを実装することです。これらを連携させることで、実行時に動的にバックエンドサーバーを選択でき、Nginx の reload が一切不要になります。

サービスディスカバリに関しては、Consul が最も成熟しており、Nacos は Spring Cloud ユーザーに適しており、etcd は K8s 環境に適しています。既存の技術スタックに基づいて選択し、「最適なソリューション」を盲目に追求しないでください。

実際に試してみましょう:まず lua-resty-healthcheck から始め、ヘルスチェックを動作させます。バックエンドサービスがダウンして自動的に除外され、回復して自動的に追加されるというフローが確認できたら、サービスディスカバリを統合します。Apache APISIX の balancer.lua はわずか400行のコードです。直接参考にでき、ゼロから書く必要はありません。

この仕組みの本質は、Nginx を「生きさせる」ことです。静的設定が動的検知に変わり、深夜に起きて設定を変更する日々は終わりです。

Nginx ダイナミック upstream の実装

OpenResty 3層アーキテクチャを使用して動的サービスディスカバリとヘルスチェックを実現

⏱️ 目安時間: 120 分

  1. 1

    ステップ1: 依存モジュールのインストール

    OpenResty と必要な Lua ライブラリをインストール:

    • OpenResty のインストール(ngx.balancer を含む)
    • lua-resty-balancer のインストール(負荷分散アルゴリズム)
    • lua-resty-healthcheck のインストール(ヘルスチェック)
    • lua-resty-http のインストール(HTTP クライアント、サービスディスカバリ API 呼び出し用)
  2. 2

    ステップ2: 共有メモリの設定

    nginx.conf の http ブロックに追加:

    ```nginx
    lua_shared_dict healthcheck 2m;
    lua_shared_dict upstream_servers 5m;
    ```

    • healthcheck:ヘルスチェック状態を保存(各 upstream 約 100KB)
    • upstream_servers:サーバーリストを保存(Consul/Nacos/etcd から取得)
  3. 3

    ステップ3: ヘルスチェックの実装

    init_worker_by_lua_block でヘルスチェックを開始:

    ```lua
    local healthcheck = require "resty.healthcheck"
    local checker = healthcheck.new({
    shm_name = "healthcheck",
    checks = {
    active = {
    type = "http",
    http_path = "/health",
    interval = 2,
    healthy = { successes = 2 },
    unhealthy = { http_failures = 3 }
    }
    }
    })
    ```

    • active:能動的検知、2秒ごとに HTTP リクエストを送信
    • unhealthy:連続3回失敗で down とマーク
  4. 4

    ステップ4: サービスディスカバリの統合

    サービスディスカバリソリューションを選択:

    • Consul:/v1/catalog/service/{name} API を呼び出し
    • Nacos:/nacos/v1/ns/instance/list API を呼び出し
    • etcd:/v3/kv/range API を呼び出し

    ngx.timer.every を使用して5秒ごとにサーバーリストを更新、共有メモリに保存。
  5. 5

    ステップ5: ダイナミック upstream の設定

    upstream ブロックで balancer_by_lua_block を使用:

    ```nginx
    upstream backend {
    server 0.0.0.1; # プレースホルダー
    keepalive 64; # 接続プール
    balancer_by_lua_block {
    local servers = get_healthy_servers()
    local rr = roundrobin:new(servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
    balancer.set_more_tries(2)
    }
    }
    ```

    • server 0.0.0.1 はプレースホルダー、実際のバックエンドは Lua で動的に選択
    • keepalive 64 で64個の接続プールを維持
    • set_more_tries(2) で最大2回リトライ
  6. 6

    ステップ6: テストとチューニング

    テスト環境にデプロイして検証:

    • ヘルスチェック:バックエンドサービスを停止、Nginx が自動的に除外するか確認
    • サービスディスカバリ:コンテナを再起動、IP が自動的に更新されるか確認
    • パフォーマンステスト:wrk または ab で QPS とレスポンス時間をテスト
    • パラメータ調整:検知間隔(2-10秒)、接続プールサイズ(64-128)、リトライ回数(2-3回)

FAQ

lua-resty-upstream-healthcheck と lua-resty-healthcheck の違いは何ですか?
lua-resty-upstream-healthcheck は OpenResty 公式ライブラリで、能動的チェックのみをサポートします。lua-resty-healthcheck はコミュニティ強化版で、能動的+パッシブチェックをサポートし、Apache APISIX で大規模な本番環境検証済みです。lua-resty-healthcheck を直接使用することを推奨します。
Nginx で reload なしに upstream を動的に更新するにはどうすればいいですか?
OpenResty の balancer_by_lua_block フックを使用し、実行時に Lua コードで動的にバックエンドサーバーを選択します。バックエンドリストは共有メモリ、Redis、またはサービスディスカバリ API から取得でき、nginx -s reload を実行する必要が一切ありません。
Consul、Nacos、etcd のどれがサービスディスカバリに適していますか?
選択は技術スタックによります:

• Consul:最も成熟、機能が完全、一般的なマイクロサービスアーキテクチャに適している
• Nacos:Spring Cloud Alibaba のデフォルト統合、コンソールが充実
• etcd:軽量、Kubernetes ネイティブ統合、K8s 環境に適している
ダイナミック upstream はパフォーマンスに影響しますか?
オーバーヘッドはありますが制御可能です。ヘルスチェックとサービスディスカバリは非同期実行で、リクエスト処理をブロックしません。実測:QPS 5000+ でレスポンス安定 &lt;50ms。ポイントはパラメータの適切な設定:検知間隔 2-10秒、共有メモリ 2-5MB、接続プール 64-128。
Kubernetes 環境で動的サービスディスカバリを実現するにはどうすればいいですか?
2つの方法:1)K8s API を直接呼び出し、Endpoints を読み取って Pod IP リストを取得;2)CoreDNS サービスディスカバリを使用、DNS クエリでサービス名を解決。方法1の方が柔軟で、カナリアリリースやカスタムルーティングなどの高度な機能を実現できます。
ヘルスチェックの検知間隔はどのくらいに設定すべきですか?
2〜10秒を推奨します。高同時接続シーンでは2秒で障害を迅速に検知、低同時接続シーンでは5〜10秒でリソース消費を削減。間隔が短すぎるとバックエンドの負荷と Nginx のリソース使用量が増加し、長すぎると障害発見が遅れます。fall(連続失敗回数)と rise(連続成功回数)パラメータと組み合わせて調整します。

8 min read · 公開日: 2026年5月7日 · 更新日: 2026年5月14日

シリーズの読書導線 第 5 / 5 記事

Nginx 実践ガイド

検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。

シリーズ全体を見る

関連記事

コメント

GitHubアカウントでログインしてコメントできます