> ## 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.

# PostgreSQL للفرق: تثبيت آمن وصلاحيات واضحة
- URL: https://arabroot.io/articles/postgresql-للفرق-تثبيت-امن-وصلاحيات-واضحة/
- Published: 2026-10-10T19:00:00.000Z
- Updated: 2026-10-11T11:18:00.000Z
- Description: كيف تثبت PostgreSQL لفريقك بأدوار لا تسمح للتطبيق بحذف الجداول، واتصال مشفر، ونسخة احتياطية تختبر استعادتها كل ليلة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, DevOps وCI/CD, الاستضافة الذاتية

أغلب فرق التطوير تبدأ PostgreSQL بنفس الطريقة: سطر `docker run -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres` من صفحة Docker Hub، ثم يتصل التطبيق بالمستخدم `postgres` نفسه، وتمر الأشهر وهذا الإعداد يعمل في الإنتاج دون أن يراجعه أحد. والمشكلة أن هذا الإعداد يعني أن المنفذ مفتوح لكل الإنترنت، وأن التطبيق يملك صلاحيات المستخدم الخارق Superuser، فأي ثغرة حقن SQL Injection أو خطأ في كود الترحيل Migration يستطيع أن يحذف الجداول كلها، وأن النسخ الاحتياطي غالباً غير موجود أو لم تجرب استعادته أبداً. وفي [مقال أنواع قواعد البيانات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D9%85%D8%AA%D9%89-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%83%D9%84-%D9%86%D9%88%D8%B9/) شرحنا متى تختار قاعدة علائقية Relational مثل PostgreSQL، وفي هذا المقال سوف نقوم بتجهيزها لفريق حقيقي: تثبيت بإصدار مثبت، وأدوار Roles واضحة، وتطبيق لا يستطيع حذف الجداول، واتصال مشفر، ونسخة احتياطية اختبرنا استعادتها فعلاً.

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

- التثبيت مع Docker Compose بإصدار مثبت، وكلمات مرور من ملفات Secrets، ومنفذ لا يفتح إلا على السيرفر نفسه.
- التثبيت على Ubuntu من مستودع PGDG الرسمي، ولماذا نفضله على حزمة التوزيعة.
- الأدوار Roles: من يملك الجداول، ومن يغيرها، ومن يستخدمها فقط.
- المخطط Schema والصلاحيات الافتراضية Default Privileges، والخطأين اللذين وقعنا فيهما أثناء المختبر.
- مختبر حقيقي: مستخدم التطبيق يحاول حذف جدول فيرفض PostgreSQL.
- من يدخل ومن أين: الملف pg\_hba.conf والتشفير TLS.
- النسخ الاحتياطي المنطقي بالأداة pg\_dump، واختبار الاستعادة كل ليلة، والاستعادة على سيرفر جديد.
- أساسيات المراقبة Monitoring، والأخطاء الشائعة، وقائمة تحقق.

وهذا المقال يركز على التشغيل اليومي لقاعدة واحدة، أما الاستعادة إلى لحظة محددة قبل خطأ بشري فلها [مقال pgBackRest والاستعادة إلى نقطة زمنية](https://arabroot.io/articles/%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9-postgresql-%D8%A7%D9%84%D9%89-%D8%A7%D9%8A-%D9%84%D8%AD%D8%B8%D8%A9-%D9%85%D8%B9-pgbackrest/)، وضبط الذاكرة والأداء له [مقال ضبط أداء PostgreSQL](https://arabroot.io/articles/%D8%B6%D8%A8%D8%B7-%D8%A7%D8%AF%D8%A7%D8%A1-postgresql-%D8%A8%D8%A7%D9%84%D9%82%D9%8A%D8%A7%D8%B3/).

## التثبيت مع Docker Compose

إذا كانت خدماتك تعمل على Docker كما شرحنا في [دليل تثبيت Docker على Ubuntu](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-docker-%D8%B9%D9%84%D9%89-ubuntu/)، فالأسهل أن تعمل قاعدة البيانات بجانبها في ملف Compose، ولكن بأربعة فروق عن السطر المنسوخ من الإنترنت: إصدار مثبت بالرقم الكامل، وكلمة المرور من ملف Secret وليس من متغير مكتوب في الملف، والمنفذ على `127.0.0.1` فقط، وملفات الإعداد من المجلد حتى تراجعها في Git. والملف الكامل كما يلي:

```yaml
# PostgreSQL 18 for a small team: pinned image, password from a secret file, local port only, TLS and pg_hba from files.
name: lab-pgsetup
services:
  db:
    image: postgres:18.6
    container_name: lab-pgsetup-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_superuser
      POSTGRES_DB: postgres
      POSTGRES_INITDB_ARGS: "--data-checksums --auth-host=scram-sha-256 --auth-local=peer"
    secrets: [pg_superuser, pg_app_migrator, pg_app_user, pg_app_readonly]
    # the key must belong to the postgres user with mode 0600, which a bind mount from Windows or macOS cannot give,
    # so it is copied inside the container before the normal entrypoint starts
    entrypoint: ["bash", "-c", "install -d -o postgres -m 700 /run/pgcerts && install -o postgres -m 600 /certs-src/server.key /certs-src/server.crt /run/pgcerts/ && exec docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf"]
    volumes:
      - lab-pgsetup-data:/var/lib/postgresql
      - ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro
      - ./conf/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
      - ./certs:/certs-src:ro
      - ./initdb:/docker-entrypoint-initdb.d:ro
      - ./backup.sh:/scripts/backup.sh:ro
      - ./restore-test.sh:/scripts/restore-test.sh:ro
      - lab-pgsetup-backups:/backup
    ports:
      - "127.0.0.1:55432:5432"
    shm_size: 256mb
    healthcheck:
      test: ["CMD", "runuser", "-u", "postgres", "--", "pg_isready", "-d", "postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
secrets:
  pg_superuser:
    file: ./secrets/pg_superuser.txt
  pg_app_migrator:
    file: ./secrets/pg_app_migrator.txt
  pg_app_user:
    file: ./secrets/pg_app_user.txt
  pg_app_readonly:
    file: ./secrets/pg_app_readonly.txt
volumes:
  lab-pgsetup-data:
    name: lab-pgsetup-data
  lab-pgsetup-backups:
    name: lab-pgsetup-backups
```

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

- الصورة `postgres:18.6` وليس `postgres:latest` أو `postgres:18`، والسبب أن الوسم المتحرك قد يأتي بإصدار جديد عند أي `docker compose pull` دون أن تقرر ذلك، والترقية إلى إصدار رئيسي Major Version جديد تحتاج نقل البيانات ولا تتم بتغيير الصورة فقط.
- تغير مكان البيانات في صورة PostgreSQL 18 وما بعده، فكما تذكر [صفحة الصورة الرسمية](https://hub.docker.com/%5F/postgres?ref=arabroot.io) أصبح `PGDATA` هو `/var/lib/postgresql/18/docker`، وأصبح ال Volume يربط على `/var/lib/postgresql` وليس على `/var/lib/postgresql/data` كما في الأدلة القديمة، فإذا نسخت سطر Volume قديماً فسوف تكتب البيانات داخل ال Container وتضيع عند حذفه.
- المتغير `POSTGRES_PASSWORD_FILE` يقرأ كلمة المرور من ملف، والملفات في المجلد `secrets` لا تدخل Git أبداً، وأنشأناها بسكربت صغير يولد كلمات عشوائية وشهادة TLS للمختبر.
- تفعيل فحص سلامة الصفحات Data Checksums أصبح الافتراضي في `initdb` منذ [PostgreSQL 18](https://www.postgresql.org/docs/18/release-18.html?ref=arabroot.io)، وكتبناه صراحة في `POSTGRES_INITDB_ARGS` حتى لا يعتمد الإعداد على إصدار معين، وهذا الفحص يكشف الصفحة التالفة على القرص بدل أن تقرأها القاعدة كأنها سليمة.
- المنفذ `127.0.0.1:55432` لا يقبل اتصالاً من خارج السيرفر، والتطبيقات الأخرى في Compose تصل إلى القاعدة باسم الخدمة `db` داخل الشبكة كما شرحنا في [مقال شبكات Docker](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A7%D8%AA-docker-%D9%88%D8%A3%D9%81%D8%B6%D9%84-%D8%A7%D9%84%D9%85%D9%85%D8%A7%D8%B1%D8%B3%D8%A7%D8%AA/)، وتذكر أن Docker يتجاوز قواعد UFW عند نشر المنافذ، لذلك لا يحميك جدار الحماية إذا كتبت `5432:5432`.
- فحص الصحة Healthcheck يعمل باسم المستخدم `postgres` عبر `runuser`، وفي أول محاولة تركناه يعمل باسم root فامتلأ السجل كل خمس ثوان بالرسالة `Peer authentication failed for user "postgres"` لأن قاعدة `peer` تطابق اسم مستخدم النظام مع اسم مستخدم القاعدة.

وقد يتساءل البعض: لماذا هذا السطر الطويل في `entrypoint`؟ والإجابة أن PostgreSQL يرفض أن يبدأ إذا كان مفتاح TLS الخاص Private Key قابلاً للقراءة من مستخدمين آخرين، ولا يمكن ضبط المالك والصلاحيات على ملف مربوط من Windows أو macOS، لذلك ننسخ الشهادة داخل ال Container بالمالك `postgres` والصلاحية `600` قبل أن يبدأ السكربت الأصلي للصورة. وعلى سيرفر Linux تستطيع ضبط المالك على الملف نفسه وحذف هذا السطر.

أما ملف الإعدادات فهو قصير، وكل سطر فيه له سبب:

```ini
listen_addresses = '*'
port = 5432
max_connections = 100
shared_buffers = 256MB
password_encryption = scram-sha-256
hba_file = '/etc/postgresql/pg_hba.conf'

ssl = on
ssl_cert_file = '/run/pgcerts/server.crt'
ssl_key_file  = '/run/pgcerts/server.key'
ssl_min_protocol_version = 'TLSv1.2'

log_connections = 'authentication'
log_disconnections = on
log_line_prefix = '%m [%p] %q%u@%d from %h '
log_min_duration_statement = 500ms
log_lock_waits = on
log_temp_files = 0
shared_preload_libraries = 'pg_stat_statements'
```

لاحظ أن `log_connections` في PostgreSQL 18 لم يعد قيمة `on` أو `off` فقط، وإنما يقبل مراحل مثل `authentication` لتسجيل من دخل وبأي طريقة، وأن `log_min_duration_statement` يسجل كل استعلام أبطأ من نصف ثانية، وسوف نحتاجه في المراقبة. وقيم الذاكرة هنا قيم بداية فقط، وطريقة حسابها بالقياس شرحناها في [مقال ضبط الأداء](https://arabroot.io/articles/%D8%B6%D8%A8%D8%B7-%D8%A7%D8%AF%D8%A7%D8%A1-postgresql-%D8%A8%D8%A7%D9%84%D9%82%D9%8A%D8%A7%D8%B3/).

## التثبيت على Ubuntu من مستودع PGDG

إذا كانت القاعدة على سيرفر مخصص دون Docker، فالطريقة البسيطة هي `apt install postgresql` من مستودع التوزيعة، وهي تعمل، ولكنها تربطك بالإصدار الرئيسي الذي اختارته التوزيعة طوال عمرها، فإذا احتجت إصداراً أحدث أو أقدم لتطابق سيرفراً آخر فلن تجده. والحل الأنسب هو مستودع PGDG الذي يديره مجتمع PostgreSQL نفسه، والذي توصي به [صفحة التنزيل الرسمية لـ Ubuntu](https://www.postgresql.org/download/linux/ubuntu/?ref=arabroot.io)، وفيه كل الإصدارات المدعومة وتحديثاتها الصغرى Minor Releases في يوم صدورها، وكل إصدار رئيسي مدعوم خمس سنوات كما في [سياسة الإصدارات](https://www.postgresql.org/support/versioning/?ref=arabroot.io). والأوامر على Ubuntu 26.04 كما يلي:

```bash
sudo apt install -y postgresql-common ca-certificates
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt install -y postgresql-18
pg_lsclusters
```

والمخرج سوف يكون كما يلي (من Container بنظام Ubuntu 26.04.1، وعلى سيرفر حقيقي يبدأ systemd ال Cluster تلقائياً):

```text
--- distro package:
postgresql:
  Installed: (none)
  Candidate: 18+290ubuntu1
You can now start installing packages from apt.postgresql.org.
Types: deb
URIs: https://apt.postgresql.org/pub/repos/apt
Suites: resolute-pgdg
Components: main
Architectures: amd64
Signed-By: /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg
--- installed:
ii  postgresql-18  18.6-1.pgdg26.04+2 amd64        The World's Most Advanced Open Source Relational Database
Ver Cluster Port Status Owner    Data directory              Log file
18  main    5432 online postgres /var/lib/postgresql/18/main /var/log/postgresql/postgresql-18-main.log
PostgreSQL 18.6 (Ubuntu 18.6-1.pgdg26.04+2) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 15.2.0-16ubuntu1) 15.2.0, 64-bit
/var/lib/postgresql/18/main
/etc/postgresql/18/main/pg_hba.conf
on
```

في المخرج أعلاه لاحظ التالي:

- السكربت يضيف ملف المستودع مع مفتاح التوقيع `Signed-By`، فلا تحتاج إلى نسخ أوامر `apt-key` القديمة من المدونات.
- الحزمة جاءت بالإصدار `18.6` نفسه الذي في صورة Docker، والسطر الأخير `on` هو `data_checksums`، أي أن الإعداد الافتراضي في 18 يفعله.
- على Ubuntu ملفات الإعداد في `/etc/postgresql/18/main` والبيانات في `/var/lib/postgresql/18/main`، والأمر `pg_lsclusters` يعرض كل ال Clusters على السيرفر، وهو مفيد عند الترقية حيث يعمل القديم والجديد معاً بالأداة `pg_upgradecluster`.

وبعد التثبيت ينطبق كل ما يلي في هذا المقال كما هو، فقط ضع الملفات في `/etc/postgresql/18/main` ونفذ سكربت الأدوار بالأمر `sudo -u postgres psql`. والترخيص في الحالتين هو [PostgreSQL License](https://www.postgresql.org/about/licence/?ref=arabroot.io) وهو ترخيص متساهل Permissive يشبه MIT وBSD.

## الأدوار Roles: من يملك الجداول ومن يستخدمها

في PostgreSQL لا يوجد فرق بين المستخدم User والمجموعة Group، فكلاهما دور Role، والدور الذي يملك صلاحية `LOGIN` نسميه مستخدماً، والدور الذي يمنح لأدوار أخرى نسميه مجموعة. والقاعدة الأهم هنا هي أقل صلاحية Least Privilege: كل جهة تتصل بالقاعدة تأخذ ما تحتاجه فقط، والسبب أن الخطأ أو الاختراق في أي جهة يتوقف عند حدود صلاحياتها. ولذلك نستخدم أربعة أدوار لكل تطبيق:

| الدور         | يسجل الدخول؟ | ماذا يفعل                                                | من يستخدمه            |
| ------------- | ------------ | -------------------------------------------------------- | --------------------- |
| app\_owner    | لا           | يملك المخطط والجداول، فهو الوحيد الذي يستطيع ALTER وDROP | لا أحد مباشرة         |
| app\_migrator | نعم          | عضو في app\_owner، يشغل ملفات الترحيل Migrations         | خط النشر CI/CD        |
| app\_user     | نعم          | قراءة وكتابة البيانات فقط                                | التطبيق نفسه          |
| app\_readonly | نعم          | قراءة فقط                                                | التقارير والدعم الفني |

الصورة التالية تبين العلاقة بين الأدوار الأربعة والمخطط:

![ثلاثة أجهزة على اليسار: خط النشر CI/CD يتصل بالدور app_migrator الذي ينتقل بالأمر SET ROLE إلى app_owner المالك الذي لا يسجل الدخول ويملك صلاحية CREATE وALTER وDROP على المخطط shop، وسيرفر التطبيق يتصل بالدور app_user الذي يملك SELECT وINSERT وUPDATE وDELETE فقط ويرفض له DROP TABLE، وجهاز التقارير يتصل بالدور app_readonly للقراءة فقط، وفي قاعدة البيانات app المخطط shop فيه الجدولان customers وorders وقاعدة الصلاحيات الافتراضية، والمخطط public مغلق](https://arabroot.io/content/images/2026/10/postgres-setup-01-roles.webp)

التطبيق يستخدم البيانات ولا يملكها، والمالك لا يسجل الدخول أبداً

وقد يتساءل البعض: لماذا لا يكون `app_migrator` نفسه هو المالك؟ والإجابة أن فصل المالك عن المستخدم الذي يسجل الدخول يجعلك تغير كلمة مرور خط النشر أو تضيف مستخدماً ثانياً للترحيل، أو تسحب الصلاحية من شخص غادر الفريق، دون أن تنقل ملكية أي جدول. وسكربت الإنشاء الكامل يعمل مرة واحدة عند أول تشغيل من المجلد `docker-entrypoint-initdb.d`:

```bash
#!/bin/bash
# Runs once, on an empty data directory. Passwords come from secret files, never from the compose file.
set -euo pipefail
psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
  -v migrator_pw="$(cat /run/secrets/pg_app_migrator)" \
  -v user_pw="$(cat /run/secrets/pg_app_user)" \
  -v ro_pw="$(cat /run/secrets/pg_app_readonly)" <<'SQL'
-- the owner of the schema and tables: nobody logs in as it
CREATE ROLE app_owner NOLOGIN;
-- migrations (CI or the deploy job) log in as app_migrator and act as app_owner
CREATE ROLE app_migrator LOGIN PASSWORD :'migrator_pw';
GRANT app_owner TO app_migrator;
-- the running application: data only, no DDL
CREATE ROLE app_user LOGIN PASSWORD :'user_pw' CONNECTION LIMIT 40;
-- reports and support staff
CREATE ROLE app_readonly LOGIN PASSWORD :'ro_pw' CONNECTION LIMIT 5;

CREATE DATABASE app OWNER app_owner;
REVOKE ALL ON DATABASE app FROM PUBLIC;
GRANT CONNECT ON DATABASE app TO app_migrator, app_user, app_readonly;

-- migrations always run as app_owner and create objects in shop, so default privileges apply
ALTER ROLE app_migrator IN DATABASE app SET role = 'app_owner';
ALTER ROLE app_migrator IN DATABASE app SET search_path = shop;
ALTER ROLE app_user     IN DATABASE app SET search_path = shop;
ALTER ROLE app_user     IN DATABASE app SET statement_timeout = '15s';
ALTER ROLE app_user     IN DATABASE app SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE app_readonly IN DATABASE app SET search_path = shop;
ALTER ROLE app_readonly IN DATABASE app SET default_transaction_read_only = on;
SQL

psql -v ON_ERROR_STOP=1 -U postgres -d app <<'SQL'
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE SCHEMA shop AUTHORIZATION app_owner;
GRANT USAGE ON SCHEMA shop TO app_user, app_readonly;
-- tables and sequences that app_owner creates later get these grants automatically
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA shop
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA shop
  GRANT USAGE, SELECT ON SEQUENCES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA shop
  GRANT SELECT ON TABLES TO app_readonly;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SQL
```

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

- كلمات المرور تمرر إلى `psql` كمتغيرات `:'user_pw'` من ملفات ال Secrets، فلا تظهر في الملف ولا في سجل الأوامر، وهي تحفظ كهاش `scram-sha-256`، وتذكر أن [PostgreSQL 18](https://www.postgresql.org/docs/18/release-18.html?ref=arabroot.io) أعلن إلغاء كلمات مرور MD5 في إصدار قادم وأصبح يحذرك عند استخدامها.
- `CONNECTION LIMIT` لكل دور يمنع تطبيقاً فيه تسرب اتصالات من أخذ كل اتصالات السيرفر، وحساب هذا الرقم شرحناه في [مقال تجميع الاتصالات Connection Pooling](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/).
- الإعدادات المرتبطة بالدور داخل القاعدة `ALTER ROLE … IN DATABASE app SET` تجعل لكل اتصال من التطبيق مهلة استعلام `statement_timeout` ومهلة للمعاملة المعلقة `idle_in_transaction_session_timeout` دون تعديل الكود.
- `REVOKE ALL ON DATABASE app FROM PUBLIC` يمنع أي دور آخر على السيرفر من مجرد الاتصال بهذه القاعدة، فإذا استضفت عدة تطبيقات على نفس السيرفر فلا يرى أحدها قاعدة الآخر.

## المخطط Schema والصلاحيات الافتراضية Default Privileges

المخطط Schema مجلد داخل قاعدة البيانات يجمع الجداول، وكل قاعدة جديدة فيها المخطط `public`. وقبل [PostgreSQL 15](https://www.postgresql.org/docs/release/15.0/?ref=arabroot.io) كان أي مستخدم يستطيع إنشاء جداول فيه، ثم تغير ذلك فأصبح مالك `public` هو الدور `pg_database_owner` أي مالك القاعدة، وسحبت صلاحية الإنشاء من الجميع، كما تشرح [وثائق المخططات](https://www.postgresql.org/docs/18/ddl-schemas.html?ref=arabroot.io). ومع ذلك نضع جداول التطبيق في مخطط خاص `shop` يملكه `app_owner`، والسبب أن الصلاحيات عليه تصبح صريحة وواضحة عند المراجعة.

والمشكلة التي يقع فيها أغلب الفرق هي هذه: الأمر `GRANT SELECT ON ALL TABLES IN SCHEMA shop TO app_user` يعطي الصلاحية على الجداول الموجودة الآن فقط، فإذا أضاف الترحيل جدولاً جديداً غداً فالتطبيق لا يستطيع قراءته، ويظهر الخطأ في الإنتاج بعد النشر. والحل هو [ALTER DEFAULT PRIVILEGES](https://www.postgresql.org/docs/18/sql-alterdefaultprivileges.html?ref=arabroot.io) الذي يقول: كل جدول ينشئه الدور `app_owner` في هذا المخطط مستقبلاً يأخذ هذه الصلاحيات تلقائياً. ولاحظ كلمة «ينشئه الدور `app_owner`»، فهي سبب الخطأ الأول الذي وقعنا فيه.

### الخطأ الأول: الترحيل أنشأ الجدول باسمه وليس باسم المالك

في النسخة الأولى من السكربت لم يكن فيه السطر `SET role = 'app_owner'`، فاتصل `app_migrator` وأنشأ جدولاً، وبما أنه عضو في `app_owner` فقد ورث صلاحية الإنشاء في المخطط، ولكن الجدول أصبح ملكه هو:

```text
CREATE TABLE shop.customers (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL);
CREATE TABLE
SELECT tableowner FROM pg_tables WHERE tablename='customers';
  tableowner
--------------
 app_migrator
(1 row)

=== app_user
SELECT count(*) FROM customers;
ERROR:  permission denied for table customers
```

والسبب أن الصلاحيات الافتراضية مسجلة باسم `app_owner`، والجدول أنشأه `app_migrator`، فلم تطبق عليه. والحل إما أن يبدأ كل ملف ترحيل بالأمر `SET ROLE app_owner`، وهذا يعتمد على ذاكرة كل مطور، أو أن تجعله إعداداً للدور نفسه كما في السكربت النهائي: `ALTER ROLE app_migrator IN DATABASE app SET role = 'app_owner'`، فيبدأ كل اتصال من خط النشر وهو يعمل باسم المالك، كما تشرح [وثائق عضوية الأدوار](https://www.postgresql.org/docs/18/role-membership.html?ref=arabroot.io).

### الخطأ الثاني: الجداول ذهبت إلى public

بعد إصلاح الخطأ الأول أنشأنا الجداول باسمها فقط `CREATE TABLE customers` دون اسم المخطط، فلم تذهب إلى `shop`، وإنما إلى `public`:

```text
 schemaname | tablename | tableowner
------------+-----------+------------
 public     | customers | app_owner
 public     | orders    | app_owner
```

وهذا غريب في البداية لأننا سحبنا كل الصلاحيات على `public`، ولكن `app_owner` هو مالك القاعدة، ومالك القاعدة هو `pg_database_owner` الذي يملك `public`، فيستطيع الإنشاء فيه دون أي صلاحية إضافية، و`search_path` الافتراضي هو `"$user", public`. والحل هو السطر `ALTER ROLE app_migrator IN DATABASE app SET search_path = shop`، فتذهب الجداول التي لا يذكر مخططها إلى `shop`.

## المختبر: تطبيق لا يستطيع حذف الجداول

بعد السكربت النهائي شغلنا القاعدة من الصفر، واتصلنا من Container آخر في نفس شبكة Compose عبر TLS، أولاً باسم `app_migrator` لإنشاء الجداول:

```sql
SELECT current_user, session_user, current_schema;
CREATE TABLE customers (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL);
CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers(id),
  total numeric(12,2) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
SELECT schemaname, tablename, tableowner FROM pg_tables WHERE schemaname = 'shop';
```

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

```text
 current_user | session_user | current_schema
--------------+--------------+----------------
 app_owner    | app_migrator | shop
(1 row)

CREATE TABLE
CREATE TABLE
 schemaname | tablename | tableowner
------------+-----------+------------
 shop       | customers | app_owner
 shop       | orders    | app_owner
(2 rows)
```

لاحظ أن `session_user` هو من سجل الدخول، و`current_user` هو الدور الذي يعمل باسمه الآن، والجداول في `shop` ويملكها `app_owner`. الآن نتصل باسم `app_user` كما يفعل التطبيق، فنضيف بيانات ثم نحاول ما لا يجب أن ينجح:

```sql
INSERT INTO customers (name) SELECT 'customer ' || g FROM generate_series(1, 1000) g;
INSERT INTO orders (customer_id, total, created_at)
  SELECT 1 + (g % 1000), round((random() * 500)::numeric, 2), now() - g * interval '1 minute'
  FROM generate_series(1, 50000) g;
SELECT count(*) FROM orders;
DROP TABLE orders;
TRUNCATE orders;
ALTER TABLE orders ADD COLUMN note text;
CREATE TABLE shop.tmp (x int);
CREATE TABLE public.tmp (x int);
```

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

```text
INSERT 0 1000
INSERT 0 50000
 count
-------
 50000
(1 row)

ERROR:  must be owner of table orders
ERROR:  permission denied for table orders
ERROR:  must be owner of table orders
ERROR:  permission denied for schema shop
LINE 1: CREATE TABLE shop.tmp (x int);
                     ^
ERROR:  permission denied for schema public
LINE 1: CREATE TABLE public.tmp (x int);
                     ^
```

وهذا ما نريده بالضبط: التطبيق أضاف ألف عميل وخمسين ألف طلب، لأن الصلاحيات الافتراضية أعطته `INSERT` على الجداول الجديدة دون أي `GRANT` يدوي، ولكنه لا يستطيع حذف الجدول ولا تفريغه بالأمر `TRUNCATE` ولا تغيير بنيته ولا إنشاء جداول، فإذا وجد مخترق ثغرة حقن SQL في التطبيق فأسوأ ما يستطيعه هو ما يستطيعه التطبيق نفسه. ولاحظ أن `DELETE` ما زال مسموحاً لأن التطبيق يحتاجه، وحماية البيانات من حذف خاطئ بالأمر `DELETE` تكون بالنسخ الاحتياطي والاستعادة إلى نقطة زمنية كما سيأتي.

أما دور القراءة فقد ضبطنا له `default_transaction_read_only = on`، ولكن هذا الإعداد وحده ليس حماية، والسبب أن المستخدم يستطيع تغييره في جلسته:

```text
DELETE FROM customers WHERE id = 1;
ERROR:  cannot execute DELETE in a read-only transaction
SET default_transaction_read_only = off;
SET
DELETE FROM customers WHERE id = 1;
ERROR:  permission denied for table customers
```

فالرفض الأول جاء من الإعداد، وبعد أن أطفأه المستخدم جاء الرفض الثاني من الصلاحيات، وهي الحماية الحقيقية، أما الإعداد فهو حزام أمان إضافي يمنع الخطأ غير المقصود. والقائمة النهائية للأدوار من الأمر `\du`:

```text
                               List of roles
  Role name   |                         Attributes
--------------+------------------------------------------------------------
 app_migrator |
 app_owner    | Cannot login
 app_readonly | 5 connections
 app_user     | 40 connections
 postgres     | Superuser, Create role, Create DB, Replication, Bypass RLS
```

💡

إذا كان تطبيقك يخدم عدة عملاء في نفس الجداول، فالخطوة التالية بعد الأدوار هي سياسات الصفوف Row-Level Security التي تمنع العميل من رؤية بيانات غيره حتى لو نسي الكود الشرط، وقد شرحناها عملياً في [مقال بناء تطبيق SaaS متعدد المستأجرين](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/).

## من يدخل ومن أين؟ pg\_hba.conf وTLS

الصلاحيات تحدد ماذا يفعل الدور بعد أن يدخل، أما الملف [pg\_hba.conf](https://www.postgresql.org/docs/18/auth-pg-hba-conf.html?ref=arabroot.io) فيحدد من يستطيع الدخول أصلاً: أي نوع اتصال، ولأي قاعدة، وبأي دور، ومن أي عنوان، وبأي طريقة مصادقة Authentication. وPostgreSQL يقرأ الملف من الأعلى إلى الأسفل ويأخذ أول سطر يطابق الاتصال، ثم يتوقف، فإذا فشلت المصادقة في ذلك السطر فلا ينتقل إلى السطر التالي. والملف الذي استخدمناه:

```text
# TYPE   DATABASE  USER          ADDRESS          METHOD
local    all       postgres                       peer
local    all       all                            scram-sha-256
# inside the container (healthcheck, backups run with docker exec)
host     all       all           127.0.0.1/32     scram-sha-256
# everything from the network must use TLS, and only these roles to this database
hostssl  app       app_user      0.0.0.0/0        scram-sha-256
hostssl  app       app_migrator  0.0.0.0/0        scram-sha-256
hostssl  app       app_readonly  0.0.0.0/0        scram-sha-256
# anything else is refused, with or without TLS
host     all       all           0.0.0.0/0        reject
```

الصورة التالية تبين ما حدث لثلاثة اتصالات في المختبر:

![ثلاثة اتصالات تمر على أسطر الملف pg_hba.conf من الأعلى: الاتصال الأول من التطبيق مع TLS يطابق سطر hostssl app app_user فيدخل بتشفير TLSv1.3 ومصادقة SCRAM، والثاني من نفس المستخدم دون TLS والثالث من المستخدم الخارق postgres مع TLS لا يطابقان أي سطر حتى يصلا إلى السطر الأخير host all all reject فيرفضان برسالة pg_hba.conf rejects connection](https://arabroot.io/content/images/2026/10/postgres-setup-02-hba.webp)

أول سطر يطابق هو الذي يقرر، والسطر الأخير reject يغلق كل ما لم تسمح به صراحة

والمخرج الحقيقي للاتصالات الثلاثة كما يلي:

```text
=== no tls
psql: error: connection to server at "db" (192.168.48.2), port 5432 failed: FATAL:  pg_hba.conf rejects connection for host "192.168.48.3", user "app_user", database "app", no encryption
=== superuser over network
psql: error: connection to server at "db" (192.168.48.2), port 5432 failed: FATAL:  pg_hba.conf rejects connection for host "192.168.48.3", user "postgres", database "app", SSL encryption
=== tls info
 ssl | version |         cipher
-----+---------+------------------------
 t   | TLSv1.3 | TLS_AES_256_GCM_SHA384
(1 row)
```

في المخرج أعلاه لاحظ التالي:

- الرسالة تذكر العنوان والدور والقاعدة وحالة التشفير، وهي نفسها تظهر في سجل السيرفر، فإذا اشتكى مطور من أنه لا يستطيع الاتصال فابحث عنها في السجل أولاً.
- المستخدم الخارق `postgres` لا يدخل من الشبكة أبداً حتى مع TLS وكلمة مرور صحيحة، فالإدارة تكون من داخل السيرفر بالأمر `docker exec -u postgres` أو عبر SSH، وهذا يغلق أخطر باب لتخمين كلمة المرور.
- الجدول `pg_stat_ssl` يبين لكل اتصال هل هو مشفر وبأي بروتوكول، وسوف نستخدمه في المراقبة للتأكد من أنه لا يوجد اتصال دون تشفير.

ولكن التشفير وحده لا يكفي لمنع من يقف في منتصف الطريق Man in the Middle، والسبب أن الوضع الافتراضي للكلاينت في [libpq](https://www.postgresql.org/docs/18/libpq-ssl.html?ref=arabroot.io) هو `sslmode=prefer` ثم `require` يشفر ولكنه لا يتحقق من هوية السيرفر، فيقبل أي شهادة. والوضع الصحيح للإنتاج هو `verify-full` مع شهادة الجهة المصدرة `sslrootcert`، وعندها يتحقق الكلاينت من أن اسم السيرفر في الشهادة هو الاسم الذي يتصل به:

```text
$ psql "host=db dbname=app user=app_user sslmode=verify-full sslrootcert=/ca.crt"
psql: error: connection to server at "db" (192.168.48.2), port 5432 failed: server certificate for "db.lab.internal" (and 2 other names) does not match host name "db"

$ psql "host=db.lab.internal dbname=app user=app_user sslmode=verify-full sslrootcert=/ca.crt" -c "select current_user"
 current_user
--------------
 app_user
(1 row)
```

فالاتصال الأول رفض لأن الاسم `db` غير موجود في الشهادة، والثاني نجح لأن الاسم يطابق. وفي المختبر استخدمنا شهادة موقعة ذاتياً Self-Signed، وفي الإنتاج استخدم جهة إصدار داخلية أو شهادة لنطاق حقيقي، وقد شرحنا الشهادات وصيغها في [مقال الشهادات الرقمية](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/)، وإعدادات السيرفر الكاملة في [وثائق TLS في PostgreSQL](https://www.postgresql.org/docs/18/ssl-tcp.html?ref=arabroot.io).

⚠️

لا تقم بإضافة السطر `host all all 0.0.0.0/0 trust` «مؤقتاً» لحل مشكلة اتصال، والسبب أن `trust` يدخل أي شخص بأي دور دون كلمة مرور، بما فيه المستخدم الخارق، والسطور المؤقتة في هذا الملف تبقى سنوات. وبعد أي تعديل على الملف نفذ `SELECT pg_reload_conf();` ثم راجع `SELECT * FROM pg_hba_file_rules WHERE error IS NOT NULL;` للتأكد من أنه لا يوجد سطر خاطئ.

## النسخ الاحتياطي المنطقي مع pg\_dump

النسخ الاحتياطي في PostgreSQL نوعان: النسخ المنطقي Logical Backup بالأداة [pg\_dump](https://www.postgresql.org/docs/18/app-pgdump.html?ref=arabroot.io) الذي يكتب الجداول والبيانات كأوامر يعاد تنفيذها، والنسخ الفيزيائي Physical Backup الذي ينسخ ملفات القاعدة نفسها مع سجل WAL. والمنطقي هو البداية الصحيحة لقاعدة صغيرة أو متوسطة، والسبب أنه بسيط، ويعمل بين الإصدارات المختلفة، وتستطيع استعادة جدول واحد منه. ولكنه يمثل لحظة واحدة فقط، فإذا نسخت في الثانية فجراً وحدث الخطأ في المساء فقد خسرت بيانات اليوم كله، ولهذا تحتاج القواعد المهمة النسخ الفيزيائي مع الاستعادة إلى نقطة زمنية، وهو موضوع [مقال pgBackRest](https://arabroot.io/articles/%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9-postgresql-%D8%A7%D9%84%D9%89-%D8%A7%D9%8A-%D9%84%D8%AD%D8%B8%D8%A9-%D9%85%D8%B9-pgbackrest/). والقواعد العامة للنسخ مثل قاعدة 3-2-1 وال RPO وال RTO شرحناها في [مقال النسخ الاحتياطي بقاعدة 3-2-1](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/)، فلن نكررها.

الصورة التالية تبين الخطة كاملة:

![سيرفر الإنتاج فيه القاعدة app والأدوار، الخطوة 1 تخرج ملفين: app.dump من pg_dump بصيغة custom فيه الجداول والبيانات والصلاحيات وإعدادات القاعدة، وglobals.sql من pg_dumpall فيه الأدوار وعضوياتها دون كلمات المرور، الخطوة 2 تنقلهما إلى قرص آخر ونسخة خارج السيرفر، الخطوة 3 اختبار استعادة كل ليلة في قاعدة app_restore_test مع مقارنة عدد الصفوف، والخطوة 4 الاستعادة على سيرفر جديد بعد كارثة: الأدوار أولاً ثم pg_restore --create](https://arabroot.io/content/images/2026/10/postgres-setup-03-backup.webp)

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

والسكربت الذي يعمل كل ليلة داخل ال Container:

```bash
#!/bin/sh
# Nightly logical backup: one custom-format dump per database plus the roles, kept for 14 days.
# Run on the host: docker exec -u postgres lab-pgsetup-db sh /scripts/backup.sh
set -eu
DIR=/backup/$(date -u +%Y-%m-%dT%H%MZ)
mkdir -p "$DIR"
pg_dumpall --globals-only --no-role-passwords > "$DIR/globals.sql"
pg_dump --format=custom --compress=zstd:3 --file="$DIR/app.dump" app
pg_restore --list "$DIR/app.dump" > /dev/null          # the file is readable, not a proof it restores
find /backup -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
ls -l "$DIR"
```

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

- الصيغة `--format=custom` مضغوطة، وتسمح للأداة `pg_restore` باستعادة جدول واحد، وباستعادة متوازية بالخيار `--jobs`، وهي أفضل من ملف SQL نصي لكل شيء تقريباً، والضغط `zstd` متاح منذ PostgreSQL 16.
- `pg_dump` ينسخ قاعدة واحدة فقط ولا ينسخ الأدوار، لأن الأدوار على مستوى ال Cluster كله، لذلك نحفظها بالأمر [pg\_dumpall --globals-only](https://www.postgresql.org/docs/18/app-pg-dumpall.html?ref=arabroot.io)، والخيار `--no-role-passwords` يجعل الملف خالياً من هاش كلمات المرور، فتعيد ضبطها من مكان حفظ الأسرار عند الاستعادة.
- `pg_restore --list` يتأكد فقط من أن الملف مقروء، وليس دليلاً على أن الاستعادة تنجح.
- وتذكر أن ال Volume `/backup` على نفس السيرفر، فهو نسخة واحدة فقط من ثلاث، وانقل المجلد بعدها إلى مكان آخر بأداة مثل restic كما في [مقارنة أدوات النسخ الاحتياطي](https://arabroot.io/articles/restic-%D8%A3%D9%85-borg-%D8%A3%D9%85-kopia/).

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

```text
total 512
-rw-r--r-- 1 postgres postgres 516657 Oct 10 05:40 app.dump
-rw-r--r-- 1 postgres postgres   1179 Oct 10 05:40 globals.sql
```

### اختبار الاستعادة كل ليلة

النسخة التي لم تجرب استعادتها ليست نسخة، والطريقة الأسهل لتجربتها هي استعادتها في قاعدة مؤقتة على نفس السيرفر ومقارنة عدد الصفوف، ثم حذفها:

```bash
#!/bin/sh
# Restores the latest dump into a scratch database and compares row counts with production.
set -eu
LATEST=$(ls -d /backup/*/ | sort | tail -1)
dropdb --if-exists app_restore_test
createdb --owner=app_owner app_restore_test
START=$(date +%s)
pg_restore --dbname=app_restore_test --jobs=2 --exit-on-error "$LATEST/app.dump"
echo "restore took $(( $(date +%s) - START ))s from $LATEST"
for t in shop.customers shop.orders; do
  a=$(psql -XAtc "select count(*) from $t" app)
  b=$(psql -XAtc "select count(*) from $t" app_restore_test)
  [ "$a" = "$b" ] && echo "OK   $t $a rows" || { echo "FAIL $t prod=$a restored=$b"; exit 1; }
done
dropdb app_restore_test
```

وعند تشغيله أول مرة لم ينجح، وكانت أخطاؤنا مفيدة، ففي النسخة الأولى استخدمنا `--no-owner --role=app_owner` مع قاعدة يملكها `postgres`، فظهر الخطأ:

```text
pg_restore: error: could not execute query: ERROR:  permission denied for database app_restore_test
Command was: CREATE SCHEMA shop;
```

فجعلنا `app_owner` مالك القاعدة المؤقتة، فظهر الخطأ التالي:

```text
pg_restore: error: could not execute query: ERROR:  permission denied to create extension "pg_stat_statements"
HINT:  Must be superuser to create this extension.
Command was: CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public;
```

والسبب أن هذه الإضافة Extension لا ينشئها إلا المستخدم الخارق، فالحل الأبسط هو أن تعمل الاستعادة باسم `postgres` دون `--no-owner`، فتبقى ملكية كل شيء كما كانت في الإنتاج، وهذا ما في السكربت النهائي. والمخرج بعد الإصلاح:

```text
restore took 1s from /backup/2026-10-10T0540Z/
OK   shop.customers 1000 rows
OK   shop.orders 50000 rows
```

وهنا الفائدة الحقيقية من الاختبار: هذه الأخطاء ظهرت في مختبر هادئ، ولم تظهر في ليلة تعطل فيها السيرفر والإدارة تسأل متى يعود النظام. واجعل السكربت يرسل تنبيهاً عند الفشل، وسجل الوقت الذي أخذته الاستعادة، فهو تقريباً وقت توقفك عند الكارثة.

### الاستعادة على سيرفر جديد

الآن نفترض أن السيرفر ضاع، ولدينا الملفان فقط، فنشغل Container جديداً من نفس الصورة ونستعيد ملف البيانات مباشرة:

```text
$ createdb app && pg_restore -d app --exit-on-error /tmp/app.dump
pg_restore: error: could not execute query: ERROR:  role "app_owner" does not exist
Command was: ALTER SCHEMA shop OWNER TO app_owner;
```

وهذا هو الخطأ الذي يقع فيه من يحفظ ملف `pg_dump` فقط، فالأدوار ليست فيه. والترتيب الصحيح أن تستعيد الأدوار أولاً، ثم القاعدة بالخيار `--create` الذي ينشئها بنفس الاسم مع صلاحياتها وإعدادات الأدوار داخلها:

```bash
psql -X -q -f /tmp/globals.sql            # "role postgres already exists" is expected
pg_restore --create --dbname=postgres --exit-on-error /tmp/app.dump
psql -XAtc "select count(*) from shop.orders" app
psql -XAt -c "select r.rolname, s.setconfig from pg_db_role_setting s join pg_roles r on r.oid = s.setrole"
```

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

```text
restored
50000
app_migrator|{role=app_owner,search_path=shop}
app_user|{search_path=shop,statement_timeout=15s,idle_in_transaction_session_timeout=60s}
app_readonly|{search_path=shop,default_transaction_read_only=on}
```

لاحظ السطور الثلاثة الأخيرة، فإعدادات الأدوار داخل القاعدة مثل `search_path` و`statement_timeout` لا توجد في `globals.sql`، وإنما تأتي مع القاعدة عند استخدام `--create` فقط، فإذا استعدت دون هذا الخيار في قاعدة أنشأتها بنفسك فسوف يعمل التطبيق دون مهلة الاستعلام، وتعود الجداول الجديدة إلى `public`، ولن تلاحظ ذلك إلا بعد أسابيع. وبقيت خطوة أخيرة: الأدوار استعيدت دون كلمات مرور، فاضبطها بالأمر `ALTER ROLE app_user PASSWORD '…'` من مكان حفظ الأسرار قبل تشغيل التطبيق.

## المراقبة: ماذا تتابع كل يوم؟

لا تحتاج في البداية إلى نظام مراقبة كبير، فPostgreSQL يجمع إحصائياته في جداول النظام كما في [وثائق الإحصائيات](https://www.postgresql.org/docs/18/monitoring-stats.html?ref=arabroot.io)، وهذه أربعة استعلامات تجيب عن أهم الأسئلة:

```sql
-- size, transactions, deadlocks, temp files and cache hits per database
SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size,
       numbackends, xact_commit, xact_rollback, deadlocks, temp_files,
       round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 1) AS cache_hit_pct
FROM pg_stat_database WHERE datname IN ('app', 'postgres');
-- who is connected and what are they doing
SELECT usename, state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY 1, 2;
-- how close are we to the limit
SELECT (SELECT count(*) FROM pg_stat_activity) AS connections, current_setting('max_connections') AS max;
-- transactions open for more than five minutes
SELECT pid, usename, now() - xact_start AS xact_age, state, left(query, 40) AS query
FROM pg_stat_activity WHERE state <> 'idle' AND xact_start < now() - interval '5 minutes';
```

والمخرج من المختبر:

```text
 datname  |  size   | numbackends | xact_commit | xact_rollback | deadlocks | temp_files | cache_hit_pct
----------+---------+-------------+-------------+---------------+-----------+------------+---------------
 postgres | 7678 kB |           0 |         162 |             0 |         0 |          0 |          95.1
 app      | 12 MB   |           1 |          70 |             8 |         0 |          0 |          99.7
(2 rows)

 usename  | state  | count
----------+--------+-------
 postgres | active |     1
(1 row)

 connections | max
-------------+-----
           9 | 100
(1 row)

 pid | usename | xact_age | state | query
-----+---------+----------+-------+-------
(0 rows)
```

وما الذي تبحث عنه في هذه الأرقام؟

- **عدد الاتصالات Connections:** إذا اقترب من `max_connections` فالمشكلة غالباً في مجمع الاتصالات وليس في الحد نفسه.
- **المعاملات الطويلة Long Transactions:** المعاملة المفتوحة دقائق تمسك أقفالاً وتمنع ال VACUUM من تنظيف الصفوف القديمة، وأسبابها وطريقة قراءة الأقفال في [مقال الأقفال والجمود Deadlock](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/).
- **الجمود Deadlocks والملفات المؤقتة Temp Files:** العداد الذي يزيد كل يوم يعني مشكلة في ترتيب الأقفال أو استعلامات ترتب بيانات أكبر من ذاكرتها، وطريقة إصلاح الثانية في [مقال ضبط الأداء](https://arabroot.io/articles/%D8%B6%D8%A8%D8%B7-%D8%A7%D8%AF%D8%A7%D8%A1-postgresql-%D8%A8%D8%A7%D9%84%D9%82%D9%8A%D8%A7%D8%B3/).
- **حجم القاعدة ومساحة القرص:** القرص الممتلئ يوقف PostgreSQL عن الكتابة تماماً، فراقب المساحة الحرة بنسبة وليس بالحجم فقط.
- **السجل Log:** الإعدادات في `postgresql.conf` أعلاه تسجل الاتصالات المرفوضة، والاستعلامات الأبطأ من نصف ثانية، وانتظار الأقفال، وكل ملف مؤقت، فاقرأه أو أرسله إلى نظام السجلات كما شرحنا في [مقال السجلات Logging](https://arabroot.io/articles/%D8%A7%D9%84%D8%B3%D8%AC%D9%84%D8%A7%D8%AA-logging-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%88%D9%85%D8%AF%D9%8A%D8%B1%D9%8A-%D8%A7%D9%84%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D8%A7%D8%AA/).

وعندما تحتاج لوحات ورسوماً وتنبيهات، فالطريقة المعتادة هي [postgres\_exporter](https://github.com/prometheus-community/postgres%5Fexporter?ref=arabroot.io) مع Prometheus وGrafana، وهو يقرأ نفس هذه الجداول، وأعطه دوراً خاصاً عضواً في `pg_monitor` وليس المستخدم الخارق. أما أبطأ الاستعلامات بالإضافة `pg_stat_statements` التي فعلناها في الإعداد، فقد شرحناها في [مقال PostgreSQL للجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/).

## أخطاء شائعة

- **التطبيق يتصل بالمستخدم `postgres`:** أي ثغرة في التطبيق تصبح تحكماً كاملاً في القاعدة، بل وتنفيذ أوامر على السيرفر عبر `COPY … PROGRAM` المسموح للمستخدم الخارق.
- **التطبيق يملك الجداول:** حتى دون صلاحية المستخدم الخارق، المالك يستطيع `DROP` و`TRUNCATE`، فافصل المالك عن الدور الذي يستخدمه التطبيق.
- **`GRANT … ON ALL TABLES` دون صلاحيات افتراضية:** يعمل اليوم ويتعطل مع أول جدول جديد.
- **المنفذ 5432 مفتوح للإنترنت:** بسبب `-p 5432:5432` في Docker الذي يتجاوز جدار الحماية، وPostgreSQL المفتوح من أكثر الخدمات التي تفحصها البرامج الآلية بحثاً عن كلمات مرور ضعيفة.
- **`sslmode=require` وتظن أنك محمي:** الاتصال مشفر ولكن الكلاينت لا يعرف مع من يتكلم، والحماية الكاملة هي `verify-full`.
- **نسخ `pg_dump` فقط:** دون الأدوار، ودون اختبار استعادة، وعلى نفس القرص.
- **الوسم `latest` في الإنتاج:** ترقية رئيسية غير مقصودة تمنع القاعدة من البدء لأن ملفات البيانات من إصدار آخر.

## قائمة تحقق

1. هل الإصدار مثبت برقمه الكامل، والتحديثات الصغرى تطبق كل فترة؟
2. هل المنفذ مغلق من الخارج، والإدارة من داخل السيرفر فقط؟
3. هل يوجد دور مالك لا يسجل الدخول، ودور للترحيل، ودور للتطبيق، ودور للقراءة؟
4. هل الصلاحيات الافتراضية مضبوطة للمالك، وهل جربت أن جدولاً جديداً يقرأه التطبيق؟
5. هل جربت أن دور التطبيق لا يستطيع `DROP` و`TRUNCATE` و`ALTER`؟
6. هل ينتهي `pg_hba.conf` بسطر `reject`، ولا يوجد فيه `trust` ولا `md5`؟
7. هل الكلاينت يتصل بالوضع `verify-full`؟
8. هل النسخة اليومية تحفظ القاعدة والأدوار، وتنقل خارج السيرفر، وتجرب استعادتها آلياً؟
9. هل جربت الاستعادة على سيرفر جديد مرة واحدة على الأقل، وكتبت خطواتها ووقتها؟
10. هل يوجد تنبيه على الاتصالات والمعاملات الطويلة ومساحة القرص؟

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- ثبت PostgreSQL بإصدار مثبت، مع Docker Compose بكلمات مرور من ملفات Secrets ومنفذ على `127.0.0.1`، أو على Ubuntu من مستودع PGDG الرسمي، وانتبه لمكان البيانات الجديد في صورة 18.
- أربعة أدوار لكل تطبيق: مالك لا يسجل الدخول، ودور ترحيل يعمل باسم المالك، ودور تطبيق للبيانات فقط، ودور قراءة، وفي المختبر رفض PostgreSQL أوامر `DROP` و`TRUNCATE` و`ALTER` من التطبيق.
- الصلاحيات الافتراضية تطبق على ما ينشئه المالك فقط، فاجعل دور الترحيل يعمل باسم المالك وحدد له `search_path`، وإلا ذهبت الجداول إلى المالك الخطأ أو المخطط الخطأ.
- `pg_hba.conf` يقرأ من الأعلى ويأخذ أول سطر يطابق، فاجعله ينتهي بالرفض، واسمح بالشبكة مع TLS فقط، وتحقق من هوية السيرفر بالوضع `verify-full`.
- النسخة المنطقية ملفان: القاعدة بالأداة `pg_dump` والأدوار بالأداة `pg_dumpall --globals-only`، والاستعادة على سيرفر جديد تكون بالأدوار أولاً ثم `pg_restore --create`، وجرب الاستعادة آلياً كل ليلة.
- للاستعادة إلى لحظة قبل خطأ بشري تحتاج النسخ الفيزيائي مع WAL، وهو موضوع المقال التالي.

[![](https://arabroot.io/content/images/2026/10/postgres-pitr-cover-cover.webp)دليلاستعادة PostgreSQL إلى أي لحظة مع pgBackRestكيف تأخذ نسخاً احتياطية من PostgreSQL مع أرشيف WAL، وتستعيد القاعدة إلى الثانية التي سبقت حذفاً خاطئاً.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9-postgresql-%D8%A7%D9%84%D9%89-%D8%A7%D9%8A-%D9%84%D8%AD%D8%B8%D8%A9-%D9%85%D8%B9-pgbackrest/)[![](https://arabroot.io/content/images/2026/10/postgres-tuning-cover-cover.webp)دليلضبط أداء PostgreSQL بالقياس وليس بالتخمينكيف تضبط ذاكرة PostgreSQL ونقاط التفتيش وال autovacuum، وتقيس أثر كل إعداد على نفس الحمل قبل أن تعتمده.](https://arabroot.io/articles/%D8%B6%D8%A8%D8%B7-%D8%A7%D8%AF%D8%A7%D8%A1-postgresql-%D8%A8%D8%A7%D9%84%D9%82%D9%8A%D8%A7%D8%B3/)[![](https://arabroot.io/content/images/2026/10/connection-pooling-cover-cover.webp)دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/backup-321-cover-cover.webp)دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/)

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

- أكتوبر 2026: كتابة المقال واختباره على PostgreSQL 18.6 (الصورة postgres:18.6) وعلى Ubuntu 26.04.1 مع الحزمة postgresql-18 من مستودع PGDG، وDocker Engine 29.8.0.