الأدلة التقنية

Apache APISIX عملياً: من أول Route إلى بوابة إنتاج

كيف تشغل Apache APISIX مع etcd، وتحمي واجهاتك بالتوكنات والحدود وauthentik، وتراقبها وتأخذ نسخة احتياطية من إعدادها.

Apache APISIX عملياً: من أول Route إلى بوابة إنتاج

في مقارنة أدوات بوابات API اخترنا Apache APISIX عندما تحتاج بوابة كاملة مفتوحة بكل إضافاتها ولوحة إدارة، واستخدمناها هناك في الوضع المستقل من ملف واحد لنقارنها بغيرها. أما في الإنتاج فالوضع المعتاد مختلف: الإعداد في etcd، وتغيره عبر Admin API أو لوحة الإدارة وهو يصل إلى كل العقد خلال لحظات، ومعه فحص صحة الخدمات، والدخول عبر مزود الهوية، والمقاييس والتتبع، والنسخ الاحتياطي. وهذا ما سوف نبنيه في هذا الدليل خطوة بعد خطوة، ونكسر كل جزء بعد أن يعمل لنرى ماذا يحدث.

دليلما هي بوابة API Gateway ومتى تحتاجها؟ما الذي تفعله بوابة API ولا يفعله ال Reverse Proxy، وما الذي يبقى في خدماتك، ومتى لا تحتاج إلى بوابة أصلاً.دليلKong أم APISIX أم Tyk أم KrakenD؟ أدوات بوابات APIما المفتوح فعلاً في كل أداة بوابة API، وكيف تختلف في حفظ الإعداد، وأرقام من تجربة واحدة على نفس الجهاز.دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.

وسوف نناقش في هذا المقال ما يلي:

  • التثبيت بـ Docker Compose: etcd وAPISIX 3.19 مع لوحة الإدارة المدمجة، والخطأ الذي يظهر إذا بدأت APISIX قبل etcd.
  • أول Upstream بفحص صحة، وأول Route، ثم نوقف إحدى النسخ لنرى فحص الصحة يعمل.
  • المستهلكون Consumers وإضافة jwt-auth، وتوكن منتهي وتوكن معدل.
  • الحد بـ limit-count وlimit-req، والإطلاق التدريجي بـ traffic-split، وCORS.
  • الدخول عبر authentik بإضافة openid-connect، لل API وللمتصفح.
  • مقاييس Prometheus والتتبع بال OpenTelemetry إلى Jaeger.
  • التوفر العالي، وماذا يحدث عندما يتوقف etcd، والنسخ الاحتياطي للإعداد.

مكونات التجربة

الصورة التالية تبين ما سوف نشغله، وكل ال Containers تبدأ أسماؤها بـ lab-gw- والمنافذ المنشورة على 127.0.0.1 فقط:

طرفية curl وعميل ومتصفح يتصلان على المنفذ 19180 بـ APISIX 3.19، وتحتها proxy :9080, admin + /ui :9180 وmetrics :9091, control :9090، وتحتها etcd مكتوب بجانبه routes, upstreams, consumers (watched live)، وشخص you, Admin API يتصل بـ APISIX. ومن APISIX أسهم خضراء إلى app-v1-a وapp-v1-b داخل مربع upstream orders-v1, health checks، وإلى app-v2 (canary). وأسهم متقطعة إلى authentik OIDC, JWKS وإلى Jaeger OTLP :4318، وPrometheus scrapes :9091
APISIX مع etcd وثلاث نسخ من الخدمة وauthentik وJaeger وPrometheus

والخدمة الخلفية Nginx صغير يرد بإصداره واسم ال Container الذي رد، وبالترويستين اللتين تضيفهما البوابة، اسم المستهلك وtraceparent، حتى نرى أثر كل إضافة في الرد نفسه، وهذا ملف app-v1.conf، وملف app-v2.conf مثله مع v2:

# A tiny backend: answers with its version, its container name and the headers the gateway added.
server {
    listen 8080;
    default_type application/json;
    location = /health { return 200 '{"status":"ok"}\n'; }
    location / {
        return 200 '{"version":"v1","node":"$hostname","path":"$uri","consumer":"$http_x_consumer_username","traceparent":"$http_traceparent"}\n';
    }
}

التثبيت بـ Docker Compose

الأسرار في ملف .env

# Lab-only secrets. Generate your own on a real server: openssl rand -hex 32
APISIX_ADMIN_KEY=lab-admin-key-7f3c9a1e5b2d4c6e8f0a
PG_PASS=lab-pg-pass-b81f2c
AUTHENTIK_SECRET_KEY=lab-authentik-secret-key-0c4e9a7d2f6b1e3a5c8d0f2b4a6c8e0d
AUTHENTIK_BOOTSTRAP_PASSWORD=Lab-Admin-Pass-2026
AUTHENTIK_BOOTSTRAP_TOKEN=lab-bootstrap-token-5d7f9b1c3e
APISIX_KEYRING=lab-keyring-9c2e4a6b8d0f1e3a5c7e

هذه القيم للتجربة فقط، وعلى السيرفر الحقيقي قم بتوليد كل واحدة منها بالأمر openssl rand -hex 32، ومفتاح التشفير APISIX_KEYRING طوله 16 أو 32 حرفاً بالضبط كما سيأتي.

ملف config.yaml

# APISIX 3.19 with etcd as the configuration store (traditional mode: proxy and Admin API in one process).
apisix:
  node_listen: 9080
  data_encryption:            # secrets in plugins (JWT secret, client_secret) are stored encrypted in etcd
    enable_encrypt_fields: true
    keyring:
      - ${{APISIX_KEYRING}}   # your own 16 or 32 characters; the example file ships a public default
  enable_control: true
  control:
    ip: 0.0.0.0
    port: 9090
deployment:
  role: traditional
  role_traditional:
    config_provider: etcd
  admin:
    enable_admin_ui: true
    admin_key_required: true
    admin_key:
      - name: admin
        role: admin
        key: ${{APISIX_ADMIN_KEY}}
    allow_admin:
      - 0.0.0.0/0          # the port is published on 127.0.0.1 only; narrow this on a real server
  etcd:
    host:
      - http://lab-gw-etcd:2379
    prefix: /apisix
    timeout: 30
plugin_attr:
  prometheus:
    export_addr:
      ip: 0.0.0.0
      port: 9091
# Only the plugins this gateway uses. Listing plugins replaces the default list,
# and opentelemetry is not in the default list, so it must be named here.
plugins:
  - request-id
  - opentelemetry
  - cors
  - openid-connect
  - jwt-auth
  - key-auth
  - proxy-rewrite
  - api-breaker
  - limit-count
  - limit-req
  - traffic-split
  - prometheus
stream_plugins: []

في الإعداد أعلاه لاحظ التالي:

  • ${{APISIX_ADMIN_KEY}} صيغة APISIX لقراءة متغير البيئة، فلا يكتب المفتاح في الملف نفسه.
  • الوضع traditional يعني أن نفس العملية تستقبل الطلبات وتقدم Admin API، وفي البيئات الكبيرة تفصلهما بوضعي data_plane وcontrol_plane كما في صفحة أوضاع النشر.
  • enable_admin_ui يفعل لوحة الإدارة المدمجة على مسار /ui في منفذ الإدارة، وهي مفعلة افتراضياً بحسب توثيق اللوحة، ولم تعد مشروعاً منفصلاً كما ذكرنا في المقارنة.
  • قائمة plugins إذا كتبتها تحل محل القائمة الافتراضية كلها، وهذا مفيد لأنك تحمل فقط ما تستخدمه، ولكن انتبه إلى أن opentelemetry ليست في القائمة الافتراضية أصلاً، فإذا لم تذكرها هنا فسوف يرفض Admin API إضافتها برسالة unknown plugin [opentelemetry]، وهذا ما حدث معنا في المحاولة الأولى.
  • data_encryption يشفر الحقول السرية للإضافات قبل حفظها في etcd، مثل سر JWT وclient_secret، وملف الإعداد المثال في المستودع يأتي بمفتاحين ثابتين معروفين للجميع، لذلك ضع مفتاحك أنت قبل أن تحفظ أي سر.

ملف compose.yaml

# Apache APISIX lab: etcd + APISIX (with its built-in dashboard) + three backends + Jaeger + Prometheus + authentik.
name: lab-gw-apisix
services:
  etcd:
    image: gcr.io/etcd-development/etcd:v3.7.2
    container_name: lab-gw-etcd
    command:
      - etcd
      - --name=etcd0
      - --data-dir=/etcd-data
      - --listen-client-urls=http://0.0.0.0:2379
      - --advertise-client-urls=http://lab-gw-etcd:2379
    volumes: [etcd-data:/etcd-data]
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 5s
      retries: 10

  apisix:
    image: apache/apisix:3.19.0-debian
    container_name: lab-gw-apisix
    env_file: .env
    volumes:
      - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
    ports:
      - "127.0.0.1:19180:9080"   # proxy (your clients)
      - "127.0.0.1:19181:9180"   # Admin API and dashboard at /ui
      - "127.0.0.1:19182:9090"   # Control API (health check status)
    restart: unless-stopped
    depends_on:
      etcd: { condition: service_healthy }

  app-v1-a: { image: nginx:1.31.6-alpine, container_name: lab-gw-app-v1-a, hostname: app-v1-a, volumes: ["./app-v1.conf:/etc/nginx/conf.d/default.conf:ro"] }
  app-v1-b: { image: nginx:1.31.6-alpine, container_name: lab-gw-app-v1-b, hostname: app-v1-b, volumes: ["./app-v1.conf:/etc/nginx/conf.d/default.conf:ro"] }
  app-v2:   { image: nginx:1.31.6-alpine, container_name: lab-gw-app-v2,   hostname: app-v2,   volumes: ["./app-v2.conf:/etc/nginx/conf.d/default.conf:ro"] }

  jaeger:
    image: cr.jaegertracing.io/jaegertracing/jaeger:2.22.0
    container_name: lab-gw-jaeger
    ports: ["127.0.0.1:19186:16686"]

  prometheus:
    image: prom/prometheus:v3.13.4
    container_name: lab-gw-prometheus
    volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml:ro"]
    ports: ["127.0.0.1:19190:9090"]

  postgresql:
    image: postgres:16-alpine
    container_name: lab-gw-ak-db
    environment: { POSTGRES_DB: authentik, POSTGRES_USER: authentik, POSTGRES_PASSWORD: "${PG_PASS}" }
    volumes: [ak-db:/var/lib/postgresql/data]
    profiles: [oidc]
  authentik:
    image: ghcr.io/goauthentik/server:2026.8.3
    container_name: lab-gw-authentik
    command: server
    environment: &ak
      AUTHENTIK_POSTGRESQL__HOST: lab-gw-ak-db
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: "${PG_PASS}"
      AUTHENTIK_SECRET_KEY: "${AUTHENTIK_SECRET_KEY}"
      AUTHENTIK_BOOTSTRAP_PASSWORD: "${AUTHENTIK_BOOTSTRAP_PASSWORD}"
      AUTHENTIK_BOOTSTRAP_TOKEN: "${AUTHENTIK_BOOTSTRAP_TOKEN}"
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
    ports: ["127.0.0.1:19195:9000"]
    depends_on: [postgresql]
    profiles: [oidc]
  authentik-worker:
    image: ghcr.io/goauthentik/server:2026.8.3
    container_name: lab-gw-authentik-worker
    command: worker
    environment: *ak
    depends_on: [postgresql]
    profiles: [oidc]

volumes:
  etcd-data:
  ak-db:

وملف prometheus.yml يقرأ المقاييس من APISIX كل خمس ثوان:

global: { scrape_interval: 5s }
scrape_configs:
  - job_name: apisix
    metrics_path: /apisix/prometheus/metrics
    static_configs: [{ targets: ["lab-gw-apisix:9091"] }]

authentik في Profile منفصل اسمه oidc لأنه أثقل المكونات، فنشغله عندما نصل إليه، وقد شرحنا تثبيته للإنتاج في دليل authentik. والآن نشغل الباقي:

docker compose up -d
curl -i http://127.0.0.1:19180/
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Server: APISIX/3.19.0

{"error_msg":"404 Route Not Found"}

البوابة تعمل ولا يوجد أي مسار بعد، فكل طلب يعطي 404.

نكسرها: APISIX قبل etcd

في النسخة الأولى من ملف compose لم يكن عندنا فحص صحة لل etcd، وكان depends_on ينتظر بدء ال Container فقط، فبدأت APISIX قبل أن يستمع etcd وتوقفت، وهذا ما ظهر في سجلها:

Warning! Request etcd endpoint 'http://lab-gw-etcd:2379/version' error, connection refused, retry time=1
Warning! Request etcd endpoint 'http://lab-gw-etcd:2379/version' error, connection refused, retry time=2
request etcd endpoint 'http://lab-gw-etcd:2379/version' error, connection refused
all etcd nodes are unavailable

والسبب أن APISIX تقرأ إعدادها من etcd عند البدء ولا تبدأ دونه، لذلك أضفنا healthcheck لل etcd وcondition: service_healthy وrestart: unless-stopped كما في الملف أعلاه.

لوحة الإدارة

افتح http://127.0.0.1:19181/ui/ في المتصفح، فتطلب منك اللوحة مفتاح الإدارة من نافذة الإعدادات، وبدونه ترى الرسالة failed to check token، لأن اللوحة نفسها تستخدم Admin API بنفس المفتاح. وبعد أن تضع المفتاح ترى كل الموارد: Routes وUpstreams وConsumers وGlobal Rules وPlugin Metadata وغيرها، والصورة التالية من تثبيتنا بعد أن أضفنا مسارات التجربة:

لوحة APISIX Dashboard وفيها قائمة جانبية Services وRoutes وStream Routes وUpstreams وConsumers وConsumer Groups وSSLs وGlobal Rules وPlugin Metadata وPlugin Configs وSecrets وProtos، وفي الوسط جدول Routes فيه أربعة مسارات orders وorders-public بالمسار /api/catalog/* وpartners-api بالمسار /api/partners/* وweb-app بالمسار /web/*، وبجانب كل منها View وDelete، والمسار orders تظهر خانة URI فيه فارغة
لوحة الإدارة المدمجة في APISIX 3.19 على منفذ الإدارة

لاحظ أن خانة URI للمسار orders فارغة في الجدول، والسبب أننا عرفناه بالحقل uris الذي يقبل عدة مسارات، واللوحة تعرض الحقل uri المفرد فقط، فلا تظن أن المسار بلا عنوان. وفي هذا الدليل سوف نستخدم Admin API بالأمر curl، لأن الأوامر تنسخ وتراجع وتحفظ في Git، واللوحة مفيدة للقراءة والتجربة السريعة.

أول Upstream وأول Route

ال Upstream هو مجموعة النسخ التي تخدم الطلب ومعها طريقة التوزيع وفحص الصحة، وال Route هو القاعدة التي تربط مساراً بال Upstream مع الإضافات. ونبدأ بمتغيرين حتى تقصر الأوامر:

K=lab-admin-key-7f3c9a1e5b2d4c6e8f0a
A=http://127.0.0.1:19181/apisix/admin

curl -s -X PUT $A/upstreams/orders-v1 -H "X-API-KEY: $K" -d '{
  "name": "orders-v1",
  "type": "roundrobin",
  "nodes": { "lab-gw-app-v1-a:8080": 1, "lab-gw-app-v1-b:8080": 1 },
  "timeout": { "connect": 2, "send": 5, "read": 5 },
  "checks": {
    "active": {
      "http_path": "/health",
      "healthy":   { "interval": 2, "successes": 2 },
      "unhealthy": { "interval": 1, "http_failures": 2, "tcp_failures": 2, "timeouts": 2 }
    },
    "passive": {
      "unhealthy": { "http_statuses": [502, 503, 504], "http_failures": 3, "tcp_failures": 2 }
    }
  }
}'

curl -s -X PUT $A/routes/orders -H "X-API-KEY: $K" -d '{
  "name": "orders",
  "uris": ["/api/orders", "/api/orders/*"],
  "methods": ["GET", "POST"],
  "upstream_id": "orders-v1"
}'

for i in 1 2 3 4; do curl -s http://127.0.0.1:19180/api/orders/7; done

والمخرج سوف يكون كما يلي:

{"version":"v1","node":"app-v1-b","path":"/api/orders/7","consumer":"","traceparent":""}
{"version":"v1","node":"app-v1-a","path":"/api/orders/7","consumer":"","traceparent":""}
{"version":"v1","node":"app-v1-a","path":"/api/orders/7","consumer":"","traceparent":""}
{"version":"v1","node":"app-v1-b","path":"/api/orders/7","consumer":"","traceparent":""}

في الإعداد أعلاه لاحظ التالي:

  • الطلبات توزعت على النسختين، وال Route أصبح فعالاً فور الأمر دون إعادة تحميل، لأن APISIX تراقب etcd.
  • الفحص النشط Active يسأل /health كل ثانية عن النسخة المريضة وكل ثانيتين عن السليمة، ويخرج النسخة بعد فشلين أو مهلتين، ويعيدها بعد نجاحين متتاليين.
  • الفحص السلبي Passive يراقب الطلبات الحقيقية، فإذا ردت النسخة بـ 502 أو 503 أو 504 ثلاث مرات أخرجها دون انتظار الفحص النشط.
  • المهلات timeout لكل Upstream، وقد شرحنا كيف تختار قيمها في المهلة Timeout.

نكسرها: نسخة معلقة

حالة الفحص تقرأها من Control API على المنفذ 19182، ولاحظ أن الفحص لا يبدأ إلا بعد أول طلب يصل إلى ال Upstream، فإذا سألت قبل ذلك لن تجد شيئاً. الآن نجمد إحدى النسختين بالأمر docker pause، فتبقى تقبل الاتصال على مستوى الشبكة ولكنها لا ترد، وهذا أقرب إلى سيرفر معلق من سيرفر متوقف:

docker pause lab-gw-app-v1-b
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1:19180/api/orders/7; done
curl -s http://127.0.0.1:19182/v1/healthcheck/upstreams/orders-v1
docker logs --since 60s lab-gw-apisix 2>&1 | grep healthcheck
200 0.010454s
200 0.013370s
200 0.046402s
200 0.033796s
200 0.049122s
200 0.018897s
lab-gw-app-v1-a healthy {'timeout_failure': 0, 'http_failure': 0, 'tcp_failure': 0, 'success': 0}
lab-gw-app-v1-b unhealthy {'timeout_failure': 2, 'http_failure': 0, 'tcp_failure': 0, 'success': 0}
[healthcheck] (upstream#/apisix/upstreams/orders-v1) unhealthy TIMEOUT increment (1/2) for '192.168.160.6(192.168.160.6:8080)'
[healthcheck] (upstream#/apisix/upstreams/orders-v1) unhealthy TIMEOUT increment (2/2) for '192.168.160.6(192.168.160.6:8080)'

اختصرنا هنا رد Control API إلى سطر لكل نسخة وحذفنا بداية سطور السجل. والصورة التالية تبين التسلسل بالوقت من السجل:

خط زمني: docker pause لـ app-v1-b، ثم 11:19:31 timeout 1/2، ثم 11:19:33 timeout 2/2 وunhealthy، ثم docker unpause، ثم 11:19:41 success 1/2، ثم 11:19:42 success 2/2 وhealthy. وتحته active check every 1 s, 2 timeouts mark it down، ومربع 12 requests while b was paused: all 200, all from app-v1-a, slowest 0.049 s
بعد مهلتين خلال ثانيتين خرجت النسخة المعلقة، وعادت بعد نجاحين متتاليين

كل الطلبات نجحت وأبطؤها 49 ملي ثانية، لأن الفحص النشط أخرج النسخة قبل أن تصلها طلباتنا. وعندما نعيدها بالأمر docker unpause lab-gw-app-v1-b يكتب السجل healthy SUCCESS increment (1/2) ثم (2/2) وتعود إلى التوزيع.

وفي محاولة سابقة استخدمنا docker stop بدل pause، فكانت النتيجة مختلفة تماماً: الطلبات نجحت ولكن أغلبها أخذ نحو ثماني ثوان:

200 8.207086s
200 2.056457s
200 8.165964s
200 8.046156s

والسبب أن ال Container المتوقف يختفي اسمه من DNS شبكة Docker، والنسخة معرفة في ال Upstream باسمها lab-gw-app-v1-b، فصارت APISIX تنتظر حل الاسم حتى تنتهي مهلته عند كل محاولة. لذلك عرف النسخ في الإنتاج بعناوين ثابتة أو عبر اكتشاف الخدمات الذي تدعمه APISIX مع Consul وغيره، ولا تعتمد على أسماء تختفي عندما تتوقف الخدمة.

المستهلكون Consumers وإضافة jwt-auth

المستهلك Consumer في APISIX هو العميل الذي يتصل بال API، مثل تطبيق الجوال أو شريك، وله بيانات دخول Credentials، وبعد المصادقة تعرف البوابة اسمه فتستخدمه في الحدود والمقاييس وترسله إلى الخدمة. نضيف مستهلكاً وبيانات JWT له، ثم نضيف إلى المسار التحقق وحداً بخمسة طلبات في الدقيقة لكل مستهلك:

curl -s -X PUT $A/consumers -H "X-API-KEY: $K" -d '{"username":"mobile_app","desc":"the mobile app"}'
curl -s -X PUT $A/consumers/mobile_app/credentials/jwt-1 -H "X-API-KEY: $K" -d '{
  "plugins": { "jwt-auth": { "key": "mobile-app-key", "secret": "mobile-app-secret-at-least-32-chars!!", "algorithm": "HS256" } }
}'
curl -s -X PATCH $A/routes/orders -H "X-API-KEY: $K" -d '{
  "plugins": {
    "jwt-auth": { "hide_credentials": true },
    "limit-count": { "count": 5, "time_window": 60, "key_type": "var", "key": "consumer_name",
                     "rejected_code": 429, "rejected_msg": "too many requests, slow down" }
  }
}'

ورد الأمر الثاني فيه السر كما حفظ:

{"value":{"plugins":{"jwt-auth":{"key":"mobile-app-key","algorithm":"HS256","secret":"ARbTyZ6ZGBohm01nHY/R6UjMlrKe9RVLZ/c/op/S6NWTBbEOJyf2OS50/rhCQxhU"}},"id":"jwt-1"},"key":"/apisix/consumers/mobile_app/credentials/jwt-1"}

والسر الذي أرسلناه لا يظهر كما هو، لأن الحقل secret من الحقول التي تشفرها APISIX قبل الحفظ في etcd بالمفتاح الذي في keyring. ولكن انتبه إلى أن طلب GET على نفس المورد من Admin API يعيد السر مفكوكاً، فمن يملك مفتاح الإدارة يملك كل الأسرار.

والتوكن نصنعه بملف jwt.py صغير، والحقل key فيه هو الذي تبحث عنه الإضافة افتراضياً لتعرف المستهلك:

# Signs an HS256 JWT for the mobile_app consumer (standard library only).
#   python jwt.py [valid|expired]
import base64, hashlib, hmac, json, sys, time

SECRET = b"mobile-app-secret-at-least-32-chars!!"
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

now = int(time.time())
exp = now - 60 if (sys.argv[1:] or ["valid"])[0] == "expired" else now + 900
payload = {"key": "mobile-app-key", "sub": "alice", "iat": now, "exp": exp}
head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + "." + b64(json.dumps(payload).encode())
print(head + "." + b64(hmac.new(SECRET, head.encode(), hashlib.sha256).digest()))
curl -i http://127.0.0.1:19180/api/orders/7
T=$(python jwt.py)
curl -i http://127.0.0.1:19180/api/orders/7 -H "Authorization: Bearer $T"
curl -i http://127.0.0.1:19180/api/orders/7 -H "Authorization: Bearer $(python jwt.py expired)"
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="jwt"
{"message":"Missing JWT token in request"}

HTTP/1.1 200 OK
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 4
X-RateLimit-Reset: 60
{"version":"v1","node":"app-v1-b","path":"/api/orders/7","consumer":"mobile_app","traceparent":""}

HTTP/1.1 401 Unauthorized
{"message":"failed to verify jwt"}

لاحظ أن الخدمة استلمت اسم المستهلك mobile_app في الترويسة X-Consumer-Username دون أن نضيف شيئاً، وأن التوكن نفسه لم يصلها لأننا فعلنا hide_credentials، وأن ترويسات الحد تخبر الكلاينت كم بقي له.

نكسرها: نغير محتوى التوكن، ثم نتجاوز الحد

نأخذ التوكن الصالح ونغير الحقل sub فيه إلى admin ونترك التوقيع كما هو، كما يفعل من يحاول رفع صلاحياته:

P=$(python -c "import base64,json,sys;t=sys.argv[1].split('.');p=json.loads(base64.urlsafe_b64decode(t[1]+'=='));p['sub']='admin';print(t[0]+'.'+base64.urlsafe_b64encode(json.dumps(p).encode()).rstrip(b'=').decode()+'.'+t[2])" $T)
curl -i http://127.0.0.1:19180/api/orders/7 -H "Authorization: Bearer $P"
HTTP/1.1 401 Unauthorized
{"message":"failed to verify jwt"}

وقد يتساءل البعض: لماذا لا نغير آخر حرف من التوقيع فقط؟ والإجابة أننا فعلنا ذلك أولاً فقبلت البوابة التوكن، والسبب ليس ثغرة في البوابة، وإنما أن آخر حرف في ترميز base64url لتوقيع طوله 32 بايت يحمل بتات زائدة لا تدخل في البايتات، فبعض التغييرات فيه تعطي نفس التوقيع بعد فك الترميز، لذلك اختبر دائماً بتغيير المحتوى وليس بتغيير الحرف الأخير.

ثم نرسل ستة طلبات أخرى بنفس التوكن خلال الدقيقة نفسها:

for i in 1 2 3 4 5 6; do curl -s -o /tmp/r -w "%{http_code}\n" http://127.0.0.1:19180/api/orders/7 -H "Authorization: Bearer $T"; done; cat /tmp/r
200
200
200
429
429
429
{"error_msg":"too many requests, slow down"}

الطلب الصالح في الخطوة السابقة والطلب الذي غيرنا آخر حرف فيه حسبا من الخمسة، أما الطلب بالمحتوى المعدل فرفض قبل أن يصل إلى الحد فلم يحسب، لذلك مرت ثلاثة فقط ثم بدأ الرفض برمز 429 ورسالتنا. ولاحظ أن العداد هنا في ذاكرة كل عقدة، فإذا شغلت ثلاث عقد فاستخدم "policy": "redis" حتى يكون العداد مشتركاً كما شرحنا في الحد على عدة سيرفرات.

الإطلاق التدريجي بـ traffic-split وCORS وlimit-req

الآن نضيف مساراً عاماً /api/catalog/* يرسل عشرة بالمئة من الطلبات إلى الإصدار الجديد v2، وكل طلب فيه الترويسة X-Canary: 1 إلى v2 دائماً، حتى يجرب فريقك الإصدار الجديد قبل الجميع:

curl -s -X PUT $A/upstreams/orders-v2 -H "X-API-KEY: $K" -d '{"name":"orders-v2","type":"roundrobin","nodes":{"lab-gw-app-v2:8080":1}}'
curl -s -X PUT $A/routes/orders-public -H "X-API-KEY: $K" -d '{
  "name": "orders-public",
  "uri": "/api/catalog/*",
  "upstream_id": "orders-v1",
  "plugins": {
    "traffic-split": {
      "rules": [
        { "match": [ { "vars": [ ["http_x_canary", "==", "1"] ] } ],
          "weighted_upstreams": [ { "upstream_id": "orders-v2", "weight": 1 } ] },
        { "weighted_upstreams": [ { "upstream_id": "orders-v2", "weight": 10 }, { "weight": 90 } ] }
      ]
    }
  }
}'
for i in $(seq 1 200); do curl -s http://127.0.0.1:19180/api/catalog/1; done | python -c "import sys,json,collections;print(collections.Counter(json.loads(l)['version'] for l in sys.stdin))"
curl -s -H "X-Canary: 1" http://127.0.0.1:19180/api/catalog/1
Counter({'v1': 182, 'v2': 18})
{"version":"v2","node":"app-v2","path":"/api/catalog/1","consumer":"","traceparent":""}

من مئتي طلب ذهب 18 إلى v2، أي 9 بالمئة وهي قريبة من العشرة لأن التوزيع بالأوزان وليس بالعد الدقيق، والعنصر الذي ليس فيه upstream_id في الأوزان يعني ال Upstream الأصلي للمسار. وعندما تطمئن للإصدار الجديد ترفع الوزن إلى 50 ثم 100 بأمر واحد، وإذا ظهرت مشكلة ترجعه إلى صفر في ثانية، وهذه هي الفكرة نفسها التي شرحناها في الإطلاق التدريجي ولكن على مستوى الحركة بدل الكود. ولاحظ أن توثيق الإضافة يذكر أن ال Upstream المعرف داخلها مباشرة لا يدعم فحص الصحة، لذلك عرفنا orders-v2 كمورد مستقل.

ثم نضيف على نفس المسار CORS لموقع واحد مسموح، وحداً بطريقة الدلو المثقوب Leaky Bucket بمعدل طلبين في الثانية ودفعة من ثلاثة:

curl -s -X PATCH $A/routes/orders-public/plugins -H "X-API-KEY: $K" -d '{
  "cors": { "allow_origins": "https://app.example.com", "allow_methods": "GET,POST", "allow_headers": "Authorization,Content-Type", "max_age": 3600 },
  "limit-req": { "rate": 2, "burst": 3, "key_type": "var", "key": "remote_addr", "rejected_code": 429 }
}'
['cors', 'limit-req']

وهنا وقعنا في خطأ يجب أن تعرفه: الرد يقول إن على المسار إضافتين فقط، أي أن traffic-split اختفت. والسبب أن PATCH على المسار الفرعي /plugins يستبدل كائن الإضافات كله، بينما PATCH على المسار نفسه مع الحقل plugins يدمج الإضافات، فأعدناها بهذا الشكل:

curl -s -X PATCH $A/routes/orders-public -H "X-API-KEY: $K" -d '{"plugins":{"traffic-split":{"rules":[{"match":[{"vars":[["http_x_canary","==","1"]]}],"weighted_upstreams":[{"upstream_id":"orders-v2","weight":1}]},{"weighted_upstreams":[{"upstream_id":"orders-v2","weight":10},{"weight":90}]}]}}}'
['cors', 'limit-req', 'traffic-split']

ولهذا السبب بالذات احفظ تعريف كل مسار كاملاً في Git وأرسله بـ PUT، بدل تعديلات صغيرة متتالية لا تعرف بعدها ما الذي على البوابة فعلاً. والآن نجرب CORS بطلب تمهيدي من الموقع المسموح ومن موقع آخر:

curl -i -X OPTIONS http://127.0.0.1:19180/api/catalog/1 -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST"
curl -i -X OPTIONS http://127.0.0.1:19180/api/catalog/1 -H "Origin: https://evil.example" -H "Access-Control-Request-Method: POST"
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Max-Age: 3600
Access-Control-Allow-Headers: Authorization,Content-Type

HTTP/1.1 200 OK

الموقع الآخر حصل على 200 أيضاً ولكن دون أي ترويسة Access-Control-Allow-*، فيمنع المتصفح الطلب الحقيقي، وهذا هو السلوك الصحيح كما شرحنا في شرح CORS. ثم نرسل عشرة طلبات في نفس اللحظة لنرى limit-req:

curl -s --parallel --parallel-immediate --parallel-max 10 -w '%{http_code}:%{time_total}\n' \
  $(for i in $(seq 10); do printf -- '-o /dev/null http://127.0.0.1:19180/api/catalog/1 '; done) | sort
200:0.012792
200:0.515082
200:1.013429
200:1.506668
429:0.006566
429:0.007809
429:0.008597
429:0.010291
429:0.011606
429:0.014000

وهذا هو الفرق بين limit-req وlimit-count: الطلب الأول مر فوراً، والثلاثة التالية دخلت الدفعة فأخرتها البوابة نصف ثانية بين كل واحد والآخر حتى يبقى المعدل طلبين في الثانية، والستة الباقية رفضت فوراً. فإذا أردت أن تنعم الحركة نحو الخدمة فاستخدم limit-req، وإذا أردت حصة بعدد في مدة فاستخدم limit-count.

والصورة التالية تلخص المسارات حتى الآن، ومعها مسار الشركاء الذي سنضيفه في الخطوة التالية:

شريط علوي global rule on every request: prometheus + opentelemetry (sampler always_on). ثم ثلاثة صفوف: /api/orders/* consumer mobile_app يمر بـ jwt-auth 401 ثم limit-count 5 / 60 s, 429 ثم إلى orders-v1. و/api/catalog/* anyone يمر بـ cors app.example.com ثم limit-req 2 r/s, burst 3 ثم traffic-split 90 / 10, X-Canary إلى v1 or v2. و/api/partners/* authentik token يمر بـ openid-connect bearer, JWKS إلى orders-v1
المسارات الثلاثة والإضافات على كل منها بالترتيب

الدخول عبر authentik بإضافة openid-connect

JWT بسر مشترك مناسب لعميل واحد تعرفه، ولكن عندما يصدر التوكنات مزود هوية مثل authentik فالأفضل أن تتحقق البوابة منها بمفاتيحه العامة دون أي سر مشترك، وهذا عمل إضافة openid-connect، وقد شرحنا تدفقات OAuth وOpenID Connect في شرح OAuth وOpenID Connect. نشغل authentik ثم ننشئ فيه مزود OAuth2 وتطبيقاً عبر واجهته البرمجية بالتوكن الأولي من ملف .env:

docker compose --profile oidc up -d
set -a; . ./.env; set +a
python ak-setup.py

وهذا ملف ak-setup.py، وهو يفعل نفس ما تفعله من لوحة authentik: مزود OAuth2 من نوع Confidential ومعه التطبيق:

# Creates the OAuth2 provider and application for APISIX in authentik through its API (bootstrap token from .env).
import json, urllib.request
import os
B = "http://127.0.0.1:19195/api/v3"; H = {"Authorization": "Bearer " + os.environ["AUTHENTIK_BOOTSTRAP_TOKEN"], "Content-Type": "application/json"}
def call(path, data=None, method=None):
    r = urllib.request.Request(B + path, data=json.dumps(data).encode() if data else None, headers=H, method=method or ("POST" if data else "GET"))
    try: return json.load(urllib.request.urlopen(r))
    except urllib.error.HTTPError as e: print(e.code, e.read()[:200]); raise
# the worker creates the bootstrap token a little after the server reports ready, so wait for it
import time
for _ in range(60):
    try: call("/core/users/me/"); break
    except Exception: time.sleep(5)
flow = lambda slug: call(f"/flows/instances/?slug={slug}")["results"][0]["pk"]
key = call("/crypto/certificatekeypairs/?name=authentik%20Self-signed%20Certificate")["results"][0]["pk"]
maps = [m["pk"] for m in call("/propertymappings/provider/scope/?page_size=50")["results"] if m["managed"] in (
    "goauthentik.io/providers/oauth2/scope-openid", "goauthentik.io/providers/oauth2/scope-profile", "goauthentik.io/providers/oauth2/scope-email")]
p = call("/providers/oauth2/", {
    "name": "apisix-lab", "client_type": "confidential", "client_id": "apisix-lab", "client_secret": "apisix-lab-client-secret-9a8b7c6d",
    "authorization_flow": flow("default-provider-authorization-implicit-consent"), "invalidation_flow": flow("default-provider-invalidation-flow"),
    "signing_key": key, "grant_types": ["authorization_code", "refresh_token", "client_credentials"], "property_mappings": maps, "access_token_validity": "minutes=5",
    "redirect_uris": [{"matching_mode": "strict", "url": "http://127.0.0.1:19180/web/callback"}]})
a = call("/core/applications/", {"name": "APISIX lab", "slug": "apisix-lab", "provider": p["pk"]})
print("provider", p["pk"], p["client_id"], "app", a["slug"])
provider 1 apisix-lab app apisix-lab

في الإعداد أعلاه لاحظ التالي:

  • المفتاح signing_key يجعل authentik يوقع التوكنات بال RS256 بمفتاح خاص، وينشر المفتاح العام في رابط JWKS، فلا تحتاج البوابة إلى أي سر للتحقق.
  • القائمة grant_types مهمة: في المحاولة الأولى لم نكتبها، فكانت فارغة في المزود الذي أنشأناه، ورد authentik على طلب client_credentials برمز 400 وinvalid_grant، وكتب في سجله Invalid grant_type for provider، فإذا أنشأت المزود عبر الواجهة البرمجية فتأكد أنها تحتوي ما تحتاجه.
  • مدة التوكن خمس دقائق فقط، حتى نرى انتهاءه في التجربة.
  • authentik يبني ال Issuer من اسم المضيف الذي وصل إليه الطلب، لذلك نطلب التوكن من داخل شبكة Docker باسم lab-gw-authentik:9000، نفس الاسم الذي تستخدمه APISIX في رابط الاكتشاف، وإلا فلن يطابق حقل iss ما تتوقعه البوابة. وفي الإنتاج يكون للاثنين نفس النطاق العام فلا تظهر هذه المشكلة.

ثم نضيف مسارين: مسار API للشركاء يقبل فقط توكن Bearer من authentik، ومسار ويب يحول المتصفح إلى صفحة الدخول:

curl -s -X PUT $A/routes/partners -H "X-API-KEY: $K" -d '{
  "name": "partners-api",
  "uri": "/api/partners/*",
  "upstream_id": "orders-v1",
  "plugins": {
    "openid-connect": {
      "client_id": "apisix-lab",
      "client_secret": "apisix-lab-client-secret-9a8b7c6d",
      "discovery": "http://lab-gw-authentik:9000/application/o/apisix-lab/.well-known/openid-configuration",
      "bearer_only": true,
      "use_jwks": true,
      "ssl_verify": false,
      "claim_validator": { "audience": { "required": true, "match_with_client_id": true } }
    }
  }
}'
curl -s -X PUT $A/routes/web -H "X-API-KEY: $K" -d '{
  "name": "web-app",
  "uri": "/web/*",
  "upstream_id": "orders-v1",
  "plugins": {
    "openid-connect": {
      "client_id": "apisix-lab",
      "client_secret": "apisix-lab-client-secret-9a8b7c6d",
      "discovery": "http://lab-gw-authentik:9000/application/o/apisix-lab/.well-known/openid-configuration",
      "redirect_uri": "http://127.0.0.1:19180/web/callback",
      "scope": "openid profile email",
      "ssl_verify": false,
      "session": { "secret": "lab-session-secret-at-least-16" }
    }
  }
}'

في الإعداد أعلاه لاحظ التالي:

  • bearer_only يعني أن المسار لا يحول أحداً إلى صفحة الدخول، وإنما يطلب توكناً في الترويسة أو يرد بـ 401.
  • use_jwks يجعل البوابة تتحقق من التوقيع بالمفاتيح العامة محلياً، بدل أن تسأل authentik عن كل توكن عبر رابط الفحص Introspection، فلا يصبح authentik في طريق كل طلب.
  • claim_validator.audience يرفض توكناً صدر لتطبيق آخر في نفس authentik، وهذا الفحص غير مفعل افتراضياً.
  • ssl_verify: false لأن authentik هنا على HTTP داخل الشبكة، والقيمة الافتراضية أصبحت true منذ 3.16 كما يذكر التوثيق، فاتركها كذلك في الإنتاج مع HTTPS.

والآن نطلب توكناً بطريقة client_credentials كما يفعل سيرفر الشريك، ونجرب المسار:

AT=$(docker run --rm --network lab-gw-apisix_default curlimages/curl:8.11.1 -s -X POST http://lab-gw-authentik:9000/application/o/token/ \
  -d grant_type=client_credentials -d client_id=apisix-lab -d client_secret=apisix-lab-client-secret-9a8b7c6d -d scope=openid \
  | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl -i http://127.0.0.1:19180/api/partners/1
curl -i http://127.0.0.1:19180/api/partners/1 -H "Authorization: Bearer $AT"
curl -i http://127.0.0.1:19180/api/partners/1 -H "Authorization: Bearer $(python jwt.py)"
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="apisix"

HTTP/1.1 200 OK
{"version":"v1","node":"app-v1-a","path":"/api/partners/1","consumer":"","traceparent":"00-fa825ef74c8bcd1612d5ce58f8c8dbad-f8cf0c66885280ff-01"}

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="apisix", error="invalid_token", error_description="jwt signature verification failed: 'iss' claim is required."

والتوكن الذي أصدره authentik فيه هذه الحقول بعد فك ترميزه، ولاحظ aud الذي يطابق client_id، وexp بعد خمس دقائق من iat:

{'alg': 'RS256', 'kid': 'F8VMZoLzRO-i_yhEF4BckNtuZsgzOQ1sMq-WaOgwM9zztAYA4dFZEYpKzown_TmE1RKDbGaR_uyuhSqL0W3j2w', 'typ': 'JWT'}
{'iss': 'http://lab-gw-authentik:9000/application/o/apisix-lab/', 'sub': 'c34cd93aefc591a4f8ec7b6c33c80b89735694987abce80ea4318b0b6e1d18e1', 'aud': 'apisix-lab', 'exp': 1791718746, 'iat': 1791718446, 'auth_time': 1791718446, 'acr': 'goauthentik.io/providers/oauth2/default', 'jti': 'q0Y1Ib1nkW3iuCbxMXLhCNs7xGKlLbzTEQdyuiPd', 'azp': 'apisix-lab', 'uid': '3VkuvxTFyYhRvGDCffc3SyID5hEZBQXG6t9PNOuw', 'scope': 'openid'}

ثم ننتظر حتى تمر خمس دقائق ونرسل نفس التوكن مرة أخرى:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="apisix", error="invalid_token", error_description="JWT expired"

وأما مسار الويب فالمتصفح الذي ليس معه جلسة يتحول إلى authentik:

curl -i http://127.0.0.1:19180/web/home
HTTP/1.1 302 Moved Temporarily
Set-Cookie: session=AQAA2LW77TZHee31pOIgXKJIajlvhI6NZkziAT2T6W-VEa1HdMtqAAAAAACTAQBUsQSbZmPm-B0ruLgZyv98...
Location: http://lab-gw-authentik:9000/application/o/authorize/?scope=openid%20profile%20email&client_id=apisix-lab&response_type=code&state=3dafa3b784070110178d1572929abbd7&redirect_uri=http%3A%2F%2F127.0.0.1%3A19180%2Fweb%2Fcallback

والصورة التالية تبين الطريقتين:

لوحتان. API CLIENT: BEARER TOKEN: partner app يطلب من authentik بال client_credentials (1) ويعود بتوكن RS256, exp 5 min، ثم يرسله إلى APISIX (2)، وAPISIX تقرأ public keys (JWKS) من authentik، ثم ترسل الطلب إلى الخدمة (3) بعد signature, exp, aud checked. BROWSER: LOGIN AND SESSION: GET /web/home من المتصفح إلى APISIX (1)، ثم 302 to /authorize + session cookie (2)، ثم user logs in, back to /web/callback?code= عبر authentik (3)، ثم APISIX swaps the code for tokens and keeps them in its session (4)
توكن Bearer للأنظمة، وتحويل إلى صفحة الدخول مع جلسة للمتصفح

في التحويل لاحظ أن عنوان authentik هو الاسم الداخلي lab-gw-authentik الذي لا يعرفه متصفحك، لأن الإضافة تأخذ رابط صفحة الدخول من ملف الاكتشاف، ولذلك يجب أن يكون رابط الاكتشاف في الإنتاج هو النطاق العام لمزود الهوية، وليس الاسم الداخلي. والجلسة هنا في ملف تعريف ارتباط مشفر بال session.secret، فإذا شغلت عدة عقد فيجب أن يكون السر نفسه في كلها، وإلا فالمستخدم الذي يصل إلى عقدة أخرى يخرج من الجلسة.

المقاييس والتتبع

نضيف قاعدة عامة Global Rule تعمل على كل الطلبات في كل المسارات، فيها Prometheus وOpenTelemetry، ونخبر إضافة OpenTelemetry بعنوان Jaeger عبر Plugin Metadata:

curl -s -X PUT $A/plugin_metadata/opentelemetry -H "X-API-KEY: $K" -d '{
  "resource": { "service.name": "apisix-gateway" },
  "collector": { "address": "lab-gw-jaeger:4318", "request_timeout": 3 },
  "batch_span_processor": { "batch_timeout": 1, "inactive_timeout": 1 }
}'
curl -s -X PUT $A/global_rules/observability -H "X-API-KEY: $K" -d '{
  "plugins": { "prometheus": { "prefer_name": true }, "opentelemetry": { "sampler": { "name": "always_on" } } }
}'

وقد يتساءل البعض: لماذا لم نضع عنوان Jaeger في config.yaml تحت plugin_attr كما فعلنا مع Prometheus؟ والإجابة أننا فعلنا ذلك في المحاولة الأولى ولم يصل أي Span إلى Jaeger ولم تظهر الترويسة traceparent في الخدمة، وبعد أن أرسلنا نفس القيم إلى plugin_metadata/opentelemetry وصلت كلها، وهذا ما يصفه توثيق الإضافة في الإصدار الحالي. وبعد إرسال بعض الطلبات نقرأ المقاييس:

docker exec lab-gw-prometheus wget -qO- http://lab-gw-apisix:9091/apisix/prometheus/metrics | grep '^apisix_http_status'
apisix_http_status{code="200",route="orders",matched_uri="/api/orders/*",matched_host="",service="",consumer="mobile_app",node="192.168.160.6",request_type="traditional_http",request_llm_model="",llm_model="",response_source="upstream"} 1
apisix_http_status{code="200",route="orders-public",matched_uri="/api/catalog/*",matched_host="",service="",consumer="",node="192.168.160.2",request_type="traditional_http",request_llm_model="",llm_model="",response_source="upstream"} 16
apisix_http_status{code="200",route="orders-public",matched_uri="/api/catalog/*",matched_host="",service="",consumer="",node="192.168.160.6",request_type="traditional_http",request_llm_model="",llm_model="",response_source="upstream"} 14
apisix_http_status{code="401",route="orders",matched_uri="/api/orders/*",matched_host="",service="",consumer="",node="",request_type="traditional_http",request_llm_model="",llm_model="",response_source="apisix"} 1

لاحظ أن كل مقياس فيه المسار باسمه لأننا فعلنا prefer_name، والمستهلك، والنسخة التي ردت، وresponse_source الذي يفرق بين رد الخدمة ورد البوابة نفسها مثل 401، وهذا يجيب عن سؤال «هل الأخطاء من خدمتي أم من البوابة؟» في استعلام واحد. ومن Prometheus نسأل عن العدد بحسب المسار والرمز، وعن p99 لكل مسار:

sum by (route, code) (apisix_http_status)
{'code': '200', 'route': 'orders'} 3
{'code': '200', 'route': 'orders-public'} 30
{'code': '401', 'route': 'orders'} 1

histogram_quantile(0.99, sum by (le, route) (rate(apisix_http_latency_bucket{type="request"}[5m])))
p99 ms {'route': 'orders'} 4.97
p99 ms {'route': 'orders-public'} 197.83333333333334

وقيمة p99 العالية في orders-public ليست بطئاً في الخدمة، وإنما هي التأخير الذي أضافته limit-req عمداً للطلبات في الدفعة. وأما التتبع فالطلب التالي يصل إلى الخدمة ومعه traceparent:

{"version":"v1","node":"app-v1-a","path":"/api/orders/42","consumer":"mobile_app","traceparent":"00-49188bc63775f8d42fca6ce42f924c10-f1394e1dea55fa1c-01"}

ونفس معرف ال Trace نجده في Jaeger عبر واجهته البرمجية على المنفذ 19186:

49188bc63775f8d42fca6ce42f924c10 GET /api/orders/* 3186 us {'apisix.route_id': 'orders', 'apisix.route_name': 'orders', 'http.method': 'GET', 'http.status_code': '200', 'http.target': '/api/orders/42'}

فإذا كانت خدماتك ترسل Spans بال OpenTelemetry كما في التتبع الموزع فسوف تكمل نفس ال Trace الذي بدأته البوابة، وترى في Jaeger كم أخذت البوابة وكم أخذت كل خدمة. وفي الإنتاج لا تجعل العينة always_on لكل الطلبات، وإنما نسبة صغيرة بـ trace_id_ratio.

التوفر العالي، وماذا يحدث عندما يتوقف etcd؟

عقد APISIX لا تحفظ حالة خاصة بها، فكل عقدة تقرأ نفس الإعداد من etcd، وبالتالي تضيف عقدة بتشغيل Container آخر بنفس config.yaml، وتضع أمام العقد موزع حمل أو عنواناً عائماً كما في Keepalived. أما etcd فهو الذي يحتاج إلى عناية، فتشغله بثلاث عقد حتى يتحمل توقف واحدة، وقد شرحنا لماذا يحتاج إلى أغلبية في اكتشاف الخدمات، وتكتب عناوين العقد الثلاث في deployment.etcd.host. والسؤال المهم: ماذا يحدث للطلبات إذا توقف etcd كله؟

docker stop lab-gw-etcd
for i in 1 2 3; do curl -s -o /dev/null -w '%{http_code} ' http://127.0.0.1:19180/api/catalog/1; done; echo
curl -s -w ' %{http_code}\n' -X PUT $A/routes/test -H "X-API-KEY: $K" -d '{"uri":"/test","upstream_id":"orders-v1"}'
docker logs --since 10s lab-gw-apisix 2>&1 | grep etcd | tail -1
200 200 200
{"error_msg":"failed to fetch upstream info by upstream id [orders-v1]: has no healthy etcd endpoint available"} 400
[lua] config_etcd.lua:209: watchdir err: has no healthy etcd endpoint available, context: ngx.timer

الطلبات استمرت تنجح لأن كل عقدة تحمل نسخة من الإعداد في ذاكرتها، أما أي تغيير في الإعداد فرفض، وبعد docker start lab-gw-etcd قبل نفس الأمر برمز 201. وهذا يعني أن توقف etcd لا يوقف الحركة، ولكنه يمنعك من إلغاء مفتاح مسروق أو إخراج خدمة معطلة يدوياً حتى يعود، ولاحظ أن عقدة APISIX جديدة أو عقدة أعيد تشغيلها خلال التوقف لن تبدأ أصلاً كما رأينا في بداية الدليل.

النسخ الاحتياطي للإعداد

كل إعداد البوابة في etcd، لذلك نأخذ نسختين: لقطة Snapshot كاملة من etcd للاستعادة السريعة، وملفات JSON مقروءة لكل نوع من الموارد تحفظها في Git وتقارن بها، وهذا ملف backup.sh:

#!/bin/sh
# Two backups of the APISIX configuration:
#  1) an etcd snapshot (everything, restore with etcdutl snapshot restore into a new data dir)
#  2) readable JSON per resource from the Admin API, kept in Git next to your code
set -e
KEY=${APISIX_ADMIN_KEY:?set APISIX_ADMIN_KEY}
ADMIN=http://127.0.0.1:19181/apisix/admin
DIR=backup-$(date +%Y%m%d-%H%M)
mkdir -p $DIR
docker exec lab-gw-etcd etcdctl snapshot save /etcd-data/snap.db >/dev/null 2>&1
docker cp lab-gw-etcd:/etcd-data/snap.db $DIR/etcd-snapshot.db
for r in routes upstreams consumers global_rules plugin_metadata/opentelemetry; do
  curl -sf "$ADMIN/$r" -H "X-API-KEY: $KEY" > "$DIR/$(echo $r | tr / _).json"
done
# credentials are a sub-resource of each consumer, the consumers list does not include them
for u in $(grep -o '"username":"[^"]*"' $DIR/consumers.json | cut -d'"' -f4); do
  curl -sf "$ADMIN/consumers/$u/credentials" -H "X-API-KEY: $KEY" > "$DIR/credentials_$u.json"
done
chmod 600 $DIR/*      # these files hold secrets (client_secret, JWT secrets)
ls -l $DIR
-rw-r--r-- 1 wajdy 197609   205 Oct 11 14:35 consumers.json
-rw-r--r-- 1 wajdy 197609   305 Oct 11 14:35 credentials_mobile_app.json
-rw-r--r-- 1 wajdy 197609 40992 Oct 11 14:35 etcd-snapshot.db
-rw-r--r-- 1 wajdy 197609   279 Oct 11 14:35 global_rules.json
-rw-r--r-- 1 wajdy 197609   290 Oct 11 14:35 plugin_metadata_opentelemetry.json
-rw-r--r-- 1 wajdy 197609  2275 Oct 11 14:35 routes.json
-rw-r--r-- 1 wajdy 197609   787 Oct 11 14:35 upstreams.json

في الإعداد أعلاه لاحظ التالي:

  • في النسخة الأولى من السكربت لم يكن فيه سطر بيانات الدخول، فلاحظنا أن consumers.json لا يحتوي سر JWT أصلاً، لأن بيانات الدخول مورد فرعي لكل مستهلك، ونسخة احتياطية دون هذه البيانات تعطيك بعد الاستعادة مستهلكين لا يستطيع أحد منهم الدخول.
  • ملفات JSON فيها client_secret وسر JWT مقروءين، لأن Admin API يفك تشفيرهما عند القراءة، فعاملها كملف أسرار، ولا ترفعها إلى Git كما هي، وإنما استبدل الأسرار فيها بمراجع إلى مدير أسرار، فتوثيق jwt-auth مثلاً يذكر أن APISIX تقرأ الأسرار من HashiCorp Vault عبر مورد Secret.
  • الأمر chmod 600 لا أثر له على Windows كما يظهر في المخرج أعلاه، فعلى السيرفر الحقيقي سوف تجد الصلاحيات -rw-------.
  • اللقطة تستعيدها بالأمر etcdutl snapshot restore إلى مجلد بيانات جديد ثم تشغل etcd عليه، ومفتاح APISIX_KEYRING يجب أن يكون نفسه، وإلا فلن تستطيع APISIX فك الأسرار المشفرة في اللقطة، لذلك احفظه مع النسخة الاحتياطية في مكان آمن آخر.

وهذه حالة اللقطة كما يقرؤها etcdutl:

docker exec lab-gw-etcd etcdutl snapshot status /etcd-data/snap.db -w table
┌──────────┬──────────┬────────────┬────────────┬─────────┐
│   HASH   │ REVISION │ TOTAL KEYS │ TOTAL SIZE │ VERSION │
├──────────┼──────────┼────────────┼────────────┼─────────┤
│ 9cecd86b │       53 │         23 │      41 kB │   3.7.0 │
└──────────┴──────────┴────────────┴────────────┴─────────┘

وكل التجربة من البداية في ملف steps.sh في مجلد التجربة، فيه كل أوامر Admin API بالترتيب، وقد حذفنا كل شيء بما فيه ال Volumes ثم أعدنا بناءه به من الصفر وتحققنا من مسار JWT ومسار authentik، وهذه هي الطريقة التي ننصحك بها للإنتاج أيضاً: الإعداد ملف في Git يعيد بناء البوابة كاملة.

قائمة أمان قبل الإنتاج

  • منفذ الإدارة 9180 ومنفذ التحكم 9090 ومنفذ المقاييس 9091 لا تنشر على الإنترنت، وallow_admin فيه عناوين شبكة الإدارة فقط بدل 0.0.0.0/0 الذي استخدمناه في التجربة.
  • مفتاح الإدارة طويل وعشوائي ومن متغير بيئة، ومفتاحك الخاص في keyring قبل حفظ أي سر.
  • قائمة plugins فيها فقط ما تستخدمه.
  • الرمز 429 للطلبات الزائدة بدل 503 الافتراضي، وعداد Redis إذا كان عندك أكثر من عقدة.
  • كل مسار فيه مصادقة إلا ما قررت أن يكون عاماً، وتوكن منتهي وتوكن لتطبيق آخر في اختباراتك.
  • HTTPS أمام البوابة أو فيها، وssl_verify مفعل مع مزود الهوية.
  • العينة في التتبع نسبة صغيرة، ونسخة احتياطية يومية تختبر استعادتها.

إزالة التجربة

docker compose --profile oidc down -v

هذا الأمر يحذف ال Containers العشرة وشبكة المشروع lab-gw-apisix_default وال Volumes الخاصة بالمشروع فقط، وهما lab-gw-apisix_etcd-data وlab-gw-apisix_ak-db.

الخلاصة

  • APISIX في الإنتاج تحفظ إعدادها في etcd، وكل تغيير عبر Admin API يصل إلى العقد دون إعادة تحميل، ولوحة الإدارة مدمجة على /ui منذ 3.13.
  • فحص الصحة النشط أخرج النسخة المعلقة بعد مهلتين خلال ثانيتين دون أن يفشل طلب واحد، ولكن تعريف النسخ بأسماء تختفي من DNS جعل كل طلب ينتظر ثماني ثوان.
  • jwt-auth رفضت التوكن المنتهي والتوكن المعدل، وopenid-connect تحققت من توكن authentik بمفاتيحه العامة ومن ال audience، ورفضته بعد انتهائه.
  • PATCH على /plugins يستبدل كل الإضافات، وopentelemetry تحتاج أن تكون في قائمة plugins وأن يكون عنوان ال Collector في Plugin Metadata.
  • توقف etcd لا يوقف الحركة ولكنه يجمد الإعداد، والنسخة الاحتياطية تحتاج بيانات دخول المستهلكين ومفتاح التشفير معها.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل، والتجربة على Docker Engine 29.8.0 مع apache/apisix:3.19.0-debian وetcd v3.7.2 وauthentik 2026.8.3 وJaeger 2.22.0 وPrometheus v3.13.4 وnginx:1.31.6-alpine.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.