Docker Build — Ayrıntıda Gizli Şeytan

Selçuk Usta
5 min readNov 19, 2019

--

Yeni bir projeye başladığımızda öncelikle klasör hiyerarşimizi hazırlarız. Sonrasında bu klasörlere ihtiyaç duyulan proje nesnelerini ekler, GIT repository’imizi oluştururuz. .gitignore dosyamızı da ekleriz ki repository’de yer almasını istemediğimiz kritik — ya da geliştiriciye özel — bilgileri, dosyaları public olarak sunmayalım. Bunun yanı sıra, projemizi containerize edeceksek bir de son olarak Dockerfile ekleriz. Hemen hemen docker image’ımızın ilk versiyonu için hazır gibiyiz: docker build -t selcukusta/proje:1.0.0

Ve sonrasında mikro mikro uygulamalar, bir çok image. Hepsi de çalışıyor, boyutları da oldukça ideal denilebilir. Belki de şikayet etttiğimiz tek nokta şu olabilir: Bu build neden bu kadar uzun sürüyor? Ya da şu da olabilir: Image içerisindeki tüm dosyalar uygulamanın çalışması için gerçekten gerekli mi?

Fotoğraf, Steve Halama tarafından çekilmiş ve Unsplash üzerinde yayınlanmıştır.

Ayrıntıdaki şeytan — ben buna, yukarıdaki görselde de desteklendiği üzere balinanın su altında kalan kısmı da diyorum, asıl heybetli ve detay kısmı — bu aralar Dockerfile kullanılan projelerde atlandığına sıkça rastladığım, .dockerignore dosyası.

Neden .gitignore kullanıyoruz?

Girizgahta da belirttiğim gibi, kodu repository’den çeken contributer’ın ihtiyacı olmadığını düşündüğünüz (IDE ayarlarınızı tuttuğunuz dosya); hesap bilgilerinizin yer aldığı (SaaS CI aracına giriş için kullandığınız bilgiler); projeyi derlediğiniz de ya da bir betik çalıştırdığınızda zaten oluşacak olan bundle bir veya birden fazla dosya/klasörü, dışarıda tutmaya özen gösteririz. Böylece projenizin klon süresi de kısalacaktır.

Bir .NET Core projesini örnek alırsak eğer muhtemelen /bin ve /obj klasörlerinizi, /node_modules klasörünüzü ya da Visual Studio Code ile geliştirme yapıyorsanız /.vscode klasörünüzü .gitignore dosyasına ekliyorsunuzdur.

Peki bu klasörlere Docker imajı içerisinde ihtiyaç var mı?

Bir projeyi containerize ederken kullandığımız dosya, hepimizin bildiği üzere Dockerfile isimli dosya. Bu dosya, “nasıl yapalım?” sorusuna cevap veren imperative bir domain-specific language (DSL). Genellikle sonuç çıktısı az değişeninden çok değişenine doğru sıralı bir emir dizisi olarak nitelendirebiliriz. Bu sıralama mantığının altında yatan konu ise cache özelliği. Eğer build context içerisinde statik bir konfigürasyon dosyanız varsa — dosya sıklıkla değişmiyorsa — ve bunu image içerisine kopyalacaksanız, üst sıralara yerleştirmeniz önerilir. Böylece bir sonraki build esnasında aynı komut cache’ten gelir ve bir maliyet oluşturmaz.

Ara başlıktaki sorumuzun hemen üstündeki paragrafa gidelim. /bin ,/obj , /node_modules gibi dinamik olarak üretilen ve çok sık değişen — paket güncellemesi, kod değişikliği gibi nedenlerle — klasör ve içeriklerini build context içerisine almamız demek, cache denilen nimetten büyük ölçüde yararlanamamız anlamına gelecektir. Özellikle 100min/month gibi birim fiyatlarla çalışan SaaS uygulamalarında CI operasyonlarını yürütüyorsanız 1 dakikalık tasarrufun bile işe yaradığını söyleyebilirim.

Sanırım sorunun cevabı kafamızda yavaş yavaş netleşmiştir. Ancak bu .dockerignore dosyası oluşturma/düzenleme işlemini atlama konusunu bir noktada anlayabiliyorum. Yukarıda bahsettiğim .NET Core projesi klasörleri, projeyi ilk build ettiğimiz anda oluşuyor ve hemen hemen hiç işimizin olmadığı dosyalar olarak kenarda bekliyor. Docker image’ımızı, .dockerignore dosyası olmadan oluşturduğumuzda ve image’dan bir container ürettiğimizde Running görüyoruz ve çok da irdelemiyoruz. Hele bir de image private registry içerisinde ise konforu bozmak çok makul gelmiyor. Öyle ya kaç tane daha .[X]ignore dosyası ile uğraşabiliriz ki?

Yukarıdaki örnek repository’de küçük bir örnek ile, varsayılan proje şablonu sonucu oluşan build context’leri göstermeye çalıştım.

Bir .dockerignore dosyası kullanmadığımızda build context yaklaşık 190~200MB oluyor ve context’in yaklaşık 5.5~6 saniyede Docker Daemon’a aktarıldığını görüyoruz.

Tam tersi durumda ise build context’in 3.50–4.00KB olduğunu ve transfer süresinin de 0.1 saniye olduğunu görüyoruz.

Multi-stage build kullansam olmaz mı?

Kavramların karıştığı noktalardan biri de bu oluyor genellikle. Örnek repository’deki Dockerfile , multi-stage oluşturulmuş bir dosya. .dockerignore kullansanız da kullanmasanız da oluşan final imajınızın boyutu genellikle aynı olacaktır. Farkı yalnızca intermediate layer olarak adlandırılan ve emirleriniz için oluşturulmuş ara imajlarda gözlemleyebilirsiniz. docker image ls komutunu çalıştırdığımızda <none> olarak listede gördüğünüz meşhur ve gizemli imajlar.

O halde multi-stage kullanmama gerek yok(!)

Dilinizi ısırın :) ve hemen örnek repository içerisindeki One-Stage.Dockerfile dosyasına göz atın. Her iki Dockerfile üzerinden de build aldığınızda build çıktısının aynı olduğunu göreceksiniz.

Çünkü One-Stage.Dockerfile içerisindeki 5.satırda projenin runtime aşaması için gereksiz tüm dosyaların silinmesini söylüyoruz. Şöyle de bir kanıt sunabiliriz sanırım:

Yukarıdaki görselde göreceğiniz üzere kaynak kodda yer alan WeatherForecast.cs dosyasının final imajında olmadığını görebiliyoruz. Demek ki diğer kritik dosyalarımı da rm -rf ile temizlesem aynı güvenliği sağlamış olurum.

Diyorsanız, kesinlikle yanılıyorsunuz!

Eğer henüz kurmadıysanız şiddetle tavsiye ettiğim bir uygulama olan Dive ile imajınız yaratılırken oluşan tüm intermediate layer’ları görebiliyor, inceleyebiliyorsunuz. Şu imajımızı bir irdeleyelim:

Bingo! 196MB olarak gördüğümüz layer’ın detaylarını incelediğimizde kaynak kodun da imaj içerisine gömüldüğünü görebiliyoruz. İşe tam bu noktada, public olarak kullanılabilecek bir image’da neden multi-stage build kullanmamız gerektiğini görebiliyoruz.

Yine Dive tarafından yapılan imaj verimlilik puanlamasında da göreceğimiz üzere .dockerignore kullanılmış, multi-stage bir docker build (solda, %99 verimli olarak işaretlendi); .dockerignore kullanılmamış ve single-stage bir docker build’e (sağda, %91 verimli olarak işaretlendi) tercih edilmelidir desek yanlış olmayacaktır.

Sonuç

Kendi kendime referans vererek — bu örneği çok düşünmüştüm ama bir yerlerde kullanmalıydım :) — konuyu sonuçlandırayım.

Bir de vurucu bir Dockerfile örneği vereyim. Golang ile geliştirilmiş küçük bir API projemizi dockerize ettik. Build stage’inde base image olarak golang:alpine3.10 kullandık. Boyutu 359MB. Runtime stage’inde ise scratch kullandık çünkü projeyi Linux ortamında çalışır bir şekilde build ettiğimizde herhangi bir platform, paket, vb. bağımlılıklarımız kalmıyordu. Son durumda ise final imajımızın boyutu 6.22MB oldu. Ayrıca proje klasöründe geliştirme aşamasında kullandığımız ancak build ve runtime esnasında ihtiyaç duymadığımız 100'e yakın PDF dosyamız bulunuyor. Bu dosyaları da .dockerignore dosyası ile build context dışında bırakarak 58~60MB’dan 8.32KB’a düşen bir transfer aşaması elde ettik.

Dolayısıyla, docker build aşaması hiç bitmeyen keyifli bir yolculuk gibi. Bu yolculukla yükünüz hep hafif, pahanız hep ağır olsun.

--

--

Selçuk Usta

Engineering Manager (at) Hepsiburada. Former trainer & consultant. Tweets are mostly about tech and coding. https://superpeer.com/selcukusta