في مقارنة أدوات بوابات 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 فقط:

والخدمة الخلفية 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 وغيرها، والصورة التالية من تثبيتنا بعد أن أضفنا مسارات التجربة:

لاحظ أن خانة 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 healthcheck200 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 إلى سطر لكل نسخة وحذفنا بداية سطور السجل. والصورة التالية تبين التسلسل بالوقت من السجل:

كل الطلبات نجحت وأبطؤها 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/r200
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/1Counter({'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) | sort200: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.
والصورة التالية تلخص المسارات حتى الآن، ومعها مسار الشركاء الذي سنضيفه في الخطوة التالية:

الدخول عبر 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/homeHTTP/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والصورة التالية تبين الطريقتين:

في التحويل لاحظ أن عنوان 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 -1200 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.