Türkçe · English
Gecelik yedekle yapılabilecek en iyi şey **"dünkü yedeğe dön"**dür. Oysa
istenen neredeyse hiçbir zaman bu değildir: veriyi bozan UPDATE dün 14:33'te
koştuysa 14:32'ye dönmek gerekir. Aradaki fark bir günlük iştir.
Tam yedek + o andan sonraki değişiklik günlüğü bu farkı kapatır. PostgreSQL'de bu günlük WAL, MariaDB'de binlog'dur.
./scripts/pitr.sh durum
./scripts/pitr.sh don postgresql "2026-09-01 14:32:00" --prova
| Motor | PITR | Neden |
|---|---|---|
| postgresql | ✅ | WAL arşivleniyor (archive_mode, docker-compose.yml) |
| mariadb | ✅ | binlog zaten açık (config/mariadb/my.cnf) |
| mongodb | ❌ | oplog ile teknik olarak mümkün (replica set şart, oplog penceresi kadar geriye) ama bu turda yapılmadı |
| redis | ❌ | AOF yalnız komut akışıdır; olaylarda zaman damgası yoktur, "14:32'ye dön" ifade edilemez |
| mssql | ❌ | işlem günlüğü yedeği (LOG backup) gerekir; bu yığın yalnız FULL yedek alıyor |
| diğerleri | ❌ | zaman damgalı bir değişiklik günlüğü yığında tutulmuyor |
MongoDB satırı bilerek "mümkün ama yapılmadı" diye yazıldı. "Yapılabilir" ile "yapıldı ve ölçüldü" arasındaki farkı gizlemek, felaket günü öğrenilecek en pahalı şeydir.
Bu belgede tekrar tekrar geçecek olan tek kural şudur:
Değişiklik günlüğü tek başına veri değildir. WAL de binlog da bir tabanın üzerine oynatılır. Taban yoksa dönülebilir aralık da yoktur.
backup.sh PostgreSQL'i pg_dumpall ile yedekler — bu mantıksal bir
yedektir, yani SQL cümleleridir. WAL ise blok düzeyi değişikliklerdir.
Birinin üzerine diğeri oynatılamaz: LSN'ler, blok düzenleri ve dosya
düğümleri tutmaz. Bu yüzden PITR'ın kendi tabanı var:
./scripts/pitr.sh taban postgresql # pg_basebackup — FİZİKSEL kopya
→ backups/postgresql/taban/postgresql_taban_<tarih>.tar.gz (+ .meta)
MariaDB'de binlog da mantıksal düzeydedir (satır/ifade olayları), yani dump
ile aynı dili konuşur. Şart olan tek şey, dump'ın hangi binlog
konumundan sonrasını temsil ettiğinin dosyada yazılı olmasıdır; bunu
mariadb-dump --master-data=2 yapar ve backup.sh zaten o bayrakla dump
alıyor. Bu yüzden pitr.sh aday tabanları iki yerden toplar:
backups/mariadb/taban/—pitr.sh taban mariadb'nin ürettikleribackups/mariadb/full/—backup.sh'ın gecelik yedekleri
İkincisi bilerek: her gün ayrıca bir dump daha almak, backup.sh'ın başında
anlatılan OOM olayını (aynı container'da ikinci bir ağır iş) geri getirirdi.
Ama bağımlılık yok: konum satırını taşımayan bir dosya aday listesine
hiç girmez, sessizce "taban var" sayılmaz.
Compose'a PITR ayarları eklendi. archive_mode yeniden başlatma ister,
reload yetmez:
docker compose up -d postgresql mariadb # container'lar yeniden yaratılır
./scripts/pitr.sh kur postgresql
./scripts/pitr.sh kur mariadb
./scripts/pitr.sh taban postgresql
./scripts/pitr.sh taban mariadb
./scripts/pitr.sh durumkur komutunun asıl işi izinleri ölçmektir. Arşiv dizini host'ta durur
ama container'ın kullanıcısı (postgres = uid 999, mysql = uid 999) oraya
yazar. Dizin yoksa docker onu root:root olarak yaratır ve
archive_command her segmentte "Permission denied" der. Bunun kötü tarafı
gürültülü olması değil, sessiz olmasıdır: PostgreSQL yeniden dener,
pg_wal büyür, arıza ancak veri diski dolduğunda görünür. kur, motorun
kendi kullanıcısıyla bir dosya oluşturup silerek bunu söz olarak değil
ölçüm olarak doğrular.
docker-compose.yml → postgresql
volumes:
- ${STACK_DIR:-.}/backups/postgresql/wal:/wal-archive
- ${STACK_DIR:-.}/config/postgresql:/etc/pitr:ro
environment:
PG_WAL_ARSIV: /wal-archive
command:
- -c
- archive_mode=${POSTGRES_ARCHIVE_MODE:-on}
- -c
- archive_command=sh /etc/pitr/wal-archive.sh %p %f
- -c
- archive_timeout=${POSTGRES_ARCHIVE_TIMEOUT:-300}wal_level değiştirilmedi: replica PITR için yeterlidir (logical
daha fazlasını yazar ve diski boşuna şişirir). Eksik olan tek şey
arşivlemeydi — replica seviyesi WAL'ı üretir ama pg_wal dolunca geri
dönüştürür; yani "dün 14:32" o segment geri dönüştürüldüğü anda ulaşılamaz
olur.
docker-compose.yml → mariadb
volumes:
- ${STACK_DIR:-.}/backups/mariadb/binlog:/binlog-archiveBinlog'un yeri değişmedi; buraya bir kopya çıkarılıyor. Sebebi aşağıda.
| Yer | Kim yazar | |
|---|---|---|
| PostgreSQL WAL | backups/postgresql/wal/ |
motor, archive_command ile sürekli |
| PostgreSQL taban | backups/postgresql/taban/ |
pitr.sh taban postgresql |
| MariaDB binlog (asıl) | mariadb_data hacmi, /var/lib/mysql/mysql-bin.* |
motor |
| MariaDB binlog (kopya) | backups/mariadb/binlog/ |
pitr.sh arsivle mariadb |
| MariaDB taban | backups/mariadb/taban/ + backups/mariadb/full/ |
pitr.sh taban / backup.sh |
Arşiv yedeklerin altında, ayrı bir kökte değil. Ayrı bir yer seçseydik
iki şey bozulurdu: backup.sh stats disk uyarısı arşivi saymazdı ve
sync-remote.sh uzağa yalnız yedekleri gönderirdi — felaket günü tam yedek
uzakta, PITR arşivi ölen sunucuda kalırdı.
log_bin yolunu /binlog-archivee taşımak cazip görünüyor ama yıkıcı:
MariaDB hangi binlog'ların var olduğunu mysql-bin.index dosyasından bilir
ve PURGE'ü ona göre yapar. Yol değişince yeni bir index açılır, eski
dizinde kalan dosyalar bir daha hiç silinmez ve
binlog_expire_logs_seconds onları artık görmez. Sessizce dolan bir veri
hacmi, çözmeye çalıştığımız sorunun daha kötüsü olurdu.
Kopya ise host'ta durur: motorun hacmi ölse bile binlog elde kalır.
./scripts/pitr.sh temizle # varsayılan: PITR_RETENTION_DAYS (= RETENTION_DAYS)
./scripts/pitr.sh temizle 14PITR_RETENTION_DAYS varsayılan olarak RETENTION_DAYS'e eşittir; ayrışırsa
pitr.sh durum bunu uyarı olarak gösterir. Arşiv daha uzunsa disk
sessizce dolar; kısaysa elinizdeki yedeklerin bir kısmından ileri gidilemez.
Ama asıl mesele gün sayısı değil: yaşa bakan düz bir silme, saklanan bir
tabanın ihtiyaç duyduğu WAL'ı da siler. backup.sh clean her motorda en
yeni 3 kopyayı yaşı ne olursa olsun korur (kapalı bir motorun son
kurtarma noktası yok olmasın diye). O kural yüzünden 30 günlük bir taban elde
kalabilir; WAL'ını 7 günde silersek elimizde açılamayan bir taban kalır
ve kimse fark etmez.
Bu yüzden temizle iki ölçütü birlikte uygular:
- yaş >
PITR_RETENTION_DAYS, ve - dosya, saklanan en eski tabanın gerektirdiği zamandan da eski
(taban dosyasının mtime'ı yedeğin bitişidir, gereken WAL ise
başlangıcından itibarendir — aradaki fark
PITR_KORUMA_TAMPON, varsayılan 4 saat)
*.history dosyaları yaş ne olursa olsun kalır. Birkaç yüz bayttır ama
kurtarma, hangi zaman çizgisinin nereden ayrıldığını yalnız oradan okur;
silinirse latest çizgiyi izleyen her kurtarma eski çizgide takılır ve bunu
hiçbir hata mesajı söylemez.
Taban dosyalarını temizle SİLMEZ. Onlar backups/<motor>/taban/
altında ve backup.sh clean oraya da bakıyor (find özyinelemeli, *.gz
deseni hem tar.gz'yi hem sql.gz'yi kapsıyor). İki sahibi olan bir
politika, hiç sahibi olmayandan beterdir.
./scripts/pitr.sh durum
./scripts/pitr.sh durum postgresql --json # controller / panel için
- En eski = en eski taban yedeğinin bittiği an (
.meta→bitis_epoch). Ondan öncesine dönmek imkânsızdır. - En yeni = arşive düşmüş son segmentin arşivlenme anı (dosyanın
mtime'ı). Sunucuda o andan sonra üretilmiş WAL da vardır ama o henüz
pg_waldadır; ölen bir sunucuda ona ulaşılamaz, dolayısıyla "dönülebilir" saymak yalan olurdu. - Dosya mtime'ı kullanılıyor çünkü sunucu kapalıyken de okunur — ve PITR'a en çok sunucu kapalıyken bakılır.
- Arşivde boşluk varsa en yeni, boşluktan önceki son segmentin zamanına
çekilir ve sebebi yazılır. Boşluk, segment adlarının ardışıklığından
bulunur (24 hex = 8 zaman çizgisi + 8 log kimliği + 8 sıra; log kimliği
başına segment sayısı = 2³² /
wal_segment_size). Zaman çizgileri ayrı ayrı değerlendirilir, yoksa her PITR'dan sonra yanlış alarm üretirdi.
- En eski = binlog'u hâlâ duran en eski tabanın alındığı an. Tabanın
işaret ettiği binlog
PURGEedilmişse o tabandan ileri gidilemez; o taban aralığa dahil edilmez vedurumkaç tabanın bu yüzden kullanılamaz olduğunu ayrıca yazar. - En yeni = şu an. MariaDB'de binlog yerel bir dosyadır ve her commit anında yazılır; PostgreSQL'deki gibi bir "arşive düşme gecikmesi" yoktur. Sunucu kapalıysa bu doğru olmaz — o hâlde host arşivindeki en yeni binlog'un zamanı kullanılır ve sebep yazılır.
./scripts/pitr.sh arsivle postgresql # pg_switch_wal + arşive düştüğünü DOĞRULA
./scripts/pitr.sh arsivle mariadb # FLUSH BINARY LOGS + host'a kopyalaPostgreSQL'de segment dolmadan arşive düşmez. archive_timeout=300
yazı olmuşsa segmenti 5 dakikada bir zorla kapatır — yani RPO'nun üst
sınırı budur. Bedeli: yazı olan her 5 dakikada bir, yarısı boş olsa da tam
boyutlu (16 MB) bir segment arşive düşer. Boştaki bir sunucuda bu hiç olmaz
(PostgreSQL yazı yoksa segment değiştirmez), yani sabit bir disk maliyeti
değil.
# 1) ÖNCE PROVA — üretime dokunmaz
./scripts/pitr.sh don postgresql "2026-09-01 14:32:00" --prova \
--dogrula "SELECT count(*) FROM siparisler WHERE tutar < 0"
# 2) sonuç doğruysa üretime
./scripts/pitr.sh don postgresql "2026-09-01 14:32:00"1. --prova. Kurtarma tek kullanımlık bir container ve hacimde,
--network none ile yapılır; üretim container'ına, gateway'e ya da yığın
ağına giden bir yol yoktur. Arşiv salt okunur bağlanır: provanın
arşivi kirletmesi teknik olarak imkânsızdır. Bu, "dokunmuyoruz" sözünü
niyete değil çekirdeğe bağlar (restore-drill.sh ile aynı sözleşme).
--dogrula kurtarılan kopyaya kendi sorgunuzu sorar ve cevabını JSON'a
koyar. Neden var: "kurtarma başarıyla bitti" cümlesi istenen verinin
geldiğini kanıtlamaz. Üretimde uygulamadan önce sorulacak tek doğru soru,
"o satır orada mı, sonraki satır orada değil mi" sorusudur.
2. Güvenlik yedeği. Üretimde don, yıkıcı adıma geçmeden önce mevcut
hâlin tam bir kopyasını alır (PostgreSQL'de pg_basebackup, MariaDB'de
mariadb-dump). Yanlış ana dönmek de bir arızadır ve geri alınabilmelidir.
Güvenlik yedeği alınamazsa hiçbir şey yapılmaz.
3. Aralık kapısı. Hedef aralığın dışındaysa iş reddedilir (çıkış 2) ve gerekçe sayılarla yazılır:
[✗] Hedef aralığın ÖNCESİNDE: istenen 2026-09-02 10:00:00 +0300 ·
en eski dönülebilir an 2026-09-02 17:52:49 +0300
(bunu en eski taban yedeği belirliyor). Daha geriye gitmek için
o tarihi kapsayan bir taban gerekir; elde yok.
"Aralık dışında" tek başına kullanıcıyı ekranın karşısında bırakır: ne kadar geriye gidilebildiğini ve o sınırı neyin koyduğunu bilmeden ne yapacağına karar veremez. Sessizce "en yakın ana" dönmek ise ona olmayan bir veriyi var sanmasını öğretirdi.
Hedef zaman yerel saatle verilir. pitr.sh onu motora açık saat
dilimiyle geçirir:
- PostgreSQL:
recovery_target_time = '2026-09-01 14:32:00+0300' - MariaDB:
mariadb-binlogTZ=UTCile çağrılır, hedef UTC yazılır
Dilim yazılmasaydı sunucunun kendi ayarına göre yorumlanırdı; container UTC, host Europe/Istanbul iken bu sessizce üç saatlik bir kaymadır ve geri gelen veri "yaklaşık doğru" göründüğü için kimse fark etmez.
- PostgreSQL:
recovery_target_inclusive = on→ hedef an dahildir. - MariaDB:
--stop-datetime, damgası hedefe eşit ya da büyük ilk olayda durur → hedef an dahil değildir.
Saniye altı hassasiyet gerekiyorsa MariaDB'de hedefi bir saniye ileri alın; binlog olaylarının damgası zaten saniye çözünürlüğündedir.
[!] Varsa REPLİKALAR artık uyumsuz — ./stack.sh replica off/on ile yeniden kurun.
[!] Yeni bir PITR tabanı alın: ./scripts/pitr.sh taban <motor>
PostgreSQL'de kurtarma yeni bir zaman çizgisi başlatır; replikalar eski
çizgideki verinin peşindedir. MariaDB'de binlog oynatma sırasında
gtid_strict_mode geçici olarak kapatılır (eski GTID'leri yeniden yazmak
katı kipte "out-of-order sequence number" hatasıdır) ve her çıkış
yolunda — hata, zaman aşımı, Ctrl+C dahil — geri konur. Geri konamazsa
betik bunu ekrana yazar.
| Kod | Anlamı |
|---|---|
| 0 | iş tamam |
| 1 | başarısız — iş denendi, olmadı |
| 2 | reddedildi — hedef aralık dışında ya da onay verilmedi. Yıkıcı hiçbir iş yapılmadı; bu kod "veriniz yerinde" demektir |
| 3 | kapsam dışı / ölçülemedi — motor PITR desteklemiyor, docker yok, kilit başkasında. İş hiç denenemedi |
| 4 | iş bitti ama geçici container/hacim sızdı |
2 ile 1'in ayrı olması bilerek: "reddedildi" bir arıza değil, güvenlik kapısının çalıştığının kanıtıdır. Panel ikisini aynı renkte gösterirse kullanıcı gerçek arızayı görmeyi bırakır.
don ve durum --json son satırda tek satır JSON basar:
{"komut":"don","engine":"postgresql","target":"2026-09-01T14:32:00+0300",
"mode":"prova","ok":true,"seconds":37,"base":"…/postgresql_taban_….tar.gz",
"stopped_at":"LOG: recovery stopping before commit of transaction 734, …",
"verify":"A","cleanup":true,"detail":"PROVA GEÇTİ: …"}stopped_at motorun kendi cümlesidir, bizim yorumumuz değil.
Kurtarmanın nerede durduğunu ondan başka kimse bilmiyor.
scripts/e2e/pitr.sh. Ölçtüğü asıl şey tek cümledir:
T1'de A satırı yazılır · T2'de B satırı yazılır · T1 ile T2 arasına dönülür → kopyada A var, B yok.
./scripts/e2e/pitr.sh # postgresql + mariadb
./scripts/e2e/pitr.sh mariadbNeden bu cümle: tek bir satır saymak yetmez. Taban yedeğini olduğu gibi açıp değişiklik günlüğünü hiç oynatmayan bir kurtarma da "başarılı" görünür; hedefi aşan bir kurtarma da. İki satırın biri varken diğeri yoksa, hem tabanın üzerine günlüğün oynatıldığı hem de doğru yerde durulduğu aynı anda kanıtlanır.
Paket ayrıca şunları ölçer: aralık dışına (hem geleceğe hem çok geriye)
dönme denemesinin çıkış 2 ile reddedilmesi; prova ve reddedilen denemeler
sonrasında üretimde hâlâ A,B bulunması (yani "dokunmadık" iddiasının
ölçülmesi); arşiv temizliğinin saklama süresine uyması ve saklanan bir
tabanın gerektirdiği segmente dokunmaması.
Ölçülemeyen her şey t_unknown'dır ve scripts/e2e/lib.sh onu
başarısız sayar — "bilmiyorum" ile "iyi" aynı şey değildir.
Bu paket yıkıcı değildir: kurtarma yalnız --prova ile yapılır, üretimde
yalnız kendi test tablosu (e2e_pitr) oluşturulup sonunda düşürülür.
Bunlar artık scripts/crontab.template içinde hazır — install.sh
state/crontab'ı üretiyor, crontab state/crontab ile yüklüyorsunuz.
Önceki sürümde "eklenebilir" diye örnek olarak duruyordu ve kimse eklemiyordu;
sonucu, MariaDB'de hiç ilerlemeyen bir PITR penceresiydi.
*/15 * * * * scripts/pitr.sh arsivle # RPO üst sınırı: 15 dakika
0 1 * * * scripts/pitr.sh taban # WAL/binlog tek başına veri değildir
15 3 * * * scripts/pitr.sh temizle # arşiv sonsuza kadar büyümesinMotor adı verilmezse PITR'li motorların hepsinde çalışır. Kapalı motor atlanır (çıkış 3) ve alarm üretmez; her sabah alarm veren bir cron, bakılmayan bir crondur. Motorları crontab'a tek tek yazmıyoruz: yığına üçüncü bir PITR motoru eklendiği gün sessizce arşivsiz kalırdı.
taban ile gecelik tam yedek bilerek farklı saatte: ikisi aynı kilidi
paylaşır, çakışırlarsa biri hiç çalışmaz.
Önceki sürüm burada "PostgreSQL WAL arşivi için zamanlanmış bir işe gerek
yok, archive_command sürekli çalışır" diyordu. Doğru ama eksik:
archive_command çalışamazsa kimse fark etmiyor. Sunucuda ölçülen iki
hâl:
- Dizin yok. docker, bind-mount kaynağını root:root yaratır;
postgreskullanıcısı yazamaz ve arşivleme saniyede bir çıkış 1 döner. Bunu düzelten tek şey elle yazılanpitr.sh kuridi — yani PITR, o komut yazılana kadar kapalı kalıyordu. Artıkinstall.shdizinleri açıyor vearsivleizni kendisi ölçüp düzeltiyor. - Devir yapılmış. Yükseltilen düğümde
archive_modeve/wal-archiveyoksa arşivleme durur; pencerenin üst sınırı devir anına donar. Yani PITR tam da bir olayın ardından, en çok gerektiği anda ölür.postgresql-replicaartık ana kopyayla aynı arşiv ayarlarını taşıyor.
arsivle ayrıca pg_switch_wal() çağırıp segmentin arşive düştüğünü
doğruluyor: yazı olmuş ama segment dolmamışsa "en yeni dönülebilir an"
saatlerce geride kalır ve kullanıcı aslında elinde olan veriye dönemez.
Arşiv dizinine iki ayrı kimlik yazar: motorun kendi kullanıcısı
(postgres/mysql, container'ın içinden) ve sunucudaki yönetici
(pitr.sh taban, temizle). Yalnız birine açmak diğerini kırar — dizin
postgres:root 750 olduğunda yönetici arşivi listeleyemiyor, temizle
hiçbir şey silemiyordu. Doğru hâl: sahibi motorun kullanıcısı, grubu
sunucudaki grup, mod 2775. Bozulmuşsa:
./stack.sh doctor # yazamadığınız dizinleri sahibiyle listeler
sudo ./stack.sh doctor --duzelt| Değişken | Varsayılan | Ne yapar |
|---|---|---|
POSTGRES_ARCHIVE_MODE |
on |
WAL arşivleme. off PITR'ı bitirir — ama arşive yazamayan bir kurulumda pg_walın veri diskini doldurmasından iyidir; bu kaçış yolu bilerek var |
POSTGRES_ARCHIVE_TIMEOUT |
300 |
Yazı olmuşsa segmenti kaç saniyede bir zorla kapat (= RPO üst sınırı) |
PITR_RETENTION_DAYS |
RETENTION_DAYS |
Arşiv saklama süresi |
PITR_KORUMA_TAMPON |
14400 |
En eski tabanın gerektirdiği WAL için güvenlik payı (sn) |
PITR_ZAMAN_CIZGISI |
latest |
recovery_target_timeline. latest üst üste PITR yapılabilmesi için gerekli |
PITR_KURTARMA_SURESI |
1800 |
Kurtarmanın tamamlanması için üst sınır (sn) |
PROVA_MEM_MB |
1024 |
Geçici kopyanın bellek tavanı |
PITR_DOGRULA_DB |
DEFAULT_DATABASE |
--dogrula sorgusunun koşacağı veritabanı (PostgreSQL) |
ASSUME_YES=yes |
— | Üretim don için onay sorma (otomasyon) |
durum "archive_mode KAPALI" diyor ama compose'da on yazıyor.
archive_mode yeniden başlatma ister, reload yetmez. docker compose up -d postgresql ile container'ı yeniden yaratın. durum compose'daki değere
değil çalışan sunucuya sorar; aradaki fark tam olarak budur.
durum "arşivleme N kez başarısız oldu" diyor.
Neredeyse her zaman izin: arşiv dizini docker tarafından root olarak
yaratılmıştır. ./scripts/pitr.sh kur postgresql. Bu uyarıyı ciddiye alın —
PostgreSQL arşivlenemeyen segmenti serbest bırakmaz, pg_wal büyür ve
sonunda sunucu PANIC ile durur.
don "İSTENEN ANA ULAŞAMADI, arşivin ulaştığı son işlem X" diyor,
oysa hedef aralığın içindeydi.
En sık sebep eksik segment değil, sessiz bir veritabanıdır. Hedefle son
işlem arasında hiç yazı olmadıysa o aralıkta WAL kaydı da yoktur ve
PostgreSQL hedefi reddeder. Mesajdaki X anını (ya da öncesini) hedef alın.
Ölçümde bu birebir görüldü: son işlem 17:53:14, hedef 17:55:00, arada tek
yazı yok → FATAL: recovery ended before configured recovery target was reached.
don "KURTARMA KOMUTU ÇALIŞTIRILAMADI" diyor.
/etc/pitr bağlaması yok — container PITR ayarları eklendikten sonra
yeniden yaratılmamış. docker compose up -d postgresql. Bu mesaj ayrı
tutuluyor çünkü PostgreSQL'in günlüğe yazdığı cümle yukarıdakiyle birebir
aynıdır; suçu arşive atan bir mesaj, operatörü var olmayan bir eksik
segmenti aramaya gönderirdi.
don "Yedekleme kilidi başkasında" deyip çıkış 3 veriyor.
backup.sh, restore-drill.sh ve pitr.sh aynı kilidi paylaşır: tek
sunucuda iki ağır iş aynı cgroup'u zorlar ve temizlik turu, okuduğumuz
dosyayı ayağımızın altından çekebilir. Çıkış 3 "kurtarma başarısız" değil,
"kurtarma denenmedi" demektir.