Konfigurasi sistem terdistribusi yang dapat dikompilasi

Dalam postingan kali ini kami ingin berbagi cara menarik dalam menangani konfigurasi sistem terdistribusi.
Konfigurasi direpresentasikan langsung dalam bahasa Scala dengan cara yang aman. Contoh implementasi dijelaskan secara rinci. Berbagai aspek proposal dibahas, termasuk pengaruhnya terhadap proses pembangunan secara keseluruhan.

Konfigurasi sistem terdistribusi yang dapat dikompilasi

(dalam bahasa Rusia)

Pengantar

Membangun sistem terdistribusi yang kuat memerlukan penggunaan konfigurasi yang benar dan koheren di semua node. Solusi umumnya adalah dengan menggunakan deskripsi penerapan tekstual (terraform, ansible, atau sejenisnya) dan file konfigurasi yang dibuat secara otomatis (seringkali — didedikasikan untuk setiap node/peran). Kami juga ingin menggunakan protokol yang sama dengan versi yang sama pada setiap node yang berkomunikasi (jika tidak, kami akan mengalami masalah ketidakcocokan). Di dunia JVM, ini berarti setidaknya perpustakaan perpesanan harus memiliki versi yang sama di semua node yang berkomunikasi.

Bagaimana dengan pengujian sistem? Tentu saja, kita harus melakukan pengujian unit untuk semua komponen sebelum melakukan pengujian integrasi. Untuk dapat mengekstrapolasi hasil pengujian pada waktu proses, kita harus memastikan bahwa versi semua pustaka tetap sama baik pada waktu proses maupun lingkungan pengujian.

Saat menjalankan pengujian integrasi, seringkali lebih mudah untuk memiliki classpath yang sama di semua node. Kita hanya perlu memastikan bahwa classpath yang sama digunakan pada penerapan. (Ada kemungkinan untuk menggunakan classpath yang berbeda pada node yang berbeda, namun lebih sulit untuk merepresentasikan konfigurasi ini dan menerapkannya dengan benar.) Jadi untuk mempermudah, kami hanya akan mempertimbangkan classpath yang identik pada semua node.

Konfigurasi cenderung berkembang seiring dengan perangkat lunak. Kami biasanya menggunakan versi untuk mengidentifikasi berbagai
tahapan evolusi perangkat lunak. Tampaknya masuk akal untuk mencakup konfigurasi dalam manajemen versi dan mengidentifikasi konfigurasi yang berbeda dengan beberapa label. Jika hanya ada satu konfigurasi dalam produksi, kami dapat menggunakan versi tunggal sebagai pengidentifikasi. Terkadang kami mungkin memiliki beberapa lingkungan produksi. Dan untuk setiap lingkungan kita mungkin memerlukan cabang konfigurasi terpisah. Jadi konfigurasi mungkin diberi label dengan cabang dan versi untuk mengidentifikasi konfigurasi yang berbeda secara unik. Setiap label dan versi cabang sesuai dengan satu kombinasi node terdistribusi, port, sumber daya eksternal, versi perpustakaan classpath pada setiap node. Di sini kita hanya akan membahas satu cabang dan mengidentifikasi konfigurasi berdasarkan versi desimal tiga komponen (1.2.3), dengan cara yang sama seperti artefak lainnya.

Di lingkungan modern, file konfigurasi tidak lagi dimodifikasi secara manual. Biasanya kami menghasilkan
file konfigurasi pada waktu penerapan dan jangan pernah menyentuhnya setelah itu. Jadi mungkin ada yang bertanya mengapa kita masih menggunakan format teks untuk file konfigurasi? Pilihan yang tepat adalah menempatkan konfigurasi di dalam unit kompilasi dan memanfaatkan validasi konfigurasi waktu kompilasi.

Dalam postingan kali ini kita akan membahas ide menyimpan konfigurasi dalam artefak yang dikompilasi.

Konfigurasi yang dapat dikompilasi

Pada bagian ini kita akan membahas contoh konfigurasi statis. Dua layanan sederhana - layanan gema dan klien layanan gema sedang dikonfigurasi dan diimplementasikan. Kemudian dua sistem terdistribusi berbeda dengan kedua layanan dipakai. Satu untuk konfigurasi node tunggal dan satu lagi untuk konfigurasi dua node.

Sistem terdistribusi tipikal terdiri dari beberapa node. Node dapat diidentifikasi menggunakan beberapa jenis:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

atau hanya

case class NodeId(hostName: String)

atau bahkan

object Singleton
type NodeId = Singleton.type

Node-node ini melakukan berbagai peran, menjalankan beberapa layanan dan harus dapat berkomunikasi dengan node lain melalui koneksi TCP/HTTP.

Untuk koneksi TCP setidaknya diperlukan nomor port. Kami juga ingin memastikan bahwa klien dan server menggunakan protokol yang sama. Untuk memodelkan koneksi antar node, mari kita deklarasikan kelas berikut:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

dimana Port hanyalah sebuah Int dalam rentang yang diizinkan:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Tipe halus

Lihat halus perpustakaan. Singkatnya, ini memungkinkan untuk menambahkan batasan waktu kompilasi ke tipe lainnya. Pada kasus ini Int hanya diperbolehkan memiliki nilai 16-bit yang dapat mewakili nomor port. Tidak ada persyaratan untuk menggunakan perpustakaan ini untuk pendekatan konfigurasi ini. Sepertinya sangat cocok.

Untuk HTTP (REST) ​​​​kita mungkin juga memerlukan jalur layanan:

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

Tipe hantu

Untuk mengidentifikasi protokol selama kompilasi kami menggunakan fitur Scala untuk mendeklarasikan argumen tipe Protocol yang tidak digunakan di kelas. Itu yang disebut tipe hantu. Pada saat runtime kami jarang membutuhkan sebuah instance dari pengidentifikasi protokol, itu sebabnya kami tidak menyimpannya. Selama kompilasi, tipe hantu ini memberikan keamanan tipe tambahan. Kami tidak dapat melewati port dengan protokol yang salah.

Salah satu protokol yang paling banyak digunakan adalah REST API dengan serialisasi Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

dimana RequestMessage adalah jenis pesan dasar yang dapat dikirim klien ke server dan ResponseMessage adalah pesan respons dari server. Tentu saja, kami dapat membuat deskripsi protokol lain yang menentukan protokol komunikasi dengan presisi yang diinginkan.

Untuk keperluan postingan ini kami akan menggunakan versi protokol yang lebih sederhana:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Dalam protokol ini, pesan permintaan ditambahkan ke url dan pesan respons dikembalikan sebagai string biasa.

Konfigurasi layanan dapat dijelaskan berdasarkan nama layanan, kumpulan port, dan beberapa dependensi. Ada beberapa cara yang mungkin untuk merepresentasikan semua elemen ini di Scala (misalnya, HList, tipe data aljabar). Untuk keperluan posting ini kita akan menggunakan Pola Kue dan mewakili potongan-potongan yang dapat digabungkan (modul) sebagai ciri-ciri. (Pola Kue bukanlah persyaratan untuk pendekatan konfigurasi yang dapat dikompilasi ini. Ini hanyalah salah satu kemungkinan implementasi ide tersebut.)

Dependensi dapat direpresentasikan menggunakan Pola Kue sebagai titik akhir dari node lain:

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

Layanan Echo hanya memerlukan port yang dikonfigurasi. Dan kami menyatakan bahwa port ini mendukung protokol echo. Perhatikan bahwa kita tidak perlu menentukan port tertentu pada saat ini, karena sifat ini memungkinkan deklarasi metode abstrak. Jika kita menggunakan metode abstrak, kompiler akan memerlukan implementasi dalam contoh konfigurasi. Di sini kami telah menyediakan implementasinya (8081) dan itu akan digunakan sebagai nilai default jika kita melewatkannya dalam konfigurasi konkret.

Kita dapat mendeklarasikan ketergantungan dalam konfigurasi klien layanan gema:

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

Ketergantungan memiliki tipe yang sama dengan echoService. Secara khusus, hal ini menuntut protokol yang sama. Oleh karena itu, kita dapat yakin bahwa jika kita menghubungkan kedua dependensi ini, keduanya akan berfungsi dengan benar.

Implementasi layanan

Suatu layanan memerlukan fungsi untuk memulai dan mematikannya dengan baik. (Kemampuan untuk mematikan layanan sangat penting untuk pengujian.) Sekali lagi ada beberapa opsi untuk menentukan fungsi tersebut untuk konfigurasi tertentu (misalnya, kita dapat menggunakan kelas tipe). Untuk postingan kali ini kita akan menggunakan Pola Kue lagi. Kami dapat mewakili layanan menggunakan cats.Resource yang sudah menyediakan bracketing dan pelepasan sumber daya. Untuk memperoleh sumber daya, kita harus menyediakan konfigurasi dan beberapa konteks runtime. Jadi fungsi awal layanan mungkin terlihat seperti:

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

dimana

  • Config — jenis konfigurasi yang diperlukan oleh starter layanan ini
  • AddressResolver — objek runtime yang memiliki kemampuan untuk mendapatkan alamat sebenarnya dari node lain (teruskan membaca untuk detailnya).

jenis lainnya berasal cats:

  • F[_] — jenis efek (Dalam kasus paling sederhana F[A] bisa jadi adil () => A. Dalam posting ini kita akan menggunakan cats.IO.)
  • Reader[A,B] — kurang lebih merupakan sinonim untuk suatu fungsi A => B
  • cats.Resource — memiliki cara untuk memperoleh dan melepaskan
  • Timer — memungkinkan untuk tidur/mengukur waktu
  • ContextShift - analog dari ExecutionContext
  • Applicative — pembungkus fungsi yang berlaku (hampir satu monad) (pada akhirnya kita mungkin akan menggantinya dengan yang lain)

Dengan menggunakan antarmuka ini kita dapat mengimplementasikan beberapa layanan. Misalnya, layanan yang tidak melakukan apa pun:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(Lihat Kode sumber untuk implementasi layanan lainnya — layanan gema,
klien gema dan pengontrol seumur hidup.)

Node adalah objek tunggal yang menjalankan beberapa layanan (memulai rantai sumber daya diaktifkan oleh Pola Kue):

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

Perhatikan bahwa di dalam node kita menentukan jenis konfigurasi persis yang diperlukan oleh node ini. Kompiler tidak akan membiarkan kita membuat objek (Kue) dengan tipe yang tidak mencukupi, karena setiap sifat layanan mendeklarasikan batasan pada Config jenis. Kami juga tidak akan dapat memulai node tanpa menyediakan konfigurasi lengkap.

Resolusi alamat node

Untuk membuat koneksi kita memerlukan alamat host sebenarnya untuk setiap node. Ini mungkin diketahui lebih lambat dibandingkan bagian konfigurasi lainnya. Oleh karena itu, kita memerlukan cara untuk menyediakan pemetaan antara id node dan alamat sebenarnya. Pemetaan ini adalah fungsi:

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

Ada beberapa cara yang mungkin untuk mengimplementasikan fungsi tersebut.

  1. Jika kita mengetahui alamat sebenarnya sebelum penerapan, selama pembuatan instance host node, maka kita dapat membuat kode Scala dengan alamat sebenarnya dan menjalankan build setelahnya (yang melakukan pemeriksaan waktu kompilasi dan kemudian menjalankan rangkaian pengujian integrasi). Dalam hal ini fungsi pemetaan kita diketahui secara statis dan dapat disederhanakan menjadi seperti a Map[NodeId, NodeAddress].
  2. Kadang-kadang kita mendapatkan alamat sebenarnya hanya pada saat berikutnya ketika node benar-benar dimulai, atau kita tidak memiliki alamat node yang belum dimulai. Dalam hal ini kita mungkin memiliki layanan penemuan yang dimulai sebelum semua node lainnya dan setiap node mungkin mengiklankan alamatnya di layanan tersebut dan berlangganan dependensi.
  3. Jika kita bisa memodifikasi /etc/hosts, kita dapat menggunakan nama host yang telah ditentukan sebelumnya (seperti my-project-main-node dan echo-backend) dan kaitkan saja nama ini dengan alamat ip pada waktu penerapan.

Dalam postingan ini kami tidak membahas kasus ini secara lebih rinci. Faktanya, dalam contoh mainan kita, semua node akan memiliki alamat IP yang sama — 127.0.0.1.

Dalam posting ini kita akan mempertimbangkan dua tata letak sistem terdistribusi:

  1. Tata letak node tunggal, dimana semua layanan ditempatkan pada node tunggal.
  2. Tata letak dua node, di mana layanan dan klien berada pada node yang berbeda.

Konfigurasi untuk a simpul tunggal tata letaknya adalah sebagai berikut:

Konfigurasi simpul tunggal

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

Di sini kita membuat konfigurasi tunggal yang memperluas konfigurasi server dan klien. Kami juga mengonfigurasi pengontrol siklus hidup yang biasanya akan menghentikan klien dan server setelahnya lifetime interval berlalu.

Kumpulan implementasi dan konfigurasi layanan yang sama dapat digunakan untuk membuat tata letak sistem dengan dua node terpisah. Kita hanya perlu menciptakan dua konfigurasi node terpisah dengan layanan yang sesuai:

Konfigurasi dua node

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Lihat bagaimana kami menentukan ketergantungan. Kami menyebutkan layanan yang disediakan node lain sebagai ketergantungan dari node saat ini. Tipe ketergantungan diperiksa karena mengandung tipe phantom yang menjelaskan protokol. Dan saat runtime kita akan memiliki id node yang benar. Ini adalah salah satu aspek penting dari pendekatan konfigurasi yang diusulkan. Ini memberi kita kemampuan untuk mengatur port hanya sekali dan memastikan bahwa kita mereferensikan port yang benar.

Implementasi dua node

Untuk konfigurasi ini kami menggunakan implementasi layanan yang persis sama. Tidak ada perubahan sama sekali. Namun, kami membuat dua implementasi node berbeda yang berisi kumpulan layanan berbeda:

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

Node pertama mengimplementasikan server dan hanya memerlukan konfigurasi sisi server. Node kedua mengimplementasikan klien dan memerlukan bagian konfigurasi lainnya. Kedua node memerlukan spesifikasi seumur hidup. Untuk keperluan layanan pos ini, node akan memiliki masa pakai tak terbatas yang dapat diakhiri dengan menggunakan SIGTERM, sedangkan klien echo akan berhenti setelah durasi terbatas yang dikonfigurasi. Lihat aplikasi pemula untuk rincian.

Proses pembangunan secara keseluruhan

Mari kita lihat bagaimana pendekatan ini mengubah cara kita bekerja dengan konfigurasi.

Konfigurasi sebagai kode akan dikompilasi dan menghasilkan artefak. Tampaknya masuk akal untuk memisahkan artefak konfigurasi dari artefak kode lainnya. Seringkali kita dapat memiliki banyak konfigurasi pada basis kode yang sama. Dan tentu saja, kita dapat memiliki beberapa versi dari berbagai cabang konfigurasi. Dalam suatu konfigurasi, kita dapat memilih versi perpustakaan tertentu dan ini akan tetap konstan setiap kali kita menerapkan konfigurasi ini.

Perubahan konfigurasi menjadi perubahan kode. Jadi hal ini harus dicakup oleh proses jaminan kualitas yang sama:

Tiket -> PR -> tinjauan -> penggabungan -> integrasi berkelanjutan -> penerapan berkelanjutan

Ada konsekuensi berikut dari pendekatan ini:

  1. Konfigurasi ini koheren untuk contoh sistem tertentu. Tampaknya tidak ada cara untuk membuat koneksi yang salah antar node.
  2. Tidak mudah mengubah konfigurasi hanya dalam satu node. Tampaknya tidak masuk akal untuk masuk dan mengubah beberapa file teks. Jadi penyimpangan konfigurasi menjadi lebih kecil kemungkinannya.
  3. Perubahan konfigurasi kecil tidak mudah dilakukan.
  4. Sebagian besar perubahan konfigurasi akan mengikuti proses pengembangan yang sama, dan akan melewati beberapa tinjauan.

Apakah kita memerlukan repositori terpisah untuk konfigurasi produksi? Konfigurasi produksi mungkin berisi informasi sensitif yang ingin kami jauhkan dari jangkauan banyak orang. Jadi mungkin ada baiknya menyimpan repositori terpisah dengan akses terbatas yang akan berisi konfigurasi produksi. Kami dapat membagi konfigurasi menjadi dua bagian - satu yang berisi parameter produksi paling terbuka dan satu lagi yang berisi bagian konfigurasi rahasia. Hal ini akan memungkinkan sebagian besar pengembang mengakses sebagian besar parameter sekaligus membatasi akses ke hal-hal yang sangat sensitif. Sangat mudah untuk mencapai hal ini menggunakan sifat perantara dengan nilai parameter default.

Variasi

Mari kita lihat pro dan kontra dari pendekatan yang diusulkan dibandingkan dengan teknik manajemen konfigurasi lainnya.

Pertama-tama, kami akan mencantumkan beberapa alternatif terhadap berbagai aspek cara yang diusulkan dalam menangani konfigurasi:

  1. File teks di mesin target.
  2. Penyimpanan nilai kunci terpusat (seperti etcd/zookeeper).
  3. Komponen subproses yang dapat dikonfigurasi ulang/direstart tanpa memulai ulang proses.
  4. Konfigurasi di luar artefak dan kontrol versi.

File teks memberikan fleksibilitas dalam hal perbaikan ad-hoc. Administrator sistem dapat login ke node target, membuat perubahan dan memulai ulang layanan. Ini mungkin tidak baik untuk sistem yang lebih besar. Tidak ada jejak yang tertinggal dibalik perubahan tersebut. Perubahan itu tidak dikaji oleh sepasang mata yang lain. Mungkin sulit untuk mengetahui apa yang menyebabkan perubahan tersebut. Itu belum diuji. Dari sudut pandang sistem terdistribusi, administrator bisa saja lupa memperbarui konfigurasi di salah satu node lainnya.

(Ngomong-ngomong, jika pada akhirnya ada kebutuhan untuk mulai menggunakan file konfigurasi teks, kita hanya perlu menambahkan parser + validator yang bisa menghasilkan hal yang sama Config ketik dan itu sudah cukup untuk mulai menggunakan konfigurasi teks. Hal ini juga menunjukkan bahwa kompleksitas konfigurasi waktu kompilasi sedikit lebih kecil dibandingkan kompleksitas konfigurasi berbasis teks, karena dalam versi berbasis teks kita memerlukan beberapa kode tambahan.)

Penyimpanan nilai kunci terpusat adalah mekanisme yang baik untuk mendistribusikan parameter meta aplikasi. Disini kita perlu memikirkan apa yang kita anggap sebagai nilai konfigurasi dan apa yang sekedar data. Diberikan suatu fungsi C => A => B biasa kita sebut nilai yang jarang berubah C "konfigurasi", sementara data sering berubah A - cukup masukkan data. Konfigurasi harus diberikan ke fungsi lebih awal dari data A. Dengan adanya gagasan ini kita dapat mengatakan bahwa frekuensi perubahan yang diharapkan dapat digunakan untuk membedakan data konfigurasi dari data biasa. Selain itu, data biasanya berasal dari satu sumber (pengguna) dan konfigurasi berasal dari sumber berbeda (admin). Berurusan dengan parameter yang dapat diubah setelah proses inisialisasi menyebabkan peningkatan kompleksitas aplikasi. Untuk parameter seperti itu kita harus menangani mekanisme pengirimannya, penguraian dan validasi, serta menangani nilai yang salah. Oleh karena itu, untuk mengurangi kompleksitas program, sebaiknya kita mengurangi jumlah parameter yang dapat berubah saat runtime (atau bahkan menghilangkannya sama sekali).

Dari perspektif postingan ini kita harus membedakan antara parameter statis dan dinamis. Jika logika layanan memerlukan perubahan yang jarang terjadi pada beberapa parameter saat runtime, maka kami dapat menyebutnya parameter dinamis. Jika tidak, mereka bersifat statis dan dapat dikonfigurasi menggunakan pendekatan yang diusulkan. Untuk konfigurasi ulang dinamis, pendekatan lain mungkin diperlukan. Misalnya, bagian dari sistem mungkin dimulai ulang dengan parameter konfigurasi baru dengan cara yang mirip dengan memulai ulang proses terpisah pada sistem terdistribusi.
(Pendapat saya yang sederhana adalah menghindari konfigurasi ulang runtime karena akan meningkatkan kompleksitas sistem.
Mungkin lebih mudah jika hanya mengandalkan dukungan OS untuk memulai ulang proses. Meskipun hal ini tidak selalu memungkinkan.)

Salah satu aspek penting dalam penggunaan konfigurasi statis yang terkadang membuat orang mempertimbangkan konfigurasi dinamis (tanpa alasan lain) adalah waktu henti layanan selama pembaruan konfigurasi. Memang jika kita harus melakukan perubahan pada konfigurasi statis, kita harus me-restart sistem agar nilai baru menjadi efektif. Persyaratan waktu henti bervariasi untuk setiap sistem, sehingga mungkin tidak terlalu penting. Jika ini penting, maka kita harus membuat rencana terlebih dahulu untuk memulai ulang sistem. Misalnya, kita bisa menerapkannya Koneksi AWS ELB terkuras. Dalam skenario ini kapanpun kita perlu me-restart sistem, kita memulai instance baru dari sistem secara paralel, kemudian mengalihkan ELB ke sana, sambil membiarkan sistem lama menyelesaikan layanan koneksi yang ada.

Bagaimana dengan menyimpan konfigurasi di dalam artefak berversi atau di luar? Menyimpan konfigurasi di dalam artefak berarti dalam sebagian besar kasus konfigurasi ini telah melewati proses jaminan kualitas yang sama seperti artefak lainnya. Jadi dapat dipastikan bahwa konfigurasinya berkualitas baik dan dapat dipercaya. Sebaliknya konfigurasi dalam file terpisah berarti tidak ada jejak siapa dan mengapa melakukan perubahan pada file tersebut. Apakah ini penting? Kami percaya bahwa untuk sebagian besar sistem produksi, lebih baik memiliki konfigurasi yang stabil dan berkualitas tinggi.

Versi artefak memungkinkan untuk mengetahui kapan dibuat, nilai apa yang dikandungnya, fitur apa yang diaktifkan/dinonaktifkan, siapa yang bertanggung jawab untuk membuat setiap perubahan dalam konfigurasi. Mungkin diperlukan upaya untuk menjaga konfigurasi di dalam artefak dan itu adalah pilihan desain yang harus diambil.

Pro kontra

Di sini kami ingin menyoroti beberapa kelebihan dan mendiskusikan beberapa kelemahan dari pendekatan yang diusulkan.

Kelebihan

Fitur konfigurasi yang dapat dikompilasi dari sistem terdistribusi lengkap:

  1. Pemeriksaan konfigurasi statis. Hal ini memberikan tingkat keyakinan yang tinggi, bahwa konfigurasi sudah benar mengingat batasan tipe.
  2. Bahasa konfigurasi yang kaya. Biasanya pendekatan konfigurasi lain terbatas pada sebagian besar substitusi variabel.
    Dengan menggunakan Scala, seseorang dapat menggunakan berbagai fitur bahasa untuk membuat konfigurasi menjadi lebih baik. Misalnya, kita dapat menggunakan ciri untuk memberikan nilai default, objek untuk menetapkan cakupan berbeda, yang dapat kita rujuk vals didefinisikan hanya sekali dalam lingkup luar (KERING). Dimungkinkan untuk menggunakan urutan literal, atau contoh kelas tertentu (Seq, Map, Dll).
  3. DSL. Scala memiliki dukungan yang layak untuk penulis DSL. Seseorang dapat menggunakan fitur ini untuk membuat bahasa konfigurasi yang lebih nyaman dan ramah pengguna akhir, sehingga konfigurasi akhir setidaknya dapat dibaca oleh pengguna domain.
  4. Integritas dan koherensi antar node. Salah satu keuntungan memiliki konfigurasi untuk seluruh sistem terdistribusi di satu tempat adalah bahwa semua nilai didefinisikan secara ketat satu kali dan kemudian digunakan kembali di semua tempat yang kita perlukan. Ketik juga deklarasi port aman untuk memastikan bahwa dalam semua kemungkinan konfigurasi yang benar, node sistem akan menggunakan bahasa yang sama. Ada ketergantungan eksplisit antar node yang membuatnya sulit untuk lupa menyediakan beberapa layanan.
  5. Perubahan berkualitas tinggi. Pendekatan keseluruhan untuk meneruskan perubahan konfigurasi melalui proses PR normal menetapkan standar kualitas yang tinggi juga dalam konfigurasi.
  6. Perubahan konfigurasi secara bersamaan. Setiap kali kami membuat perubahan apa pun dalam konfigurasi, penerapan otomatis memastikan bahwa semua node diperbarui.
  7. Penyederhanaan aplikasi. Aplikasi tidak perlu mengurai dan memvalidasi konfigurasi serta menangani nilai konfigurasi yang salah. Ini menyederhanakan aplikasi secara keseluruhan. (Beberapa peningkatan kompleksitas terjadi pada konfigurasi itu sendiri, namun hal ini merupakan trade-off yang sadar terhadap keselamatan.) Cukup mudah untuk kembali ke konfigurasi biasa — cukup tambahkan bagian yang hilang. Lebih mudah untuk memulai konfigurasi terkompilasi dan menunda implementasi bagian tambahan di lain waktu.
  8. Konfigurasi berversi. Karena perubahan konfigurasi mengikuti proses pengembangan yang sama, sebagai hasilnya kami mendapatkan artefak dengan versi unik. Ini memungkinkan kita untuk mengganti konfigurasi kembali jika diperlukan. Kami bahkan dapat menerapkan konfigurasi yang digunakan setahun yang lalu dan cara kerjanya persis sama. Konfigurasi yang stabil meningkatkan prediktabilitas dan keandalan sistem terdistribusi. Konfigurasinya diperbaiki pada waktu kompilasi dan tidak dapat dengan mudah diubah pada sistem produksi.
  9. Modularitas. Kerangka kerja yang diusulkan bersifat modular dan modul dapat digabungkan dengan berbagai cara
    mendukung konfigurasi yang berbeda (pengaturan/tata letak). Secara khusus, dimungkinkan untuk memiliki tata letak simpul tunggal skala kecil dan pengaturan multi simpul skala besar. Masuk akal untuk memiliki beberapa tata letak produksi.
  10. Pengujian. Untuk tujuan pengujian, seseorang mungkin mengimplementasikan layanan tiruan dan menggunakannya sebagai ketergantungan dengan cara yang aman. Beberapa tata letak pengujian yang berbeda dengan berbagai bagian diganti dengan tiruan dapat dipertahankan secara bersamaan.
  11. Tes integrasi. Terkadang dalam sistem terdistribusi sulit untuk menjalankan pengujian integrasi. Dengan menggunakan pendekatan yang dijelaskan untuk mengetik konfigurasi aman dari sistem terdistribusi lengkap, kita dapat menjalankan semua bagian terdistribusi pada satu server dengan cara yang dapat dikontrol. Sangat mudah untuk meniru situasi tersebut
    ketika salah satu layanan tidak tersedia.

Kekurangan

Pendekatan konfigurasi yang dikompilasi berbeda dari konfigurasi “normal” dan mungkin tidak sesuai dengan semua kebutuhan. Berikut adalah beberapa kelemahan dari konfigurasi yang dikompilasi:

  1. Konfigurasi statis. Ini mungkin tidak cocok untuk semua aplikasi. Dalam beberapa kasus, terdapat kebutuhan untuk segera memperbaiki konfigurasi dalam produksi tanpa mengabaikan semua tindakan keselamatan. Pendekatan ini menjadikannya lebih sulit. Kompilasi dan penempatan ulang diperlukan setelah melakukan perubahan apa pun dalam konfigurasi. Ini adalah fitur dan bebannya.
  2. Pembuatan konfigurasi. Ketika konfigurasi dibuat oleh beberapa alat otomatisasi, pendekatan ini memerlukan kompilasi berikutnya (yang mungkin gagal). Mungkin diperlukan upaya tambahan untuk mengintegrasikan langkah tambahan ini ke dalam sistem pembangunan.
  3. Instrumen. Ada banyak alat yang digunakan saat ini yang mengandalkan konfigurasi berbasis teks. Beberapa dari mereka
    tidak akan berlaku ketika konfigurasi dikompilasi.
  4. Pergeseran pola pikir sangat diperlukan. Pengembang dan DevOps sudah familiar dengan file konfigurasi teks. Ide menyusun konfigurasi mungkin tampak aneh bagi mereka.
  5. Sebelum memperkenalkan konfigurasi yang dapat dikompilasi, diperlukan proses pengembangan perangkat lunak berkualitas tinggi.

Ada beberapa keterbatasan dari contoh yang diterapkan:

  1. Jika kami memberikan konfigurasi tambahan yang tidak diminta oleh implementasi node, compiler tidak akan membantu kami mendeteksi implementasi yang tidak ada. Hal ini dapat diatasi dengan menggunakan HList atau ADT (kelas kasus) untuk konfigurasi simpul, bukan sifat dan Pola Kue.
  2. Kami harus menyediakan beberapa boilerplate di file konfigurasi: (package, import, object deklarasi;
    override defuntuk parameter yang memiliki nilai default). Masalah ini mungkin dapat diatasi sebagian dengan menggunakan DSL.
  3. Dalam posting ini kami tidak membahas konfigurasi ulang dinamis dari cluster node serupa.

Kesimpulan

Dalam posting ini kita telah membahas ide untuk merepresentasikan konfigurasi secara langsung dalam kode sumber dengan cara yang aman. Pendekatan ini dapat digunakan di banyak aplikasi sebagai pengganti xml dan konfigurasi berbasis teks lainnya. Meskipun contoh kami telah diterapkan di Scala, contoh tersebut juga dapat diterjemahkan ke bahasa lain yang dapat dikompilasi (seperti Kotlin, C#, Swift, dll.). Seseorang dapat mencoba pendekatan ini dalam proyek baru dan, jika tidak cocok, beralih ke cara lama.

Tentu saja, konfigurasi yang dapat dikompilasi memerlukan proses pengembangan berkualitas tinggi. Sebagai imbalannya, ia berjanji untuk menyediakan konfigurasi kokoh berkualitas tinggi.

Pendekatan ini dapat diperluas dengan berbagai cara:

  1. Seseorang dapat menggunakan makro untuk melakukan validasi konfigurasi dan gagal pada waktu kompilasi jika terjadi kegagalan kendala logika bisnis.
  2. DSL dapat diimplementasikan untuk mewakili konfigurasi dengan cara yang ramah pengguna domain.
  3. Manajemen sumber daya dinamis dengan penyesuaian konfigurasi otomatis. Misalnya, ketika kita menyesuaikan jumlah node cluster, kita mungkin ingin (1) node tersebut mendapatkan konfigurasi yang sedikit dimodifikasi; (2) manajer cluster untuk menerima info node baru.

Terima kasih

Saya ingin mengucapkan terima kasih kepada Andrey Saksonov, Pavel Popov, Anton Nehaev karena telah memberikan masukan yang inspiratif pada draf postingan ini yang membantu saya memperjelasnya.

Sumber: www.habr.com