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 && echoConfirm 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
EOFCreate main.go. The plugin reads the non-secret conf marker once for each Plugin Context and logs it when request headers arrive:
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
YAMLCopy 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 reloadIf 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 reloadWait 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_MARKERExpected: 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
| Symptom | Cause | Fix |
|---|---|---|
| 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. |