Gece üçte çalışan rapor betiği sabah masanıza Müşteri_Raporu.csv diye bir dosya bırakmış. Aynı betiği SSH oturumunuzda elle çalıştırıyorsunuz: dosya adı da içeriği de tertemiz çıkıyor. Kod değişmedi, sunucu değişmedi, veritabanı değişmedi. Değişen tek şey, işi kimin başlattığı.
Bu tablonun arkasında neredeyse her zaman aynı sebep vardır ve sürpriz olan tarafı şudur: sunucunuzda muhtemelen hiç Türkçe locale kurulu değil. SSH oturumunuzun düzgün çalışmasının nedeni sunucu değil, sizin bilgisayarınızdır. OpenSSH istemcisi varsayılan olarak LANG ve LC_* değişkenlerini karşı tarafa gönderir (SendEnv LANG LC_*), sunucudaki sshd de bunları kabul eder (AcceptEnv LANG LC_*). Yani oturumunuzdaki UTF-8 desteği, dizüstünüzden bavulla taşınmış geçici bir ayardır. Cron'un ise bir istemcisi yoktur; işi tamamen çıplak bir ortamda başlatır ve sunucunun gerçek hâli ortaya çıkar.
Bu yazıda önce bozulmanın hangi katmanda gerçekleştiğini kesin olarak teşhis edeceğiz — çünkü "dosya adı bozuk" ile "ekranım bozuk gösteriyor" tamamen farklı iki durumdur ve çözümleri birbirinin yerine geçmez. Sonra locale-gen, /etc/default/locale, systemd birimleri ve crontab tarafında ayarı kalıcı hâle getireceğiz. FTP ve zip/tar üzerinden gelen dosya adlarını onaracağız. En sonda da yalnızca Türkçe konuşan sistem yöneticilerinin başına gelen bir tuzağı ele alacağız: tr_TR locale'i açık olan bir sunucuda kabuk betiklerinin i/I yüzünden sessizce yanlış karar vermesi.
Dosya mı Bozuk, Yoksa Sadece Ekranınız mı?#
Bu ayrımı yapmadan atılan her adım boşa gider. ls çıktısında soru işaretleri görmeniz, dosya adının diskte bozuk olduğu anlamına gelmez. GNU ls, çıktıyı bir terminale yazarken geçerli locale'de basılamayan baytların yerine ? koyar. Locale C ya da POSIX ise UTF-8 çok baytlı dizilerin hiçbiri basılabilir sayılmaz ve tertemiz bir Müşteri.csv ekrana M??teri.csv diye düşer.
Karar vermenin tek güvenilir yolu baytlara bakmaktır:
# Dosya adlarının gerçek baytları
ls | od -c | head
# Basılamayan baytları kaçış dizisi olarak göster
ls -b
# Aynı dizini zorla UTF-8 yorumuyla listele
LC_ALL=C.UTF-8 ls
UTF-8'de Türkçe harfler iki bayttır: ş = c5 9f, ğ = c4 9f, ı = c4 b1, ü = c3 bc, İ = c4 b0. Çıktıyı bu tabloya göre okuyun:
| Ekranda gördüğünüz | Baytlar | Gerçek durum | Yapılacak |
|---|---|---|---|
M??teri.csv | c5 9f (2 bayt) | Dosya adı sağlam, UTF-8 | Oturumun locale'ini düzeltin |
Müşteri.csv | c3 83 c2 bc (4 bayt) | Çift kodlanmış | Kaynağı düzeltin, sonra convmv |
Mþsteri.csv | fe (tek bayt) | cp1254 / cp857 baytı | convmv ile dönüştürün |
Müşteri.csv | c5 9f | Her şey yolunda | Sorun başka katmanda |
İki bayt görüyorsanız veri yerindedir, yalnızca gözlük yanlıştır. Tek bayt ya da dörtlü c3 83 blokları görüyorsanız dosya adının kendisi gerçekten bozuktur; dizini gezen her betik, her yedekleme ve her rsync bu bozukluğu taşımaya devam eder.
Sunucunun Locale'i Nedir, Kim Belirler?#
Üç değişken vardır ve öncelik sırası nettir: LC_ALL > tek tek LC_* > LANG. LC_ALL bir balyozdur; tanımlıysa diğer her şeyi ezer. Bu yüzden sistem geneli dosyalarda kullanılmaz, yalnızca tek bir komutu ya da betiği sabitlemek için kullanılır.
# Oturumunuzun locale'i (istemcinizden gelmiş olabilir)
locale
# Sunucuda GERÇEKTEN üretilmiş locale'ler
locale -a | grep -i -E 'tr_TR|C\.utf'
# Cron'un ve systemd'nin göreceği çıplak ortam
env -i /usr/bin/locale
Son komut bu yazının en önemli teşhis aracıdır. env -i bütün ortam değişkenlerini siler ve size sunucunun devraldığı değil, sahip olduğu locale'i gösterir. Çıktıda LANG= boşsa ya da LANG=C görüyorsanız, cron'daki bozulmanın sebebini bulmuşsunuz demektir.
Bir başka net sinyal, LANG tanımlıyken karşılığı üretilmemişse Perl'in verdiği uyarıdır. Bunu görüyorsanız istemciniz sunucuda karşılığı olmayan bir değer göndermiştir:
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
LC_ALL = (unset),
LANG = "tr_TR.UTF-8"
are supported and installed on your system.
perl: falling back to the standard locale ("C").
Bu uyarı apt ve dpkg çıktılarında da sık görülür ve genelde göz ardı edilir. Oysa doğrudan "istediğin locale bu makinede yok" demektir.
tr_TR.UTF-8 Locale'ini Sunucuya Kurmak#
Debian ve Ubuntu'da locale'ler locale.gen üzerinden üretilir. Minimal sunucu kurulumlarında ve konteyner imajlarında locales paketi çoğu zaman hiç yoktur:
sudo apt-get update && sudo apt-get install -y locales
# İstenen satırların başındaki yorum işaretini kaldır
sudo sed -i 's/^# *\(tr_TR.UTF-8 UTF-8\)/\1/' /etc/locale.gen
sudo sed -i 's/^# *\(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen
sudo locale-gen
sudo update-locale LANG=tr_TR.UTF-8
# Doğrulama
locale -a | grep tr_TR
cat /etc/default/locale
AlmaLinux, Rocky ve RHEL tarafında locale'ler dil paketleriyle gelir. Minimal imajlarda glibc-minimal-langpack kuruludur ve içinde yalnızca C vardır:
sudo dnf install -y glibc-langpack-tr
sudo localectl set-locale LANG=tr_TR.UTF-8
localectl status
locale -a | grep tr_TR
İki dağıtımda da aynı sürprize hazır olun: bu değişiklikler açık oturumları etkilemez. /etc/default/locale ve /etc/locale.conf dosyaları giriş anında okunur, dolayısıyla mevcut SSH oturumunuz eski değerlerle çalışmaya devam eder. Çıkıp yeniden bağlanın ve env -i /usr/bin/locale ile tekrar ölçün.
Cron'da Locale'i Açıkça Tanımlamak#
Cron'un ortamı kasıtlı olarak dardır ve bu darlık locale'i de kapsar. Dağıtımdan dağıtıma davranış değiştiği için tahmin yürütmeyin, ölçün. Bir dakikalığına şu satırı ekleyin:
* * * * * /usr/bin/locale > /tmp/cron-locale.txt 2>&1
Bir dakika sonra cat /tmp/cron-locale.txt size cron'un gerçekte ne devrettiğini söyler. LANG= boş ya da LANG=C çıkıyorsa çözüm, crontab'ın en üstüne açık tanım koymaktır:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=tr_TR.UTF-8
LC_ALL=tr_TR.UTF-8
30 3 * * * /usr/local/bin/gece-raporu.sh >> /var/log/gece-raporu.log 2>&1
Üç ayrıntıya dikkat edin. Birincisi, crontab'daki bu atamalar kabuk değildir: değişken genişletme, komut ikamesi ve tırnak yorumu yoktur; LANG=$OTHER yazarsanız değişmez metin olarak atanır. İkincisi, bu satırlar yalnızca kendilerinden sonra gelen görevler için geçerlidir, dosyanın en üstüne koyun. Üçüncüsü, crontab'da % işareti satır sonu anlamına gelir; date +%Y-%m-%d yazacaksanız \% ile kaçırmanız gerekir. Cron'un sessizce başarısız olmasının diğer nedenlerini cron görevim çalışmıyor yazısında topladık.
Daha dayanıklı yaklaşım, betiği çağıran ortama güvenmek yerine betiğin kendi ortamını kurmasıdır. Böylece aynı betik cron'dan, systemd'den, elden veya bir CI hattından çalıştığında aynı davranır:
#!/usr/bin/env bash
set -euo pipefail
# Karşılaştırma ve ayrıştırma deterministik olsun
export LC_ALL=C.UTF-8
# Ama insana gösterilecek tarih Türkçe kalsın
export LC_TIME=tr_TR.UTF-8
echo "Rapor tarihi: $(date '+%d %B %Y %A')"
Bu ayrımın neden gerekli olduğunu aşağıdaki i/I bölümünde göreceksiniz. Betik iskeletinin genel kuralları için bash betik yazma temelleri yazısına bakabilirsiniz.
systemd Servislerinde LANG Tanımlama#
systemd, birimleri başlatırken kullanıcı oturumunun ortamını devretmez. Uzun süre çalışan bir Python servisi, bir kuyruk işçisi ya da bir yedekleme birimi Türkçe dosya adı üretiyorsa locale'i birim dosyasında tanımlamanız gerekir:
[Service]
Type=simple
Environment=LANG=tr_TR.UTF-8
Environment=LC_TIME=tr_TR.UTF-8
Environment=PYTHONIOENCODING=UTF-8
ExecStart=/opt/rapor/venv/bin/python /opt/rapor/main.py
Paketten gelen bir birimi doğrudan düzenlemek yerine drop-in kullanın; paket güncellendiğinde değişikliğiniz kaybolmaz:
sudo systemctl edit rapor.service
sudo systemctl daemon-reload
sudo systemctl restart rapor.service
# Birimin gerçekte hangi ortamla çalıştığını doğrulayın
systemctl show rapor.service -p Environment
systemd /etc/locale.conf dosyasını erken açılışta okuyup yönetici ortamına aktarır; Debian tabanlı sistemlerde bunun eşdeğeri /etc/default/locale'dir. Ancak dağıtım ve sürüm farkları yüzünden buna güvenmek yerine systemctl show-environment ile ölçmek her zaman daha ucuzdur. Servis birimlerinin genel yapısı ve drop-in mantığı için systemd servis yönetimi yazısı işinizi görür.
FTP ile Yüklenen Dosya Adları Neden Soru İşaretine Dönüyor?#
FTP, 1985 tarihli RFC 959 ile tanımlandığında Unicode diye bir şey yoktu; dosya adları için tek baytlık bir karakter kümesi varsayılır. UTF-8 desteği ancak RFC 2640 ile, üstelik istemci ile sunucunun anlaşmasına bağlı olarak eklendi. Windows'tan bir dosya yüklerken istemci UTF-8 anlaşması yapmazsa çalışma raporu.pdf sunucuya Türkçe Windows'un ANSI kod sayfası olan cp1254 baytlarıyla iner ve dosya sistemi bunu UTF-8 sanmaz, anlamsız tek baytlar olarak saklar.
Sunucunuzun UTF-8 duyurusu yapıp yapmadığını dışarıdan görebilirsiniz; FEAT yanıtında UTF8 satırı olmalıdır:
printf 'FEAT\r\nQUIT\r\n' | nc ftp.ornek.com 21
Çözüm sırası şudur:
- İstemci tarafını sabitleyin. FileZilla'da Site Yöneticisi altındaki Karakter Seti sekmesinden "UTF-8 kullanmaya zorla" seçeneğini işaretleyin. Bu tek adım vakaların çoğunu kapatır.
- Sunucu tarafını hizalayın. vsftpd kullanıyorsanız
/etc/vsftpd.confiçineutf8_filesystem=YESekleyip servisi yeniden başlatın. - Mümkünse FTP'yi bırakın. SFTP, dosya adlarını protokol düzeyinde UTF-8 olarak tanımlar; anlaşma diye bir adım yoktur, dolayısıyla bu sorun sınıfı tamamen ortadan kalkar. Üstelik trafik de şifrelidir; SSH bağlantısı ve güvenliği yazısındaki anahtar tabanlı erişimle birleştirince FTP'yi tümden kapatabilirsiniz.
Zip ve Tar Arşivlerinden Çıkan Bozuk Türkçe Dosya Adları#
ZIP biçiminin dosya adları için bir kodlama alanı yoktur. Arşivi üreten program, genel amaçlı bayrak alanının Unicode bitini işaretlemediyse adların hangi kod sayfasıyla yazıldığı hiçbir yerde durmaz; Linux tarafındaki unzip de baytları olduğu gibi diske yazar. Sonuç: müşteriden gelen belgeler.zip açılır, içinden Ýhale ÞartnamesÝ.docx çıkar.
Türkçe Windows'ta iki ayrı kod sayfası dolaşımdadır ve hangisinin kullanıldığı arşivi üreten programa bağlıdır: Gezgin'in "sıkıştırılmış klasör" özelliği DOS/OEM kod sayfası cp857'yi, WinRAR ve 7-Zip ise çoğunlukla ANSI kod sayfası cp1254'ü ya da doğrudan UTF-8'i kullanır. Debian ve Ubuntu'daki unzip bunu komut satırından belirtmenize izin verir:
# Açmadan önce hangi kod sayfasının doğru olduğunu görün
unzip -l -O cp857 arsiv.zip
unzip -l -O cp1254 arsiv.zip
# Doğru görüneni seçip açın
unzip -O cp857 arsiv.zip -d /var/www/uploads
-O seçeneği her dağıtımda derlenmiş olmayabilir. Yoksa libarchive'in bsdtar aracı aynı işi yapar ve tar arşivlerinde de çalışır:
sudo apt-get install -y libarchive-tools
bsdtar --hdrcharset=CP857 -xvf arsiv.zip -C /var/www/uploads
Dosyalar zaten yanlış adlarla açıldıysa arşive dönmenize gerek yok; convmv adları yerinde dönüştürür. Varsayılan olarak kuru çalışır, yani ne yapacağını yazar ama dokunmaz; uygulamak için --notest gerekir:
sudo apt-get install -y convmv
# Önce provası: hiçbir şeyi değiştirmez
convmv -f cp1254 -t utf8 -r /var/www/uploads
# Sonuç doğruysa uygula
convmv -f cp1254 -t utf8 -r --notest /var/www/uploads
| Arşiv nereden geldi | Muhtemel kodlama | Komut |
|---|---|---|
| Windows Gezgini, sağ tık ile sıkıştır | cp857 (OEM) | unzip -O cp857 |
| WinRAR / 7-Zip (Türkçe Windows) | cp1254 veya UTF-8 | unzip -O cp1254 |
| macOS Finder | UTF-8, ama ayrıştırılmış (NFD) | Açılır; convmv --nfc gerekebilir |
Linux zip / tar | UTF-8 | Sorunsuz |
Son satırdaki macOS durumu sinsidir çünkü ekranda hiçbir bozukluk görünmez. macOS ş harfini tek bir kod noktası yerine s artı birleşen karakter olarak saklar. Dosya listesi kusursuz görünür ama betiğinizdeki grep "şartname" eşleşmez, PHP tarafındaki dizi karşılaştırması tutmaz, veritabanındaki kayıtla dosya adı denk gelmez. Normalleştirme tek komutla düzelir:
convmv --nfc -f utf8 -t utf8 -r --notest /var/www/uploads
Bayt mı Karakter mi? Sayma, Kesme ve Hizalama Sorunları#
Locale yalnızca ekrana basılan harfleri değil, standart araçların saymayı nasıl yaptığını da belirler. Türkçe harfler UTF-8'de iki bayt tuttuğu için bayt sayan bir araçla karakter sayan bir araç aynı metin üzerinde farklı sonuç verir:
# 6 harf + satır sonu = 13 bayt
echo "ığüşçö" | wc -c
# Locale C ise wc -m de baytları sayar: yine 13
echo "ığüşçö" | LC_ALL=C wc -m
# Locale UTF-8 ise karakterleri sayar: 7
echo "ığüşçö" | LC_ALL=C.UTF-8 wc -m
Bunun pratikteki karşılıkları şunlardır: sabit genişlikli rapor sütunları kayar, cut -c ile alınan alan bir Türkçe harfin ortasından bölünüp geçersiz bayt üretir, awk içindeki length() beklenenin neredeyse iki katını döndürür, VARCHAR(50) diye tanımlanan alan 50 harf değil daha azını alır. Sabit genişlikli metin üreten her raporlama betiğinde locale'in UTF-8 olduğundan emin olun; alan kesiyorsanız bayt tabanlı kesme yerine karakter farkındalığı olan araçlar kullanın.
Türkiye'ye Özgü Tuzak: i/I Dönüşümü ve Sıralama#
Buraya kadarki adımların hepsi "locale'i Türkçe yap" yönünde ilerledi. Şimdi ters yöne dönmenin zamanı, çünkü tr_TR locale'inin küçük/büyük harf kuralı dünyanın geri kalanından farklıdır: Türkçe'de i harfinin büyüğü İ, I harfinin küçüğü ise ı'dır. Bu dilbilimsel olarak doğrudur ve tam da bu yüzden tehlikelidir; kabuk betikleriniz İngilizce anahtar kelimelerle çalışır:
LC_ALL=tr_TR.UTF-8 bash -c 'X=INSTALL; echo "${X,,}"' # ınstall
LC_ALL=C.UTF-8 bash -c 'X=INSTALL; echo "${X,,}"' # install
Üstteki satırda üretilen ınstall, hiçbir if [ "$x" = "install" ] karşılaştırmasını geçmez. Aynı mantık gawk içindeki tolower() ve toupper() çağrıları ile GNU grep -i için de geçerlidir: grep -i install komutu tr_TR locale'inde INSTALL satırını atlayabilir. Java'nın String.toLowerCase() metodu da varsayılan locale'i kullandığı için aynı hataya düşer; literatürde doğrudan "Turkish I problem" diye anılır.
İkinci etki sıralamadır. LC_COLLATE değiştiğinde hem sort çıktısının sırası hem de düzenli ifadelerdeki [a-z] gibi aralıkların neyi kapsadığı değişir:
printf 'çilek\ncilt\nışık\nisim\n' | LC_ALL=C sort
printf 'çilek\ncilt\nışık\nisim\n' | LC_ALL=tr_TR.UTF-8 sort
İki çıktı farklıdır ve locale'leri farklı iki sunucuda çalışan aynı betik farklı sonuç üretir. Bu yüzden karşılaştırma, ayrıştırma, sıralama ve benzersizleştirme yapan her betikte export LC_ALL=C.UTF-8 satırı bir tercih değil sigortadır: UTF-8 desteğini korursunuz ama i/I ve sıralama sürprizlerini kapatırsınız. Türkçe locale'i yalnızca insanın okuyacağı çıktıya (LC_TIME, LC_MONETARY) bırakın.
Hangi Katmanda Hangi Locale: Kalıcı Kural#
Sunucuyu teslim etmeden önce şu tabloyu bir kontrol listesi gibi geçin:
| Katman | Önerilen ayar | Gerekçe |
|---|---|---|
Sistem geneli (/etc/locale.conf, /etc/default/locale) | LANG=C.UTF-8 veya en_US.UTF-8 | UTF-8 desteği tam, i/I sürprizi yok |
| Kullanıcı oturumu | tr_TR.UTF-8 serbest | Çıktıyı insan okuyor |
| Kabuk betikleri | export LC_ALL=C.UTF-8 | Karşılaştırma ve sıralama deterministik |
| cron | crontab başında açıkça LANG= | Hiçbir şey devralmaz |
| systemd birimleri | Environment=LANG= | Oturum ortamını devralmaz |
| Web sunucusu / PHP | default_charset = "UTF-8" | Çıktı başlığı ve iç dönüşümler |
| Veritabanı | utf8mb4 artı bilinçli collation | Ayrı bir katman, ayrı bir karar |
Son satır önemlidir: sistem locale'i ile veritabanı karakter seti birbirinden bağımsızdır ve birini düzeltmek diğerini düzeltmez. Sunucunun locale'i kusursuzken veritabanınız hâlâ latin1 olabilir. O durumun teşhisi ve onarımı için veritabanı taşımada collation sorunu yazısına, WordPress özelindeki karşılığı için de WordPress Türkçe karakter sorunu yazısına bakın.
Sıkça Sorulan Sorular#
SSH oturumumda Türkçe karakterler düzgün, cron'da bozuk. Neden?#
Çünkü oturumunuzdaki locale sunucunun değil, sizin bilgisayarınızın ayarıdır. OpenSSH istemcisi LANG ve LC_* değişkenlerini bağlantıyla birlikte gönderir, sunucudaki sshd de bunları kabul eder. Cron'un böyle bir kaynağı yoktur ve sunucunun kendi varsayılanıyla çalışır. env -i /usr/bin/locale komutu sunucunun gerçek ayarını gösterir; buradaki değer boş ya da C ise sebep budur.
Sistem geneline tr_TR.UTF-8 mi yoksa C.UTF-8 mi ayarlamalıyım?#
Sunucu tarafında C.UTF-8 daha güvenlidir. UTF-8 desteğinin tamamını verir ama Türkçe'ye özgü küçük ve büyük harf dönüşümünü ve sıralama kurallarını devreye sokmaz. Böylece kabuk betikleri, grep -i çağrıları ve sort çıktıları her sunucuda aynı davranır. Türkçe biçimlendirme gereken yerlerde LC_TIME gibi tek tek değişkenleri tr_TR.UTF-8 yapmak yeterlidir.
locale-gen çalıştırdım ama locale hâlâ değişmedi, ne yapmalıyım?#
Değişiklik yalnızca yeni oturumlara uygulanır. /etc/default/locale ve /etc/locale.conf dosyaları giriş anında okunur, açık olan SSH oturumunuz eski değerleri taşımaya devam eder. Çıkıp yeniden bağlanın. Servisler için ayrıca systemctl daemon-reload ve ilgili birimin yeniden başlatılması gerekir; cron görevleri ise crontab içindeki tanımı görene kadar değişmez.
Dosya adlarındaki Türkçe karakterler bozuk, dosyaları yeniden yüklemek zorunda mıyım?#
Hayır. Baytlar diskte duruyorsa convmv aracıyla adları yerinde dönüştürebilirsiniz; komut varsayılan olarak kuru çalışır, --notest eklemeden hiçbir şeyi değiştirmez. Yalnızca baytların hangi kod sayfasından geldiğini doğru tahmin etmeniz gerekir; Türkçe Windows kaynaklı dosyalarda önce cp1254, sonra cp857 deneyin. Adlar soru işaretine dönüşmüşse bilgi gerçekten kaybolmuştur ve yeniden yükleme gerekir.
Betiklerimde LC_ALL kullanmak neden tavsiye ediliyor da sistem genelinde kullanılmıyor?#
LC_ALL diğer bütün locale değişkenlerini ezen bir balyozdur. Bir betiğin içinde bu tam olarak istediğiniz şeydir: betik, kendisini çağıran ortam ne olursa olsun aynı davranır. Ancak /etc/default/locale gibi sistem geneli bir dosyada tanımlarsanız, kullanıcıların ve uygulamaların tek tek LC_TIME veya LC_MONETARY ayarlaması imkânsız hâle gelir. Sistem genelinde LANG kullanın.
unzip -O seçeneği sunucumda yok, alternatifi ne?#
Bu seçenek Info-ZIP unzip paketine Debian ve Ubuntu tarafından eklenen bir yamadır, RHEL ailesinde bulunmayabilir. Alternatifi libarchive'in bsdtar aracıdır; --hdrcharset=CP857 parametresiyle hem zip hem tar arşivlerinde aynı işi görür. Arşivi zaten açtıysanız hiçbirine gerek yok, convmv ile dosya adlarını yerinde dönüştürmek en kısa yoldur.