~/yazilar/docker-compose-ile-cok-servisli-uygulama
Docker Compose ile üç servisi bir arada çalıştırmak
içindekiler
web-1 | psycopg.OperationalError: connection failed: connection to server at "10.200.1.2", port 5432 failed: Connection refused
web-1 | Is the server running on that host and accepting TCP/IP connections?
web-1 exited with code 1Uygulamayı, veritabanını ve Redis'i aynı anda başlatınca karşına çıkan ilk hata genelde bu. Container'lar neredeyse aynı anda kalkıyor. PostgreSQL ilk açılışta veri dizinini hazırlarken birkaç saniye TCP bağlantısı kabul etmiyor, uygulama da o arada bağlanmaya çalışıp çöküyor. Bir sonraki denemede çalışıyor, sonra sunucu yeniden başladığında bir daha çöküyor.
Çözüm, üç servisi tek bir compose.yaml ile tarif edip başlatma sırasını Compose'a bırakmak. Sonunda tek komutla kalkan, servislerini doğru sırayla başlatan ve container'lar silinip yeniden oluşturulsa da verisini kaybetmeyen bir kurulum olacak.
Uygulama ve dizin düzeni
Uygulamanın kendisi burada çok önemli değil. app/ dizininde 8000 portunda dinleyen, DATABASE_URL ve REDIS_URL ortam değişkenlerini okuyan, açılışta veritabanında tablolarını oluşturan küçük bir web uygulaması var. Benim örneğimde Python ve gunicorn, ama sen kendi uygulamanı koyabilirsin. Dockerfile'ı ayrıntısıyla bir sonraki yazıda yazıyorum, şimdilik app/ altında build alınabilen bir Dockerfile olduğunu varsay.
uygulama/
├── compose.yaml
├── .env
├── .env.example
├── .gitignore
└── app/
├── Dockerfile
├── requirements.txt
└── ...Compose, proje adını varsayılan olarak dizin adından alıyor. Container'lar uygulama-web-1, volume'lar uygulama_pgdata gibi isimlerle oluşuyor. Dizin adını değiştirirsen Compose bunu yeni bir proje sanıp boş volume'larla başlıyor. Bu yüzden proje adını dosyanın içine sabitliyorum.
.env dosyası
Parolaları compose dosyasına yazmıyorum. Compose, compose.yaml ile aynı dizindeki .env dosyasını kendiliğinden okuyup dosyadaki ${DEGISKEN} ifadelerini dolduruyor:
POSTGRES_USER=uygulama
POSTGRES_PASSWORD=degistir-beni
POSTGRES_DB=uygulama
APP_TAG=1.0.0Burada karıştırılan bir nokta var. .env dosyasındaki değişkenler sadece compose dosyasındaki ${...} ifadelerini doldurmak için kullanılıyor, container'ın içine kendiliğinden geçmiyor. Container'a bir değişken vermek istiyorsan bunu environment: altında açıkça yazman (ya da env_file: kullanman) gerekiyor. Ben environment: tercih ediyorum, çünkü hangi servisin hangi değişkeni gördüğü dosyaya bakınca belli oluyor. env_file: ile bütün dosyayı her servise verirsen Redis de veritabanı parolasını görmüş oluyor.
chmod 600 .env
echo ".env" >> .gitignore
sed 's/=.*/=/' .env > .env.example.env.example sadece değişken isimlerini taşıyor ve repoya giriyor. Asıl .env hiçbir zaman girmiyor. Parolayı openssl rand -base64 24 gibi bir şeyle üret, ama içinde @, / ya da : olmamasına dikkat et. Aşağıda parolayı bir bağlantı URL'sinin içine koyuyorum ve bu karakterler URL'yi bozuyor.
compose.yaml
Dosyanın tamamı şöyle, parça parça açıklamasını altta yapıyorum:
name: uygulama
services:
web:
build: ./app
image: uygulama-web:${APP_TAG:?APP_TAG tanimli degil}
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379/0
ports:
- "127.0.0.1:8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- frontend
- backend
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER tanimli degil}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD tanimli degil}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB tanimli degil}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
networks:
- backend
redis:
image: redis:7.4-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 10
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
pgdata:
redisdata:En üstte version: alanı yok. Eski örneklerde görürsün ama güncel Compose bu alanı kullanmıyor, yazarsan sadece "obsolete" uyarısı veriyor. Dosya adı da docker-compose.yml yerine compose.yaml. Komut da docker-compose değil, Docker'ın eklentisi olan docker compose.
Ağlar ve portlar
db ve redis için hiç ports: yazmadım. Uygulama onlara db ve redis isimleriyle Compose'un oluşturduğu ağ üzerinden ulaşıyor, host'tan dışarı açılmalarına gerek yok. İlk yazıda anlattığım gibi Docker'ın yayınladığı portlar UFW'yi atlatıyor. Veritabanını 5432:5432 diye yayınlamak, UFW ne derse desin, onu ağdaki herkese açmak demek. Yönetim için psql gerekiyorsa container'ın içinden çalıştırıyorum (aşağıda var). Host'tan bir araçla bağlanman şartsa 127.0.0.1:5432:5432 yaz ve SSH tüneli kullan.
Web uygulamasını da şimdilik sadece 127.0.0.1 üzerinde yayınladım. Önüne bir reverse proxy koyunca bu satır tamamen kalkacak, onu serinin son yazısında yapıyorum.
İki ağ kullanmamın sebebi internal: true. backend ağındaki container'ların dışarıya, yani internete çıkışı yok. Veritabanının ya da Redis'in internete bağlanması için bir sebep yok. Biri ele geçirilirse dışarıya veri taşıması da zorlaşıyor. web iki ağda birden duruyor: frontend üzerinden port yayınlıyor ve dışarı çıkabiliyor, backend üzerinden veritabanına ulaşıyor.
Healthcheck ve depends_on
Yazının başındaki hatanın çözümü burada. depends_on tek başına sadece başlatma sırasını belirliyor: önce db container'ı başlatılıyor, sonra web. Ama "başlatıldı" ile "bağlantı kabul ediyor" aynı şey değil. condition: service_healthy ile Compose, web'i başlatmadan önce db ve redis için healthcheck'in başarılı olmasını bekliyor.
PostgreSQL healthcheck'indeki -h 127.0.0.1 bilerek orada. Resmi image ilk açılışta veritabanını hazırlarken PostgreSQL'i geçici olarak sadece Unix soketi üzerinden dinleyen bir modda çalıştırıyor, hazırlık bitince kapatıp normal şekilde yeniden başlatıyor. -h vermezsen pg_isready soketten bağlanıyor ve bu geçici sunucuyu "hazır" sayabiliyor. Uygulama tam o yeniden başlatma anına denk gelirse aynı hatayı yine görürsün. TCP üzerinden kontrol edince bu ara durum atlanıyor.
$${POSTGRES_USER} içindeki çift dolar işareti de önemli. Tek $ yazarsan Compose değişkeni dosyayı okurken kendisi doldurmaya çalışıyor. $$ ile ifade olduğu gibi container'a gidiyor ve oradaki kabuk dolduruyor. Bu örnekte iki yol da aynı sonucu verirdi, ama container içindeki değişkeni kullanmak healthcheck'i .env'den bağımsız kılıyor.
start_period ilk açılıştaki hazırlık süresi için bir tolerans. Bu sürede başarısız denemeler retries sayacına yazılmıyor. Büyük bir veritabanının açılışta kurtarma yapması uzun sürebiliyor, o durumda bu değeri büyüt.
Redis tarafında redis-cli ping çıktısını grep ile kontrol ediyorum. Redis açılışta AOF dosyasını yüklerken PONG yerine bir hata dönüyor, çıktıya bakmak bu durumu da yakalıyor.
Bir sınırı da bilmek lazım: depends_on sadece docker compose up sırasında işe yarıyor. Uygulama çalışırken veritabanı yeniden başlarsa Compose web'e dokunmuyor. Uygulamanın kopan bağlantıyı yeniden kurabilmesi gerekiyor. Healthcheck ilk açılıştaki yarışı çözüyor, uygulamadaki bağlantı yeniden deneme mantığının yerini tutmuyor.
Değişkenlerin zorunlu olması
${POSTGRES_PASSWORD:?...} sözdizimi değişken tanımlı değilse Compose'un hiçbir şey başlatmadan hata vermesini sağlıyor. Bu olmadan tanımsız değişken boş string oluyor ve Compose sadece bir uyarı yazıp devam ediyor. PostgreSQL image'ı parola olmadan veri dizinini oluşturmayı reddedip kapanıyor, yani hatayı çok daha geç ve daha karışık bir yerde görüyorsun.
Ayağa kaldırmak
Önce dosyanın geçerli olduğuna bakıyorum, sonra başlatıyorum:
docker compose config --quiet && echo "compose dosyası geçerli"
docker compose up -d --build --wait
docker compose psNAME IMAGE COMMAND SERVICE STATUS PORTS
uygulama-db-1 postgres:17-alpine "docker-entrypoint.s…" db Up 41 seconds (healthy) 5432/tcp
uygulama-redis-1 redis:7.4-alpine "docker-entrypoint.s…" redis Up 41 seconds (healthy) 6379/tcp
uygulama-web-1 uygulama-web:1.0.0 "gunicorn --bind 0.0…" web Up 12 seconds 127.0.0.1:8000->8000/tcp--wait bütün servisler çalışır (healthcheck'i olanlar sağlıklı) duruma gelene kadar komutun dönmesini bekletiyor. Script içinde ya da bir deploy adımında işe yarıyor. PORTS sütununa da bak: db için 5432/tcp yazıyor, önünde host adresi yok. Port açık değil, sadece image'ın bu portu kullandığı bilgisi.
docker compose config parametresiz çalıştırılırsa değişkenleri doldurulmuş dosyanın tamamını ekrana basıyor. Hata ayıklarken çok işe yarıyor ama çıktıda parolalar da açık açık görünüyor. Ekran paylaşırken ya da bir ticket'a yapıştırırken dikkat.
Loglar ve veritabanına kısa bir bakış:
docker compose logs -f web
curl -s http://127.0.0.1:8000/
docker compose exec db psql -U uygulama -d uygulama -c '\dt'Veri gerçekten kalıcı mı
docker compose down
docker compose up -d --wait
docker volume ls --filter name=uygulamaDRIVER VOLUME NAME
local uygulama_pgdata
local uygulama_redisdatadown container'ları ve ağları siliyor, volume'lara dokunmuyor. Yeniden up dediğinde PostgreSQL boş bir dizin yerine dolu bir dizin buluyor ve ilk kurulum adımlarını atlıyor. Veri olduğu yerde.
docker compose down -v ise volume'ları da siliyor. Geliştirme ortamında her şeyi sıfırlamak için kullanışlı, üretimde parmak alışkanlığıyla yazılırsa felaket.
Ama kalıcı veri ile yedeklenmiş veri ayrı şeyler. Volume aynı diskte duruyor, disk giderse o da gidiyor. Basit bir mantıksal yedek:
docker compose exec -T db pg_dump -U uygulama -d uygulama | gzip > "yedek-$(date +%F).sql.gz"-T burada şart, çünkü çıktıyı bir dosyaya yönlendiriyorum ve pseudo-terminal açılmasını istemiyorum. Bunu her gece çalıştırmak için bir systemd timer yazmak systemd yazısında anlattığım yapıyla birkaç dakikalık iş. Yedeği başka bir makineye taşımayı da unutma.
Veritabanı için bind mount (./pgdata:/var/lib/postgresql/data) yerine named volume kullandım. Bind mount'ta dizinin sahibi host'taki kullanıcı oluyor, container içindeki postgres kullanıcısının UID'si ise image'a göre değişiyor. İzin hatalarıyla uğraşmak istemiyorsan named volume daha rahat.
Sık hatalar
Parolayı değiştirdim, uygulama bağlanamıyor. .env içinde POSTGRES_PASSWORD'ü değiştirip yeniden başlattıktan sonra:
FATAL: password authentication failed for user "uygulama"POSTGRES_USER, POSTGRES_PASSWORD ve POSTGRES_DB sadece veri dizini boşken, yani ilk kurulumda kullanılıyor. Sonradan değiştirmek veritabanındaki parolayı değiştirmiyor, sadece uygulamanın kullandığı URL'yi değiştiriyor. Parolayı veritabanında da değiştirmek gerekiyor:
docker compose exec db psql -U uygulama -d uygulama -c "ALTER USER uygulama WITH PASSWORD 'yeni-parola-degistir-beni';"Sağlıksız bağımlılık. up şu hatayla duruyorsa:
dependency failed to start: container uygulama-db-1 is unhealthyHealthcheck'in son denemelerinin çıktısı container'ın içinde saklanıyor:
docker inspect --format '{{json .State.Health}}' uygulama-db-1 | jqÇoğu zaman sebep healthcheck komutundaki bir yazım hatası ya da yanlış kullanıcı adı oluyor.
Değişken boş geliyor. The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. uyarısını görüyorsan Compose .env dosyasını bulamamış ya da değişken adında yazım hatası var. Yukarıdaki :? sözdizimini kullanırsan bu uyarı yerine net bir hata alırsın. Windows'ta düzenlenip kopyalanan .env dosyalarında satır sonlarındaki \r karakteri de değerlerin sonuna ekleniyor, cat -A .env ile bak.
Major sürüm yükseltmesi. postgres:16-alpine olan etiketi postgres:17-alpine yapıp up dersen container açılmıyor:
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17...PostgreSQL'in veri dizini major sürümler arasında uyumlu değil. Etiketi geri al, pg_dump ile yedek al, yeni sürümle boş bir volume'da başlatıp yedeği geri yükle. Minor sürüm güncellemelerinde (aynı major içinde) bu sorun yok.
version uyarısı. İnternetten aldığın bir dosyada version: "3.8" varsa her komutta the attribute 'version' is obsolete uyarısı geliyor. Satırı sil, başka bir şey değişmiyor.
Sıradaki yazı: Küçük ve güvenli bir image için Dockerfile yazmak