Konfigurasi Sistem Terdistribusi yang Dikompilasi

Saya ingin memberi tahu Anda satu mekanisme menarik untuk bekerja dengan konfigurasi sistem terdistribusi. Konfigurasi direpresentasikan secara langsung dalam bahasa terkompilasi (Scala) menggunakan tipe aman. Posting ini memberikan contoh konfigurasi tersebut dan membahas berbagai aspek penerapan konfigurasi terkompilasi ke dalam proses pengembangan secara keseluruhan.

Konfigurasi Sistem Terdistribusi yang Dikompilasi

(Inggris)

pengenalan

Membangun sistem terdistribusi yang andal berarti semua node menggunakan konfigurasi yang benar, tersinkronisasi dengan node lain. Teknologi DevOps (terraform, ansible, atau semacamnya) biasanya digunakan untuk menghasilkan file konfigurasi secara otomatis (seringkali spesifik untuk setiap node). Kami juga ingin memastikan bahwa semua node yang berkomunikasi menggunakan protokol yang sama (termasuk versi yang sama). Jika tidak, ketidakcocokan akan terjadi pada sistem terdistribusi kami. Di dunia JVM, salah satu konsekuensi dari persyaratan ini adalah versi perpustakaan yang sama yang berisi pesan protokol harus digunakan di mana pun.

Bagaimana dengan pengujian sistem terdistribusi? Tentu saja, kami berasumsi bahwa semua komponen memiliki pengujian unit sebelum kami melanjutkan ke pengujian integrasi. (Agar kita dapat mengekstrapolasi hasil pengujian ke runtime, kita juga harus menyediakan kumpulan pustaka yang identik selama pengujian dan saat runtime.)

Saat bekerja dengan pengujian integrasi, seringkali lebih mudah menggunakan classpath yang sama di semua node. Yang harus kita lakukan adalah memastikan bahwa classpath yang sama digunakan saat runtime. (Meskipun sangat mungkin untuk menjalankan node yang berbeda dengan classpath yang berbeda, hal ini menambah kompleksitas pada keseluruhan konfigurasi dan kesulitan dengan pengujian penerapan dan integrasi.) Untuk tujuan postingan ini, kami berasumsi bahwa semua node akan menggunakan classpath yang sama.

Konfigurasi berkembang seiring dengan aplikasi. Kami menggunakan versi untuk mengidentifikasi berbagai tahapan evolusi program. Tampaknya logis untuk mengidentifikasi versi konfigurasi yang berbeda. Dan tempatkan konfigurasi itu sendiri di sistem kontrol versi. Jika hanya ada satu konfigurasi dalam produksi, maka kita cukup menggunakan nomor versinya. Jika kita menggunakan banyak instance produksi, maka kita memerlukan beberapa instance produksi
cabang konfigurasi dan label tambahan selain versi (misalnya, nama cabang). Dengan cara ini kami dapat dengan jelas mengidentifikasi konfigurasi sebenarnya. Setiap pengidentifikasi konfigurasi secara unik sesuai dengan kombinasi spesifik dari node terdistribusi, port, sumber daya eksternal, dan versi pustaka. Untuk keperluan postingan ini kita akan berasumsi bahwa hanya ada satu cabang dan kita dapat mengidentifikasi konfigurasinya dengan cara biasa menggunakan tiga angka yang dipisahkan oleh titik (1.2.3).

Di lingkungan modern, file konfigurasi jarang dibuat secara manual. Lebih sering mereka dihasilkan selama penerapan dan tidak lagi disentuh (sehingga jangan merusak apa pun). Sebuah pertanyaan wajar muncul: mengapa kita masih menggunakan format teks untuk menyimpan konfigurasi? Alternatif yang layak tampaknya adalah kemampuan untuk menggunakan kode reguler untuk konfigurasi dan memanfaatkan pemeriksaan waktu kompilasi.

Dalam postingan kali ini kita akan mengeksplorasi ide merepresentasikan konfigurasi di dalam artefak yang dikompilasi.

Konfigurasi yang dikompilasi

Bagian ini memberikan contoh konfigurasi terkompilasi statis. Dua layanan sederhana diimplementasikan - layanan gema dan klien layanan gema. Berdasarkan kedua layanan ini, dua opsi sistem disusun. Dalam satu versi, kedua layanan terletak di node yang sama, di versi lain - di node yang berbeda.

Biasanya sistem terdistribusi berisi beberapa node. Anda dapat mengidentifikasi node menggunakan nilai dari beberapa jenis NodeId:

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

ΠΈΠ»ΠΈ

case class NodeId(hostName: String)

atau bahkan

object Singleton
type NodeId = Singleton.type

Node melakukan berbagai peran, mereka menjalankan layanan dan koneksi TCP/HTTP dapat dibuat di antara mereka.

Untuk menggambarkan koneksi TCP kita memerlukan setidaknya nomor port. Kami juga ingin mencerminkan protokol yang didukung pada port tersebut untuk memastikan bahwa klien dan server menggunakan protokol yang sama. Kami akan menjelaskan koneksi menggunakan kelas berikut:

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

dimana Port - hanya bilangan bulat Int menunjukkan kisaran nilai yang dapat diterima:

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

Tipe halus

Lihat perpustakaan halus ΠΈ saya laporan. Singkatnya, perpustakaan memungkinkan Anda menambahkan batasan pada tipe yang diperiksa pada waktu kompilasi. Dalam hal ini, nilai nomor port yang valid adalah bilangan bulat 16-bit. Untuk konfigurasi yang dikompilasi, penggunaan pustaka yang disempurnakan tidak wajib, namun meningkatkan kemampuan kompiler untuk memeriksa konfigurasi.

Untuk protokol HTTP (REST), selain nomor port, kita mungkin juga memerlukan jalur ke 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 pada waktu kompilasi, kami menggunakan parameter tipe yang tidak digunakan dalam kelas. Keputusan ini disebabkan oleh fakta bahwa kami tidak menggunakan instance protokol saat runtime, namun kami ingin kompiler memeriksa kompatibilitas protokol. Dengan menentukan protokolnya, kami tidak akan bisa melewatkan layanan yang tidak sesuai sebagai ketergantungan.

Salah satu protokol yang umum adalah REST API dengan serialisasi Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

dimana RequestMessage - jenis permintaan, ResponseMessage β€” tipe respons.
Tentu saja, kita dapat menggunakan deskripsi protokol lain yang memberikan keakuratan deskripsi yang kita perlukan.

Untuk keperluan postingan ini, kami akan menggunakan versi protokol yang disederhanakan:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Di sini permintaannya adalah string yang ditambahkan ke url dan responsnya adalah string yang dikembalikan di badan respons HTTP.

Konfigurasi layanan dijelaskan berdasarkan nama layanan, port, dan dependensi. Elemen-elemen ini dapat direpresentasikan dalam Scala dalam beberapa cara (misalnya, HList-s, tipe data aljabar). Untuk keperluan posting ini, kami akan menggunakan Pola Kue dan mewakili modul yang digunakan trait'ov. (Pola Kue bukan merupakan elemen wajib dari pendekatan ini. Ini hanyalah salah satu kemungkinan implementasi.)

Ketergantungan antar layanan dapat direpresentasikan sebagai metode yang mengembalikan port EndPointdari 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)
  }

Untuk membuat layanan gema, yang Anda perlukan hanyalah nomor port dan indikasi bahwa port tersebut mendukung protokol gema. Kami mungkin tidak menentukan port tertentu, karena... ciri-ciri memungkinkan Anda mendeklarasikan metode tanpa implementasi (metode abstrak). Dalam hal ini, saat membuat konfigurasi konkret, kompiler akan meminta kita untuk menyediakan implementasi metode abstrak dan memberikan nomor port. Karena kami telah menerapkan metode ini, saat membuat konfigurasi tertentu, kami tidak boleh menentukan port yang berbeda. Nilai default akan digunakan.

Dalam konfigurasi klien kami mendeklarasikan ketergantungan pada layanan echo:

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

Ketergantungannya memiliki jenis yang sama dengan layanan yang diekspor echoService. Khususnya, di klien gema kami memerlukan protokol yang sama. Oleh karena itu, saat menghubungkan dua layanan, kami dapat yakin semuanya akan berfungsi dengan benar.

Implementasi layanan

Suatu fungsi diperlukan untuk memulai dan menghentikan layanan. (Kemampuan untuk menghentikan layanan sangat penting untuk pengujian.) Sekali lagi, ada beberapa opsi untuk mengimplementasikan fitur tersebut (misalnya, kita dapat menggunakan kelas tipe berdasarkan tipe konfigurasi). Untuk keperluan posting ini kita akan menggunakan Pola Kue. Kami akan mewakili layanan menggunakan kelas cats.Resource, Karena Kelas ini sudah menyediakan sarana untuk menjamin pelepasan sumber daya dengan aman jika terjadi masalah. Untuk mendapatkan resource, kita perlu menyediakan konfigurasi dan konteks runtime yang sudah jadi. Fungsi startup layanan dapat terlihat seperti ini:

  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 untuk layanan ini
  • AddressResolver β€” objek runtime yang memungkinkan Anda mengetahui alamat node lain (lihat di bawah)

dan tipe lainnya dari perpustakaan cats:

  • F[_] β€” jenis efek (dalam kasus paling sederhana F[A] bisa saja sebuah fungsi () => A. Dalam posting ini kita akan menggunakan cats.IO.)
  • Reader[A,B] - kurang lebih identik dengan fungsi A => B
  • cats.Resource - sumber daya yang dapat diperoleh dan dilepaskan
  • Timer β€” pengatur waktu (memungkinkan Anda tertidur sebentar dan mengukur interval waktu)
  • ContextShift - analog ExecutionContext
  • Applicative β€” kelas tipe efek yang memungkinkan Anda menggabungkan efek individual (hampir satu monad). Dalam aplikasi yang lebih kompleks sepertinya lebih baik digunakan Monad/ConcurrentEffect.

Dengan menggunakan fungsi tanda tangan 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](()))
  }

(cm. sumber, di mana layanan lain diimplementasikan - layanan gema, klien gema
ΠΈ pengontrol seumur hidup.)

Node adalah objek yang dapat meluncurkan beberapa layanan (peluncuran rantai sumber daya dipastikan oleh Pola Kue):

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

Harap dicatat bahwa kami menentukan jenis konfigurasi yang tepat yang diperlukan untuk node ini. Jika kita lupa menentukan salah satu jenis konfigurasi yang diperlukan oleh layanan tertentu, maka akan terjadi kesalahan kompilasi. Selain itu, kita tidak akan dapat memulai sebuah node kecuali kita menyediakan beberapa objek dengan tipe yang sesuai dengan semua data yang diperlukan.

Resolusi Nama Host

Untuk terhubung ke host jarak jauh, kita memerlukan alamat IP asli. Ada kemungkinan bahwa alamat tersebut akan diketahui lebih lambat daripada konfigurasi lainnya. Jadi kita memerlukan fungsi yang memetakan ID node ke suatu alamat:

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

Ada beberapa cara untuk mengimplementasikan fungsi ini:

  1. Jika alamatnya diketahui oleh kami sebelum penerapan, maka kami dapat membuat kode Scala dengan
    alamat dan kemudian jalankan build. Ini akan mengkompilasi dan menjalankan tes.
    Dalam hal ini, fungsinya akan diketahui secara statis dan dapat direpresentasikan dalam kode sebagai pemetaan Map[NodeId, NodeAddress].
  2. Dalam beberapa kasus, alamat sebenarnya hanya diketahui setelah node dimulai.
    Dalam hal ini, kita dapat menerapkan β€œlayanan penemuan” yang berjalan sebelum node lain dan semua node akan mendaftar ke layanan ini dan meminta alamat node lain.
  3. Jika kita bisa memodifikasi /etc/hosts, maka Anda dapat menggunakan nama host yang telah ditentukan sebelumnya (seperti my-project-main-node ΠΈ echo-backend) dan cukup tautkan nama-nama ini
    dengan alamat IP selama penerapan.

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

Selanjutnya, kami mempertimbangkan dua opsi untuk sistem terdistribusi:

  1. Menempatkan semua layanan pada satu node.
  2. Dan menghosting layanan gema dan klien gema di node yang berbeda.

Konfigurasi untuk satu simpul:

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.
}

Objek mengimplementasikan konfigurasi klien dan server. Konfigurasi time-to-live juga digunakan sehingga setelah interval lifetime menghentikan program. (Ctrl-C juga berfungsi dan membebaskan semua sumber daya dengan benar.)

Kumpulan sifat konfigurasi dan implementasi yang sama dapat digunakan untuk membuat sistem yang terdiri dari dua node terpisah:

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"
  }

Penting! Perhatikan bagaimana layanan-layanan tersebut dihubungkan. Kami menetapkan layanan yang diimplementasikan oleh satu node sebagai implementasi metode ketergantungan node lain. Tipe ketergantungan diperiksa oleh kompiler, karena berisi jenis protokol. Saat dijalankan, ketergantungan akan berisi ID node target yang benar. Berkat skema ini, kami menentukan nomor port tepat satu kali dan dijamin selalu merujuk ke port yang benar.

Implementasi dua node sistem

Untuk konfigurasi ini, kami menggunakan implementasi layanan yang sama tanpa perubahan. Satu-satunya perbedaan adalah kita sekarang memiliki dua objek yang mengimplementasikan rangkaian 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 server. Node kedua mengimplementasikan klien dan menggunakan bagian konfigurasi yang berbeda. Kedua node juga memerlukan manajemen seumur hidup. Node server berjalan tanpa batas waktu hingga dihentikan SIGTERM'om, dan node klien berakhir setelah beberapa waktu. Cm. aplikasi peluncur.

Proses pembangunan secara umum

Mari kita lihat bagaimana pendekatan konfigurasi ini mempengaruhi proses pengembangan secara keseluruhan.

Konfigurasi akan dikompilasi bersama dengan kode lainnya dan artefak (.jar) akan dibuat. Tampaknya masuk akal untuk menempatkan konfigurasi dalam artefak terpisah. Ini karena kita dapat memiliki beberapa konfigurasi berdasarkan kode yang sama. Sekali lagi, dimungkinkan untuk menghasilkan artefak yang sesuai dengan cabang konfigurasi berbeda. Ketergantungan pada versi perpustakaan tertentu disimpan bersama dengan konfigurasi, dan versi ini disimpan selamanya setiap kali kami memutuskan untuk menerapkan versi konfigurasi tersebut.

Setiap perubahan konfigurasi berubah menjadi perubahan kode. Dan oleh karena itu, masing-masing
perubahan tersebut akan ditanggung oleh proses penjaminan mutu normal:

Tiket di pelacak bug -> PR -> review -> gabung dengan cabang terkait ->
integrasi -> penerapan

Konsekuensi utama penerapan konfigurasi terkompilasi adalah:

  1. Konfigurasi akan konsisten di semua node sistem terdistribusi. Karena kenyataan bahwa semua node menerima konfigurasi yang sama dari satu sumber.

  2. Mengubah konfigurasi hanya di salah satu node merupakan masalah. Oleh karena itu, β€œpenyimpangan konfigurasi” tidak mungkin terjadi.

  3. Menjadi lebih sulit untuk membuat perubahan kecil pada konfigurasi.

  4. Sebagian besar perubahan konfigurasi akan terjadi sebagai bagian dari keseluruhan proses pengembangan dan akan ditinjau.

Apakah saya memerlukan repositori terpisah untuk menyimpan konfigurasi produksi? Konfigurasi ini mungkin berisi kata sandi dan informasi sensitif lainnya yang ingin kami batasi aksesnya. Berdasarkan hal ini, tampaknya masuk akal untuk menyimpan konfigurasi akhir dalam repositori terpisah. Anda dapat membagi konfigurasi menjadi dua bagianβ€”satu berisi pengaturan konfigurasi yang dapat diakses publik dan satu lagi berisi pengaturan terbatas. Ini akan memungkinkan sebagian besar pengembang memiliki akses ke pengaturan umum. Pemisahan ini mudah dicapai dengan menggunakan ciri-ciri perantara yang mengandung nilai default.

Variasi yang memungkinkan

Mari kita coba bandingkan konfigurasi yang dikompilasi dengan beberapa alternatif umum:

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

File teks memberikan fleksibilitas yang signifikan dalam hal perubahan kecil. Administrator sistem dapat masuk ke node jarak jauh, membuat perubahan pada file yang sesuai, dan memulai ulang layanan. Namun, untuk sistem yang besar, fleksibilitas seperti itu mungkin tidak diinginkan. Perubahan yang dilakukan tidak meninggalkan jejak di sistem lain. Tidak ada yang meninjau perubahan tersebut. Sulit untuk menentukan siapa sebenarnya yang melakukan perubahan dan apa alasannya. Perubahan tidak diuji. Jika sistem terdistribusi, maka administrator mungkin lupa membuat perubahan terkait pada node lain.

(Perlu dicatat juga bahwa menggunakan konfigurasi yang dikompilasi tidak menutup kemungkinan untuk menggunakan file teks di masa depan. Cukup menambahkan parser dan validator yang menghasilkan tipe yang sama dengan output Config, dan Anda dapat menggunakan file teks. Oleh karena itu, kompleksitas sistem dengan konfigurasi yang dikompilasi agak lebih kecil daripada kompleksitas sistem yang menggunakan file teks, karena file teks memerlukan kode tambahan.)

Penyimpanan nilai kunci terpusat adalah mekanisme yang baik untuk mendistribusikan parameter meta aplikasi terdistribusi. Kita perlu memutuskan apa yang dimaksud dengan parameter konfigurasi dan apa yang hanya berupa data. Mari kita punya fungsi C => A => B, dan parameternya C jarang berubah, dan data A - sering. Dalam hal ini kita dapat mengatakan demikian C - parameter konfigurasi, dan A - data. Tampaknya parameter konfigurasi berbeda dari data karena umumnya lebih jarang berubah dibandingkan data. Selain itu, data biasanya berasal dari satu sumber (dari pengguna), dan parameter konfigurasi dari sumber lain (dari administrator sistem).

Jika parameter yang jarang berubah perlu diperbarui tanpa memulai ulang program, hal ini sering kali dapat menyebabkan kerumitan program, karena kita perlu mengirimkan parameter, menyimpan, mengurai dan memeriksa, serta memproses nilai yang salah. Oleh karena itu, dari sudut pandang mengurangi kompleksitas program, masuk akal untuk mengurangi jumlah parameter yang dapat berubah selama pengoperasian program (atau tidak mendukung parameter tersebut sama sekali).

Untuk keperluan posting ini, kami akan membedakan antara parameter statis dan dinamis. Jika logika layanan memerlukan perubahan parameter selama pengoperasian program, maka kami akan menyebut parameter tersebut dinamis. Jika tidak, opsinya bersifat statis dan dapat dikonfigurasi menggunakan konfigurasi yang dikompilasi. Untuk konfigurasi ulang dinamis, kita mungkin memerlukan mekanisme untuk memulai ulang bagian program dengan parameter baru, serupa dengan cara proses sistem operasi dimulai ulang. (Menurut pendapat kami, disarankan untuk menghindari konfigurasi ulang secara real-time, karena hal ini meningkatkan kompleksitas sistem. Jika memungkinkan, lebih baik menggunakan kemampuan OS standar untuk memulai ulang proses.)

Salah satu aspek penting dalam penggunaan konfigurasi statis yang membuat orang mempertimbangkan konfigurasi ulang dinamis adalah waktu yang diperlukan sistem untuk melakukan boot ulang setelah pembaruan konfigurasi (waktu henti). Faktanya, jika kita perlu melakukan perubahan pada konfigurasi statis, kita harus me-restart sistem agar nilai baru dapat diterapkan. Masalah downtime bervariasi dalam tingkat keparahan untuk sistem yang berbeda. Dalam beberapa kasus, Anda dapat menjadwalkan reboot pada saat beban minimal. Jika Anda perlu memberikan layanan berkelanjutan, Anda dapat menerapkannya Koneksi AWS ELB terkuras. Pada saat yang sama, ketika kita perlu me-reboot sistem, kita meluncurkan instance paralel dari sistem ini, mengalihkan penyeimbang ke sana, dan menunggu hingga koneksi lama selesai. Setelah semua koneksi lama dihentikan, kami mematikan sistem lama.

Sekarang mari kita pertimbangkan masalah penyimpanan konfigurasi di dalam atau di luar artefak. Jika kita menyimpan konfigurasi di dalam artefak, setidaknya kita memiliki kesempatan untuk memverifikasi kebenaran konfigurasi selama perakitan artefak. Jika konfigurasi berada di luar artefak yang dikontrol, sulit untuk melacak siapa yang membuat perubahan pada file ini dan alasannya. Seberapa pentingkah itu? Menurut pendapat kami, bagi banyak sistem produksi, penting untuk memiliki konfigurasi yang stabil dan berkualitas tinggi.

Versi artefak memungkinkan Anda menentukan kapan artefak itu dibuat, nilai apa yang dikandungnya, fungsi apa yang diaktifkan/dinonaktifkan, dan siapa yang bertanggung jawab atas setiap perubahan dalam konfigurasi. Tentu saja, menyimpan konfigurasi di dalam artefak memerlukan upaya tertentu, jadi Anda perlu membuat keputusan yang tepat.

Pro dan kontra

Saya ingin membahas pro dan kontra dari teknologi yang diusulkan.

Keuntungan

Di bawah ini adalah daftar fitur utama dari konfigurasi sistem terdistribusi yang dikompilasi:

  1. Pemeriksaan konfigurasi statis. Memungkinkan Anda yakin akan hal itu
    konfigurasinya benar.
  2. Bahasa konfigurasi yang kaya. Biasanya, metode konfigurasi lain paling banyak terbatas pada substitusi variabel string. Saat menggunakan Scala, berbagai fitur bahasa tersedia untuk meningkatkan konfigurasi Anda. Misalnya kita bisa menggunakan
    ciri-ciri untuk nilai default, menggunakan objek untuk mengelompokkan parameter, kita dapat merujuk ke vals yang dideklarasikan hanya sekali (KERING) dalam lingkup terlampir. Anda dapat membuat instance kelas apa pun langsung di dalam konfigurasi (Seq, Map, kelas khusus).
  3. DSL. Scala memiliki sejumlah fitur bahasa yang memudahkan pembuatan DSL. Dimungkinkan untuk memanfaatkan fitur-fitur ini dan menerapkan bahasa konfigurasi yang lebih nyaman bagi kelompok pengguna sasaran, sehingga konfigurasi tersebut setidaknya dapat dibaca oleh pakar domain. Spesialis dapat, misalnya, berpartisipasi dalam proses peninjauan konfigurasi.
  4. Integritas dan sinkronisasi antar node. Salah satu keuntungan menyimpan konfigurasi seluruh sistem terdistribusi pada satu titik adalah semua nilai dideklarasikan tepat satu kali dan kemudian digunakan kembali kapan pun diperlukan. Menggunakan tipe phantom untuk mendeklarasikan port memastikan bahwa node menggunakan protokol yang kompatibel di semua konfigurasi sistem yang benar. Memiliki ketergantungan wajib yang eksplisit antar node memastikan bahwa semua layanan terhubung.
  5. Perubahan berkualitas tinggi. Membuat perubahan pada konfigurasi menggunakan proses pengembangan umum memungkinkan tercapainya standar kualitas tinggi untuk konfigurasi juga.
  6. Pembaruan konfigurasi simultan. Penerapan sistem otomatis setelah perubahan konfigurasi memastikan bahwa semua node diperbarui.
  7. Menyederhanakan aplikasi. Aplikasi tidak memerlukan penguraian, pemeriksaan konfigurasi, atau penanganan nilai yang salah. Hal ini mengurangi kompleksitas aplikasi. (Beberapa kompleksitas konfigurasi yang diamati dalam contoh kita bukanlah atribut dari konfigurasi yang dikompilasi, tetapi hanya keputusan sadar yang didorong oleh keinginan untuk memberikan keamanan tipe yang lebih baik.) Cukup mudah untuk kembali ke konfigurasi biasa - cukup implementasikan konfigurasi yang hilang bagian. Oleh karena itu, Anda dapat, misalnya, memulai dengan konfigurasi yang telah dikompilasi, menunda implementasi bagian yang tidak diperlukan hingga benar-benar diperlukan.
  8. Konfigurasi terverifikasi. Karena perubahan konfigurasi mengikuti nasib biasa dari perubahan lainnya, keluaran yang kami dapatkan adalah artefak dengan versi unik. Hal ini memungkinkan kita, misalnya, untuk kembali ke versi konfigurasi sebelumnya jika diperlukan. Kami bahkan dapat menggunakan konfigurasi dari tahun lalu dan sistem akan bekerja sama persis. Konfigurasi yang stabil meningkatkan prediktabilitas dan keandalan sistem terdistribusi. Karena konfigurasinya sudah diperbaiki pada tahap kompilasi, cukup sulit untuk memalsukannya dalam produksi.
  9. Modularitas. Kerangka kerja yang diusulkan bersifat modular dan modul-modulnya dapat digabungkan dengan berbagai cara untuk menciptakan sistem yang berbeda. Secara khusus, Anda dapat mengonfigurasi sistem untuk berjalan pada satu node dalam satu perwujudan, dan pada beberapa node di perwujudan lainnya. Anda dapat membuat beberapa konfigurasi untuk instance produksi sistem.
  10. Pengujian. Dengan mengganti layanan individual dengan objek tiruan, Anda bisa mendapatkan beberapa versi sistem yang sesuai untuk pengujian.
  11. Tes integrasi. Memiliki konfigurasi tunggal untuk seluruh sistem terdistribusi memungkinkan untuk menjalankan semua komponen dalam lingkungan terkendali sebagai bagian dari pengujian integrasi. Sangat mudah untuk meniru, misalnya, situasi di mana beberapa node dapat diakses.

Kekurangan dan keterbatasan

Konfigurasi yang dikompilasi berbeda dari pendekatan konfigurasi lainnya dan mungkin tidak cocok untuk beberapa aplikasi. Berikut adalah beberapa kelemahannya:

  1. Konfigurasi statis. Terkadang Anda perlu segera memperbaiki konfigurasi dalam produksi, melewati semua mekanisme perlindungan. Dengan pendekatan ini, hal ini bisa menjadi lebih sulit. Paling tidak, kompilasi dan penerapan otomatis masih diperlukan. Hal ini merupakan fitur yang berguna dari pendekatan ini dan juga merupakan kelemahan dalam beberapa kasus.
  2. Pembuatan konfigurasi. Jika file konfigurasi dibuat oleh alat otomatis, upaya tambahan mungkin diperlukan untuk mengintegrasikan skrip build.
  3. Peralatan. Saat ini, utilitas dan teknik yang dirancang untuk bekerja dengan konfigurasi didasarkan pada file teks. Tidak semua utilitas/teknik tersebut akan tersedia dalam konfigurasi terkompilasi.
  4. Diperlukan perubahan sikap. Pengembang dan DevOps terbiasa dengan file teks. Gagasan untuk menyusun suatu konfigurasi mungkin agak tidak terduga dan tidak biasa serta menyebabkan penolakan.
  5. Diperlukan proses pembangunan yang berkualitas tinggi. Agar dapat menggunakan konfigurasi yang dikompilasi dengan nyaman, diperlukan otomatisasi penuh pada proses pembuatan dan penerapan aplikasi (CI/CD). Kalau tidak, itu akan sangat merepotkan.

Mari kita juga memikirkan sejumlah batasan dari contoh yang dipertimbangkan yang tidak terkait dengan gagasan konfigurasi yang dikompilasi:

  1. Jika kami memberikan informasi konfigurasi yang tidak perlu yang tidak digunakan oleh node, maka kompiler tidak akan membantu kami mendeteksi implementasi yang hilang. Masalah ini dapat diatasi dengan meninggalkan Pola Kue dan menggunakan tipe yang lebih kaku, misalnya, HList atau tipe data aljabar (kelas kasus) untuk mewakili konfigurasi.
  2. Ada baris dalam file konfigurasi yang tidak berhubungan dengan konfigurasi itu sendiri: (package, import,deklarasi objek; override defuntuk parameter yang memiliki nilai default). Hal ini sebagian dapat dihindari jika Anda menerapkan DSL Anda sendiri. Selain itu, jenis konfigurasi lain (misalnya XML) juga menerapkan batasan tertentu pada struktur file.
  3. Untuk keperluan postingan ini, kami tidak mempertimbangkan konfigurasi ulang dinamis dari sekelompok node serupa.

Kesimpulan

Dalam postingan ini, kami mengeksplorasi ide untuk merepresentasikan konfigurasi dalam kode sumber menggunakan kemampuan lanjutan dari sistem tipe Scala. Pendekatan ini dapat digunakan dalam berbagai aplikasi sebagai pengganti metode konfigurasi tradisional berdasarkan xml atau file teks. Meskipun contoh kita diterapkan di Scala, ide yang sama dapat ditransfer ke bahasa kompilasi lainnya (seperti Kotlin, C#, Swift, ...). Anda dapat mencoba pendekatan ini di salah satu proyek berikut, dan, jika tidak berhasil, lanjutkan ke file teks, tambahkan bagian yang hilang.

Tentu saja, konfigurasi yang dikompilasi memerlukan proses pengembangan berkualitas tinggi. Sebagai imbalannya, kualitas tinggi dan keandalan konfigurasi terjamin.

Pendekatan yang dipertimbangkan dapat diperluas:

  1. Anda dapat menggunakan makro untuk melakukan pemeriksaan waktu kompilasi.
  2. Anda dapat menerapkan DSL untuk menyajikan konfigurasi dengan cara yang dapat diakses oleh pengguna akhir.
  3. Anda dapat menerapkan manajemen sumber daya dinamis dengan penyesuaian konfigurasi otomatis. Misalnya, mengubah jumlah node dalam sebuah cluster mengharuskan (1) setiap node menerima konfigurasi yang sedikit berbeda; (2) manajer cluster menerima informasi tentang node baru.

Ucapan Terima Kasih

Saya ingin mengucapkan terima kasih kepada Andrei Saksonov, Pavel Popov, dan Anton Nekhaev atas kritik konstruktif mereka terhadap draf artikel ini.

Sumber: www.habr.com

Tambah komentar