Sistem Build Berbasis Artefak

Halaman ini membahas sistem build berbasis artefak dan filosofi di balik pembuatannya. Bazel adalah sistem build berbasis artefak. Meskipun sistem build berbasis tugas merupakan langkah yang baik di atas skrip build, sistem ini memberikan terlalu banyak kendali kepada engineer individual dengan membiarkan mereka menentukan tugasnya sendiri.

Sistem build berbasis artefak memiliki sejumlah kecil tugas yang ditentukan oleh sistem yang dapat dikonfigurasi engineer secara terbatas. Engineer tetap memberi tahu sistem apa yang harus di-build, tetapi sistem build menentukan cara mem-build-nya. Seperti sistem build berbasis tugas, sistem build berbasis artefak, seperti Bazel, masih memiliki file build, tetapi konten buildfile tersebut sangat berbeda. Daripada merupakan rangkaian perintah imperatif dalam bahasa skrip Turing-complete yang menjelaskan cara menghasilkan output, buildfile di Bazel adalah manifes deklaratif yang menjelaskan sekumpulan artefak untuk di-build, dependensinya, dan serangkaian opsi terbatas yang memengaruhi cara pembuatannya. Saat engineer menjalankan bazel pada command line, mereka menentukan kumpulan target yang akan di-build (apa), dan Bazel bertanggung jawab untuk mengonfigurasi, menjalankan, dan menjadwalkan langkah kompilasi (bagaimana). Karena kini sistem build memiliki kontrol penuh atas kapan alat akan dijalankan, sistem build dapat membuat jaminan yang jauh lebih kuat sehingga memungkinkannya menjadi jauh lebih efisien sambil tetap menjamin ketepatan.

Perspektif fungsional

Sangat mudah untuk membuat analogi antara sistem build berbasis artefak dan pemrograman fungsional. Bahasa pemrograman imperatif tradisional (seperti Java, C, dan Python) menentukan daftar pernyataan yang akan dijalankan satu per satu, dengan cara yang sama seperti sistem build berbasis tugas memungkinkan programmer menentukan serangkaian langkah yang akan dijalankan. Bahasa pemrograman fungsional (seperti Haskell dan ML), sebaliknya, distrukturkan lebih menyerupai serangkaian persamaan matematika. Dalam bahasa fungsional, programmer menjelaskan komputasi yang akan dilakukan, tetapi membiarkan detail waktu dan cara tepatnya komputasi dijalankan ke compiler.

Hal ini mengacu pada gagasan untuk mendeklarasikan manifes dalam sistem build berbasis artefak dan memungkinkan sistem mengetahui cara mengeksekusi build tersebut. Banyak masalah tidak mudah diekspresikan menggunakan pemrograman fungsional, tetapi masalah yang sangat diuntungkan darinya: bahasa sering kali dapat dengan mudah memparalelkan program tersebut dan membuat jaminan kuat tentang kebenarannya yang tidak mungkin dilakukan dalam bahasa yang imperatif. Masalah yang paling mudah untuk dinyatakan menggunakan pemrograman fungsional adalah masalah yang hanya melibatkan perubahan satu bagian data menjadi data lain menggunakan serangkaian aturan atau fungsi. Dan itulah sistem build: seluruh sistem pada dasarnya adalah fungsi matematika yang mengambil file sumber (dan alat seperti compiler) sebagai input dan menghasilkan biner sebagai output. Jadi, tidak mengherankan jika sistem build berfungsi dengan baik berdasarkan prinsip pemrograman fungsional.

Memahami sistem build berbasis artefak

Sistem build Google, Blaze, adalah sistem build berbasis artefak pertama. Bazel adalah versi {i>open source<i} dari Blaze.

Seperti inilah tampilan buildfile (biasanya bernama BUILD) di Bazel:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

Di Bazel, file BUILD menentukan target—dua jenis target di sini adalah java_binary dan java_library. Setiap target berkaitan dengan artefak yang dapat dibuat oleh sistem: target biner menghasilkan biner yang dapat dieksekusi secara langsung, dan target library menghasilkan library yang dapat digunakan oleh biner atau library lainnya. Setiap target memiliki:

  • name: cara target direferensikan pada command line dan oleh target lainnya
  • srcs: file sumber yang akan dikompilasi untuk membuat artefak bagi target
  • deps: target lain yang harus dibuat sebelum target ini dan ditautkan ke target tersebut

Dependensi dapat berada dalam paket yang sama (seperti dependensi MyBinary pada :mylib) atau pada paket yang berbeda dalam hierarki sumber yang sama (seperti dependensi mylib pada //java/com/example/common).

Seperti sistem build berbasis tugas, Anda menjalankan build menggunakan alat command line Bazel. Untuk membuat target MyBinary, jalankan bazel build :MyBinary. Setelah memasukkan perintah tersebut untuk pertama kalinya di repositori bersih, Bazel:

  1. Mengurai setiap file BUILD di ruang kerja untuk membuat grafik dependensi di antara artefak.
  2. Menggunakan grafik untuk menentukan dependensi transitif MyBinary; yaitu, setiap target yang menjadi dependensi MyBinary dan setiap target yang diandalkan target tersebut, secara rekursif.
  3. Membangun setiap dependensi tersebut secara berurutan. Bazel memulai dengan mem-build setiap target yang tidak memiliki dependensi lain, dan melacak dependensi mana yang masih perlu dibuat untuk setiap target. Segera setelah semua dependensi target dibuat, Bazel akan mulai mem-build target tersebut. Proses ini berlangsung hingga setiap dependensi transitif MyBinary telah dibuat.
  4. Mem-build MyBinary untuk menghasilkan biner akhir yang dapat dieksekusi yang menautkan semua dependensi yang di-build pada langkah 3.

Pada dasarnya, mungkin tidak terlihat bahwa apa yang terjadi di sini jauh berbeda dengan apa yang terjadi saat menggunakan sistem build berbasis tugas. Memang, hasil akhirnya adalah biner yang sama, dan proses untuk menghasilkannya melibatkan analisis banyak langkah untuk menemukan dependensi di antara keduanya, lalu menjalankan langkah-langkah tersebut secara berurutan. Tetapi ada perbedaan yang penting. Yang pertama muncul pada langkah 3: karena Bazel tahu bahwa setiap target hanya menghasilkan library Java, yang harus dilakukan hanyalah menjalankan compiler Java, bukan skrip yang ditentukan pengguna arbitrer, sehingga Bazel tahu bahwa aman untuk menjalankan langkah-langkah ini secara paralel. Hal ini dapat menghasilkan urutan peningkatan performa yang signifikan dibandingkan mem-build target satu per satu pada mesin multicore, dan hanya mungkin dilakukan karena pendekatan berbasis artefak membuat sistem build bertanggung jawab atas strategi eksekusinya sendiri, sehingga dapat membuat jaminan yang lebih kuat tentang paralelisme.

Namun, manfaatnya jauh melampaui paralelisme. Hal berikutnya yang diberikan oleh pendekatan ini kepada kita akan terlihat jelas saat developer mengetik bazel build :MyBinary untuk kedua kalinya tanpa membuat perubahan apa pun: Bazel akan keluar kurang dari satu detik dengan pesan yang menyatakan bahwa target sudah diperbarui. Hal ini memungkinkan karena paradigma pemrograman fungsional yang telah kita bahas sebelumnya—Bazel tahu bahwa setiap target hanya merupakan hasil dari menjalankan compiler Java, dan mengetahui bahwa output dari compiler Java hanya bergantung pada inputnya, jadi selama input belum berubah, output dapat digunakan kembali. Dan analisis ini berfungsi di setiap level; jika MyBinary.java berubah, Bazel dapat mem-build ulang MyBinary tetapi menggunakan kembali mylib. Jika file sumber untuk //java/com/example/common berubah, Bazel dapat mem-build ulang library tersebut, mylib, dan MyBinary, tetapi menggunakan kembali //java/com/example/myproduct/otherlib. Karena mengetahui properti alat yang dijalankannya di setiap langkah, Bazel hanya dapat membangun ulang kumpulan artefak minimum setiap kali, sekaligus menjamin bahwa alat tersebut tidak akan menghasilkan build yang usang.

Membingkai ulang proses build dalam hal artefak, bukan tugas, memang halus tetapi andal. Dengan mengurangi fleksibilitas yang diekspos kepada programmer, sistem build dapat mengetahui lebih banyak hal yang dilakukan di setiap langkah build. Fitur ini dapat menggunakan pengetahuan ini untuk membuat build jauh lebih efisien dengan memparalelkan proses build dan menggunakan kembali outputnya. Namun, ini baru langkah pertama, dan elemen penyusun paralelisme dan penggunaan kembali ini membentuk dasar bagi sistem build yang terdistribusi dan sangat skalabel.

Trik keren Bazel lainnya

Sistem build berbasis artefak pada dasarnya memecahkan masalah dengan paralelisme dan penggunaan kembali yang melekat dalam sistem build berbasis tugas. Namun, masih ada beberapa masalah yang muncul sebelumnya yang belum kami tangani. Bazel memiliki cara cerdas untuk menyelesaikan setiap masalah, dan kita harus membahasnya sebelum melanjutkan.

Alat sebagai dependensi

Salah satu masalah yang kami temukan sebelumnya adalah build bergantung pada alat yang diinstal di komputer, dan mereproduksi build di seluruh sistem menjadi sulit karena versi atau lokasi alat yang berbeda. Masalahnya menjadi lebih sulit saat project Anda menggunakan bahasa yang memerlukan alat berbeda berdasarkan platform yang dibuat atau dikompilasi (misalnya, Windows versus Linux), dan setiap platform tersebut memerlukan serangkaian alat yang sedikit berbeda untuk melakukan tugas yang sama.

Bazel menyelesaikan bagian pertama dari masalah ini dengan memperlakukan alat sebagai dependensi untuk setiap target. Setiap java_library di ruang kerja secara implisit bergantung pada compiler Java, yang secara default menggunakan compiler populer. Setiap kali mem-build java_library, Bazel akan memeriksa untuk memastikan compiler yang ditentukan tersedia di lokasi yang diketahui. Sama seperti dependensi lainnya, jika compiler Java berubah, setiap artefak yang bergantung padanya akan dibuat ulang.

Bazel menyelesaikan bagian kedua dari masalah ini, yaitu independensi platform, dengan menetapkan konfigurasi build. Target bergantung pada jenis konfigurasi, bukan target bergantung langsung pada alatnya:

  • Konfigurasi host: alat build yang berjalan selama build
  • Konfigurasi target: membangun biner yang pada akhirnya Anda minta

Memperluas sistem build

Bazel sudah dilengkapi dengan target untuk beberapa bahasa pemrograman populer secara langsung, tetapi engineer selalu ingin melakukan lebih banyak lagi—bagian dari manfaat sistem berbasis tugas adalah fleksibilitasnya dalam mendukung segala jenis proses build, dan sebaiknya jangan menyerah dalam sistem build berbasis artefak. Untungnya, Bazel memungkinkan jenis target yang didukungnya diperluas dengan menambahkan aturan kustom.

Untuk menentukan aturan dalam Bazel, penulis aturan mendeklarasikan input yang diperlukan aturan (dalam bentuk atribut yang diteruskan dalam file BUILD) dan kumpulan output tetap yang dihasilkan aturan tersebut. Penulis juga menentukan tindakan yang akan dihasilkan oleh aturan tersebut. Setiap tindakan mendeklarasikan input dan output-nya, menjalankan file yang dapat dieksekusi tertentu, atau menulis string tertentu ke file, dan dapat terhubung ke tindakan lain melalui input dan output-nya. Ini berarti bahwa tindakan adalah unit composable level terendah dalam sistem build—tindakan dapat melakukan apa pun yang diinginkannya selama tindakan tersebut hanya menggunakan input dan output yang dideklarasikan, dan Bazel menangani tindakan penjadwalan dan meng-cache hasilnya sebagaimana mestinya.

Sistem ini tidaklah sempurna, mengingat tidak ada cara untuk menghentikan developer tindakan melakukan sesuatu seperti memperkenalkan proses yang tidak deterministik sebagai bagian dari tindakan mereka. Namun, hal ini jarang terjadi dalam praktiknya. Mendorong kemungkinan penyalahgunaan hingga ke tingkat tindakan akan sangat mengurangi peluang error. Aturan yang mendukung berbagai bahasa dan alat umum tersedia secara online, dan sebagian besar project tidak perlu menentukan aturannya sendiri. Bahkan bagi mereka yang memilikinya, definisi aturan hanya perlu didefinisikan di satu tempat terpusat dalam repositori, yang berarti sebagian besar engineer akan dapat menggunakan aturan tersebut tanpa perlu mengkhawatirkan implementasinya.

Mengisolasi lingkungan

Tindakan terdengar seperti dapat mengalami masalah yang sama dengan tugas di sistem lain—bukankah masih mungkin untuk menulis tindakan yang menulis ke file yang sama dan pada akhirnya saling bertentangan satu sama lain? Sebenarnya, Bazel membuat konflik tersebut tidak mungkin terjadi dengan menggunakan sandbox. Pada sistem yang didukung, setiap tindakan diisolasi dari setiap tindakan lainnya melalui sandbox sistem file. Secara efektif, setiap tindakan hanya dapat melihat tampilan terbatas sistem file yang menyertakan input yang telah dideklarasikan dan output yang dihasilkannya. Hal ini diterapkan oleh sistem seperti LXC di Linux, teknologi yang sama di belakang Docker. Artinya, tindakan tidak dapat bertentangan satu sama lain karena tindakan tersebut tidak dapat membaca file apa pun yang tidak dideklarasikan, dan file apa pun yang ditulis tetapi tidak dideklarasikan akan dihapus setelah tindakan selesai. Bazel juga menggunakan sandbox untuk membatasi tindakan agar tidak berkomunikasi melalui jaringan.

Membuat dependensi eksternal menjadi deterministik

Masih ada satu masalah yang tersisa: sistem build sering kali perlu mendownload dependensi (baik alat maupun library) dari sumber eksternal, bukan secara langsung mem-build-nya. Hal ini dapat dilihat dalam contoh melalui dependensi @com_google_common_guava_guava//jar, yang mendownload file JAR dari Maven.

Bergantung pada file di luar ruang kerja saat ini dapat berisiko. File tersebut dapat berubah kapan saja, sehingga berpotensi mengharuskan sistem build untuk terus memeriksa apakah file tersebut baru. Jika file jarak jauh berubah tanpa perubahan yang terkait pada kode sumber ruang kerja, hal tersebut juga dapat menyebabkan build yang tidak dapat direproduksi—build mungkin berfungsi pada suatu hari lalu gagal pada hari berikutnya tanpa alasan yang jelas karena perubahan dependensi yang tidak terlihat. Terakhir, dependensi eksternal dapat menimbulkan risiko keamanan yang besar jika dimiliki oleh pihak ketiga: jika penyerang dapat menyusup server pihak ketiga tersebut, ia dapat mengganti file dependensi dengan sesuatu dari desainnya sendiri, sehingga berpotensi memberinya kontrol penuh atas lingkungan build Anda dan outputnya.

Masalah dasarnya adalah kita ingin sistem build mengetahui file-file ini tanpa harus memeriksanya dalam kontrol sumber. Mengupdate dependensi harus menjadi pilihan yang tepat, tetapi pilihan tersebut harus dilakukan sekali di tempat terpusat, bukan dikelola oleh engineer individu atau otomatis oleh sistem. Hal ini karena meskipun dengan model "Live at Head", kita masih menginginkan build menjadi deterministik, yang menyiratkan bahwa jika memeriksa commit dari minggu lalu, Anda akan melihat dependensi Anda sebagaimana adanya, bukan seperti saat ini.

Bazel dan beberapa sistem build lainnya mengatasi masalah ini dengan memerlukan file manifes di seluruh ruang kerja yang mencantumkan hash kriptografis untuk setiap dependensi eksternal di ruang kerja. Hash adalah cara ringkas untuk merepresentasikan file secara unik tanpa memeriksa seluruh file ke dalam kontrol sumber. Setiap kali dependensi eksternal baru direferensikan dari ruang kerja, hash dependensi tersebut akan ditambahkan ke manifes, baik secara manual maupun otomatis. Saat menjalankan build, Bazel akan memeriksa hash sebenarnya dari dependensi cache-nya terhadap hash yang diharapkan yang ditentukan dalam manifes, lalu mendownload ulang file hanya jika hash tersebut berbeda.

Jika artefak yang kita download memiliki hash yang berbeda dengan yang dideklarasikan dalam manifes, build akan gagal kecuali jika hash dalam manifes diupdate. Hal ini dapat dilakukan secara otomatis, tetapi perubahan tersebut harus disetujui dan dimasukkan ke kontrol sumber sebelum build menerima dependensi baru. Ini berarti selalu ada data kapan dependensi diperbarui, dan dependensi eksternal tidak dapat berubah tanpa perubahan yang sesuai pada sumber ruang kerja. Ini juga berarti bahwa saat memeriksa kode sumber versi lama, build dijamin akan menggunakan dependensi yang sama dengan yang digunakannya pada saat versi tersebut diperiksa (atau akan gagal jika dependensi tersebut tidak lagi tersedia).

Tentu saja, hal ini masih dapat menjadi masalah jika server jarak jauh menjadi tidak tersedia atau mulai menyajikan data yang rusak. Hal ini dapat menyebabkan semua build Anda mulai gagal jika Anda tidak memiliki salinan lain dari dependensi tersebut yang tersedia. Untuk menghindari masalah ini, sebaiknya, untuk project yang tidak umum, Anda mencerminkan semua dependensinya ke server atau layanan yang Anda percayai dan kontrol. Jika tidak, Anda akan selalu bergantung pada pihak ketiga untuk ketersediaan sistem build Anda, meskipun hash yang check-in menjamin keamanannya.