Toz ve gaz bulutundan üretim ortamına; Asp.Net Core ve Docker ile CI/CD — Bölüm 1

Photo by Lance Asper on Unsplash

Sizden istenen özelliği üstün bir başarıyla, hatasız bir şekilde kodladınız. Ortalıkta bug’dan eser yok. Gelsin commit’ler gitsin deployment’lar. Ancak o da ne? Test ortamında, ya da daha kötüsü üretim ortamında yazdığınız kod patlıyor(!). Acaba kaçımızın bu durumla yüzleştiğinde verdiği ilk tepki “Ama benim makinemde çalışıyordu!” olmuştur?


Yazılım geliştirme sürecinde bu ve buna benzer sorunlarla sıkça karşılaşıyoruz. Geliştirme, test, üretim gibi çalışma ortamları arasındaki küçük farklar (konfigürasyon, bağımlı kütüphaneler vb.) uygulamanın her ortamda farklı çalışmasına ve en kötü senaryoda, ortaya bir kaosun çıkmasına imkan verebiliyor. İhtiyacımız olan şey, uygulamayı farklı ortamlar arasında taşırken manuel süreçleri tamamen ortadan kaldırmak ve geleneksel kurulum yöntemlerinin dışına çıkarak (dosya kopyalama, manuel olarak kurulum paketi oluşturma gibi yöntemleri kast ediyorum), çalışma zamanı platformunu da kurulum paketinin bir parçası gibi düşünerek sorunsuz bir şekilde bu işi gerçekleştirmek.

Başlamadan önce küçük bir not: Yazıyı yazarken bol bol İngilizce kelime kullanacağım. Bu, İngilizce’ye hayran olduğum için değil, çalışma hayatında geliştiriciler olarak her gün kullandığımız, işimizle ilgili olan birçok kelimenin yeterli Türkçe karşılığı olmamasından. Henüz ilk taslağı yazarken fark ettim ki, kullanacağım kelimelerin Türkçe karşılıklarını düşünmekle fazla vakit harcıyorum. Bu nedenle çalışma ortamında iş arkadaşlarımla teknik bir konuyu tartışırken nasıl konuşuyorsam, bu yazıda da aynı dili kullanmaya devam edeceğim. Şikayetçi olana sektör değişikliği öneriyorum :-)

Son zamanlarda mikroservis mimarisinin parlamasıyla birlikte gözde teknolojilerden biri haline gelen Docker ve zaten çok sevdiğimiz ama Core geçişi sonrası tadından yenmez bir hale gelen Asp.Net Core ile geliştirme, test ve üretim aşamalarında konteynerleştirme (evet kötü bir kelime, biliyorum) bu yazı serisinin ana konusu. Yol boyunca uğrayacağımız duraklar ise şunlar:

  • Asp.Net Core uygulamalarını Docker ile nasıl konteynerleştiririz
  • Uygulamaları konteyner içinde nasıl debug eder, nasıl farklı ortamlar arasında taşırız
  • Oluşturduğumuz konteyner imajları ile sürekli entegrasyon (continuous integration) ve sürekli dağıtım (continuous delivery) nasıl uygularız
  • Her kod güncellemesinden sonra otomatik olarak nasıl test ortamlarını ayağa kaldırırız

Bu yazıda ise;

  • Önce nasıl bir CI/CD süreci oluşturacağımıza bakacağız
  • Oluşturduğumuz Docker imajlarını saklamak için bir Docker registry oluşturacağız
  • Asp.Net Core projemizi bir Docker konteyner içinde çalışır hale getireceğiz
  • Asp.Net Core projesinin kullanacağı ve yine Docker konteyner içinde çalışan bir Mongo veritabanı oluşturacağız
  • Oluşturduğumuz iki konteyneri birbirine bağlayarak CI/CD sürecinin ilk adımlarını atmış olacağız

Nasıl bir süreç tanımlamak istiyoruz?

Birazdan, benim daha önceden oluşturduğum bir Asp.Net Core projesini, Docker yardımıyla, geliştirme ve CI/CD süreçlerine sokmak için ilk adımları atacağız. Tüm süreci şu şekilde özetleyebiliriz:

  1. Geliştirici çalışmasını tamamladığında kodları source control repository’ye (Git) commit eder.
  2. Kullandığımız CI/CD sunucusu tanımlanmış hook’lar sayesinde kaynak kodları source control provider’dan alır.
  3. CI/CD sunucusu üzerinde .Net SDK yüklü olan bir container ile uygulamayı derler (build) ve bir paket oluşturur.
  4. Yine CI/CD sunucusu paket içinde gelen birim testlerini (unit test) çalıştırır.
  5. Birim testleri başarılı olarak çalıştıktan sonra, kuruluma hazır Docker imajı, bir Docker registry’sine yüklenir (Biz kendi registry’mizi kurup bunun üzerinde çalışacağız).
  6. İmaj registry’ye yüklendikten sonra, uygulamanın dışarı ile olan bağımlılıklarını test etmek için bir ya da daha fazla container ile entegrasyon testleri çalıştırılır (Bu uygulama örneğinde bağımlılık olarak bir MongoDB veritabanı seçtim).
  7. Entegrasyon testleri başarılı olduğunda hem otomatik hem de isteğe bağlı olarak, istenen sayıda demo ortamı ayağa kaldırılır.
  8. Herşey yolunda gitmişse (en büyük arzumuz), uygulamanın production ortamına taşınması gerçekleştirilir.

Benimle birlikte siz de süreci adım adım ilerletmek istiyorsanız, .Net Core 2 ve Docker kurulumlarınızı tamamlamayı unutmayın. Projeyi şu şekilde elde edebilirsiniz:

git clone https://github.com/onselakin/aspnet-core-docker-pipeline.git
cd aspnet-core-docker-pipeline
git checkout start-here
Örnek proje ile ilgili ufak bir bilgi: Proje tavsiye edilen geliştirme pratiklerini uygulayan bir proje değil. Yazı Docker odaklı olduğu için projenin mimari yapısı üzerinde düşünmeye gerek olmadığına karar verdim. Umarım başka yazılarda uygulama mimarileri, DDD gibi konulardan daha detaylı söz etme imkanı bulabiliriz. Özetle örnek proje, örnek alınacak bir proje değil :-)

Devam edelim…

Docker imajları için bir registry oluşturuyoruz

Docker imajlarını saklamak için bir registry tanımlayalım. Bu registry ilerleyen safhalarda CI/CD araçları yapılandırmalarında Docker imajlarımızın kaynağı olarak tanımlanacak. Her commit’te build, test ve release gibi süreçlerde bu registry’yi kullanacağız. Kendinize ait bir registry oluşturmak yerine, Docker Hub üzerinde belli bir ücret karşılığında private repo açmayı da tercih edebilirsiniz.

Projenin ana klasöründe pipeline için kullanılacak yapılandırma dosyalarını tutmak amacıyla gerekli klasörleri oluşturalım. Ben macOS üzerinde çalışıyorum. Siz Windows üzerinde çalışıyorsanız benim kullandığım komutların Windows karşılığı olan komutları kullanabilirsiniz. Birkaç ufak farka ve klasör isimlerine dikkat etmeniz yeterli olacaktır.

mkdir pipeline
cd pipeline
mkdir registry
cd registry

Bu klasörün içinde docker-compose.yml adında bir dosya oluşturup içini şöyle dolduralım.

Docker Compose birden çok konteyner ile çalışan uygulamaları tanımlamak için kullanılan bir araç, yukarıda oluşturduğumuz dosya ise bu aracın nasıl çalışacağını yapılandırdığımız dosya.

services ile çalıştıracağımız servisleri tanımlıyoruz. Şu anda local-registry adında tek bir servis çalıştırmak istiyoruz, Docker’ın registry servisi. Bu servis image ile gösterildiği gibi Docker Hub’dan registry isimli imajı indirip çalıştıracak ve ports ayarında görüldüğü gibi lokal makinenin 50000 numaralı portunu registry imajının 5000 numaralı portuna eşleştirecek. Yani kendi makinemizde 50000 portuna istek gönderdiğimizde, aslında container içinde çalışan registry uygulamasına 5000 numaralı porttan ulaşmış olacağız. volume ise registry’nin imajları lokal makinemizin hangi klasöründe saklayacağını belirtiyor.

Haydi registry’yi çalıştıralım.

docker-compose up -d

-d parametresi ile konteynerin arka planda çalışmasını sağlıyoruz. Compose yapılandırma dosyasındaki imajlara bakarak Docker Hub’dan gerekli imajları indiriyor ve konteyneri çalıştırıyor. docker-compose ps ile konteynerin çalıştığını teyid edelim.

Registry’nin çalıştığı 5000 portunu kendi makinemizde 50000 portu ile eşleştirmiştik. Registry’ye browser üzerinden ulaşabiliyor muyuz bakalım:

Yerel Docker imaj sunucumuz da hazır ve buraya kadar herşey yolunda. Tabi henüz herhangi bir imaj oluşturmadığımız için yanıt olarak gelen JSON herhangi bir imaj göstermiyor. Bu sunucuyu Asp.Net Core ve Mongo DB veritabanı imajlarını saklamak, gerektiğinde çalıştırıp, işimiz bittiğinde ortadan kaldırmak için kullanacağız.

İmajları registry altında saklamadan önce, Docker’a kullandığımız registry’nin güvenli olduğunu belirtmemiz gerekiyor. Docker ayarlarına girip oluşturduğumuz registry’yi Insecure registries listesine ekleyelim.

Harika! Tek eksiğimiz registry’ye göndereceğimiz bir imaj :-) Elimizde bir Asp.Net Core WebAPI projemiz var. Bu projeyi bir dotnet konteyneri içinde çalışır hale getirip, bu imajı registry’ye gönderebiliriz.


Bir .Net konteyneri oluşturuyoruz

Önce standart bir .Net Core imajı indirip, bunun üzerinden bir konteyner çalıştıralım. Herhangi bir sorunla karşılaşmazsak WebAPI projesini konteyner içinde çalıştırıp test edeceğiz.

docker run --rm -it microsoft/dotnet dotnet

Bu komutla Docker’a Docker Hub’da microsoft isimli organizasyona ait dotnet imajını indirmesini, bu imajdan bir konteyner oluşturup, konteyner içinde dotnet komutunu çalıştırmasını, rm parametresi ile komut çalıştıktan sonra konteyneri durdurmasını belirtmiş oluyoruz. -it parametresi konteyner içinde verdiğimiz shell komutunun çalıştırılması için gerekli olan terminal bağlantısını oluşturuyor gibi düşünebiliriz. Sonuç:

dotnet komutunun çıktısını görebildiğimize göre herşey yolunda diyebiliriz. Tekrar ifade edecek olursak, tek bir komutla Docker Hub’dan üzerinde .Net Core yüklü olan bir Linux imajı indirdik ve bu imajdan oluşturulan konteyner içinde de dotnet komutunu çalıştırdık. Enfes değil mi!

Şimdi örnek projeye dönelim. Proje çok basit bir Web API projesi. Bir MongoDB veritabanı ile kullanıcı profilleri üzerinde CRUD işlemleri yapan bir api:

Mongo DB için bir konteyner oluşturuyoruz

Uygulamayı çalıştırmak için bir MongoDB kurulumuna ihtiyacımız var. Mongo’yu kurmak için de uğraşmamıza pek gerek yok aslında. Sadece iki komutla bilgisayarımızda bir MongoDB sunucusu çalıştırabiliriz:

mkdir ~/data

ya da Windows ile çalışıyorsanız;

md C:\data

ile MongoDB verisini tutacağımız klasörü oluşturuyoruz.

docker run -d -p 27017:27017 -v ~/data:/data/db mongo

komutu ile de Mongo Docker imajı indirip bir konteyner çalıştırmaya başlıyoruz. -p parametresini zaten biliyorsunuz; yerel makinemizin 27017 portunu konteyner içindeki Mongo portuna bağlıyor. -v parametresi ile de Mongo’nun ihtiyaç duyduğu data klasörünü yerel makinemizdeki /data (C:\data) klasörüne bağlamış olduk. Böylece konteyner içinde Mongo’nun diske yazma işlerinin tamamı yerel makinenin diskine yönlendirilmiş oldu.

Mongo başlatılırken diske yazma ile ilgili bir hata alıyorsanız, volume’ü bir klasör olarak göstermek yerine bir Docker volume oluşturup Mongo konteyneri için mount etme yöntemini tercih edebilirsiniz:
docker volume create mongodata
docker run -d -p 27017:27017 --mount source=mongodata,target=/data/db mongo
Bu katkısından dolayı Serdar Kalaycı’ya teşekkür ediyoruz :-)

Projeyi çalıştırmaya hazırız artık. myapi klasörüne geçip dotnet run komutunu veriyoruz.

Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.

Uygulamamız 5000 portunda çalışıyor, mu gerçekten kontrol edelim :-)

Evet kusursuz. Swagger ile API metodlarını deneyebilirsiniz.

Web API uygulamasını konteyner içine taşıyoruz

Sırada web uygulamasını bir konteyner içinde çalıştırma adımı var. Böylece yerel makinemizde bir dotnet runtime’ı olmadan da uygulama aktif olabilecek.

Daha önce konteyner içinde dotnet’i nasıl çalıştırdığımızı görmüştük. Yine benzer bir komut kullanacağız. Ancak bu kez uygulamanın bulunduğu klasörü konteyner içinde çalışan dotnet runtime tarafından ulaşılabilir hale getirmemiz gerekiyor. -v parametresi işimizi görecek.

Hala myapi klasöründe olduğunuzu varsayıyorum. Komutumuz şöyle:

docker run --rm -it -p 5000:5000 -v ${PWD}:/api microsoft/dotnet

-v parametresinde kullandığımız ${PWD} değişkeni, komutu çalıştırırken bulunduğumuz klasörü gösteriyor, yani myapi klasörü. Bir önceki kullanımda konteyner içinde çağırmak istediğimiz komutu da vermiştik (“dotnet”). Şimdi ise sadece hangi konteyneri çalıştırmak istediğimizi belirttik (microsoft/dotnet). Yani -it ile interaktif modda konteyneri çalıştırdık ancak komut belirtmedik. Bu şekilde konteyneri çalıştırdığımızda konteynerin terminal arayüzüne (tty) geçmiş oluyoruz. Ekran görüntüsündeki root prompt’u şu anda konteyner içinde olduğumuzu gösteriyor.

Bulunduğumuz klasörü (myapi), konteyner içindeki api klasörüne bağlamıştık. Bunu da kontrol edelim:

Görüldüğü gibi myapi klasörü altındaki tüm dosyalara konteyner içinden de ulaşabiliyoruz. Konteyner içinde uygulamayı çalıştırıp Swagger arayüzüne ulaşabilecek miyiz bakalım:

Hmm bir sorunumuz var. Kestrel 5000 numaralı portu kullanamadığını söylüyor. Neden olabilir, fikriniz var mı?

Hatırlarsanız kendi Docker registry’mizi 5000 numaralı porttan hizmete açmıştık. Dolayısıyla bu port şu anda kullanımda. Yapmamız gereken myapi uygulamasını farklı bir porttan çalıştırmak. Uygulamanın içindeki Program.cs dosyasında küçük bir değişiklik yeterli olacaktır.

BuildWebHost metodunu aşağıdaki gibi değiştiriyorum.

Port sorununu çözdüğümüze göre uygulamayı konteyner içinde tekrar başlatabiliriz.

Bu yapılandırmadan sonra artık Swagger arayüzüne ulaşabiliyoruz. MongoDB bağlantımız da çalışıyor mu kontrol edelim. Swagger üzerinden GET /api/Profile metodunu çağırıyorum ve sonuç aşağıdaki gibi oluyor:

HTTP 500 :-(

Dotnet konteynerine geçerek orada hangi hatayı almışız bakalım.

Belli ki dotnet konteyneri, Mongo’nun çalıştığı konteynere bağlanamıyor. Yazının ilerleyen bölümlerinde Docker Compose kullanarak konteynerlerin network bağlantılarını da otomatik olarak halledeceğiz ama şimdilik web api uygulamasına Mongo’nun nerede çalıştığını söyleyelim. Bunun için ProfileController içindeki Mongo sunucu adresini değiştireceğiz.

Web API uygulamasından Mongo’ya ulaşmak

Önce şu anda aktif olan Docker konteyner listesini bir görelim:

docker ps --format "{{.ID}}: {{.Image}}"

mongo isimli konteynerin çalıştığı IP adresini alabilmek için bir komut daha vermemiz gerek. Komuta parametre olarak mongo konteyner ID’sini geçiyoruz.

docker inspect 6467e48fc682

Bu komutla konteyner hakkında detaylı bilgiye sahip oluyoruz. Bizi ilgilendiren kısım ise işaretlediğim IP adresi değeri. Bu adresi Mongo bağlantı dizisine eklememiz gerekiyor.

ProfileController dosyasında constructor içindeki kodu şu şekilde değiştiriyoruz:

Çalışmakta olan dotnet konteynerindeki Asp.Net sunucusunu Ctrl-C ile durdurup tekrar başlatalım (dotnet run) ve Swagger üzerinden api çağrısını yineleyelim.

Süper! Web API uygulaması ve Mongo iki farklı konteyner içinde çalışıyor ve birbirleriyle haberleşebiliyorlar.

Ne başardık?

Buraya kadar herşey yolunda gitti. Ancak herşeyi manuel olarak yaptık. Bu kadar manuel iş CI/CD süreçlerini bozar :-) Dolayısıyla bu yaptıklarımızı daha otomatik hale getirecek birşeyler yapmak gerek. Ayrıca en başta oluşturduğumuz Docker imaj registry’sini de henüz hiç kullanmadık.

Tüm bu yaptıklarımızı tek bir komutla konteyneri oluşturacak, projeyi konteyner içine taşıyacak, derleyecek ve çalıştıracak şekilde otomatik hale getirmeliyiz. Ayrıca entegrasyon testleri koşturacaksak, bu saydıklarıma paralel olarak bir MongoDB konteyneri oluşturmalı ve bu iki konteyner arasındaki bağlantıyı da test etmeliyiz. Herşey yolunda gittiğinde ise CI/CD araçları ile projeyi önce test/demo ortamına, daha sonra da üretim ortamına taşımalıyız.

Bunu da yazının ikinci bölümüne bırakalım. Kısa bir süre sonra tekrar görüşmek üzere :-)