Cấu hình có thể biên dịch của một hệ thống phân tán

Trong bài đăng này, chúng tôi muốn chia sẻ một cách thú vị để xử lý cấu hình của hệ thống phân tán.
Cấu hình được thể hiện trực tiếp bằng ngôn ngữ Scala theo cách an toàn. Một ví dụ thực hiện được mô tả chi tiết. Các khía cạnh khác nhau của đề xuất sẽ được thảo luận, bao gồm cả ảnh hưởng đến quá trình phát triển tổng thể.

Cấu hình có thể biên dịch của một hệ thống phân tán

(ở Nga)

Giới thiệu

Xây dựng các hệ thống phân tán mạnh mẽ đòi hỏi phải sử dụng cấu hình chính xác và mạch lạc trên tất cả các nút. Một giải pháp điển hình là sử dụng mô tả triển khai bằng văn bản (terraform, ansible hoặc thứ gì đó tương tự) và các tệp cấu hình được tạo tự động (thường - dành riêng cho từng nút/vai trò). Chúng tôi cũng muốn sử dụng cùng các giao thức có cùng phiên bản trên mỗi nút giao tiếp (nếu không chúng tôi sẽ gặp phải sự cố không tương thích). Trong thế giới JVM, điều này có nghĩa là ít nhất thư viện nhắn tin phải có cùng phiên bản trên tất cả các nút giao tiếp.

Còn việc kiểm tra hệ thống thì sao? Tất nhiên, chúng ta nên có các bài kiểm tra đơn vị cho tất cả các thành phần trước khi đến với các bài kiểm tra tích hợp. Để có thể ngoại suy kết quả kiểm tra trong thời gian chạy, chúng tôi phải đảm bảo rằng phiên bản của tất cả các thư viện được giữ giống hệt nhau trong cả môi trường thời gian chạy và thử nghiệm.

Khi chạy thử nghiệm tích hợp, việc có cùng một đường dẫn lớp trên tất cả các nút thường dễ dàng hơn nhiều. Chúng ta chỉ cần đảm bảo rằng cùng một đường dẫn lớp được sử dụng khi triển khai. (Có thể sử dụng các đường dẫn lớp khác nhau trên các nút khác nhau, nhưng việc biểu diễn cấu hình này và triển khai nó một cách chính xác sẽ khó khăn hơn.) Vì vậy, để đơn giản hóa mọi thứ, chúng tôi sẽ chỉ xem xét các đường dẫn lớp giống hệt nhau trên tất cả các nút.

Cấu hình có xu hướng phát triển cùng với phần mềm. Chúng tôi thường sử dụng các phiên bản để xác định các phiên bản khác nhau
các giai đoạn phát triển của phần mềm. Có vẻ hợp lý khi bao gồm cấu hình trong phần quản lý phiên bản và xác định các cấu hình khác nhau bằng một số nhãn. Nếu chỉ có một cấu hình trong quá trình sản xuất, chúng tôi có thể sử dụng một phiên bản duy nhất làm mã định danh. Đôi khi chúng ta có thể có nhiều môi trường sản xuất. Và đối với mỗi môi trường, chúng ta có thể cần một nhánh cấu hình riêng. Vì vậy, các cấu hình có thể được gắn nhãn nhánh và phiên bản để nhận dạng duy nhất các cấu hình khác nhau. Mỗi nhãn và phiên bản nhánh tương ứng với một sự kết hợp duy nhất của các nút phân phối, cổng, tài nguyên bên ngoài, phiên bản thư viện đường dẫn lớp trên mỗi nút. Ở đây chúng tôi sẽ chỉ đề cập đến nhánh duy nhất và xác định cấu hình bằng phiên bản thập phân ba thành phần (1.2.3), giống như các tạo phẩm khác.

Trong môi trường hiện đại, các tập tin cấu hình không còn được sửa đổi thủ công nữa. Thông thường chúng tôi tạo ra
tập tin cấu hình tại thời điểm triển khai và không bao giờ chạm vào họ sau đó. Vậy người ta có thể hỏi tại sao chúng ta vẫn sử dụng định dạng văn bản cho các tập tin cấu hình? Một tùy chọn khả thi là đặt cấu hình bên trong đơn vị biên dịch và hưởng lợi từ việc xác thực cấu hình tại thời điểm biên dịch.

Trong bài đăng này, chúng tôi sẽ xem xét ý tưởng giữ cấu hình trong tạo phẩm đã biên dịch.

Cấu hình có thể biên dịch

Trong phần này chúng ta sẽ thảo luận về một ví dụ về cấu hình tĩnh. Hai dịch vụ đơn giản - dịch vụ echo và ứng dụng khách của dịch vụ echo đang được cấu hình và triển khai. Sau đó, hai hệ thống phân tán khác nhau với cả hai dịch vụ sẽ được khởi tạo. Một dành cho cấu hình một nút và một dành cho cấu hình hai nút.

Một hệ thống phân tán điển hình bao gồm một vài nút. Các nút có thể được xác định bằng cách sử dụng một số loại:

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

hay chỉ

case class NodeId(hostName: String)

hoặc thậm chí

object Singleton
type NodeId = Singleton.type

Các nút này thực hiện nhiều vai trò khác nhau, chạy một số dịch vụ và có thể giao tiếp với các nút khác bằng kết nối TCP/HTTP.

Để kết nối TCP, ít nhất cần có số cổng. Chúng tôi cũng muốn đảm bảo rằng máy khách và máy chủ đang sử dụng cùng một giao thức. Để mô hình hóa kết nối giữa các nút, hãy khai báo lớp sau:

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

Ở đâu Port chỉ là một Int trong phạm vi cho phép:

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

Các loại tinh chế

Xem tinh chế thư viện. Nói tóm lại, nó cho phép thêm các ràng buộc về thời gian biên dịch cho các loại khác. Trong trường hợp này Int chỉ được phép có các giá trị 16 bit có thể biểu thị số cổng. Không có yêu cầu sử dụng thư viện này cho phương pháp cấu hình này. Nó chỉ có vẻ rất phù hợp.

Đối với HTTP (REST) ​​​​chúng ta cũng có thể cần một đường dẫn của dịch vụ:

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

Loại ảo

Để xác định giao thức trong quá trình biên dịch, chúng tôi đang sử dụng tính năng Scala của việc khai báo đối số kiểu Protocol đó không được sử dụng trong lớp. Đó là cái gọi là loại ma. Trong thời gian chạy, chúng tôi hiếm khi cần một phiên bản của mã định danh giao thức, đó là lý do tại sao chúng tôi không lưu trữ nó. Trong quá trình biên dịch, loại ảo này mang lại sự an toàn cho loại bổ sung. Chúng tôi không thể chuyển cổng với giao thức không chính xác.

Một trong những giao thức được sử dụng rộng rãi nhất là REST API với tuần tự hóa Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

Ở đâu RequestMessage là loại tin nhắn cơ bản mà máy khách có thể gửi đến máy chủ và ResponseMessage là tin nhắn phản hồi từ máy chủ. Tất nhiên, chúng tôi có thể tạo các mô tả giao thức khác chỉ định giao thức truyền thông với độ chính xác mong muốn.

Với mục đích của bài đăng này, chúng tôi sẽ sử dụng phiên bản đơn giản hơn của giao thức:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Trong giao thức này, thông báo yêu cầu được thêm vào url và thông báo phản hồi được trả về dưới dạng chuỗi đơn giản.

Cấu hình dịch vụ có thể được mô tả bằng tên dịch vụ, tập hợp các cổng và một số phụ thuộc. Có một số cách có thể để biểu diễn tất cả các phần tử này trong Scala (ví dụ: HList, kiểu dữ liệu đại số). Với mục đích của bài đăng này, chúng tôi sẽ sử dụng Mẫu Bánh và biểu thị các phần (mô-đun) có thể kết hợp làm đặc điểm. (Mẫu bánh không phải là yêu cầu đối với phương pháp cấu hình có thể biên dịch này. Đây chỉ là một cách triển khai ý tưởng khả thi.)

Các phần phụ thuộc có thể được biểu diễn bằng cách sử dụng Mẫu bánh làm điểm cuối của các nút khác:

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

Dịch vụ Echo chỉ cần cấu hình cổng. Và chúng tôi tuyên bố rằng cổng này hỗ trợ giao thức echo. Lưu ý rằng chúng tôi không cần chỉ định một cổng cụ thể tại thời điểm này, vì đặc điểm cho phép khai báo các phương thức trừu tượng. Nếu chúng ta sử dụng các phương thức trừu tượng, trình biên dịch sẽ yêu cầu triển khai trong một phiên bản cấu hình. Ở đây chúng tôi đã cung cấp việc thực hiện (8081) và nó sẽ được sử dụng làm giá trị mặc định nếu chúng ta bỏ qua nó trong cấu hình cụ thể.

Chúng ta có thể khai báo phần phụ thuộc trong cấu hình của máy khách dịch vụ echo:

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

Phần phụ thuộc có cùng loại với echoService. Đặc biệt, nó đòi hỏi cùng một giao thức. Do đó, chúng ta có thể chắc chắn rằng nếu chúng ta kết nối hai phần phụ thuộc này thì chúng sẽ hoạt động chính xác.

Triển khai dịch vụ

Một dịch vụ cần có một chức năng để khởi động và tắt máy một cách nhẹ nhàng. (Khả năng tắt một dịch vụ là rất quan trọng để thử nghiệm.) Một lần nữa, có một số tùy chọn để chỉ định chức năng như vậy cho một cấu hình nhất định (ví dụ: chúng ta có thể sử dụng các lớp loại). Đối với bài đăng này, chúng tôi sẽ sử dụng lại Mẫu Bánh. Chúng tôi có thể đại diện cho một dịch vụ bằng cách sử dụng cats.Resource đã cung cấp tính năng đóng khung và giải phóng tài nguyên. Để có được tài nguyên, chúng tôi nên cung cấp cấu hình và một số bối cảnh thời gian chạy. Vì vậy, chức năng bắt đầu dịch vụ có thể trông giống như:

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

Ở đâu

  • Config — loại cấu hình được yêu cầu bởi bộ khởi động dịch vụ này
  • AddressResolver — một đối tượng thời gian chạy có khả năng lấy địa chỉ thực của các nút khác (đọc tiếp để biết chi tiết).

các loại khác đến từ cats:

  • F[_] — loại hiệu ứng (Trong trường hợp đơn giản nhất F[A] có thể chỉ là () => A. Trong bài đăng này chúng tôi sẽ sử dụng cats.IO.)
  • Reader[A,B] — ít nhiều là từ đồng nghĩa với một hàm A => B
  • cats.Resource - có cách để có được và phát hành
  • Timer — cho phép ngủ/đo thời gian
  • ContextShift - tương tự của ExecutionContext
  • Applicative — trình bao bọc các hàm đang có hiệu lực (gần như một đơn nguyên) (cuối cùng chúng ta có thể thay thế nó bằng một thứ khác)

Sử dụng giao diện này, chúng ta có thể triển khai một số dịch vụ. Chẳng hạn, một dịch vụ không làm gì cả:

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

(Xem Mã nguồn để triển khai các dịch vụ khác - dịch vụ tiếng vang,
khách hàng echobộ điều khiển trọn đời.)

Nút là một đối tượng duy nhất chạy một vài dịch vụ (khởi động chuỗi tài nguyên được kích hoạt bởi Mẫu bánh):

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

Lưu ý rằng trong nút, chúng tôi chỉ định loại cấu hình chính xác mà nút này cần. Trình biên dịch sẽ không cho phép chúng ta xây dựng đối tượng (Bánh) với loại không đủ, bởi vì mỗi đặc điểm dịch vụ khai báo một ràng buộc trên Config kiểu. Ngoài ra, chúng tôi sẽ không thể khởi động nút nếu không cung cấp cấu hình hoàn chỉnh.

Độ phân giải địa chỉ nút

Để thiết lập kết nối, chúng ta cần một địa chỉ máy chủ thực sự cho mỗi nút. Nó có thể được biết đến muộn hơn các phần khác của cấu hình. Do đó, chúng ta cần một cách để cung cấp ánh xạ giữa id nút và địa chỉ thực của nó. Ánh xạ này là một chức năng:

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

Có một số cách có thể để thực hiện một chức năng như vậy.

  1. Nếu chúng tôi biết địa chỉ thực tế trước khi triển khai, trong quá trình khởi tạo máy chủ nút, thì chúng tôi có thể tạo mã Scala với địa chỉ thực tế và chạy bản dựng sau đó (thực hiện kiểm tra thời gian biên dịch rồi chạy bộ kiểm tra tích hợp). Trong trường hợp này, chức năng ánh xạ của chúng tôi được biết một cách tĩnh và có thể được đơn giản hóa thành một cái gì đó giống như Map[NodeId, NodeAddress].
  2. Đôi khi, chúng tôi chỉ nhận được địa chỉ thực tại thời điểm muộn hơn khi nút thực sự được khởi động hoặc chúng tôi không có địa chỉ của các nút chưa được khởi động. Trong trường hợp này, chúng tôi có thể có một dịch vụ khám phá được khởi động trước tất cả các nút khác và mỗi nút có thể quảng cáo địa chỉ của nó trong dịch vụ đó và đăng ký các phần phụ thuộc.
  3. Nếu chúng ta có thể sửa đổi /etc/hosts, chúng ta có thể sử dụng tên máy chủ được xác định trước (như my-project-main-nodeecho-backend) và chỉ liên kết tên này với địa chỉ IP tại thời điểm triển khai.

Trong bài đăng này, chúng tôi không đề cập đến những trường hợp này chi tiết hơn. Trên thực tế, trong ví dụ về đồ chơi của chúng tôi, tất cả các nút sẽ có cùng địa chỉ IP — 127.0.0.1.

Trong bài đăng này, chúng tôi sẽ xem xét hai cách bố trí hệ thống phân tán:

  1. Bố cục nút đơn, trong đó tất cả các dịch vụ được đặt trên một nút duy nhất.
  2. Bố cục hai nút, trong đó dịch vụ và máy khách nằm trên các nút khác nhau.

Cấu hình cho một nút đơn bố cục như sau:

Cấu hình nút đơn

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

Ở đây chúng tôi tạo một cấu hình duy nhất mở rộng cả cấu hình máy chủ và máy khách. Ngoài ra, chúng tôi định cấu hình bộ điều khiển vòng đời thường sẽ chấm dứt máy khách và máy chủ sau lifetime khoảng thời gian trôi qua.

Có thể sử dụng cùng một bộ triển khai và cấu hình dịch vụ để tạo bố cục của hệ thống với hai nút riêng biệt. Chúng ta chỉ cần tạo hai cấu hình nút riêng biệt với các dịch vụ phù hợp:

Cấu hình hai nút

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

Xem cách chúng tôi chỉ định phần phụ thuộc. Chúng tôi đề cập đến dịch vụ được cung cấp bởi nút khác như một phần phụ thuộc của nút hiện tại. Loại phụ thuộc được chọn vì nó chứa loại ảo mô tả giao thức. Và trong thời gian chạy, chúng tôi sẽ có id nút chính xác. Đây là một trong những khía cạnh quan trọng của phương pháp cấu hình được đề xuất. Nó cung cấp cho chúng tôi khả năng chỉ đặt cổng một lần và đảm bảo rằng chúng tôi đang tham chiếu đúng cổng.

Triển khai hai nút

Đối với cấu hình này, chúng tôi sử dụng chính xác các triển khai dịch vụ tương tự. Không có thay đổi nào cả. Tuy nhiên, chúng tôi tạo hai cách triển khai nút khác nhau chứa bộ dịch vụ khác nhau:

  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
  }

Nút đầu tiên triển khai máy chủ và nó chỉ cần cấu hình phía máy chủ. Nút thứ hai triển khai ứng dụng khách và cần một phần khác của config. Cả hai nút đều yêu cầu một số thông số kỹ thuật trọn đời. Vì mục đích của nút dịch vụ bưu chính này sẽ có thời gian tồn tại vô hạn và có thể bị chấm dứt bằng cách sử dụng SIGTERM, trong khi echo client sẽ chấm dứt sau khoảng thời gian hữu hạn được định cấu hình. Xem ứng dụng khởi đầu để biết thêm chi tiết.

Quá trình phát triển tổng thể

Hãy xem cách tiếp cận này thay đổi cách chúng ta làm việc với cấu hình như thế nào.

Cấu hình dưới dạng mã sẽ được biên dịch và tạo ra một tạo phẩm. Có vẻ hợp lý khi tách thành phần cấu hình khỏi các thành phần mã khác. Thông thường chúng ta có thể có vô số cấu hình trên cùng một cơ sở mã. Và tất nhiên, chúng ta có thể có nhiều phiên bản của nhiều nhánh cấu hình khác nhau. Trong một cấu hình, chúng tôi có thể chọn các phiên bản thư viện cụ thể và điều này sẽ không đổi bất cứ khi nào chúng tôi triển khai cấu hình này.

Thay đổi cấu hình sẽ trở thành thay đổi mã. Vì vậy, nó phải được bao phủ bởi cùng một quy trình đảm bảo chất lượng:

Yêu cầu -> PR -> đánh giá -> hợp nhất -> tích hợp liên tục -> triển khai liên tục

Có những hậu quả sau đây của cách tiếp cận này:

  1. Cấu hình nhất quán cho phiên bản của một hệ thống cụ thể. Có vẻ như không có cách nào để có kết nối không chính xác giữa các nút.
  2. Không dễ để thay đổi cấu hình chỉ trong một nút. Có vẻ không hợp lý khi đăng nhập và thay đổi một số tệp văn bản. Vì vậy, việc lệch cấu hình trở nên ít xảy ra hơn.
  3. Những thay đổi cấu hình nhỏ không dễ thực hiện.
  4. Hầu hết các thay đổi về cấu hình sẽ tuân theo cùng một quy trình phát triển và sẽ vượt qua một số đánh giá.

Chúng ta có cần một kho lưu trữ riêng cho cấu hình sản xuất không? Cấu hình sản xuất có thể chứa thông tin nhạy cảm mà chúng tôi muốn tránh xa tầm tay của nhiều người. Vì vậy, có thể đáng để giữ một kho lưu trữ riêng với quyền truy cập hạn chế sẽ chứa cấu hình sản xuất. Chúng tôi có thể chia cấu hình thành hai phần - một phần chứa các tham số sản xuất mở nhất và một phần chứa phần cấu hình bí mật. Điều này sẽ cho phép hầu hết các nhà phát triển truy cập vào phần lớn các tham số trong khi hạn chế quyền truy cập vào những thứ thực sự nhạy cảm. Thật dễ dàng để thực hiện điều này bằng cách sử dụng các đặc điểm trung gian với các giá trị tham số mặc định.

Biến thể

Chúng ta hãy xem ưu và nhược điểm của phương pháp được đề xuất so với các kỹ thuật quản lý cấu hình khác.

Trước hết, chúng tôi sẽ liệt kê một số lựa chọn thay thế cho các khía cạnh khác nhau của cách xử lý cấu hình được đề xuất:

  1. Tệp văn bản trên máy mục tiêu.
  2. Lưu trữ khóa-giá trị tập trung (như etcd/zookeeper).
  3. Các thành phần quy trình con có thể được cấu hình lại/khởi động lại mà không cần khởi động lại quy trình.
  4. Cấu hình bên ngoài tạo tác và kiểm soát phiên bản.

Tệp văn bản mang lại sự linh hoạt nhất định về mặt sửa lỗi đặc biệt. Quản trị viên hệ thống có thể đăng nhập vào nút mục tiêu, thực hiện thay đổi và chỉ cần khởi động lại dịch vụ. Điều này có thể không tốt cho các hệ thống lớn hơn. Không có dấu vết nào được để lại đằng sau sự thay đổi. Sự thay đổi không được xem xét bởi một cặp mắt khác. Có thể khó tìm ra nguyên nhân gây ra sự thay đổi. Nó chưa được thử nghiệm. Từ quan điểm của hệ thống phân tán, quản trị viên có thể đơn giản quên cập nhật cấu hình ở một trong các nút khác.

(Nhân tiện, nếu cuối cùng cần phải bắt đầu sử dụng tệp cấu hình văn bản, chúng tôi sẽ chỉ phải thêm trình phân tích cú pháp + trình xác thực có thể tạo ra cùng một Config type và thế là đủ để bắt đầu sử dụng cấu hình văn bản. Điều này cũng cho thấy độ phức tạp của cấu hình thời gian biên dịch nhỏ hơn một chút so với độ phức tạp của cấu hình dựa trên văn bản, vì trong phiên bản dựa trên văn bản, chúng ta cần một số mã bổ sung.)

Lưu trữ khóa-giá trị tập trung là một cơ chế tốt để phân phối các tham số meta ứng dụng. Ở đây chúng ta cần suy nghĩ về những gì chúng ta coi là giá trị cấu hình và những gì chỉ là dữ liệu. Cho một hàm C => A => B chúng tôi thường gọi là hiếm khi thay đổi giá trị C "cấu hình", trong khi dữ liệu được thay đổi thường xuyên A - chỉ cần nhập dữ liệu. Cấu hình phải được cung cấp cho chức năng sớm hơn dữ liệu A. Với ý tưởng này, chúng ta có thể nói rằng tần suất thay đổi dự kiến ​​có thể được sử dụng để phân biệt dữ liệu cấu hình với dữ liệu đơn thuần. Ngoài ra, dữ liệu thường đến từ một nguồn (người dùng) và cấu hình đến từ một nguồn khác (quản trị viên). Việc xử lý các tham số có thể thay đổi sau quá trình khởi tạo sẽ làm tăng độ phức tạp của ứng dụng. Đối với các tham số như vậy, chúng tôi sẽ phải xử lý cơ chế phân phối, phân tích cú pháp và xác thực của chúng, xử lý các giá trị không chính xác. Do đó, để giảm độ phức tạp của chương trình, tốt hơn chúng ta nên giảm số lượng tham số có thể thay đổi trong thời gian chạy (hoặc thậm chí loại bỏ chúng hoàn toàn).

Từ quan điểm của bài đăng này, chúng ta nên phân biệt giữa các tham số tĩnh và động. Nếu logic dịch vụ yêu cầu hiếm khi thay đổi một số tham số trong thời gian chạy thì chúng ta có thể gọi chúng là tham số động. Nếu không thì chúng ở trạng thái tĩnh và có thể được cấu hình bằng cách sử dụng phương pháp được đề xuất. Để cấu hình lại động, các phương pháp khác có thể cần thiết. Ví dụ: các bộ phận của hệ thống có thể được khởi động lại với các tham số cấu hình mới theo cách tương tự như khởi động lại các quy trình riêng biệt của hệ thống phân tán.
(Ý kiến ​​khiêm tốn của tôi là tránh cấu hình lại thời gian chạy vì nó làm tăng độ phức tạp của hệ thống.
Có thể đơn giản hơn nếu chỉ dựa vào sự hỗ trợ của hệ điều hành để khởi động lại các quy trình. Tuy nhiên, không phải lúc nào cũng có thể thực hiện được.)

Một khía cạnh quan trọng của việc sử dụng cấu hình tĩnh đôi khi khiến mọi người cân nhắc cấu hình động (không có lý do khác) là thời gian ngừng hoạt động của dịch vụ trong quá trình cập nhật cấu hình. Thật vậy, nếu phải thay đổi cấu hình tĩnh, chúng ta phải khởi động lại hệ thống để các giá trị mới có hiệu lực. Các yêu cầu về thời gian ngừng hoạt động khác nhau đối với các hệ thống khác nhau, vì vậy nó có thể không quá quan trọng. Nếu điều đó quan trọng thì chúng ta phải lập kế hoạch trước cho bất kỳ lần khởi động lại hệ thống nào. Ví dụ, chúng ta có thể triển khai Thoát kết nối AWS ELB. Trong trường hợp này, bất cứ khi nào chúng tôi cần khởi động lại hệ thống, chúng tôi sẽ khởi động song song một phiên bản mới của hệ thống, sau đó chuyển ELB sang nó, đồng thời cho phép hệ thống cũ hoàn thành việc phục vụ các kết nối hiện có.

Còn việc giữ cấu hình bên trong tạo phẩm được phiên bản hay bên ngoài thì sao? Giữ cấu hình bên trong một tạo phẩm có nghĩa là trong hầu hết các trường hợp cấu hình này đã vượt qua quy trình đảm bảo chất lượng giống như các tạo phẩm khác. Vì vậy, người ta có thể chắc chắn rằng cấu hình có chất lượng tốt và đáng tin cậy. Ngược lại, cấu hình trong một tệp riêng biệt có nghĩa là không có dấu vết về ai và tại sao đã thực hiện các thay đổi đối với tệp đó. Điều này có quan trọng không? Chúng tôi tin rằng đối với hầu hết các hệ thống sản xuất, tốt hơn hết là nên có cấu hình ổn định và chất lượng cao.

Phiên bản của tạo phẩm cho phép tìm hiểu thời điểm nó được tạo, nó chứa những giá trị gì, tính năng nào được bật/tắt, ai chịu trách nhiệm thực hiện từng thay đổi trong cấu hình. Có thể cần một số nỗ lực để duy trì cấu hình bên trong một tạo phẩm và đó là một lựa chọn thiết kế cần thực hiện.

Ưu nhược điểm

Ở đây chúng tôi muốn nêu bật một số ưu điểm và thảo luận về một số nhược điểm của phương pháp đề xuất.

Ưu điểm

Các tính năng của cấu hình có thể biên dịch được của một hệ thống phân tán hoàn chỉnh:

  1. Kiểm tra tĩnh cấu hình. Điều này mang lại mức độ tin cậy cao rằng cấu hình đúng với các ràng buộc về loại.
  2. Ngôn ngữ cấu hình phong phú. Thông thường, các phương pháp cấu hình khác được giới hạn ở mức thay thế có thể thay đổi.
    Sử dụng Scala người ta có thể sử dụng nhiều tính năng ngôn ngữ để cấu hình tốt hơn. Chẳng hạn, chúng ta có thể sử dụng các đặc điểm để cung cấp các giá trị mặc định, các đối tượng để đặt phạm vi khác nhau, chúng ta có thể tham khảo vals chỉ được xác định một lần trong phạm vi bên ngoài (DRY). Có thể sử dụng các chuỗi theo nghĩa đen hoặc các thể hiện của một số lớp nhất định (Seq, Map, Vv).
  3. DSL. Scala có sự hỗ trợ tốt cho người viết DSL. Người ta có thể sử dụng các tính năng này để thiết lập ngôn ngữ cấu hình thuận tiện hơn và thân thiện với người dùng cuối, sao cho người dùng trong miền ít nhất có thể đọc được cấu hình cuối cùng.
  4. Tính toàn vẹn và sự gắn kết giữa các nút. Một trong những lợi ích của việc cấu hình cho toàn bộ hệ thống phân tán ở một nơi là tất cả các giá trị được xác định nghiêm ngặt một lần và sau đó được sử dụng lại ở tất cả những nơi chúng ta cần. Ngoài ra, hãy nhập các khai báo cổng an toàn để đảm bảo rằng trong tất cả các cấu hình chính xác có thể, các nút của hệ thống sẽ nói cùng một ngôn ngữ. Có sự phụ thuộc rõ ràng giữa các nút khiến bạn khó có thể quên cung cấp một số dịch vụ.
  5. Chất lượng thay đổi cao. Cách tiếp cận tổng thể của việc chuyển các thay đổi cấu hình thông qua quy trình PR thông thường cũng thiết lập các tiêu chuẩn cao về chất lượng trong cấu hình.
  6. Thay đổi cấu hình đồng thời. Bất cứ khi nào chúng tôi thực hiện bất kỳ thay đổi nào trong quá trình triển khai tự động cấu hình đều đảm bảo rằng tất cả các nút đều được cập nhật.
  7. Đơn giản hóa ứng dụng. Ứng dụng không cần phân tích cú pháp và xác thực cấu hình cũng như xử lý các giá trị cấu hình không chính xác. Điều này đơn giản hóa ứng dụng tổng thể. (Một số sự gia tăng phức tạp nằm ở bản thân cấu hình, nhưng đó là một sự đánh đổi có ý thức để hướng tới sự an toàn.) Việc quay lại cấu hình thông thường khá đơn giản — chỉ cần thêm những phần còn thiếu. Việc bắt đầu với cấu hình đã biên dịch sẽ dễ dàng hơn và hoãn việc triển khai các phần bổ sung vào một thời điểm sau đó.
  8. Cấu hình theo phiên bản. Do các thay đổi về cấu hình tuân theo cùng một quy trình phát triển, kết quả là chúng tôi nhận được một tạo phẩm có phiên bản duy nhất. Nó cho phép chúng tôi chuyển đổi cấu hình trở lại nếu cần. Chúng tôi thậm chí có thể triển khai một cấu hình đã được sử dụng một năm trước và nó sẽ hoạt động theo cách tương tự. Cấu hình ổn định cải thiện khả năng dự đoán và độ tin cậy của hệ thống phân tán. Cấu hình được cố định tại thời điểm biên dịch và không thể dễ dàng giả mạo trên hệ thống sản xuất.
  9. Tính mô-đun. Khung đề xuất là mô-đun và các mô-đun có thể được kết hợp theo nhiều cách khác nhau để
    hỗ trợ các cấu hình khác nhau (thiết lập/bố cục). Đặc biệt, có thể có bố cục nút đơn ở quy mô nhỏ và cài đặt nhiều nút ở quy mô lớn. Thật hợp lý khi có nhiều bố cục sản xuất.
  10. Đang thử nghiệm. Đối với mục đích thử nghiệm, người ta có thể triển khai một dịch vụ mô phỏng và sử dụng nó như một dịch vụ phụ thuộc theo cách an toàn về loại. Có thể duy trì đồng thời một số bố cục thử nghiệm khác nhau với nhiều bộ phận khác nhau được thay thế bằng mô hình.
  11. Thử nghiệm hội nhập. Đôi khi trong các hệ thống phân tán rất khó để chạy thử nghiệm tích hợp. Sử dụng phương pháp được mô tả để nhập cấu hình an toàn của hệ thống phân tán hoàn chỉnh, chúng ta có thể chạy tất cả các phần phân tán trên một máy chủ theo cách có thể kiểm soát được. Thật dễ dàng để mô phỏng tình huống
    khi một trong các dịch vụ không còn khả dụng.

Điểm yếus

Cách tiếp cận cấu hình được biên dịch khác với cấu hình “bình thường” và nó có thể không phù hợp với mọi nhu cầu. Dưới đây là một số nhược điểm của cấu hình được biên dịch:

  1. Cấu hình tĩnh. Nó có thể không phù hợp cho tất cả các ứng dụng. Trong một số trường hợp cần phải nhanh chóng sửa cấu hình trong quá trình sản xuất mà bỏ qua mọi biện pháp an toàn. Cách tiếp cận này làm cho nó khó khăn hơn. Việc biên dịch và triển khai lại là bắt buộc sau khi thực hiện bất kỳ thay đổi nào về cấu hình. Đây vừa là đặc điểm vừa là gánh nặng.
  2. Tạo cấu hình. Khi cấu hình được tạo bởi một số công cụ tự động hóa, phương pháp này yêu cầu quá trình biên dịch tiếp theo (có thể không thành công). Có thể cần nỗ lực thêm để tích hợp bước bổ sung này vào hệ thống xây dựng.
  3. Dụng cụ. Hiện nay có rất nhiều công cụ được sử dụng dựa trên cấu hình dựa trên văn bản. Vài người trong số họ
    sẽ không được áp dụng khi cấu hình được biên dịch.
  4. Cần có sự thay đổi về tư duy. Các nhà phát triển và DevOps đã quen thuộc với các tệp cấu hình văn bản. Ý tưởng biên dịch cấu hình có thể có vẻ xa lạ đối với họ.
  5. Trước khi giới thiệu cấu hình có thể biên dịch được, cần phải có quy trình phát triển phần mềm chất lượng cao.

Có một số hạn chế của ví dụ được triển khai:

  1. Nếu chúng tôi cung cấp cấu hình bổ sung mà việc triển khai nút không yêu cầu, trình biên dịch sẽ không giúp chúng tôi phát hiện việc triển khai vắng mặt. Điều này có thể được giải quyết bằng cách sử dụng HList hoặc ADT (lớp trường hợp) cho cấu hình nút thay vì các đặc điểm và Mẫu bánh.
  2. Chúng tôi phải cung cấp một số bản soạn sẵn trong tệp cấu hình: (package, import, object tờ khai;
    override def's dành cho các tham số có giá trị mặc định). Vấn đề này có thể được giải quyết một phần bằng DSL.
  3. Trong bài đăng này, chúng tôi không đề cập đến việc cấu hình lại động của các cụm nút tương tự.

Kết luận

Trong bài đăng này, chúng tôi đã thảo luận về ý tưởng thể hiện cấu hình trực tiếp trong mã nguồn theo cách an toàn về kiểu. Cách tiếp cận này có thể được sử dụng trong nhiều ứng dụng để thay thế cho xml và các cấu hình dựa trên văn bản khác. Mặc dù ví dụ của chúng tôi đã được triển khai trong Scala nhưng nó cũng có thể được dịch sang các ngôn ngữ có thể biên dịch khác (như Kotlin, C#, Swift, v.v.). Người ta có thể thử cách tiếp cận này trong một dự án mới và trong trường hợp nó không phù hợp, hãy chuyển sang cách cũ.

Tất nhiên, cấu hình có thể biên dịch được đòi hỏi quá trình phát triển chất lượng cao. Đổi lại nó hứa hẹn sẽ cung cấp cấu hình mạnh mẽ chất lượng cao như nhau.

Cách tiếp cận này có thể được mở rộng theo nhiều cách khác nhau:

  1. Người ta có thể sử dụng macro để thực hiện xác thực cấu hình và không thành công tại thời điểm biên dịch trong trường hợp có bất kỳ lỗi ràng buộc logic nghiệp vụ nào.
  2. DSL có thể được triển khai để thể hiện cấu hình theo cách thân thiện với người dùng miền.
  3. Quản lý tài nguyên động với điều chỉnh cấu hình tự động. Ví dụ: khi chúng tôi điều chỉnh số lượng nút cụm, chúng tôi có thể muốn (1) các nút có được cấu hình được sửa đổi một chút; (2) trình quản lý cụm để nhận thông tin nút mới.

Cảm ơn

Tôi xin gửi lời cảm ơn tới Andrey Saksonov, Pavel Popov, Anton Nehaev vì đã đưa ra những phản hồi đầy cảm hứng về bản nháp của bài đăng này đã giúp tôi làm rõ hơn.

Nguồn: www.habr.com