> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Apache APISIX عملياً: من أول Route إلى بوابة إنتاج
- URL: https://arabroot.io/articles/apache-apisix-عمليا-من-اول-route-الى-بوابة-انتاج/
- Published: 2026-10-12T13:00:00.000Z
- Updated: 2026-10-11T16:37:02.000Z
- Description: كيف تشغل Apache APISIX مع etcd، وتحمي واجهاتك بالتوكنات والحدود وauthentik، وتراقبها وتأخذ نسخة احتياطية من إعدادها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps والتشغيل, الاستضافة الذاتية

في [مقارنة أدوات بوابات API](https://arabroot.io/articles/kong-%D8%A7%D9%85-apisix-%D8%A7%D9%85-tyk-%D8%A7%D9%85-krakend-%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A8%D9%88%D8%A7%D8%A8%D8%A7%D8%AA-api/) اخترنا Apache APISIX عندما تحتاج بوابة كاملة مفتوحة بكل إضافاتها ولوحة إدارة، واستخدمناها هناك في الوضع المستقل من ملف واحد لنقارنها بغيرها. أما في الإنتاج فالوضع المعتاد مختلف: الإعداد في etcd، وتغيره عبر Admin API أو لوحة الإدارة وهو يصل إلى كل العقد خلال لحظات، ومعه فحص صحة الخدمات، والدخول عبر مزود الهوية، والمقاييس والتتبع، والنسخ الاحتياطي. وهذا ما سوف نبنيه في هذا الدليل خطوة بعد خطوة، ونكسر كل جزء بعد أن يعمل لنرى ماذا يحدث.

[![](https://arabroot.io/content/images/2026/10/api-gateway-concepts-cover-cover.webp)دليلما هي بوابة API Gateway ومتى تحتاجها؟ما الذي تفعله بوابة API ولا يفعله ال Reverse Proxy، وما الذي يبقى في خدماتك، ومتى لا تحتاج إلى بوابة أصلاً.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%8A-%D8%A8%D9%88%D8%A7%D8%A8%D8%A9-api-gateway-%D9%88%D9%85%D8%AA%D9%89-%D8%AA%D8%AD%D8%AA%D8%A7%D8%AC%D9%87%D8%A7/)[![](https://arabroot.io/content/images/2026/10/api-gateway-tools-cover-cover.webp)دليلKong أم APISIX أم Tyk أم KrakenD؟ أدوات بوابات APIما المفتوح فعلاً في كل أداة بوابة API، وكيف تختلف في حفظ الإعداد، وأرقام من تجربة واحدة على نفس الجهاز.](https://arabroot.io/articles/kong-%D8%A7%D9%85-apisix-%D8%A7%D9%85-tyk-%D8%A7%D9%85-krakend-%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A8%D9%88%D8%A7%D8%A8%D8%A7%D8%AA-api/)[![](https://arabroot.io/content/images/2026/10/authentik-cover-cover-1.webp)دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-authentik-%D9%84%D9%84%D8%AF%D8%AE%D9%88%D9%84-%D8%A7%D9%84%D9%85%D9%88%D8%AD%D8%AF/)

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

- التثبيت بـ 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](https://arabroot.io/content/images/2026/10/apisix-practice-01-lab.webp)

APISIX مع etcd وثلاث نسخ من الخدمة وauthentik وJaeger وPrometheus

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

```nginx
# 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

```ini
# 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

```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` كما في [صفحة أوضاع النشر](https://apisix.apache.org/docs/apisix/deployment-modes/?ref=arabroot.io).
- `enable_admin_ui` يفعل لوحة الإدارة المدمجة على مسار `/ui` في منفذ الإدارة، وهي مفعلة افتراضياً بحسب [توثيق اللوحة](https://apisix.apache.org/docs/apisix/dashboard/?ref=arabroot.io)، ولم تعد مشروعاً منفصلاً كما ذكرنا في المقارنة.
- قائمة `plugins` إذا كتبتها تحل محل القائمة الافتراضية كلها، وهذا مفيد لأنك تحمل فقط ما تستخدمه، ولكن انتبه إلى أن opentelemetry ليست في القائمة الافتراضية أصلاً، فإذا لم تذكرها هنا فسوف يرفض Admin API إضافتها برسالة `unknown plugin [opentelemetry]`، وهذا ما حدث معنا في المحاولة الأولى.
- `data_encryption` يشفر الحقول السرية للإضافات قبل حفظها في etcd، مثل سر JWT و`client_secret`، وملف الإعداد المثال في المستودع يأتي بمفتاحين ثابتين معروفين للجميع، لذلك ضع مفتاحك أنت قبل أن تحفظ أي سر.

### ملف compose.yaml

```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 كل خمس ثوان:

```yaml
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](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-authentik-%D9%84%D9%84%D8%AF%D8%AE%D9%88%D9%84-%D8%A7%D9%84%D9%85%D9%88%D8%AD%D8%AF/). والآن نشغل الباقي:

```bash
docker compose up -d
curl -i http://127.0.0.1:19180/
```

```text
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 وتوقفت، وهذا ما ظهر في سجلها:

```text
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 فيه فارغة](https://arabroot.io/content/images/2026/10/apisix-practice-dashboard-routes.webp)

لوحة الإدارة المدمجة في APISIX 3.19 على منفذ الإدارة

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

## أول Upstream وأول Route

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

```bash
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
```

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

```text
{"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](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D9%85%D9%86%D8%B9-%D8%AE%D8%AF%D9%85%D8%A9-%D8%A8%D8%B7%D9%8A%D8%A6%D8%A9-%D9%85%D9%86-%D8%A7%D8%B3%D9%82%D8%A7%D8%B7-%D9%86%D8%B8%D8%A7%D9%85%D9%83-%D9%83%D9%84%D9%87/#timeouts).

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

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

```bash
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
```

```text
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](https://arabroot.io/content/images/2026/10/apisix-practice-03-healthcheck.webp)

بعد مهلتين خلال ثانيتين خرجت النسخة المعلقة، وعادت بعد نجاحين متتاليين

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

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

```text
200 8.207086s
200 2.056457s
200 8.165964s
200 8.046156s
```

والسبب أن ال Container المتوقف يختفي اسمه من DNS شبكة Docker، والنسخة معرفة في ال Upstream باسمها `lab-gw-app-v1-b`، فصارت APISIX تنتظر حل الاسم حتى تنتهي مهلته عند كل محاولة. لذلك عرف النسخ في الإنتاج بعناوين ثابتة أو عبر [اكتشاف الخدمات](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-%D9%88%D8%A7%D8%B9%D8%AF%D8%A7%D8%AF%D8%A7%D8%AA%D9%87%D8%A7-%D9%85%D8%B9-consul-%D9%88-etcd/) الذي تدعمه APISIX مع Consul وغيره، ولا تعتمد على أسماء تختفي عندما تتوقف الخدمة.

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

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

```bash
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" }
  }
}'
```

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

```text
{"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` فيه هو الذي تبحث عنه الإضافة افتراضياً لتعرف المستهلك:

```python
# 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()))
```

```bash
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)"
```

```text
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` ونترك التوقيع كما هو، كما يفعل من يحاول رفع صلاحياته:

```bash
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"
```

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

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

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

```bash
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
```

```text
200
200
200
429
429
429
{"error_msg":"too many requests, slow down"}
```

الطلب الصالح في الخطوة السابقة والطلب الذي غيرنا آخر حرف فيه حسبا من الخمسة، أما الطلب بالمحتوى المعدل فرفض قبل أن يصل إلى الحد فلم يحسب، لذلك مرت ثلاثة فقط ثم بدأ الرفض برمز 429 ورسالتنا. ولاحظ أن العداد هنا في ذاكرة كل عقدة، فإذا شغلت ثلاث عقد فاستخدم `"policy": "redis"` حتى يكون العداد مشتركاً كما شرحنا في [الحد على عدة سيرفرات](https://arabroot.io/articles/%D8%AA%D8%AD%D8%AF%D9%8A%D8%AF-%D9%85%D8%B9%D8%AF%D9%84-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-rate-limiting-%D9%88%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA%D9%87/#distributed).

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

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

```bash
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
```

```text
Counter({'v1': 182, 'v2': 18})
{"version":"v2","node":"app-v2","path":"/api/catalog/1","consumer":"","traceparent":""}
```

من مئتي طلب ذهب 18 إلى v2، أي 9 بالمئة وهي قريبة من العشرة لأن التوزيع بالأوزان وليس بالعد الدقيق، والعنصر الذي ليس فيه `upstream_id` في الأوزان يعني ال Upstream الأصلي للمسار. وعندما تطمئن للإصدار الجديد ترفع الوزن إلى 50 ثم 100 بأمر واحد، وإذا ظهرت مشكلة ترجعه إلى صفر في ثانية، وهذه هي الفكرة نفسها التي شرحناها في [الإطلاق التدريجي](https://arabroot.io/articles/%D9%85%D9%81%D9%87%D9%88%D9%85-feature-flags-%D9%88%D8%A7%D9%84%D8%A7%D8%B7%D9%84%D8%A7%D9%82-%D8%A7%D9%84%D8%AA%D8%AF%D8%B1%D9%8A%D8%AC%D9%8A-gradual-rollout/) ولكن على مستوى الحركة بدل الكود. ولاحظ أن [توثيق الإضافة](https://apisix.apache.org/docs/apisix/plugins/traffic-split/?ref=arabroot.io) يذكر أن ال Upstream المعرف داخلها مباشرة لا يدعم فحص الصحة، لذلك عرفنا orders-v2 كمورد مستقل.

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

```bash
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 }
}'
```

```text
['cors', 'limit-req']
```

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

```bash
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}]}]}}}'
```

```text
['cors', 'limit-req', 'traffic-split']
```

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

```bash
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"
```

```text
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](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/). ثم نرسل عشرة طلبات في نفس اللحظة لنرى limit-req:

```bash
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
```

```text
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](https://arabroot.io/content/images/2026/10/apisix-practice-02-routes.webp)

المسارات الثلاثة والإضافات على كل منها بالترتيب

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

JWT بسر مشترك مناسب لعميل واحد تعرفه، ولكن عندما يصدر التوكنات مزود هوية مثل authentik فالأفضل أن تتحقق البوابة منها بمفاتيحه العامة دون أي سر مشترك، وهذا عمل إضافة [openid-connect](https://apisix.apache.org/docs/apisix/plugins/openid-connect/?ref=arabroot.io)، وقد شرحنا تدفقات OAuth وOpenID Connect في [شرح OAuth وOpenID Connect](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/). نشغل authentik ثم ننشئ فيه مزود OAuth2 وتطبيقاً عبر واجهته البرمجية بالتوكن الأولي من ملف `.env`:

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

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

```python
# 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"])
```

```text
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، ومسار ويب يحول المتصفح إلى صفحة الدخول:

```bash
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 كما يفعل سيرفر الشريك، ونجرب المسار:

```bash
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)"
```

```text
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`:

```text
{'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'}
```

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

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

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

```bash
curl -i http://127.0.0.1:19180/web/home
```

```text
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)](https://arabroot.io/content/images/2026/10/apisix-practice-04-oidc.webp)

توكن Bearer للأنظمة، وتحويل إلى صفحة الدخول مع جلسة للمتصفح

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

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

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

```bash
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` وصلت كلها، وهذا ما يصفه [توثيق الإضافة](https://apisix.apache.org/docs/apisix/plugins/opentelemetry/?ref=arabroot.io) في الإصدار الحالي. وبعد إرسال بعض الطلبات نقرأ المقاييس:

```bash
docker exec lab-gw-prometheus wget -qO- http://lab-gw-apisix:9091/apisix/prometheus/metrics | grep '^apisix_http_status'
```

```text
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 لكل مسار:

```text
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`:

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

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

```text
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 كما في [التتبع الموزع](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9-distributed-tracing-%D9%85%D8%B9-opentelemetry/) فسوف تكمل نفس ال Trace الذي بدأته البوابة، وترى في Jaeger كم أخذت البوابة وكم أخذت كل خدمة. وفي الإنتاج لا تجعل العينة `always_on` لكل الطلبات، وإنما نسبة صغيرة بـ `trace_id_ratio`.

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

عقد APISIX لا تحفظ حالة خاصة بها، فكل عقدة تقرأ نفس الإعداد من etcd، وبالتالي تضيف عقدة بتشغيل Container آخر بنفس `config.yaml`، وتضع أمام العقد موزع حمل أو عنواناً عائماً كما في [Keepalived](https://arabroot.io/articles/%D8%B9%D9%86%D9%88%D8%A7%D9%86-ip-%D8%B9%D8%A7%D8%A6%D9%85-%D8%A8%D9%8A%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%8A%D9%86-%D9%85%D8%B9-keepalived-%D9%88-vrrp/). أما etcd فهو الذي يحتاج إلى عناية، فتشغله بثلاث عقد حتى يتحمل توقف واحدة، وقد شرحنا لماذا يحتاج إلى أغلبية في [اكتشاف الخدمات](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-%D9%88%D8%A7%D8%B9%D8%AF%D8%A7%D8%AF%D8%A7%D8%AA%D9%87%D8%A7-%D9%85%D8%B9-consul-%D9%88-etcd/#why-consensus)، وتكتب عناوين العقد الثلاث في `deployment.etcd.host`. والسؤال المهم: ماذا يحدث للطلبات إذا توقف etcd كله؟

```bash
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
```

```text
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 جديدة أو عقدة أعيد تشغيلها خلال التوقف لن تبدأ أصلاً كما رأينا في [بداية الدليل](#break-etcd-start).

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

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

```bash
#!/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
```

```text
-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](https://apisix.apache.org/docs/apisix/plugins/jwt-auth/?ref=arabroot.io) مثلاً يذكر أن APISIX تقرأ الأسرار من HashiCorp Vault عبر مورد Secret.
- الأمر `chmod 600` لا أثر له على Windows كما يظهر في المخرج أعلاه، فعلى السيرفر الحقيقي سوف تجد الصلاحيات `-rw-------`.
- اللقطة تستعيدها بالأمر `etcdutl snapshot restore` إلى مجلد بيانات جديد ثم تشغل etcd عليه، ومفتاح `APISIX_KEYRING` يجب أن يكون نفسه، وإلا فلن تستطيع APISIX فك الأسرار المشفرة في اللقطة، لذلك احفظه مع النسخة الاحتياطية في مكان آمن آخر.

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

```bash
docker exec lab-gw-etcd etcdutl snapshot status /etcd-data/snap.db -w table
```

```text
┌──────────┬──────────┬────────────┬────────────┬─────────┐
│   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` مفعل مع مزود الهوية.
- العينة في التتبع نسبة صغيرة، ونسخة احتياطية يومية تختبر استعادتها.

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

```bash
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.