Docker Build — Ayrıntıda Gizli Şeytan
--
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?
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.