From bb13a2cf50c0b595983b0e87764967f67b72cfdb Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 5 Aug 2026 10:44:31 +0545 Subject: [PATCH 1/3] feat: build apisix-runtime with ngx_http_ffi_client ai-proxy, ai-proxy-multi and ai-request-rewrite send every outbound LLM request through ngx_http_ffi_client when the runtime carries it, and cost about a third of the outbound CPU time they do on lua-resty-http. The module has to be in the runtime for that to happen. api7/ngx_http_ffi_client is still private and carries no tags, so it is pinned by commit and fetched with NGX_HTTP_FFI_CLIENT_TOKEN. The token reaches the container as a BuildKit secret rather than a build arg, so it stays out of image history, and the fetch runs with the trace off so it stays out of the build log. A build without the token warns and leaves the module out; ai-proxy falls back to lua-resty-http on such a runtime, so both runtimes work. The module compiles against lua-nginx-module's public co-ctx API, which arrived in 0.10.29. OpenResty 1.29.2.4 bundles 0.10.31rc2 and has it. --- ...kage-apisix-runtime-deb-openresty-1.21.yml | 2 + ...package-apisix-runtime-deb-ubuntu20.04.yml | 2 + .../package-apisix-runtime-rpm-el.yml | 1 + .../package-apisix-runtime-rpm-ubi.yml | 2 + .github/workflows/release-apisix-runtime.yml | 5 ++ Makefile | 14 +++++- README.md | 18 +++++++ build-apisix-runtime.sh | 50 +++++++++++++++++++ dockerfiles/Dockerfile.apisix-runtime.deb | 7 ++- dockerfiles/Dockerfile.apisix-runtime.rpm | 7 ++- 10 files changed, 105 insertions(+), 3 deletions(-) diff --git a/.github/workflows/package-apisix-runtime-deb-openresty-1.21.yml b/.github/workflows/package-apisix-runtime-deb-openresty-1.21.yml index 99e08b017..98604f85d 100644 --- a/.github/workflows/package-apisix-runtime-deb-openresty-1.21.yml +++ b/.github/workflows/package-apisix-runtime-deb-openresty-1.21.yml @@ -35,6 +35,8 @@ jobs: sudo apt-get install -y make ruby ruby-dev rubygems build-essential - name: Build apisix-runtime deb + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | if [ "${{ matrix.platform.arch }}" == "arm64" ]; then make package type=deb app=apisix-runtime runtime_version=${BUILD_APISIX_RUNTIME_VERSION} image_base=debian image_tag=bullseye-slim arch=linux/arm64/v8 diff --git a/.github/workflows/package-apisix-runtime-deb-ubuntu20.04.yml b/.github/workflows/package-apisix-runtime-deb-ubuntu20.04.yml index f1153eaca..d9b711396 100644 --- a/.github/workflows/package-apisix-runtime-deb-ubuntu20.04.yml +++ b/.github/workflows/package-apisix-runtime-deb-ubuntu20.04.yml @@ -28,6 +28,8 @@ jobs: sudo apt-get install -y make ruby ruby-dev rubygems build-essential - name: build apisix-runtime deb + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=deb app=apisix-runtime runtime_version=${BUILD_APISIX_RUNTIME_VERSION} image_base=ubuntu image_tag=24.04 diff --git a/.github/workflows/package-apisix-runtime-rpm-el.yml b/.github/workflows/package-apisix-runtime-rpm-el.yml index d92703683..6f1f80295 100644 --- a/.github/workflows/package-apisix-runtime-rpm-el.yml +++ b/.github/workflows/package-apisix-runtime-rpm-el.yml @@ -56,6 +56,7 @@ jobs: env: # Stream inner docker build output so a failing build step surfaces in CI. BUILDKIT_PROGRESS: plain + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=rpm app=apisix-runtime runtime_version=${BUILD_APISIX_RUNTIME_VERSION} \ image_base=rockylinux image_tag=${{ matrix.dist.image_tag }} arch=linux/amd64 diff --git a/.github/workflows/package-apisix-runtime-rpm-ubi.yml b/.github/workflows/package-apisix-runtime-rpm-ubi.yml index e2ecb459f..adf9084b3 100644 --- a/.github/workflows/package-apisix-runtime-rpm-ubi.yml +++ b/.github/workflows/package-apisix-runtime-rpm-ubi.yml @@ -26,6 +26,8 @@ jobs: sudo apt-get install -y make ruby ruby-dev rubygems build-essential - name: build apisix-runtime rpm + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=rpm app=apisix-runtime runtime_version=${BUILD_APISIX_RUNTIME_VERSION} image_base=registry.access.redhat.com/ubi9/ubi image_tag=9.6 diff --git a/.github/workflows/release-apisix-runtime.yml b/.github/workflows/release-apisix-runtime.yml index 7ba6f960b..b41f99e3f 100644 --- a/.github/workflows/release-apisix-runtime.yml +++ b/.github/workflows/release-apisix-runtime.yml @@ -53,10 +53,14 @@ jobs: sudo apt-get install -y make ruby ruby-dev rubygems build-essential - name: Build apisix-runtime deb + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=deb app=apisix-runtime runtime_version="${VERSION}" image_base=debian image_tag=bookworm-slim arch=${{ matrix.platform.build_arch }} - name: Build apisix-runtime-debug deb + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=deb app=apisix-runtime runtime_version="${VERSION}" image_base=debian image_tag=bookworm-slim arch=${{ matrix.platform.build_arch }} build_latest=latest artifact=apisix-runtime-debug @@ -115,6 +119,7 @@ jobs: env: # Stream inner docker build output so a failing build step surfaces in CI. BUILDKIT_PROGRESS: plain + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} run: | make package type=rpm app=apisix-runtime runtime_version="${VERSION}" \ image_base=rockylinux image_tag=${{ matrix.dist.image_tag }} \ diff --git a/Makefile b/Makefile index dc67e5f8a..b516567ac 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,16 @@ endif # Set arch to linux/amd64 if it's not defined arch ?= linux/amd64 +# api7/ngx_http_ffi_client is private, so the runtime build reads a token from +# a BuildKit secret rather than a build arg, which would land in image history. +# Without the token the runtime is built without that module. +NGX_HTTP_FFI_CLIENT_TOKEN ?= +ifneq ($(NGX_HTTP_FFI_CLIENT_TOKEN),) +ffi_client_secret=--secret id=ngx_http_ffi_client_token,env=NGX_HTTP_FFI_CLIENT_TOKEN +else +ffi_client_secret= +endif + # Detect the CPU architecture CPU_ARCH := $(shell uname -m) # Map the architecture to Docker platform @@ -94,7 +104,7 @@ endif ### $(4) is code path ifneq ($(buildx), True) define build_runtime - docker build -t apache/$(1)-$(3):$(runtime_version) \ + DOCKER_BUILDKIT=1 docker build -t apache/$(1)-$(3):$(runtime_version) \ --build-arg checkout_v=$(checkout) \ --build-arg VERSION=$(version) \ --build-arg RUNTIME_VERSION=$(runtime_version) \ @@ -102,6 +112,7 @@ define build_runtime --build-arg IMAGE_TAG=$(image_tag) \ --build-arg BUILD_LATEST=$(build_latest) \ --build-arg CODE_PATH=$(4) \ + $(ffi_client_secret) \ --platform $(arch) \ -f ./dockerfiles/Dockerfile.$(2).$(3) . endef @@ -115,6 +126,7 @@ define build_runtime --build-arg IMAGE_TAG=$(image_tag) \ --build-arg BUILD_LATEST=$(build_latest) \ --build-arg CODE_PATH=$(4) \ + $(ffi_client_secret) \ --load \ --cache-from=$(cache_from) \ --cache-to=$(cache_to) \ diff --git a/README.md b/README.md index 8bb6fab99..0c7778970 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ | image_base | False | the environment for packaging, if type is `rpm` the default image_base is `centos`, if type is `deb` the default image_base is `ubuntu` | image_base=centos | | image_tag | False | the environment for packaging, it's value can be `16.04\|18.04\|20.04\|6\|7\|8`, if type is `rpm` the default image_tag is `7`, if type is `deb` the default image_tag is `20.04` | image_tag=7 | | buildx | False | if `True`, use buildx to build docker images, which may speed up GitHub Actions | buildx=True | +| NGX_HTTP_FFI_CLIENT_TOKEN | False | environment variable holding a token that can read `api7/ngx_http_ffi_client`. It is passed to the runtime build as a BuildKit secret and never as a build arg. Without it the runtime is built without that module | NGX_HTTP_FFI_CLIENT_TOKEN=ghp_xxx make package ... | ## Example @@ -106,6 +107,23 @@ ls output/ apisix-runtime_1.0.0-0~ubuntu20.04_amd64.deb ``` +### ngx_http_ffi_client + +`ngx_http_ffi_client` is the C HTTP client the AI plugins use for outbound LLM +requests. `api7/ngx_http_ffi_client` is a private repository, so the runtime +build fetches it only when `NGX_HTTP_FFI_CLIENT_TOKEN` is set, and otherwise +prints a warning and builds the runtime without it. `ai-proxy` falls back to +`lua-resty-http` on a runtime that does not carry the module, so both runtimes +work; only the outbound CPU cost differs. + +```sh +NGX_HTTP_FFI_CLIENT_TOKEN= \ + make package type=deb app=apisix-runtime version=1.0.0 +``` + +The commit is pinned by `ngx_http_ffi_client_ver` in `build-apisix-runtime.sh`, +since the repository carries no tags yet. + ## Details - `Makefile` the entrance of the packager diff --git a/build-apisix-runtime.sh b/build-apisix-runtime.sh index fa231d529..7df560ecd 100755 --- a/build-apisix-runtime.sh +++ b/build-apisix-runtime.sh @@ -36,6 +36,16 @@ fi wasm_nginx_module_ver="0.7.0" lua_var_nginx_module_ver="v0.5.3" lua_resty_events_ver="0.2.0" +# api7/ngx_http_ffi_client is still a private repository and carries no tags, +# so it is pinned by commit and fetched with a token. A build without the token +# leaves the module out and is otherwise unchanged. +ngx_http_ffi_client_ver=${ngx_http_ffi_client_ver:-"f13fcfa4e923ad82844bf49d9d3b3d283371ef66"} +if [[ ! "$ngx_http_ffi_client_ver" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "ERROR: invalid ngx_http_ffi_client_ver: $ngx_http_ffi_client_ver" >&2 + exit 1 +fi +NGX_HTTP_FFI_CLIENT_TOKEN=${NGX_HTTP_FFI_CLIENT_TOKEN:-} +ngx_http_ffi_client_dir="ngx_http_ffi_client-${ngx_http_ffi_client_ver}" install_openssl_3(){ @@ -134,6 +144,33 @@ else lua-var-nginx-module-${lua_var_nginx_module_ver} fi +if [ "$repo" == ngx_http_ffi_client ]; then + cp -r "$prev_workdir" "./$ngx_http_ffi_client_dir" +elif [ -n "$NGX_HTTP_FFI_CLIENT_TOKEN" ]; then + # A private repository pinned by commit, so fetch rather than clone -b. + # The token stays off the trace and out of the repository's git config. + mkdir "$ngx_http_ffi_client_dir" + ( + set +x + cd "$ngx_http_ffi_client_dir" || exit 1 + git init -q + git -c "http.extraheader=Authorization: Basic $(printf 'x-access-token:%s' \ + "$NGX_HTTP_FFI_CLIENT_TOKEN" | base64 | tr -d '\n')" \ + fetch -q --depth=1 \ + https://github.com/api7/ngx_http_ffi_client.git "$ngx_http_ffi_client_ver" + git checkout -q FETCH_HEAD + ) +else + echo "WARNING: NGX_HTTP_FFI_CLIENT_TOKEN is not set, building apisix-runtime" \ + "without ngx_http_ffi_client. ai-proxy falls back to lua-resty-http" \ + "on such a runtime." >&2 +fi + +ngx_http_ffi_client_configure_arg="" +if [ -d "$ngx_http_ffi_client_dir" ]; then + ngx_http_ffi_client_configure_arg="--add-module=../$ngx_http_ffi_client_dir" +fi + cd ngx_multi_upstream_module-${ngx_multi_upstream_module_ver} || exit 1 ./patch.sh ../openresty-${OPENRESTY_VERSION} cd .. @@ -165,6 +202,11 @@ else fi +# ngx_http_ffi_client compiles against lua-nginx-module's public API, which it +# reaches through the bundled copy rather than a separate checkout. +ngx_lua_bundle_dir=$(find bundle -maxdepth 1 -type d -name 'ngx_lua-*' | head -n 1) +export NGX_HTTP_LUA_MODULE_DIR="$PWD/$ngx_lua_bundle_dir" + ./configure --prefix="$OR_PREFIX" \ --with-cc-opt="-DAPISIX_RUNTIME_VER=$runtime_version $cc_opt" \ --with-ld-opt="-Wl,-rpath,$OR_PREFIX/wasmtime-c-api/lib $ld_opt" \ @@ -177,6 +219,7 @@ fi --add-module=../wasm-nginx-module-${wasm_nginx_module_ver} \ --add-module=../lua-var-nginx-module-${lua_var_nginx_module_ver} \ --add-module=../lua-resty-events-${lua_resty_events_ver} \ + $ngx_http_ffi_client_configure_arg \ --with-poll_module \ --with-pcre-jit \ --without-http_rds_json_module \ @@ -220,6 +263,13 @@ sudo install -d "$OR_PREFIX"/lualib/resty/events/compat/ sudo install -m 644 lualib/resty/events/compat/*.lua "$OR_PREFIX"/lualib/resty/events/compat/ cd .. +if [ -d "$ngx_http_ffi_client_dir" ]; then + # the C module needs its FFI bindings on the runtime's lua_package_path + sudo install -d "$OR_PREFIX"/lualib/resty/ + sudo install -m 644 "$ngx_http_ffi_client_dir"/lib/resty/ngx_http_ffi_client.lua \ + "$OR_PREFIX"/lualib/resty/ +fi + cd "apisix-nginx-module-${apisix_nginx_module_ver}" || exit 1 sudo OPENRESTY_PREFIX="$OR_PREFIX" make install cd .. diff --git a/dockerfiles/Dockerfile.apisix-runtime.deb b/dockerfiles/Dockerfile.apisix-runtime.deb index cda3dfe89..d24f7a955 100644 --- a/dockerfiles/Dockerfile.apisix-runtime.deb +++ b/dockerfiles/Dockerfile.apisix-runtime.deb @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 ARG IMAGE_BASE="debian" ARG IMAGE_TAG="bullseye-slim" @@ -20,7 +21,11 @@ ENV build_latest=${BUILD_LATEST:-} COPY ${CODE_PATH} ./ -RUN mv ./utils/build-common.sh ./utils/determine-dist.sh ./ \ +# the secret is optional: without it the runtime is built without +# ngx_http_ffi_client +RUN --mount=type=secret,id=ngx_http_ffi_client_token \ + export NGX_HTTP_FFI_CLIENT_TOKEN="$(cat /run/secrets/ngx_http_ffi_client_token 2>/dev/null || true)" \ + && mv ./utils/build-common.sh ./utils/determine-dist.sh ./ \ && ./build-common.sh build_apisix_runtime_deb ${build_latest} \ # determine dist and write it into /tmp/dist file && ./determine-dist.sh diff --git a/dockerfiles/Dockerfile.apisix-runtime.rpm b/dockerfiles/Dockerfile.apisix-runtime.rpm index 82e3b07ca..78dbc03ff 100644 --- a/dockerfiles/Dockerfile.apisix-runtime.rpm +++ b/dockerfiles/Dockerfile.apisix-runtime.rpm @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 ARG IMAGE_BASE="registry.access.redhat.com/ubi9/ubi" ARG IMAGE_TAG="9.6" @@ -18,7 +19,11 @@ ENV runtime_version=${RUNTIME_VERSION} COPY ${CODE_PATH} ./ -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ +# the secret is optional: without it the runtime is built without +# ngx_http_ffi_client +RUN --mount=type=secret,id=ngx_http_ffi_client_token \ + export NGX_HTTP_FFI_CLIENT_TOKEN="$(cat /run/secrets/ngx_http_ffi_client_token 2>/dev/null || true)" \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ && source "$HOME/.cargo/env" \ && rustup install 1.69 \ && rustup default 1.69 \ From 74ead747ee347286d9a4b2123426e687dc3f4624 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 5 Aug 2026 10:51:08 +0545 Subject: [PATCH 2/3] fix: keep the ngx_http_ffi_client token out of the build trace The script runs under set -x, so assigning NGX_HTTP_FFI_CLIENT_TOKEN and testing it with [ -n ... ] both echoed the token into the build log. Only a derived yes/no now reaches the trace. --- build-apisix-runtime.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build-apisix-runtime.sh b/build-apisix-runtime.sh index 7df560ecd..1eac48c1b 100755 --- a/build-apisix-runtime.sh +++ b/build-apisix-runtime.sh @@ -44,7 +44,15 @@ if [[ ! "$ngx_http_ffi_client_ver" =~ ^[A-Za-z0-9._/-]+$ ]]; then echo "ERROR: invalid ngx_http_ffi_client_ver: $ngx_http_ffi_client_ver" >&2 exit 1 fi +# the trace stays off around the token, and only the derived yes/no reaches it +set +x NGX_HTTP_FFI_CLIENT_TOKEN=${NGX_HTTP_FFI_CLIENT_TOKEN:-} +if [ -n "$NGX_HTTP_FFI_CLIENT_TOKEN" ]; then + ngx_http_ffi_client_have_token="yes" +else + ngx_http_ffi_client_have_token="no" +fi +set -x ngx_http_ffi_client_dir="ngx_http_ffi_client-${ngx_http_ffi_client_ver}" @@ -146,7 +154,7 @@ fi if [ "$repo" == ngx_http_ffi_client ]; then cp -r "$prev_workdir" "./$ngx_http_ffi_client_dir" -elif [ -n "$NGX_HTTP_FFI_CLIENT_TOKEN" ]; then +elif [ "$ngx_http_ffi_client_have_token" == "yes" ]; then # A private repository pinned by commit, so fetch rather than clone -b. # The token stays off the trace and out of the repository's git config. mkdir "$ngx_http_ffi_client_dir" From 1f188757700907cc02f1b7f060374f68c6685159 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 5 Aug 2026 12:41:12 +0545 Subject: [PATCH 3/3] ci: fail a release build that has no ngx_http_ffi_client token The build itself stays lenient so pull requests from forks, which get no secrets, still pass. A release is different: without the token it would produce an official runtime with no ngx_http_ffi_client and say so only in a warning buried in the build log. --- .github/workflows/release-apisix-runtime.yml | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/release-apisix-runtime.yml b/.github/workflows/release-apisix-runtime.yml index b41f99e3f..06d18510a 100644 --- a/.github/workflows/release-apisix-runtime.yml +++ b/.github/workflows/release-apisix-runtime.yml @@ -52,6 +52,18 @@ jobs: sudo apt-get update sudo apt-get install -y make ruby ruby-dev rubygems build-essential + # A released runtime must carry ngx_http_ffi_client. The build itself is + # lenient so fork PRs, which get no secrets, still pass; here the secret + # has to be there, or the release would silently ship without the module. + - name: Require the ngx_http_ffi_client token + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} + run: | + if [ -z "${NGX_HTTP_FFI_CLIENT_TOKEN}" ]; then + echo "NGX_HTTP_FFI_CLIENT_TOKEN is not set; a release build must carry ngx_http_ffi_client" >&2 + exit 1 + fi + - name: Build apisix-runtime deb env: NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} @@ -115,6 +127,18 @@ jobs: sudo apt-get update sudo apt-get install -y make ruby ruby-dev rubygems build-essential rpm + # A released runtime must carry ngx_http_ffi_client. The build itself is + # lenient so fork PRs, which get no secrets, still pass; here the secret + # has to be there, or the release would silently ship without the module. + - name: Require the ngx_http_ffi_client token + env: + NGX_HTTP_FFI_CLIENT_TOKEN: ${{ secrets.NGX_HTTP_FFI_CLIENT_TOKEN }} + run: | + if [ -z "${NGX_HTTP_FFI_CLIENT_TOKEN}" ]; then + echo "NGX_HTTP_FFI_CLIENT_TOKEN is not set; a release build must carry ngx_http_ffi_client" >&2 + exit 1 + fi + - name: Build apisix-runtime rpm (${{ matrix.dist.el }} ${{ matrix.platform.rpm_arch }}) env: # Stream inner docker build output so a failing build step surfaces in CI.