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 daya kepada masing-masing engineer dengan mengizinkan mereka menentukan tugasnya sendiri.

Sistem build berbasis artefak memiliki sejumlah kecil tugas yang ditentukan oleh sistem yang dapat dikonfigurasi oleh engineer dengan cara terbatas. Engineer masih memberi tahu sistem apa yang akan 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 file build tersebut sangat berbeda. Daripada menjadi kumpulan perintah imperatif dalam bahasa skrip Turing-complete yang menjelaskan cara menghasilkan output, file build di Bazel adalah manifes deklaratif yang menjelaskan kumpulan artefak yang akan di-build, dependensinya, dan kumpulan opsi terbatas yang memengaruhi cara build. Saat engineer menjalankan bazel di command line, mereka menentukan kumpulan target yang akan di-build (apa), dan Bazel bertanggung jawab untuk mengonfigurasi, menjalankan, dan menjadwalkan langkah-langkah kompilasi (cara). Karena sistem build kini memiliki kontrol penuh atas alat yang akan dijalankan, sistem ini dapat memberikan jaminan yang jauh lebih kuat yang memungkinkannya menjadi jauh lebih efisien sekaligus menjamin kebenaran.

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 dieksekusi satu demi satu, dengan cara yang sama seperti sistem build berbasis tugas yang memungkinkan programmer menentukan serangkaian langkah untuk dieksekusi. Bahasa pemrograman fungsional (seperti, Haskell dan ML), sebaliknya, disusun lebih seperti serangkaian persamaan matematika. Dalam bahasa fungsional, programmer menjelaskan komputasi yang akan dilakukan, tetapi menyerahkan detail waktu dan cara komputasi tersebut dieksekusi kepada compiler.

Hal ini sesuai dengan ide mendeklarasikan manifes dalam sistem build berbasis artefak dan membiarkan sistem menentukan cara menjalankan build. Banyak masalah yang tidak dapat dengan mudah diekspresikan menggunakan pemrograman fungsional, tetapi masalah yang dapat diekspresikan akan sangat diuntungkan: bahasa ini sering kali dapat memparalelkan program tersebut dengan mudah dan memberikan jaminan yang kuat tentang kebenarannya yang tidak mungkin dalam bahasa imperatif. Masalah yang paling mudah diekspresikan menggunakan pemrograman fungsional adalah masalah yang hanya melibatkan transformasi satu bagian data ke bagian data lain menggunakan serangkaian aturan atau fungsi. Dan itulah sistem build: seluruh sistem secara efektif merupakan fungsi matematika yang mengambil file sumber (dan alat seperti compiler) sebagai input dan menghasilkan biner sebagai output. Jadi, tidak mengherankan jika sistem build berbasis prinsip pemrograman fungsional berfungsi dengan baik.

Memahami sistem build berbasis artefak

Sistem build Google, Blaze, adalah sistem build berbasis artefak pertama. Bazel adalah versi open source dari Blaze.

Berikut tampilan file build (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 sesuai 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 di command line dan oleh target lainnya
  • srcs: file sumber yang dikompilasi untuk membuat artefak untuk target
  • deps: target lain yang harus di-build sebelum target ini dan ditautkan ke dalamnya

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

Seperti sistem build berbasis tugas, Anda melakukan build menggunakan alat command line Bazel. Untuk mem-build 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 antar-artefak.
  2. Menggunakan grafik untuk menentukan dependensi transitif MyBinary; yaitu, setiap target yang bergantung pada MyBinary dan setiap target yang bergantung pada target tersebut, secara rekursif.
  3. Mem-build setiap dependensi tersebut, secara berurutan. Bazel dimulai dengan mem-build setiap target yang tidak memiliki dependensi lain dan melacak dependensi mana yang masih perlu di-build untuk setiap target. Segera setelah semua dependensi target di-build, Bazel akan mulai mem-build target tersebut. Proses ini berlanjut hingga setiap dependensi transitif MyBinary di-build.
  4. Mem-build MyBinary untuk menghasilkan biner yang dapat dieksekusi akhir yang ditautkan ke 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 sejumlah langkah untuk menemukan dependensi di antara langkah-langkah tersebut, lalu menjalankan langkah-langkah tersebut secara berurutan. Namun, ada perbedaan penting. Perbedaan pertama muncul pada langkah 3: karena Bazel tahu bahwa setiap target hanya menghasilkan library Java, Bazel tahu bahwa yang harus dilakukan hanyalah menjalankan compiler Java, bukan skrip yang ditentukan pengguna secara arbitrer, sehingga Bazel tahu bahwa aman untuk menjalankan langkah-langkah ini secara paralel. Hal ini dapat menghasilkan peningkatan performa urutan besaran dibandingkan dengan mem-build target satu per satu di mesin multicore, dan hanya mungkin karena pendekatan berbasis artefak membuat sistem build bertanggung jawab atas strategi eksekusinya sendiri sehingga dapat memberikan jaminan yang lebih kuat tentang paralelisme.

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

Mengubah proses build dalam hal artefak, bukan tugas, memang halus, tetapi efektif. Dengan mengurangi fleksibilitas yang diekspos ke programmer, sistem build dapat mengetahui lebih banyak tentang apa yang dilakukan di setiap langkah build. Sistem ini dapat menggunakan pengetahuan ini untuk membuat build jauh lebih efisien dengan memparalelkan proses build dan menggunakan kembali outputnya. Namun, ini hanyalah langkah pertama, dan elemen penyusun paralelisme dan penggunaan kembali ini membentuk dasar untuk sistem build terdistribusi dan sangat skalabel.

Trik Bazel lainnya yang berguna

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

Alat sebagai dependensi

Salah satu masalah yang kita hadapi sebelumnya adalah bahwa build bergantung pada alat yang diinstal di mesin kita, dan mereproduksi build di seluruh sistem dapat menjadi sulit karena versi atau lokasi alat yang berbeda. Masalah ini menjadi lebih sulit saat project Anda menggunakan bahasa yang memerlukan alat yang berbeda berdasarkan platform yang digunakan untuk mem-build atau mengompilasi (seperti, Windows versus Linux), dan setiap platform tersebut memerlukan kumpulan alat yang sedikit berbeda untuk melakukan pekerjaan yang sama.

Bazel memecahkan bagian pertama 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 adalah compiler yang dikenal. Setiap kali Bazel mem-build java_library, Bazel akan memeriksa untuk memastikan bahwa compiler yang ditentukan tersedia di lokasi yang diketahui. Seperti dependensi lainnya, jika compiler Java berubah, setiap artefak yang bergantung padanya akan di-build ulang.

Bazel memecahkan bagian kedua masalah, independensi platform, dengan menetapkan konfigurasi build. Daripada target yang bergantung langsung pada alatnya, target bergantung pada jenis konfigurasi:

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

Memperluas sistem build

Bazel dilengkapi dengan target untuk beberapa bahasa pemrograman populer, tetapi engineer akan selalu ingin melakukan lebih banyak—bagian dari manfaat sistem berbasis tugas adalah fleksibilitasnya dalam mendukung segala jenis proses build, dan akan lebih baik jika tidak menyerahkannya dalam sistem build berbasis artefak. Untungnya, Bazel memungkinkan jenis target yang didukungnya diperluas dengan menambahkan aturan kustom.

Untuk menentukan aturan di Bazel, penulis aturan mendeklarasikan input yang diperlukan aturan (dalam bentuk atribut yang diteruskan dalam file BUILD) dan kumpulan output tetap yang dihasilkan aturan. Penulis juga menentukan tindakan yang akan dihasilkan oleh aturan tersebut. Setiap tindakan mendeklarasikan input dan outputnya, menjalankan executable tertentu atau menulis string tertentu ke file, dan dapat terhubung ke tindakan lain melalui input dan outputnya. Artinya, tindakan adalah unit composable tingkat terendah dalam sistem build—tindakan dapat melakukan apa pun yang diinginkannya selama hanya menggunakan input dan output yang dideklarasikan, dan Bazel menangani penjadwalan tindakan dan menyimpan hasil dalam cache sebagaimana mestinya.

Sistem ini tidak sempurna karena tidak ada cara untuk menghentikan developer tindakan melakukan sesuatu seperti memperkenalkan proses nondeterministik sebagai bagian dari tindakan mereka. Namun, hal ini tidak terlalu sering terjadi dalam praktiknya, dan mendorong kemungkinan penyalahgunaan hingga ke tingkat tindakan akan sangat mengurangi peluang terjadinya error. Aturan yang mendukung banyak bahasa dan alat umum tersedia secara luas secara online, dan sebagian besar project tidak akan pernah perlu menentukan aturan mereka sendiri. Bahkan untuk project yang melakukannya, definisi aturan hanya perlu ditentukan di satu tempat pusat di repositori, yang berarti sebagian besar engineer akan dapat menggunakan aturan tersebut tanpa perlu khawatir tentang implementasinya.

Mengisolasi lingkungan

Tindakan terdengar seperti mungkin mengalami masalah yang sama seperti tugas di sistem lain—apakah masih mungkin menulis tindakan yang menulis ke file yang sama dan akhirnya saling bertentangan? Sebenarnya, Bazel membuat konflik ini tidak mungkin terjadi dengan menggunakan sandboxing. Pada sistem yang didukung, setiap tindakan diisolasi dari setiap tindakan lainnya melalui sandbox sistem file. Secara efektif, setiap tindakan hanya dapat melihat tampilan sistem file yang dibatasi yang mencakup input yang telah dideklarasikan dan output yang telah dihasilkan. Hal ini diterapkan oleh sistem seperti LXC di Linux, teknologi yang sama di balik Docker. Artinya, tindakan tidak mungkin bertentangan satu sama lain karena tidak dapat membaca file yang tidak dideklarasikan, dan file yang ditulis tetapi tidak dideklarasikan akan dibuang saat tindakan selesai. Bazel juga menggunakan sandbox untuk membatasi tindakan agar tidak berkomunikasi melalui jaringan.

Membuat dependensi eksternal deterministik

Masih ada satu masalah yang tersisa: sistem build sering kali perlu mendownload dependensi (baik alat maupun library) dari sumber eksternal, bukan mem-build-nya secara langsung. 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 berisiko. File tersebut dapat berubah kapan saja, yang berpotensi mengharuskan sistem build untuk terus memeriksa apakah file tersebut baru. Jika file jarak jauh berubah tanpa perubahan yang sesuai dalam kode sumber ruang kerja, hal ini juga dapat menyebabkan build yang tidak dapat direproduksi—build mungkin berfungsi suatu hari dan gagal pada hari berikutnya tanpa alasan yang jelas karena perubahan dependensi yang tidak diperhatikan. Terakhir, dependensi eksternal dapat menimbulkan risiko keamanan yang sangat besar jika dimiliki oleh pihak ketiga: jika penyerang dapat menyusup ke server pihak ketiga tersebut, mereka dapat mengganti file dependensi dengan sesuatu yang mereka desain sendiri, yang berpotensi memberi mereka kontrol penuh atas lingkungan build dan outputnya.

Masalah mendasar adalah kita ingin sistem build mengetahui file ini tanpa harus memeriksanya ke kontrol sumber. Memperbarui dependensi harus menjadi pilihan yang disadari, tetapi pilihan tersebut harus dibuat satu kali di tempat pusat, bukan dikelola oleh masing-masing engineer atau secara otomatis oleh sistem. Hal ini karena meskipun dengan model “Live at Head”, kita tetap ingin build menjadi deterministik, yang berarti bahwa jika Anda memeriksa commit dari minggu lalu, Anda akan melihat dependensi seperti saat itu, bukan seperti sekarang.

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

Jika artefak yang kita download memiliki hash yang berbeda dengan yang dideklarasikan dalam manifes, build akan gagal kecuali hash dalam manifes diperbarui. Hal ini dapat dilakukan secara otomatis, tetapi perubahan tersebut harus disetujui dan diperiksa ke kontrol sumber sebelum build menerima dependensi baru. Artinya, selalu ada catatan kapan dependensi diperbarui, dan dependensi eksternal tidak dapat berubah tanpa perubahan yang sesuai dalam sumber ruang kerja. Artinya juga, saat memeriksa versi kode sumber yang lebih lama, build dijamin akan menggunakan dependensi yang sama dengan yang digunakan 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 tidak tersedia atau mulai menayangkan data yang rusak—hal ini dapat menyebabkan semua build Anda mulai gagal jika Anda tidak memiliki salinan dependensi lain yang tersedia. Untuk menghindari masalah ini, sebaiknya, untuk project yang tidak sepele, 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, meskipun hash yang diperiksa menjamin keamanannya.