Docker ile Java Uygulaması Containerize Etme

Java uygulamalarını production’a taşırken en büyük baş ağrısı her zaman “bende çalışıyor ama sunucuda çalışmıyor” sendromundan kaynaklanır. JVM versiyonu farklı, classpath eksik, environment variable yok veya kütüphane sürümleri uyuşmuyor. Docker tam olarak bu sorunu ortadan kaldırıyor: bir kere yaz, her yerde aynı şekilde çalıştır. Bu yazıda sıfırdan başlayıp production-ready bir Java uygulamasını containerize etmeyi, optimize etmeyi ve deploy etmeyi konuşacağız.

Neden Java + Docker?

Java uygulamaları tarihsel olarak “taşınabilir” olduğu iddia edilse de gerçekte durum çok farklı. JRE/JDK versiyonları arasındaki farklar, uygulama sunucusu konfigürasyonları ve sistem kütüphanesi bağımlılıkları ciddi sorunlar yaratır. Docker bu durumu şu şekilde çözer:

  • Ortam tutarlılığı: Geliştirme, test ve production ortamları birebir aynı
  • Bağımlılık izolasyonu: Her uygulama kendi JVM versiyonuyla çalışır
  • Hızlı deployment: Image bir kez build edilir, saniyeler içinde deploy edilir
  • Kolay rollback: Önceki versiyona dönmek tek komut meselesi
  • Kaynak kontrolü: CPU ve memory limitleri kolayca uygulanır

Özellikle microservice mimarisine geçiş yapan ekipler için Docker vazgeçilmez hale geliyor. Bir servis Java 11, diğeri Java 17 kullanıyor olabilir, bu hiç sorun değil.

Temel Bir Spring Boot Uygulaması Hazırlamak

Örnek senaryomuzda basit bir REST API sunan Spring Boot uygulaması kullanacağız. Önce proje yapısına bakalım:

myapp/
├── src/
│   └── main/
│       ├── java/
│       │   └── com/example/myapp/
│       │       ├── MyAppApplication.java
│       │       └── HelloController.java
│       └── resources/
│           └── application.properties
├── pom.xml
└── Dockerfile

Uygulamayı önce build edelim:

# Maven ile JAR dosyası oluşturma
mvn clean package -DskipTests

# Oluşan JAR dosyasını kontrol et
ls -lh target/
# myapp-0.0.1-SNAPSHOT.jar çıktısını göreceksin

# JAR'ı local olarak test et
java -jar target/myapp-0.0.1-SNAPSHOT.jar

JAR dosyası başarıyla çalışıyorsa containerize etmeye geçebiliriz.

İlk Dockerfile: Basit Ama İşlevsel

En basit haliyle bir Dockerfile şöyle görünür:

FROM eclipse-temurin:17-jre-jammy

WORKDIR /app

COPY target/myapp-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Bu Dockerfile’ı build edip çalıştıralım:

# Image build et
docker build -t myapp:1.0 .

# Container'ı başlat
docker run -d 
  --name myapp-container 
  -p 8080:8080 
  myapp:1.0

# Logları takip et
docker logs -f myapp-container

# Uygulamanın çalıştığını doğrula
curl http://localhost:8080/hello

Bu çalışır ama production için yeterli değil. Image boyutu büyük, security açıkları var ve JVM ayarları container ortamı için optimize edilmemiş. Şimdi bunları düzeltelim.

Multi-Stage Build ile Optimizasyon

Tek aşamalı build’in en büyük sorunu final image’a build araçlarının da dahil olmasıdır. Maven veya Gradle yüzlerce MB yer kaplar ve production image’ında işi yoktur. Multi-stage build bu sorunu çözer:

# Stage 1: Build aşaması
FROM eclipse-temurin:17-jdk-jammy AS builder

WORKDIR /build

# Önce bağımlılıkları kopyala (cache optimizasyonu için)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .

# Bağımlılıkları indir
RUN ./mvnw dependency:go-offline -B

# Kaynak kodunu kopyala ve build et
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B

# Stage 2: Runtime aşaması
FROM eclipse-temurin:17-jre-jammy AS runtime

# Güvenlik için non-root kullanıcı oluştur
RUN groupadd --gid 1001 appgroup && 
    useradd --uid 1001 --gid appgroup --shell /bin/false appuser

WORKDIR /app

# Build aşamasından JAR'ı kopyala
COPY --from=builder /build/target/myapp-0.0.1-SNAPSHOT.jar app.jar

# Dosya sahipliğini ayarla
RUN chown appuser:appgroup app.jar

# Non-root kullanıcıya geç
USER appuser

EXPOSE 8080

# JVM ayarları container-aware olarak
ENTRYPOINT ["java", 
  "-XX:+UseContainerSupport", 
  "-XX:MaxRAMPercentage=75.0", 
  "-XX:+ExitOnOutOfMemoryError", 
  "-jar", "app.jar"]

Bu Dockerfile ile image boyutunda ciddi bir düşüş yaşanır. JDK içeren bir image 400-500 MB civarındayken, JRE ile 200 MB’ın altına inebilirsiniz. Daha da küçük image istiyorsanız eclipse-temurin:17-jre-alpine kullanabilirsiniz, ancak alpine’de glibc yerine musl libc kullanıldığı için bazı uygulamalarda sorun çıkabilir.

JVM Container Farkındalığı

Bu konu çok kritik ve sıkça gözden kaçıyor. Eski JVM versiyonları (Java 8u191 öncesi) container’ın memory limitini görmezden gelip host makinenin toplam RAM’ini görürdü. Örneğin 512 MB limit verdiğiniz bir container’da çalışan JVM, host makinede 16 GB RAM olduğunu düşünüp heap’i buna göre ayarlardı. Bu durum container’ın OOMKilled olmasına yol açardı.

Modern JVM versiyonlarında bu sorun büyük ölçüde giderildi ama ayarları doğru yapmak gerekiyor:

# Container'ı memory limiti ile başlat
docker run -d 
  --name myapp-container 
  --memory="512m" 
  --memory-swap="512m" 
  -p 8080:8080 
  myapp:1.0

# JVM'in container'ı doğru algılayıp algılamadığını kontrol et
docker exec myapp-container java 
  -XX:+PrintFlagsFinal 
  -version 2>&1 | grep -E "MaxHeapSize|InitialHeapSize"

# Container içinde memory bilgisini doğrula
docker exec myapp-container cat /sys/fs/cgroup/memory/memory.limit_in_bytes

-XX:+UseContainerSupport flag’i Java 10’dan itibaren varsayılan olarak açık geliyor. -XX:MaxRAMPercentage=75.0 ile container memory’sinin yüzde 75’ini heap için kullanmasını söylüyoruz. Geri kalanı metaspace, stack ve native memory için rezerve kalıyor.

Environment Variable ile Konfigürasyon

Production uygulamalarında konfigürasyonun hardcode edilmemesi şart. Spring Boot’un application.properties dosyasını environment variable ile ezebilirsiniz:

# Database bağlantısı ve diğer ayarları dışarıdan ver
docker run -d 
  --name myapp-prod 
  --memory="1g" 
  -p 8080:8080 
  -e SPRING_DATASOURCE_URL="jdbc:postgresql://db-host:5432/mydb" 
  -e SPRING_DATASOURCE_USERNAME="appuser" 
  -e SPRING_DATASOURCE_PASSWORD="supersecretpassword" 
  -e SPRING_PROFILES_ACTIVE="production" 
  -e SERVER_PORT="8080" 
  -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Xss512k" 
  myapp:1.0

Ancak şifreler gibi hassas bilgileri komut satırında geçirmek iyi bir pratik değil. Docker secrets veya environment file kullanmak daha güvenli:

# .env dosyası oluştur (git'e ekleme!)
cat > myapp.env << 'EOF'
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/mydb
SPRING_DATASOURCE_USERNAME=appuser
SPRING_DATASOURCE_PASSWORD=supersecretpassword
SPRING_PROFILES_ACTIVE=production
EOF

# env-file ile başlat
docker run -d 
  --name myapp-prod 
  --env-file myapp.env 
  --memory="1g" 
  -p 8080:8080 
  myapp:1.0

Docker Compose ile Tam Stack

Gerçek dünya senaryolarında uygulama genellikle tek başına çalışmaz. Veritabanı, cache, message broker gibi bileşenlerle birlikte gelir. Bunu Docker Compose ile yönetelim:

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: myapp:latest
    container_name: myapp
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_PROFILES_ACTIVE: production
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'
        reservations:
          memory: 512M
    restart: unless-stopped
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  postgres:
    image: postgres:15-alpine
    container_name: myapp-postgres
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d mydb"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    networks:
      - app-network

volumes:
  postgres-data:

networks:
  app-network:
    driver: bridge

Compose ile stack’i başlatmak ve yönetmek:

# .env dosyasını hazırla
echo "DB_PASSWORD=guclu_bir_sifre_123" > .env

# Tüm servisleri build edip başlat
docker compose up -d --build

# Servislerin durumunu izle
docker compose ps

# Uygulama loglarını takip et
docker compose logs -f app

# Sağlık durumunu kontrol et
docker compose exec app curl -s http://localhost:8080/actuator/health | python3 -m json.tool

# Veritabanına bağlan ve kontrol et
docker compose exec postgres psql -U appuser -d mydb -c "dt"

# Stack'i durdur (verileri koru)
docker compose stop

# Stack'i tamamen kaldır (verileri de sil)
docker compose down -v

Layered JAR ile Cache Optimizasyonu

Spring Boot 2.3’ten itibaren JAR dosyaları katmanlı olarak oluşturulabiliyor. Bu özellik Docker layer cache’ini çok daha etkin kullanmanızı sağlar. Bağımlılıklar nadiren değiştiği için onları ayrı bir layer’a koyarsak, her kod değişikliğinde sadece uygulama katmanı yeniden build edilir:

# Stage 1: Build
FROM eclipse-temurin:17-jdk-jammy AS builder

WORKDIR /build

COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -B

COPY src/ src/
RUN ./mvnw clean package -DskipTests -B

# JAR'ı katmanlara ayır
RUN java -Djarmode=layertools -jar target/myapp-0.0.1-SNAPSHOT.jar extract --destination extracted

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-jammy AS runtime

RUN groupadd --gid 1001 appgroup && 
    useradd --uid 1001 --gid appgroup --shell /bin/false appuser

WORKDIR /app

# Katmanları sırayla kopyala (en az değişenden en çok değişene)
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./

RUN chown -R appuser:appgroup /app
USER appuser

EXPOSE 8080

ENTRYPOINT ["java", 
  "-XX:+UseContainerSupport", 
  "-XX:MaxRAMPercentage=75.0", 
  "-XX:+ExitOnOutOfMemoryError", 
  "-XX:+HeapDumpOnOutOfMemoryError", 
  "-XX:HeapDumpPath=/tmp/heapdump.hprof", 
  "org.springframework.boot.loader.JarLauncher"]

Bu yaklaşımın build süresine etkisini test edelim:

# İlk build (cold)
time docker build -t myapp:layered .

# Sadece uygulama kodunu değiştir, tekrar build et
touch src/main/java/com/example/myapp/HelloController.java
time docker build -t myapp:layered .
# Bağımlılık katmanları cache'den geldiği için çok daha hızlı olacak

# Cache kullanımını görmek için
docker build --progress=plain -t myapp:layered . 2>&1 | grep -E "CACHED|RUN"

Production’da Monitoring ve Troubleshooting

Container’da çalışan Java uygulamasını izlemek ve sorun gidermek için pratik komutlar:

# Container'ın kaynak kullanımını canlı izle
docker stats myapp-container

# JVM thread dump al (uygulama donuksa)
docker exec myapp-container kill -3 1
docker logs myapp-container 2>&1 | tail -200

# Heap dump al ve analiz için dışarı çıkar
docker exec myapp-container kill -HUP 1
docker cp myapp-container:/tmp/heapdump.hprof ./heapdump.hprof

# Container içinde jcmd ile JVM bilgilerini al
docker exec myapp-container jcmd 1 VM.flags
docker exec myapp-container jcmd 1 GC.heap_info
docker exec myapp-container jcmd 1 Thread.print

# Actuator endpoint'leri üzerinden metrik al
curl http://localhost:8080/actuator/metrics/jvm.memory.used
curl http://localhost:8080/actuator/metrics/jvm.gc.pause
curl http://localhost:8080/actuator/health/liveness
curl http://localhost:8080/actuator/health/readiness

# Container içine gir ve manuel inceleme yap
docker exec -it myapp-container /bin/bash
# İçeride:
# ps aux | grep java
# cat /proc/1/status | grep -E "VmRSS|VmHeap"

Güvenlik Katmanı Eklemek

Production image’larında güvenlik hiç ihmal edilmemeli. Temel güvenlik kontrolleri:

# Image'ı vulnerability taramasından geçir
docker scout cves myapp:1.0

# Trivy ile tarama (daha kapsamlı)
docker run --rm 
  -v /var/run/docker.sock:/var/run/docker.sock 
  aquasec/trivy:latest image myapp:1.0

# Container'ın non-root çalıştığını doğrula
docker inspect myapp-container --format='{{.Config.User}}'

# Read-only filesystem ile çalıştır
docker run -d 
  --name myapp-secure 
  --read-only 
  --tmpfs /tmp:rw,noexec,nosuid,size=100m 
  --security-opt no-new-privileges:true 
  --cap-drop ALL 
  -p 8080:8080 
  myapp:1.0

# Container'ın kapabilite listesini kontrol et
docker inspect myapp-secure | grep -A 10 "CapAdd"

Gerçek Dünya: CI/CD Pipeline’a Entegrasyon

Bir GitLab CI pipeline örneği:

# .gitlab-ci.yml
stages:
  - build
  - test
  - containerize
  - deploy

variables:
  DOCKER_REGISTRY: registry.example.com
  IMAGE_NAME: myapp
  JAVA_OPTS: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

build-jar:
  stage: build
  image: eclipse-temurin:17-jdk-jammy
  script:
    - ./mvnw clean package -DskipTests -B
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 hour
  cache:
    key: "${CI_PROJECT_ID}-maven"
    paths:
      - .m2/repository/

build-image:
  stage: containerize
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $DOCKER_REGISTRY
    - docker build -t $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA .
    - docker build -t $DOCKER_REGISTRY/$IMAGE_NAME:latest .
    - docker push $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHA
    - docker push $DOCKER_REGISTRY/$IMAGE_NAME:latest
  only:
    - main

Sonuç

Java uygulamalarını Docker ile containerize etmek ilk bakışta karmaşık görünse de doğru adımları izleyince oldukça sistematik bir süreç. Özetleyecek olursak:

  • Multi-stage build kullan: Build araçları production image’ına girmesin, hem boyut hem de güvenlik kazanırsın
  • Non-root kullanıcı zorunlu: Root ile çalışan container production’da kabul edilemez
  • JVM ayarlarını container’a göre yap: UseContainerSupport ve MaxRAMPercentage kritik, atlama
  • Layered JAR ile build süresini kısalt: Her deployment’ta bağımlılıkları yeniden indirmek zaman kaybı
  • Health check ekle: Orchestrator’lar (Kubernetes, Swarm) container sağlığını buradan takip eder
  • Secret yönetimine dikkat et: Şifreler image’a gömülmemeli, environment variable veya vault kullan
  • Vulnerability taraması yap: Trivy veya Docker Scout pipeline’a entegre edilmeli

Bu adımları hayata geçirdiğinde “bende çalışıyor” sorunu tarihe karışacak. Geliştirici laptop’unda build ettiğin image, staging’de test ettiğin image ve production’da çalışan image birebir aynı olacak. Bu basit gerçek ekiplerin hayatını inanılmaz ölçüde kolaylaştırıyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir