Docs

Use Wasm Plugins in APISIX

Build, load, validate, and remove a minimal Proxy-Wasm plugin with the Go SDK version tested by Apache APISIX 3.18.0.

Applies to:Apache APISIX 3.18.0

Deployments:DockerRoles:API developerPlatform engineerTime:25 minutesImpact:Changes live traffic

Goal

Build a minimal Proxy-Wasm plugin in Go, load it into an isolated APISIX 3.18.0 quickstart, and prove that it runs for a fixed Route by matching a unique marker in the gateway log. Restore the exact original gateway configuration after validation.

APISIX 3.18.0 implements only part of the Proxy-Wasm API. All Wasm plugins in one APISIX worker share the same Wasm VM; each plugin has its own VMContext, each configured Route or Global Rule has a PluginContext, and each request has an HttpContext. Test host-call compatibility, memory limits, and failure behavior for every module before production use.

Before you begin

  • Complete Get APISIX with the APISIX 3.18.0 quickstart on a machine with Docker.
  • Install Go, TinyGo, cURL, and jq.
  • Permit Go to download github.com/tetratelabs/proxy-wasm-go-sdk@v0.16.0. APISIX 3.18.0 uses that version in its own Wasm test module.
  • Use a disposable gateway. Adding a Wasm module changes process configuration and requires a reload.

Set the endpoints and Admin API key:

export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Admin API key (press Enter for an unchanged quickstart): " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo

Confirm the tools and gateway are available, then protect every fixed resource and local artifact:

go version
tinygo version
docker exec apisix-quickstart apisix version | grep -F '3.18.0'
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/plugins/list" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 200
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/routes/wasm-plugin-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 404 || { echo 'Stop: route wasm-plugin-guide-route already exists'; exit 1; }
ROUTE_COLLISIONS=$(curl -sS --connect-timeout 3 --max-time 10 "${APISIX_ADMIN_URL}/apisix/admin/routes" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | jq -r --arg uri '/wasm-plugin-guide' '.list[] | select(.value.uri == $uri or ((.value.uris // []) | index($uri))) | .key')
test -z "${ROUTE_COLLISIONS}" || { printf 'Stop: URI is already used by %s\n' "${ROUTE_COLLISIONS}"; exit 1; }
test -z "$(docker ps -aq --filter name=^/wasm-plugin-guide-upstream$)" || { echo 'Stop: container wasm-plugin-guide-upstream already exists'; exit 1; }
test ! -e wasm-plugin-guide || { echo 'Stop: local path wasm-plugin-guide already exists'; exit 1; }

This procedure appends a new top-level wasm block to the unchanged quickstart configuration. If the configuration already contains wasm, merge the module into the deployment's source-controlled list instead of using this patch.

Steps

Step 1: Write Plugin Logic in Go

Create an isolated Go module and pin the SDK version used by the APISIX 3.18.0 Wasm tests:

mkdir wasm-plugin-guide
cd wasm-plugin-guide
cat > go.mod <<'EOF'
module apisix-wasm-guide.local

go 1.17

require github.com/tetratelabs/proxy-wasm-go-sdk v0.16.0
EOF

Create main.go. The plugin reads the non-secret conf marker once for each Plugin Context and logs it when request headers arrive:

wasm-plugin-guide/main.go
package main

import (
    "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
    "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
)

func main() {
    proxywasm.SetVMContext(&vmContext{})
}

type vmContext struct {
    types.DefaultVMContext
}

func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
    return &pluginContext{}
}

type pluginContext struct {
    types.DefaultPluginContext
    marker string
}

func (ctx *pluginContext) OnPluginStart(configurationSize int) types.OnPluginStartStatus {
    configuration, err := proxywasm.GetPluginConfiguration()
    if err != nil {
        proxywasm.LogCriticalf("guide_wasm_log could not read configuration: %v", err)
        return types.OnPluginStartStatusFailed
    }
    ctx.marker = string(configuration)
    return types.OnPluginStartStatusOK
}

func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
    return &httpContext{marker: ctx.marker}
}

type httpContext struct {
    types.DefaultHttpContext
    marker string
}

func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
    proxywasm.LogWarnf("guide_wasm_log marker=%s", ctx.marker)
    return types.ActionContinue
}

Do not pass credentials in conf when a plugin logs it. Production modules should validate structured configuration, minimize log data, and choose a severity appropriate for routine traffic.

Step 2: Compile the Code into Wasm

Download and verify the pinned Go module, format the source, then use the SDK's official WASI build shape:

go mod tidy
go mod verify
gofmt -w main.go
tinygo build -o guide-wasm-log.wasm -scheduler=none -target=wasi ./main.go
test "$(od -An -tx1 -N4 guide-wasm-log.wasm | tr -d ' \n')" = "0061736d"

Expected: guide-wasm-log.wasm begins with the WebAssembly magic bytes 00 61 73 6d. Commit go.mod and go.sum with a real plugin so dependency review and rebuilds use the same module graph.

Step 3: Back up and extend the quickstart configuration

Return to the parent directory and copy the exact active configuration to a permission-restricted backup. It can contain secrets, so never commit or retain the backup:

cd ..
umask 077
docker cp apisix-quickstart:/usr/local/apisix/conf/config.yaml wasm-plugin-guide/config.yaml.backup
test "$(grep -Ec '^wasm:[[:space:]]*$' wasm-plugin-guide/config.yaml.backup)" = 0 || { echo 'Stop: merge with the existing wasm block'; exit 1; }
cp wasm-plugin-guide/config.yaml.backup wasm-plugin-guide/config.yaml.updated
cat >> wasm-plugin-guide/config.yaml.updated <<'YAML'

wasm:
  plugins:
    - name: guide_wasm_log
      priority: 7999
      file: /usr/local/apisix/wasm/guide-wasm-log.wasm
      http_request_phase: access
YAML

Copy the module and updated configuration into the container, then reload APISIX:

docker exec apisix-quickstart mkdir -p /usr/local/apisix/wasm
docker cp wasm-plugin-guide/guide-wasm-log.wasm apisix-quickstart:/usr/local/apisix/wasm/guide-wasm-log.wasm
docker cp wasm-plugin-guide/config.yaml.updated apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reload

If the reload fails, restore immediately before troubleshooting:

docker cp wasm-plugin-guide/config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reload

Wait for the custom plugin name to appear in the authenticated plugin list:

for attempt in {1..15}; do
  curl -sS --connect-timeout 3 --max-time 5 "${APISIX_ADMIN_URL}/apisix/admin/plugins/list" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | jq -e 'index("guide_wasm_log") != null' >/dev/null && break
  sleep 1
done
curl -sS --connect-timeout 3 --max-time 10 "${APISIX_ADMIN_URL}/apisix/admin/plugins/list" -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | jq -e 'index("guide_wasm_log") != null'

Step 4: Create an upstream and Route

Start a deterministic upstream on the quickstart network and wait for its listener:

docker run -d --name wasm-plugin-guide-upstream \
  --network apisix-quickstart-net \
  hashicorp/http-echo:1.0.0 -listen=:5678 -text=wasm-plugin-upstream
for attempt in {1..15}; do
  docker exec apisix-quickstart bash -c '</dev/tcp/wasm-plugin-guide-upstream/5678' >/dev/null 2>&1 && break
  sleep 1
done
docker exec apisix-quickstart bash -c '</dev/tcp/wasm-plugin-guide-upstream/5678'

Generate a non-secret marker and create the fixed-ID Route. jq safely inserts the marker into the JSON body:

export WASM_MARKER="wasm-guide-$(date +%s)-$$"
jq -n --arg marker "${WASM_MARKER}" '{
  uri: "/wasm-plugin-guide",
  plugins: {guide_wasm_log: {conf: $marker}},
  upstream: {type: "roundrobin", nodes: {"wasm-plugin-guide-upstream:5678": 1}}
}' | curl -sS --connect-timeout 3 --max-time 15 "${APISIX_ADMIN_URL}/apisix/admin/routes/wasm-plugin-guide-route" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @- \
  | jq -e --arg marker "${WASM_MARKER}" '.value.id == "wasm-plugin-guide-route" and .value.plugins.guide_wasm_log.conf == $marker'

Validate

Send one request, require the exact upstream body, and wait up to 10 seconds for the same unique marker in the APISIX container log:

test "$(curl --fail-with-body -sS --connect-timeout 3 --max-time 15 "${GATEWAY_URL}/wasm-plugin-guide")" = "wasm-plugin-upstream"
for attempt in {1..10}; do
  docker logs apisix-quickstart 2>&1 | grep -F "guide_wasm_log marker=${WASM_MARKER}" >/dev/null && break
  sleep 1
done
docker logs apisix-quickstart 2>&1 | grep -F "guide_wasm_log marker=${WASM_MARKER}"

Expected: the request succeeds and the final command prints a log line containing the exact marker. The response proves upstream continuity; the unique log line proves that this Wasm Plugin Context handled the request.

Roll back / Clean up

Delete the Route first. Restore the exact configuration backup and reload APISIX before removing the module and upstream:

curl -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/wasm-plugin-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
docker cp wasm-plugin-guide/config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix reload
docker exec apisix-quickstart rm -f /usr/local/apisix/wasm/guide-wasm-log.wasm
docker rm -f wasm-plugin-guide-upstream
rm -rf wasm-plugin-guide
unset APISIX_ADMIN_URL APISIX_ADMIN_KEY GATEWAY_URL ROUTE_COLLISIONS WASM_MARKER

Expected: the Route and upstream are absent, the original config.yaml is restored byte-for-byte, and the local directory containing the configuration backup is removed.

Troubleshoot

SymptomCauseFix
TinyGo rejects the build command.The installed TinyGo version lacks the wasi target or is incompatible with the pinned SDK.Check tinygo targets, then use a supported TinyGo release in a reproducible build image; do not silently change the SDK version.
apisix reload fails after adding wasm.The module is absent, invalid, or uses unsupported host calls, or the existing configuration was not the unchanged baseline.Restore the backup immediately, inspect the APISIX error log, and test the module against APISIX 3.18.0 before retrying.
guide_wasm_log never appears in the plugin list.The new worker did not load the Wasm module configuration.Confirm the absolute module path and wasm.plugins entry, then inspect reload and startup logs.
The request succeeds but no marker appears.Another Route matched, the plugin was not attached, or container logs do not receive the configured error log.Read back the fixed Route and inspect the APISIX error-log destination directly. Keep the marker non-secret.
Cleanup reload fails.The backup is missing or belongs to a different container.Keep the Wasm file in place, recover the correct pre-test configuration, and reload it before deleting artifacts.