Musa Yılmaz
·8 dk okuma

Sunucuda Build Alma: GitHub Actions ile Docker Deploy

dockergithub-actionsdevopsdeploy

Kendi sunucusunda uygulama çalıştıran çoğu kişinin güncelleme akışı aynıdır: sunucuya bağlan, git pull, docker compose up -d --build. AWS Lightsail'de Django deploy yazısında ben de bu akışı anlatmıştım, çünkü tek başına çalışan bir sunucu için en kısa yol bu.

Sorun şu ki bu akış proje büyüdükçe sessizce bozuluyor. Bozulduğunda da "sunucu çöktü" gibi görünmüyor; sadece deploy'lar uzuyor, ara sıra yarıda kalıyor ve bir gün "dünkü hâline dön" demen gerektiğinde yapacak bir şeyin olmuyor.

Bu yazı o akışın yerine ne koyduğumu anlatıyor: imajı sunucuda değil CI'da üret, sunucuya sadece indirt.

Sunucuda build almanın üç bedeli

Birincisi bellek. docker compose up -d --build komutunu çalıştırdığında build, o anda çalışan konteynerlerle aynı RAM'i paylaşır. Next.js derlemesi bellekte tutumlu bir iş değildir; veritabanı ve uygulama zaten ayaktayken küçük bir sunucuda derleme swap'e düşer, kötü durumda OOM killer tarafından öldürülür. Dışarıdan görünen tek şey yarıda kesilmiş bir deploy ve hâlâ eski sürümü servis eden bir site olur.

İkincisi süre. Build sunucuda çalıştığı sürece CPU ve disk o işe gider. Site ayakta kalır ama yavaşlar. Üstelik bu maliyeti her deploy'da yeniden ödersin, çünkü tek sunucudaki build önbelleği CI'daki kadar güvenilir değildir; imaj temizliği yapan bir prune komutu önbelleği de götürür.

Üçüncüsü ve en önemlisi: geri dönemezsin. --build ile üretilen imajın anlamlı bir etiketi yoktur. Bir saat sonra bir hata fark ettiğinde "önceki imaja dön" diyemezsin; git'i geri alıp yeniden build almak zorunda kalırsın. Yani hatayı düzeltme süren, build süren kadardır. Oysa geri alma işleminin saniyeler sürmesi gerekir.

Üçünün de tek bir ortak sebebi var: imaj bir yapı çıktısıdır, ama onu çalıştığın makinede üretiyorsun.

Doğru sıra: CI üretir, sunucu indirir

Düzgün akış şöyle:

  1. master dalına push edilir.
  2. GitHub Actions imajı derler ve commit hash'iyle etiketleyip bir imaj deposuna (registry) gönderir.
  3. Sunucuya SSH ile bağlanılır, o etiket indirilir, konteynerler yeni imajla ayağa kaldırılır.

Bu üç adımın en kritik kısmı ikinci maddedeki commit hash etiketi. latest etiketi kullanma: latest her deploy'da başka bir imajı gösterir, yani sunucuda o an tam olarak hangi kodun çalıştığını söyleyemezsin ve geri alacak bir adresin olmaz. Commit hash'i ise değişmez. Sunucunun hangi sürümde olduğu tek bir satırda okunur.

İmaj deposu için GitHub Container Registry (GHCR) ayrı bir hesap açmayı gerektirmediği ve aynı token'la çalıştığı için en az sürtünmeli seçenek.

GitHub Actions tarafı

Aşağıdaki iş akışı .github/workflows/deploy.yml dosyasına gider. Dockerfile ve Compose yapısı için Docker Compose ile Next.js ve Django production kurulumu yazısındaki düzeni varsayıyorum.

name: Deploy
 
on:
  push:
    branches: [master]
 
concurrency:
  group: deploy-production
  cancel-in-progress: false
 
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
 
      - uses: docker/setup-buildx-action@v3
 
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Frontend imajını derle ve gönder
        uses: docker/build-push-action@v6
        with:
          context: ./frontend
          push: true
          tags: ghcr.io/${{ github.repository }}/frontend:${{ github.sha }}
          build-args: |
            NEXT_PUBLIC_SITE_URL=https://example.com
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Backend imajını derle ve gönder
        uses: docker/build-push-action@v6
        with:
          context: ./backend
          push: true
          tags: ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Birkaç ayrıntı gözden kaçıyor:

concurrency bloğu. Arka arkaya iki push yaparsan iki dağıtım işi paralel çalışır ve sunucuda hangisinin son sözü söyleyeceği belli olmaz. cancel-in-progress değerini false bırakmak, çalışan dağıtımın yarıda kesilmemesini sağlar; ikinci iş sırasını bekler.

packages: write izni. GHCR'a yazmak için gerekli. Varsayılan token izinleri depoya göre değişir, bu satırı açıkça yazmak sonradan "denied" hatasıyla uğraşmanı önler.

type=gha önbelleği. Buildx katman önbelleğini GitHub Actions önbelleğinde tutar. Bu olmadan her çalıştırmada npm ci ve derleme sıfırdan yapılır. Sunucuda build almaktan kurtulup CI'da her seferinde sıfırdan derlemek, kazancın yarısını geri vermek olur.

NEXT_PUBLIC değişkenlerini build arg olarak geçmek

Yukarıdaki build-args satırı tesadüf değil. Next.js, NEXT_PUBLIC_ önekli değişkenleri next build sırasında JavaScript paketinin içine gömer; çalışma zamanında .env dosyasına yazman hiçbir şeyi değiştirmez. Bu davranışın ayrıntısını ve tek imajı birden çok ortama taşımak istediğinde ne yapman gerektiğini Next.js'i kendi sunucunda çalıştırmak yazısında anlatmıştım.

Pratik sonucu şu: build sunucudan CI'ya taştığı anda, bu değişkenlerin de CI'da tanımlı olması gerekir. Dockerfile tarafında karşılığı:

FROM node:22-alpine AS builder
WORKDIR /app
 
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
 
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

Build arg'a sır koyma. Build arg değerleri imaj geçmişinde (docker history) görünür. API anahtarı, veritabanı parolası, token gibi şeyler buraya değil, çalışma zamanında sunucudaki ortam değişkenlerine girer. Zaten NEXT_PUBLIC_ önekiyle tarayıcıya gönderilen bir değer tanımı gereği sır değildir; build arg olarak geçmesi bu yüzden sorun değil.

Sunucu tarafı: etiketi dışarıdan al

Compose dosyasında build bloğu yerine image satırı olacak ve etiket sabit değil, değişken olacak:

services:
  frontend:
    image: ghcr.io/kullanici-adi/depo-adi/frontend:${APP_TAG}
    env_file: .env
    expose:
      - "3000"
    restart: unless-stopped
 
  backend:
    image: ghcr.io/kullanici-adi/depo-adi/backend:${APP_TAG}
    env_file: .env
    expose:
      - "8000"
    restart: unless-stopped

Sunucudaki .env dosyasına da bu satır eklenir:

APP_TAG=9f2c1ab...

Artık "sunucuda hangi sürüm çalışıyor" sorusunun cevabı bu satır. Deploy işlemi de bu satırı değiştirip konteynerleri yenilemekten ibaret.

Sunucunun GHCR'dan imaj çekebilmesi için bir kerelik giriş yapması gerekir. Bunun için dağıtım kullanıcısının hesabında yalnızca okuma yetkisi olan bir token kullan; CI'daki yazma yetkili token'ı sunucuya kopyalama:

echo "<SALT_OKUNUR_TOKEN>" | docker login ghcr.io -u kullanici-adi --password-stdin

Dağıtım betiği

Sunucuda /usr/local/bin/deploy.sh olarak duran, tek işi olan bir betik:

#!/usr/bin/env bash
set -euo pipefail
 
: "${APP_TAG:?APP_TAG verilmedi}"
cd /srv/uygulama
 
sed -i "s/^APP_TAG=.*/APP_TAG=$APP_TAG/" .env
 
docker compose pull
docker compose run --rm backend python manage.py migrate --noinput
docker compose up -d
 
for _ in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:3000/api/health > /dev/null; then
    docker image prune -f
    echo "dagitim tamam: $APP_TAG"
    exit 0
  fi
  sleep 2
done
 
echo "yeni surum saglik kontrolunden gecemedi" >&2
exit 1

set -euo pipefail burada süs değil. Bu satır olmadan docker compose pull başarısız olsa bile betik devam eder, up -d eski imajla çalışır ve CI sana yeşil tik gösterir. Dağıtımın sessizce hiçbir şey yapmaması, patlamasından kötüdür.

Sağlık kontrolü döngüsü de aynı sebeple var: docker compose up -d konteyner başladığında döner, uygulama hazır olduğunda değil. Araya bu döngüyü koymazsan bozuk bir sürüm başarılı deploy gibi raporlanır.

CI'daki karşılık adımı:

      - name: Sunucuda dağıt
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SSH_HOST: ${{ secrets.DEPLOY_HOST }}
          SSH_USER: ${{ secrets.DEPLOY_USER }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
            "APP_TAG=${{ github.sha }} /usr/local/bin/deploy.sh"

ssh-keyscan ile anahtarı uçuşta almak yerine sunucunun ana bilgisayar anahtarını bir secret olarak saklıyorum. ssh-keyscan, karşı tarafın kim olduğunu doğrulamadan güvenir; bir kereliğine kendin alıp secret'a koymak bu körlemesine güveni ortadan kaldırır.

Dağıtım kullanıcısı root olmasın ve sadece bu işi yapabilsin. Betiğe sudo gerekiyorsa sudoers içinde yalnızca o komuta izin ver.

Migration'lar hangi sırada

Yukarıdaki betikte migrate, up -d işleminden önce çalışıyor. Yani migration'ı yeni imajla çalıştırıyorsun ama uygulama hâlâ eski sürüm. Bu, migration'ların geriye dönük uyumlu olmasını şart koşar: yeni şema, eski kod tarafından da okunabilmeli.

Pratikte kuralı şöyle uygulayabilirsin: önce ekle, sonra kullan, en son sil.

  • Sütun eklerken null=True ya da varsayılan değerle ekle; eski kod o sütunu bilmese de yazmaya devam edebilsin.
  • Sütun silmeyi ve yeniden adlandırmayı iki dağıtıma böl: birinci dağıtımda kod o sütunu kullanmayı bırakır, ikinci dağıtımda sütun düşer.

Migration'ı sonraya bırakmak da mümkün ama o zaman yeni kod olmayan bir sütunu isteyeceği için aradaki saniyelerde 500 alırsın. Sıralamayı değiştirmek yerine migration'ları uyumlu yazmak daha az acı veriyor.

"Kesintisiz" iddiasının gerçeği

Bu kurulum deploy süresini ciddi biçimde kısaltır ama tek bir konteyner çalıştırıyorsan sıfır kesinti vermez. docker compose up -d değişen servisin konteynerini durdurup yenisini oluşturur; arada birkaç saniyelik bir boşluk vardır ve nginx o aralıkta 502 döner.

Değişen şey şu: eskiden bu boşluk build süresi kadardı ve build başarısız olursa boşluk kalıcıydı. Şimdi imaj zaten hazır olduğu için boşluk konteyner başlatma süresine iner.

Gerçekten sıfır kesinti istiyorsan yükü aynı anda iki örneğe dağıtman ve onları sırayla yenilemen gerekir. O noktaya geldiğinde ortaya çıkan yeni sorunlar — sürüm kayması, Server Actions şifreleme anahtarı, paylaşımlı önbellek — kendi sunucunda Next.js çalıştırma yazısında ayrıca anlatılıyor. Tek sunuculu projelerin çoğunda birkaç saniyelik kesinti bu karmaşıklığa değmiyor.

Geri alma

Asıl kazanç burada. Bir sürümün bozuk olduğunu fark ettiğinde:

APP_TAG=<onceki-commit-hash> /usr/local/bin/deploy.sh

İmaj zaten registry'de duruyor, indirilmesi bile gerekmeyebilir. Geri alma süresi konteyner başlatma süresine eşit.

Bunun çalışması için eski imajların silinmemiş olması gerekir. Betikteki docker image prune -f sadece hiçbir etikete bağlı olmayan (dangling) katmanları siler, etiketli eski sürümlere dokunmaz. prune -a kullanma: geri döneceğin imajı silersin.

Migration içeren bir sürümden geri dönmek ayrı bir konu — şema değişikliği kendiliğinden geri gelmez. Yukarıdaki "önce ekle, sonra kullan" kuralına uyduysan eski kod yeni şemayla çalışmaya devam eder ve geri alma sorunsuz olur. Bu kuralın asıl değeri de burada ortaya çıkıyor.

Özet

  • Sunucuda build alma. Build bir CI işidir; sunucunun işi çalıştırmaktır.
  • İmajı commit hash'iyle etiketle. latest, "hangi sürüm çalışıyor" sorusunu cevapsız bırakır.
  • NEXT_PUBLIC_ değişkenleri build arg olarak CI'da tanımlı olmalı; sırlar oraya girmez.
  • Dağıtım betiğine set -euo pipefail ve bir sağlık kontrolü döngüsü koy, yoksa başarısız deploy başarılı görünür.
  • Migration'ları geriye dönük uyumlu yaz; geri alma imkânını asıl bu sağlar.
  • Tek örnekle kesinti sıfırlanmaz, saniyelere iner. Çoğu proje için doğru takas bu.