Lokal ortamda frontend'i, backend'i ve veritabanını ayrı ayrı çalıştırıyorum. Hepsi benim bilgisayarımda sorunsuz açılıyor; peki aynı uygulamayı başka bir makineye taşıdığımda ne değişiyor? Runtime sürümü, bağlantı adresleri ve servislerin birbirini bulması da artık benim sorumluluğumda.
Docker, test, CI/CD ve deployment başlıklarını çalışırken bunları kodun izlediği tek bir yol olarak anlamak istedim. Image'ı sunucuya nasıl taşıyorum, yeni sürüm eskisinin yerini nasıl alıyor, bir şey ters giderse bunu hangi kontrol gösteriyor?
Bu sorular için Nuxt, .NET API ve PostgreSQL kullanan küçük bir To-do uygulaması hazırladım. Git to Prod projesinde uygulamanın kendisini basit tuttum; asıl odağım kodu paketleyip test etmek ve K3s'e kadar götürmek oldu. Yazıda önce çalışma notlarımdaki teoriyi anlatıp her adımda projeden kod veya deployment denemesiyle örnekleyeceğim.
Bu nedenle anlatı container ve image ile başlayıp testlere, CI/CD'ye ve K3s deployment'ına ilerliyor. Her bölümde aynı soruyu takip edeceğim: Bu aşama zincire ne katıyor ve projedeki kodda bunu nerede görebiliyorum?
Büyük Resim: Kod Nasıl Çalışan Uygulamaya Dönüşüyor?
Yazdığımız kodun kullanıcıya ulaşmasını kabaca şöyle düşünebiliriz:
Kod değişikliği
↓
Otomatik kontroller ve testler
↓
Uygulamayı container image olarak paketleme
↓
Image'ı saklanacağı depoya (registry) gönderme
↓
Hedef ortamda yeni sürümü çalıştırma (deployment)
↓
Yeni sürümün çalıştığını kontrol etme
Bu, Git to Prod projesinde kurduğum akışın da özeti. Gerçek pipeline biraz daha somut ilerliyor:
Pull request
↓
Backend ve frontend kontrolleri
↓
main'e push veya elle Release başlatma
↓
Kontrolleri tekrar çalıştırma
↓
EF Core migration → PostgreSQL VM
↓
Commit SHA etiketli image'lar → GHCR
↓
Manifestleri K3s'e uygulama ve rollout'u bekleme
↓
ALB üzerinden ana sayfa ve API kontrolü
Yazının ana hattı bu: Her adım bir sonraki adıma bir çıktı bırakıyor. Testler kodun seçilmiş davranışlarını kontrol ediyor; Docker bu kodu image'a paketliyor; registry belirli image sürümünü saklıyor; K3s de manifestteki sürümü çalıştırmaya çalışıyor. Son HTTP kontrolü, zincirin dışarıdan erişilen ucunun da yanıt verdiğini gösteriyor.
Bir de veritabanı şeması değişebilir. Bu durumda uygulama koduyla birlikte şema güncellemesinin sırasını da düşünmem gerekiyor; deployment bölümünde buna döneceğim.
Adım 1: Docker ve Container Mantığı
Kaynak kodunu başka bir makineye kopyalamak, uygulamanın orada çalışacağı anlamına gelmiyor. O makinede farklı bir runtime sürümü olabilir; ihtiyaç duyduğum kütüphaneler de kurulu olmayabilir.
Benim bilgisayarımda çalışıyordu.
Container (konteyner), uygulamayı izole bir process olarak çalıştırır. Uygulama dosyalarını ve bağımlılıklarını bir image içinde paketlediğimde hedef makinede aynı dosyaları yeniden kurmak yerine bu image'dan container başlatabilirim.
İzolasyon, farklı uygulamaların kendi bağımlılıklarıyla aynı makinede çalışmasını sağlar. Container'lar host'un CPU ve RAM'ini paylaşır; process ve dosya alanlarını ayırır.
VM kendi işletim sistemini çalıştırır. Linux container'ları ise aynı Linux kernel'ini paylaşır; her container için baştan ayrı bir işletim sistemi açılmaz. Windows ve macOS üzerinde Docker Desktop, Linux container'ları sanallaştırılmış Linux ortamında çalıştırır.
Docker, image hazırlamak ve container çalıştırmak için kullandığım araçları sağlar. Paket aynı kalabilir, ama uygulamanın bağlanacağı veritabanı ve ağ erişimini yine ayarlamam gerekir.
Dockerfile, Image ve Container Arasındaki İlişki
Bu kavramların birbiriyle ilişkisi şöyle:
| Kavram | Görevi |
|---|---|
| Dockerfile | Image'ı hazırlamak için gereken komutları tarif eder. |
| Image | Uygulama dosyalarını ve çalışma ortamını içeren pakettir. |
| Container | Image'dan başlattığımız uygulamanın izole çalışma ortamıdır. |
Dockerfile → docker build → Image → docker run → Container
docker build, Dockerfile'daki talimatlarla image hazırlar; docker run bu image'dan container oluşturup başlatır.
Aynı image'dan birden fazla container başlatabilirim. Her biri kendi process'lerini ve yazılabilir katmanını kullanır. Bu ayrım, uygulamanın değişmeyen paketiyle çalışırken ürettiği veriyi ayrı düşünmemi sağlıyor.
Image Katmanları ve Build Cache
Dockerfile'daki komutların sırası build süresini etkiler. Docker her adımın sonucunu saklayabilir; bir adım değiştiğinde sonraki adımların da yeniden çalışması gerekebilir.
Örneğin önce paket listesini kopyalayıp bağımlılıkları yükler, sonra kaynak kodu kopyalarsam yalnızca kod değiştiğinde bağımlılık kurulumunu tekrar etmeyebilirim. Projedeki frontend Dockerfile'ında bu sıralama var:
COPY package.json package-lock.json ./
RUN npm install --global npm@11.17.0 && npm ci
COPY . ./
RUN npm run build
Bu sıralama, kaynak dosyaları her değiştiğinde bağımlılıkları baştan kurmamamı sağlıyor. Docker önce değişmeyen paket listesi adımını önbellekten kullanabilir.
Build sırasında gereksiz dosyaları .dockerignore ile dışarıda bırakıyorum. Özellikle parola içeren yerel ayar dosyalarını image'a kopyalamıyorum.
Derleme Ortamı ile Çalışma Ortamını Ayırmak
Derleme araçlarını son image'a taşımam gerekmiyor. Multi-stage build, derleme ve çalıştırma aşamalarını ayırıyor; backend Dockerfile'ında önce .NET SDK, ardından yalnızca uygulama runtime'ı kullanılıyor:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /source
COPY src/ ./src/
RUN dotnet restore ./src/Api/Api.csproj
RUN dotnet publish ./src/Api/Api.csproj \
--configuration Release \
--no-restore \
--output /app/publish
RUN rm /app/publish/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_HTTP_PORTS=8080
COPY --from=build /app/publish ./
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Api.dll"]
İlk aşama kaynak kodunu yayıma hazırlıyor, ikinci aşama bu çıktıyı alıyor. SDK son image'a girmiyor. Container uygulamayı root yetkisi olmayan kullanıcıyla başlatıyor; ENTRYPOINT ise çalıştırılacak komutu belirliyor.
EXPOSE 8080, uygulamanın dinleyeceği port'u belirtir; tek başına dış erişim açmaz.
Frontend'in nasıl paketleneceği uygulamanın yapısına bağlı. Projedeki Nuxt, sunucu tarafında da kod çalıştırıp API çağrılarını ilettiği için runtime image'ında Node.js bulunuyor.
Docker Compose ile Servisleri Birlikte Çalıştırmak
Bir uygulama birden fazla servisten oluştuğunda her container için ayrı komut yazmak zorlaşır. Docker Compose, bir uygulamanın servislerini tek dosyada tanımlayıp birlikte başlatmayı, durdurmayı ve incelemeyi sağlayan araçtır. Hangi image'ın kullanılacağını, çalışma ayarlarını ve servislerin iletişimini burada tarif ederim.
Compose'ta servis, frontend veya backend gibi adı olan bir uygulama bileşeninin tanımıdır. Bu tanımdan container oluşturulur. Compose varsayılan olarak proje için ortak bir ağ oluşturur ve servisler bu ağda isimleriyle birbirine ulaşır.
Projedeki Compose dosyasının frontend bölümü şöyle:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
depends_on:
- backend
environment:
NUXT_API_BASE_URL: http://backend:8080
ports:
- "3000:3000"
restart: unless-stopped
Bu bölüm services altında bulunuyor. build.context, build sırasında erişilebilecek dizini; dockerfile, talimatların bulunduğu dosyayı seçiyor. environment çalışma ayarlarını, ports host üzerinden erişim eşlemesini belirliyor. restart: unless-stopped, elle durdurulmadığı sürece container için yeniden başlatma politikası tanımlıyor.
depends_on, frontend'den önce backend container'ının başlatılmasını sağlar.
Docker Compose ile tanımlanan bir proje, gerekli bağlantı ayarları ve dış bağımlılıkları hazır olduğunda şu komutla ayağa kaldırılabilir:
docker compose up --build
Compose dosyasının bulunduğu dizinde --build image'ları hazırlıyor, up container'ları başlatıyor. Çalışan servisleri docker compose ps, log'ları ise docker compose logs ile inceleyebilirim.
docker compose down ise Compose'un oluşturduğu container'ları ve proje ağını kaldırır; named volume'ları varsayılan olarak silmez.
Container Ağı: localhost Nereye Bakıyor?
Birden fazla container başlattığımda her birinin kendi ağ ortamı ve localhost adresi bulunur. localhost, isteği gönderen process'in çalıştığı makineyi ya da container'ı gösterir. Bu yüzden frontend container'ındaki localhost, backend container'ı değil, frontend'in kendisidir.
Compose dosyasında servisleri tanımladığımda Compose varsayılan bir network oluşturur ve bu servisleri o ağa bağlar. Aynı network'teki bir servis, diğerine değişebilen IP adresiyle değil servis adıyla ulaşabilir. Docker bu adı ilgili container'ın güncel IP'sine çözer. Projedeki ayarlar bu farkı gösteriyor:
services:
backend:
ports:
- "5157:8080"
frontend:
environment:
NUXT_API_BASE_URL: http://backend:8080
ports:
- "3000:3000"
Tarayıcı benim bilgisayarımda http://localhost:3000 adresinden Nuxt uygulamasını açar. Görev listesi istenirken tarayıcı aynı adreste /api/todo-items yoluna request gönderir. Nuxt'un server tarafındaki API route'u bu isteği alır ve backend'e iletir:
Browser
│ http://localhost:3000/api/todo-items
▼
Host'taki 3000 portu → frontend container'ındaki 3000 portu
│
▼
Nuxt server
│ http://backend:8080/api/todo-items
▼
backend servis adı → backend container'ının 8080 portu
Nuxt burada reverse proxy gibi davranıyor; tarayıcıdan gelen request'i alıp iç network'teki backend'e aktarıyor. Bu nedenle backend:8080 adresini tarayıcının bilmesi gerekmiyor. Bu adresi Nuxt server kullanıyor. Tarayıcı yalnızca kendi açtığı localhost:3000 adresine request gönderiyor.
Buradaki iki port eşlemesi farklı yerlerden erişim sağlıyor. 3000:3000, benim bilgisayarımdaki 3000 portunu frontend container'ındaki 3000'e; 5157:8080 ise host'taki 5157'yi backend container'ındaki 8080'e bağlıyor. Yazım sırası host:container şeklinde:
ports:
- "5157:8080"
Dolayısıyla host'taki bir araçla backend'e giderken localhost:5157, Compose network'ündeki frontend'den giderken backend:8080 kullanırım. Frontend ile backend'in konuşması için backend port'unu host'a yayınlamak şart değil; bu projede ports eşlemesi ayrıca host'tan test edebilmeyi de sağlıyor. Bir bağlantı hatasında hangi process'in hangi ağda bulunduğunu ve adresi nereden kullandığını kontrol etmek bu yüzden önemli.
Container Silinirse Veri Ne Oluyor?
Image, uygulamanın ve çalışma ortamının paketidir; container ise bu image'dan başlatılan çalışan kopyadır. Container çalışırken dosya yazarsa bu değişiklikler container'ın kendi yazılabilir katmanına gider. Container'ı durdurup yeniden başlatmak bu katmanı korur, ancak container'ı silip yenisini oluşturduğumda eski container'a ait katman da silinir. Veritabanı kayıtlarını burada tutarsam yeni sürüme geçerken veriyi kaybedebilirim.
Volume, container'ın ömründen ayrı tuttuğum depolama alanıdır. Volume'u container içindeki bir dizine bağlarım; uygulama o dizine yazdığında veri container'ın geçici katmanına değil volume'a gider. Yeni container'ı aynı volume ile başlatınca veriye erişmeye devam ederim.
Named volume'u Docker bir isimle yönetir. Container silinse bile volume ayrıca silinmedikçe kalır; yeni container'a aynı named volume'u bağlayabilirim. Bu, veriyi container'dan ayırır ama yedek anlamına gelmez. Volume host'un diskinde duruyorsa host'u kaybetmek volume'u da kaybettirebilir.
Bind mount ise host'ta seçtiğim dosya veya dizini container içindeki bir yola bağlar. Kaynağı Docker'ın yönettiği bir alan olarak değil, benim bilgisayarımdaki belirli bir yol olarak belirtirim. Git to Prod README'sinde geliştirme sırasında backend ayar dosyasını container'a bağlamak için şu örnek veriliyor:
services:
backend:
volumes:
- ./backend/src/Api/appsettings.Development.json:/app/appsettings.Development.json:ro
İki yolun arasındaki : ayıracının solunda host'taki dosya, sağında container içindeki hedef yol bulunur. Sondaki :ro mount'u salt okunur yapar. Backend dosyayı container içindeki /app/appsettings.Development.json yolundan okur; ayar dosyasını image'a kopyalamam veya her değişiklikte image'ı yeniden üretmem gerekmez.
Bu mount, ana Compose tanımına eklenen yerel geliştirme ayarıdır. README, proje kökünde docker-compose.local.yml adında ikinci bir dosya oluşturup şu komutla iki tanımı birleştirmeyi öneriyor:
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
-f seçenekleri kullanılacak Compose dosyalarını belirtir. Compose bunları birleştirir; ikinci dosyadaki volumes ayarı backend servisinin ana tanımına eklenir. up servisleri başlatır, --build de image'ları oluşturur veya değişiklik varsa yeniden build eder.
Bu yerel senaryoda PostgreSQL container içinde değil, host makinede çalışıyor. Backend'in bağlantı ayarında README host.docker.internal adını kullanıyor; Compose dosyasındaki extra_hosts bu adı host gateway'e yönlendiriyor. Veritabanı ve tablo yapısının önceden hazır olması da gerekiyor. Canlı ortamda ise PostgreSQL'i ayrı bir VM'de tuttum; frontend ve backend container'ları veritabanı dosyalarını saklamıyor.
Bind mount kaynak kodu geliştirme sırasında container'a göstermek için de işe yarar. Dosya değişikliğinin container içinde görünmesi tek başına uygulamayı yeniden yüklemez; hot reload davranışını uygulamanın geliştirme sunucusu sağlar. Buradaki ayar dosyası mount'u ise yalnızca dosyayı bağlar ve :ro nedeniyle container'ın bu dosyaya yazmasını engeller.
Adım 2: Testing ile Uygulamanın Davranışını Kontrol Etmek
Image'ın build olması, uygulamanın doğru davrandığını göstermiyor. Kod derlenebilir; yine de endpoint yanlış cevap döndürebilir veya veriyi kaydedemeyebilir. Bu nedenle testin kapsamını, doğrulamak istediğim davranışa göre seçiyorum.
Bir testte önce başlangıç koşullarını hazırlar, ardından davranışı çalıştırıp sonucu beklediğim değerle karşılaştırırım. Bu yapı Arrange, Act, Assert olarak geçiyor: hazırla, çalıştır, doğrula. Assertion, sonuç beklediğimden farklıysa testi başarısız yapan kontroldür.
Kontrol edeceğim şey yalnızca başarılı sonuç olmayabilir. Validation, gelen verinin kurallara uygunluğunu denetler; zorunlu başlık eksikse görev oluşturmamak da beklenen bir davranıştır. HTTP cevabındaki status code işlemin sonucunu belirtir: 201 kaynağın oluştuğunu, 404 bulunamadığını gösterir.
Bu kontrolleri sonraki değişikliklerde yeniden çalıştırınca, daha önce çalışan bir davranışın bozulup bozulmadığını görebilirim. Buna regression kontrolü deniyor; yine de test yalnızca kapsadığı senaryolar hakkında bilgi verir.
Unit Test: Bir Parçanın Davranışı
Unit test, küçük bir parçayı bağımlılıklarından ayırarak sınar. Projedeki handler, görev oluşturma işlemini yürüten kod; repository ise kayıt ekleme ve okuma gibi veri erişim işlemlerini sunan parçadır. Handler'ı test ederken gerçek veritabanı yerine fake repository kullanabilirim; böylece kontrol ettiğim davranış handler'ın kendisi olur.
Projeden bir örnek:
var repository = new FakeTodoItemRepository();
var result = await new TodoItemCreateCommandHandler(repository, TodoItemTestMapper.Instance)
.Handle(new TodoItemCreateCommand("Read", "Book", true), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(new TodoItemCreateCommandResponse(1, "Read", "Book", true), result.Response);
Assert.Empty(result.Errors);
Assert.Single(repository.Items);
Önce fake repository'yi hazırlıyorum, ardından Handle ile görev oluşturmayı çalıştırıyorum. Assertion'lar hem dönen cevabı hem de repository'ye tek kayıt eklendiğini kontrol ediyor. Gerçek PostgreSQL bağlantısı bu testin kapsamına girmiyor; bu sınır entegrasyon testinde değişiyor.
Integration Test: Parçalar Birlikte Çalışıyor mu?
Entegrasyon testi, birden fazla parçanın birlikte çalışıp çalışmadığını sınar. API'ye HTTP request gönderip gerçek veritabanına kayıt yapıldığını kontrol etmek, endpoint ile veri erişimini aynı akışta görmemi sağlar.
Bu amaçla projede WebApplicationFactory kullanıyorum; bu araç ASP.NET uygulamasını test ortamında başlatıp ona HTTP request göndermemi sağlar. Testcontainers, test sırasında gerçek bağımlılıkları container olarak başlatıp test sonunda temizlemeyi sağlayan kütüphanedir. Burada geçici PostgreSQL'i açıp tablo yapısını hazırlıyorum; testler canlı veritabanını kullanmıyor.
Görev oluşturma request'inin ilgili kısmı:
using var createResponse = await client.PostAsJsonAsync(
"/api/todo-items",
new CreateTodoItemRequest("First task", "Created by integration test", false),
cancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
Assert.NotNull(createResponse.Headers.Location);
PostAsJsonAsync, veriyi JSON biçiminde POST request'iyle gönderiyor. Test ardından 201 Created cevabını ve yeni kaynağın adresini taşıyan Location başlığını doğruluyor.
Test devamında kaydı okuyup güncelliyor ve siliyor. Fake repository ile geçen bir testin yakalayamayacağı veri erişimi sorunları bu akışta ortaya çıkabilir.
Kullanıcı Akışı ve Deployment Kontrolü
End-to-end test, kullanıcının başlattığı akışı arayüzden depolamaya kadar sınar; Browser'da görev ekleyip sonucu görmek buna örnek. Projedeki frontend proxy testi bu kadar geniş değil: production Nuxt server ile sahte backend arasındaki HTTP aktarımını kontrol ediyor, Browser arayüzünü kapsamıyor.
Test kapsamı büyüdükçe daha fazla parçayı doğrularım, ancak kurulum ve hata ayıklama da zorlaşır. Unit testte sorun handler'a kadar daralırken geniş akışta Browser, API veya veritabanından kaynaklanabilir. Bu yüzden her seviyeden testin neyi kapsadığını açık tutuyorum.
Smoke test daha dar bir kontrol yapar: deployment sonrasında ana sayfa ve temel API erişilebilir mi? Projede / ve /api/todo-items adreslerine request gönderiyorum. Bu kontrol, uygulamanın temel erişim yolunu sınarken bütün CRUD senaryolarını tekrar etmiyor.
Adım 3: CI/CD ile Değişiklikleri Dağıtıma Hazırlamak
Testleri yalnızca kendi bilgisayarımda çalıştırırsam bir değişikliği kolayca atlayabilirim. CI (Continuous Integration), her değişiklikte aynı kontrolleri otomatik çalıştırarak bu riski azaltıyor. GitHub Actions'ta YAML dosyasına yazdığım workflow, bir olayla başlıyor; örneğin pull_request, push veya elle başlatılan workflow_dispatch.
Workflow içindeki job ayrı bir iş grubudur ve bir runner üzerinde çalışır. Job'un step'leri sırayla ilerler; run terminal komutu çalıştırır, uses hazır bir Action çağırır. Bağımsız job'lar paralel çalışabilir. Projede backend ve frontend CI kontrolleri kendi workflow dosyalarında tanımlı.
Kontroller farklı şeylere bakıyor: lint kurallara uymayan kodu bulmaya, typecheck tip uyuşmazlıklarını yakalamaya, testler davranışı doğrulamaya çalışıyor. Docker image build ise paketlemenin tamamlanabildiğini gösteriyor. Runner bunları başarıyla bitirse de uygulamayı sunucuda çalıştırmış olmuyor; o ayrı deployment adımı.
CD de iki farklı hedefi anlatabilir. Continuous Delivery, başarılı değişikliği dağıtıma hazırlar; canlıya geçiş için onay bekleyebilir. Continuous Deployment ise kontrollerden geçen değişikliği otomatik olarak canlıya taşır.
Bir Adım Diğerini Beklediğinde
Testler geçmeden migration'ın veya deployment'ın başlamasını istemem. Job bağımlılıkları bu sırayı kuruyor; release workflow'undaki kısaltılmış bölüm şöyle:
migrate:
needs: [backend-ci, frontend-ci]
publish:
needs: migrate
deploy:
needs: publish
needs, bir job'un ilerlemeden önce bekleyeceği işleri belirtir. Önce backend ve frontend kontrolleri tamamlanıyor; ardından migrate veritabanı şemasını güncelliyor, publish image'ları registry'ye gönderiyor ve deploy yeni sürümü K3s'te çalıştırıyor. Migration başarısız olursa image yayımı ve deployment başlamıyor. Şema değişikliğinin neden image dağıtımından önce geldiğine deployment bölümünde döneceğim.
Registry ve Sürüm Takibi
Image hazırlandıktan sonra sunucunun da ona ulaşması gerekiyor. Container registry, image'ları sakladığım ve çalışma ortamına indirdiğim depo; Docker Hub ve GitHub Container Registry buna örnek.
Git her commit'i bir hash ile tanımlar; commit SHA, bu kimlik değeridir. latest etiketi zamanla başka image'ı gösterebilir. SHA etiketi ise hangi kaynak kodundan image üretildiğini izlemeyi kolaylaştırır.
Projede kullandığım GitHub Container Registry'nin kısaltması GHCR. Gönderdiğim image adlarından biri:
ghcr.io/tahatuzel/git-to-prod-backend:<commit-sha>
Registry'ye yeni image göndermek, çalışan container'ı kendiliğinden güncellemez. Deployment sırasında hedef ortama hangi image sürümünü çalıştıracağını ayrıca bildirmek gerekir.
Adım 4: Deployment, Kubernetes ve K3s
Image registry'ye ulaştı; peki sunucu onu nasıl çalıştıracak? Deployment, uygulama sürümünü hedef ortamda başlatıp mevcut sürümden yenisine geçme süreci. Bunun için veritabanı erişimi, bağlantı ayarları ve dış trafiğin uygulamaya ulaşacağı yol da hazır olmalı.
Aynı image'ı farklı ortamlarda kullanıp bağlantı ayarlarını çalışma anında verebilirim; her ortam için yeniden paketlemem gerekmez. Hassas bilgileri de image içine koymak yerine ortamın Secret mekanizmasıyla sağlıyorum.
Bu çalışmada Terraform ile AWS ağını ve makineleri hazırladım. PostgreSQL'i ayrı bir sanal makineye, K3s'i uygulama makinelerine kurdum; pipeline da uygulamanın yeni sürümlerini buraya taşıdı.
Veritabanı Şeması da Değişebiliyor
Uygulamanın kodu değişirken veritabanındaki tablolar da değişebilir. Örneğin yeni bir alan eklediğimde bu değişikliği migration olarak tanımlarım. Eski sürüm hâlâ çalışırken yeni sürümün ihtiyaç duyduğu alanı erkenden silmek sorun çıkarabilir; bu yüzden kod ve şema değişikliklerinin sırasını birlikte düşünmek gerekir.
Projede EF Core'un migration bundle'ı release akışında önce çalışıyor. Migration tamamlanırsa image'lar yayımlanıyor ve K3s'e dağıtılıyor. Böylece her uygulama kopyasının açılışta aynı şemayı değiştirmeye çalışmasını önlüyorum. Image'ı önceki sürüme döndürmek de veritabanı değişikliğini kendiliğinden geri almaz.
Kubernetes ve K3s Ne Yapıyor?
Compose ile servisleri tek makinede başlatabiliyorum. Birden fazla makinede ise hangi makinenin uygulamayı çalıştıracağını, kapanan bir kopyanın yerine ne geleceğini ve yeni sürüme nasıl geçileceğini de yönetmem gerekiyor. Kubernetes, container'ların bu yaşam döngüsünü yönetiyor.
Cluster içindeki makineler node olarak adlandırılır. Kubernetes uygulamayı Pod içinde çalıştırır; çoğu Pod'da bir uygulama container'ı bulunur. Kaç kopya istediğimi bir Deployment tanımında belirtirim:
spec:
replicas: 2
template:
spec:
containers:
- name: backend
image: ghcr.io/tahatuzel/git-to-prod-backend:__IMAGE_TAG__
Burada iki backend kopyası istiyorum. Kubernetes mevcut durumu bu istekle karşılaştırır; bir Pod kapanırsa yenisini oluşturmaya çalışır. Scheduler Pod için uygun node'u seçer, node üzerindeki kubelet de container runtime'a uygulamayı başlatmasını söyler. K3s'in varsayılan runtime'ı containerd; bu nedenle worker makinelerine Docker Engine kurmam gerekmedi.
K3s, Kubernetes'i daha kolay kurup çalıştırmak için bazı bileşenleriyle birlikte paketleyen bir dağıtımdır. Yani K3s'te de Deployment, Pod ve Service aynı Kubernetes kaynaklarıdır. Projemde uygulama Pod'larını worker node'larda çalıştırmak için node label ve nodeSelector kullandım. İki replica istemek tek başına bunların farklı node'lara yerleşmesini garanti etmez.
Pod yeniden oluşturulunca IP'si değişebilir. Uygulamanın bu IP'yi takip etmemesi için Service sabit bir ad sağlar; Nuxt server projede backend'e http://todo-backend:8080 üzerinden ulaşıyor. Dışarıdan gelen HTTP trafiğinin hangi servise gideceğini Ingress tanımlar, K3s içindeki Traefik ise bu kuralı uygular.
Kurulumdaki istek yolu özetle şöyle:
Browser → AWS ALB → Traefik → frontend Service → Nuxt Pod
↓
backend Service
↓
.NET Pod
↓
PostgreSQL
PostgreSQL'i cluster dışında ayrı bir VM'de tuttum. Kubernetes bu projede frontend ve backend Pod'larını yönetiyor; veritabanının diskini veya yedeklerini yönetmiyor.
K3s ile Yeni Sürümü Çalıştırmak
Kubernetes'e istediğim durumu manifest adı verilen YAML dosyalarıyla bildiririm. Projedeki backend manifestinde image etiketi release sırasında commit SHA ile değiştirilir. Bağlantı bilgisi de dosyaya açık metin olarak yazılmaz; Pod bunu Kubernetes Secret'ından alır:
spec:
replicas: 2
template:
spec:
containers:
- name: backend
image: ghcr.io/tahatuzel/git-to-prod-backend:__IMAGE_TAG__
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: todo-db
key: connectionString
Bu, projedeki Deployment tanımının ilgili alanlarının sadeleştirilmiş hali. Release script'i image etiketini commit SHA ile doldurup manifesti cluster'a uygular:
sed "s/__IMAGE_TAG__/$release_sha/g" app.yaml | sudo k3s kubectl apply -f -
sudo k3s kubectl -n todo rollout status deployment/todo-backend --timeout=300s
sudo k3s kubectl -n todo rollout status deployment/todo-frontend --timeout=300s
kubectl apply, manifestteki istenen durumu cluster'a iletir. Rolling update sırasında Kubernetes yeni Pod'ları hazır etmeye çalışırken eski kopyaları kademeli kapatır. rollout status geçiş tamamlanana kadar bekler; ancak bunun düzgün çalışması için yeni Pod'un trafiğe hazır olup olmadığını da kontrol etmesi gerekir.
Projede bu iki kontrol backend manifestinde şöyle tanımlı:
readinessProbe:
httpGet:
path: /api/todo-items
port: 8080
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8080
periodSeconds: 20
Readiness probe, her 10 saniyede /api/todo-items endpoint'ine istek gönderiyor. Başarısızsa Pod Service üzerinden trafik almıyor. Liveness probe ise her 20 saniyede container port'una bağlantı kurmayı deniyor; eşik aşılarak başarısız olursa kubelet container'ı yeniden başlatabiliyor. Bu iki kontrol farklı sorulara cevap veriyor: uygulama trafik alabilir mi, çalışmaya devam edebiliyor mu?
Rollout tamamlandıktan sonra GitHub Actions, ALB üzerinden hem ana sayfaya hem /api/todo-items yoluna HTTP isteği gönderiyor. Bu son smoke test, dış trafik yolunun da çalıştığını kontrol ediyor. Projenin akışı böylece testlerden migration'a, image yayımına, K3s rollout'una ve dışarıdan kontrole kadar ilerliyor.
Hands-on Denemesi: Host Bağlanıyor, Pod Neden Bağlanamıyor?
Sorun
İlk cluster kurulumunda worker makinesinden PostgreSQL'e bağlanabiliyordum; backend Pod'undan yaptığım aynı test ise zaman aşımına uğruyordu.
Worker host → PostgreSQL Bağlantı kuruluyor
Backend Pod → PostgreSQL Timeout
Önce iki arayüzü tanıtayım: ens5, worker makinesinin AWS VPC ağına bağlanan ağ arayüzü. cni0 ise K3s'in aynı worker üzerindeki Pod'ları bağladığı yerel ağ köprüsü. Devamında bu yolları kısaca VPC ağı ve K3s'in yerel Pod ağı olarak anacağım.
VPC'm 10.42.0.0/16 aralığını kullanıyordu. K3s'in varsayılan Pod ağı da 10.42.0.0/16 idi. Yani VPC'deki makinelerle Pod'lar aynı IP aralığını kullanıyordu. Aynı IP bloğu hem VPC'deki DB'yi hem de K3s'in yerel Pod ağını gösterebildiği için worker gelen paketi hangi yola vereceğine karar vermek zorundaydı.
Böyle tespit ettim
İlk timeout'ta paketin nerede kaybolduğunu bilmiyordum. Bunu görmek için daha küçük bir AWS ortamında sorunu kontrollü olarak tekrar oluşturdum. Bu ortamda bir K3s server, bir worker ve bir DB vardı. Server ve worker makinelerini farklı subnet'lere koydum; DB adresini ise worker'ın Pod'ları için kullandığı aralığa denk getirdim:
VPC: 10.42.0.0/16
Server EC2 subnet: 10.42.254.0/24
Worker EC2 subnet: 10.42.253.0/24
DB EC2 subnet: 10.42.1.0/24
Worker'ın Pod aralığı: 10.42.1.0/24
Pod IP: 10.42.1.2
DB IP: 10.42.1.63
Pod 10.42.1.63 adresindeki DB'ye bağlanmaya çalışırken worker'daki rota kontrolü K3s'in yerel Pod ağına giden yolu gösterdi:
Pod'dan gelen hedef: 10.42.1.63
Worker'ın seçtiği yol: K3s'in yerel Pod ağı (cni0)
DB tarafındaki sonuç: Hiç paket görülmedi
DB IP'si 10.42.1.63, worker'ın Pod'ları için kullandığı 10.42.1.0/24 aralığıyla aynı blok içindeydi. Worker'ın rota tablosunda bu blok için K3s'in yerel ağına giden bir yol vardı. Hedef bu aralığa uyduğu için worker bu paket için tek bir yol seçti: VPC ağına göndermek yerine K3s'in yerel ağına verdi. DB IP'sinde yerel bir Pod bulunmadığından istek orada sonuçsuz kaldı. Paket VPC'ye hiç çıkmadı; DB tarafındaki tcpdump'ın hiç paket görmemesi de bunu doğruladı.
Dolayısıyla bu denemede sorun PostgreSQL'in isteği reddetmesi ya da yanıtın geri dönememesi değildi: istek DB'ye hiç ulaşmadı. Worker host'undan yapılan test farklıydı; bağlantı host'un kendi IP'si ve VPC yolu üzerinden çıkıyordu. Pod testi ise Pod IP'siyle başlayıp worker'da farklı bir yol seçiyordu.
Çözüm
Çözüm, K3s'in Pod adresleri için VPC'den farklı bir CIDR kullanmasıydı:
VPC ağı: 10.42.0.0/16
Pod ağı: 10.244.0.0/16
K3s server ayarı:
--cluster-cidr=10.244.0.0/16
--cluster-cidr, K3s'in Pod'lara vereceği IP aralığını belirliyor. Cluster'ı bu ayarla yeniden kurduğumda Pod ağı 10.244.0.0/16 oldu; DB ise aynı 10.42.1.63 adresinde kaldı. Artık DB adresi K3s'in Pod aralığında değildi. Worker hedefi K3s'in yerel ağıyla eşleştirmedi; paketi VPC yoluna, ens5 üzerinden gönderdi:
Pod IP'si: 10.244.1.3
DB IP'si: 10.42.1.63
Worker rotası: VPC ağı (ens5)
DB tcpdump: SYN paketi görüldü
Sonuç: Bağlantı ve SELECT 1 / SELECT 2 başarılı
DB tcpdump'ında SYN paketi görüldü; SELECT 1 ve SELECT 2 başarılı oldu. Bu sonuç, isteğin DB'ye ulaştığını ve yanıtın Pod'a döndüğünü doğruladı. CIDR'lar ayrılınca worker'ın seçebileceği ağ yolu netleşti: DB isteği VPC üzerinden gitti. K3s'in Pod ağı ve Flannel yapılandırması K3s ağ seçeneklerinde açıklanıyor.
Hatanın hangi katmanda olduğunu aramak
Sorun olduğunda önce Pod'un durumunu ve log'larını inceleyebilirim:
sudo k3s kubectl -n todo get pods -o wide
sudo k3s kubectl -n todo describe pod POD_ADI
sudo k3s kubectl -n todo logs deployment/todo-backend
get pods -o wide, Pod'ları node ve IP bilgileriyle listeler. describe pod, seçilen Pod'un ayrıntılarını ve olaylarını; logs, uygulamanın mesajlarını gösterir. POD_ADI yerine listeden aldığım gerçek adı yazmam gerekir.
ImagePullBackOff image'ın indirilmesinde sorun olduğunu gösterir; image adı, tag ve registry erişimine bakarım. CrashLoopBackOff durumunda uygulama log'larını ve başlangıç ayarlarını incelerim. Pod çalışıyor ama hazır değilse readiness kontrolü ve onun bağımlılıkları öne çıkar.
Bağlantı hatalarında da request'i hangi ortamdan gönderdiğimi, kullanılan adresi ve aradaki ağları ayrı ayrı kontrol etmem gerekiyor.
Kendime Notlar
Bu çalışmada birden fazla kez aynı dersi gördüm: Bir adımın başarılı olması sonraki adımı garanti etmiyor. Testler kodun belirli davranışlarını kontrol etti; image build paketlemenin tamamlandığını gösterdi. Pod'dan veritabanına giden ağ yolunu ise ancak gerçek cluster'da fark ettim.
Kubernetes'te iki replica yazmak da yeterli olmadı; hangi node'da çalıştıklarını ve veritabanı gibi bağımlılıkların nerede durduğunu ayrıca düşünmem gerekti. Bir hata çıktığında artık yalnızca “uygulama çalışıyor mu?” diye bakmıyorum. Request hangi ortamdan çıkıyor, hangi adresi kullanıyor ve sıradaki kontrol neyi kanıtlıyor, bunları takip ediyorum.
Kod, test ve deployment tanımları GitHub'da:
Top comments (0)