أباتشي ستورم رائع. هذا هو السبب (وكيف) يجب أن تستخدمه.

تنتشر تدفقات البيانات المستمرة في كل مكان وقد أصبحت أكثر مع زيادة عدد أجهزة إنترنت الأشياء المستخدمة. بالطبع ، هذا يعني تخزين كميات هائلة من البيانات ومعالجتها وتحليلها لتقديم نتائج تنبؤية وقابلة للتنفيذ.

لكن بيتابايت من البيانات يستغرق وقتًا طويلاً للتحليل ، حتى باستخدام أدوات مثل Hadoop (قد تكون جيدة مثل MapReduce) أو Spark (علاج لقيود MapReduce).

في كثير من الأحيان ، لا نحتاج إلى استنتاج أنماط على مدى فترات طويلة من الزمن. من بين بيتابايتات من البيانات الواردة التي تم جمعها على مدار أشهر ، في أي لحظة ، قد لا نحتاج إلى أخذ كل ذلك في الاعتبار ، مجرد لقطة في الوقت الفعلي. ربما لا نحتاج إلى معرفة أطول علامة تصنيف شائعة على مدار خمس سنوات ، ولكن فقط الوسم الحالي.

هذا ما تم تصميم Apache Storm من أجله ، لقبول الكثير من البيانات التي تأتي بسرعة كبيرة ، ربما من مصادر مختلفة ، وتحليلها ، ونشر تحديثات في الوقت الفعلي لواجهة مستخدم أو مكان آخر ... بدون تخزين أي بيانات فعلية .

هذه المقالة ليست الدليل النهائي لعاصفة أباتشي ، ولا يُفترض أن تكون كذلك. Storm ضخم جدًا ، وقراءة واحدة فقط طويلة ربما لا تستطيع تحقيق العدالة على أي حال. بالطبع ، أي إضافات أو ملاحظات أو نقد بناء سيكون موضع تقدير كبير.

حسنًا ، الآن بعد أن انتهى الأمر ، دعنا نرى ما سنغطيه:

  • ضرورة العاصفة ، "لماذا" منها ، ما هي وما هي ليست كذلك
  • نظرة عامة على كيفية عملها.
  • كيف تبدو طوبولوجيا العاصفة تقريبًا في الكود (جافا)
  • الإعداد واللعب مع مجموعة Storm تستحق الإنتاج على Docker.
  • بضع كلمات حول موثوقية معالجة الرسائل.

أفترض أيضًا أنك على الأقل معتاد إلى حد ما على Docker و containerization.

كيف تعمل

يمكن مقارنة بنية Apache Storm بشبكة طرق تربط مجموعة من نقاط التفتيش. تبدأ حركة المرور عند نقطة تفتيش معينة (تسمى صنبور ) وتمر عبر نقاط تفتيش أخرى (تسمى البراغي ).

حركة المرور هي بالطبع تدفق البيانات التي يتم استردادها بواسطة الفوهة (من مصدر بيانات ، واجهة برمجة تطبيقات عامة على سبيل المثال) وتوجيهها إلى عدة براغي حيث يتم تصفية البيانات وتعقيمها وتجميعها وتحليلها وإرسالها إلى واجهة مستخدم من أجل للعرض (أو لأي هدف آخر).

ويطلق على شبكة من التدفق والمسامير و طوبولوجيا ، وتدفق البيانات في شكل الصفوف (قائمة من القيم التي قد يكون لها أنواع مختلفة).

أحد الأشياء المهمة التي يجب التحدث عنها هو اتجاه حركة البيانات.

تقليديًا ، سيكون لدينا واحد أو أكثر من صنبور قراءة البيانات من واجهة برمجة التطبيقات ، ونظام الانتظار ، وما إلى ذلك. ستتدفق البيانات بعد ذلك في اتجاه واحد إلى واحد أو عدة مسامير قد تعيد توجيهها إلى مسامير أخرى وما إلى ذلك. قد تنشر البراغي البيانات التي تم تحليلها إلى واجهة المستخدم أو إلى الترباس الآخر.

لكن حركة المرور تكون دائمًا أحادية الاتجاه تقريبًا ، مثل الرسم البياني الدوري الموجه (DAG). على الرغم من أنه من الممكن بالتأكيد عمل دورات ، فمن غير المرجح أن نحتاج إلى مثل هذه الطوبولوجيا المعقدة.

يتضمن تثبيت إصدار Storm عددًا من الخطوات التي يمكنك اتباعها مجانًا على جهازك. ولكن لاحقًا ، سأستخدم حاويات Docker لنشر مجموعة Storm ، وستتولى الصور إعداد كل ما نحتاجه.

بعض التعليمات البرمجية

بينما تقدم Storm دعمًا للغات أخرى ، فإن معظم الهياكل مكتوبة بلغة Java ، نظرًا لأنها الخيار الأكثر فعالية لدينا.

قد يبدو صنبور أساسي للغاية ، يصدر فقط أرقامًا عشوائية ، كما يلي:

ومسامير بسيطة تأخذ دفقًا من الأرقام العشوائية وتصدر فقط الأرقام الزوجية:

الترباس البسيط الآخر الذي سيتلقى التدفق المرشح منه EvenDigitBolt، واضرب فقط كل رقم زوجي في 10 وأرسله للأمام:

نجمعهم معًا لتشكيل الهيكل الخاص بنا:

التوازي في طبولوجيا العاصفة

قد يكون الفهم الكامل للتوازي في العاصفة أمرًا شاقًا ، على الأقل من واقع خبرتي. تتطلب البنية عملية واحدة على الأقل للعمل عليها. ضمن هذه العملية ، يمكننا موازنة تنفيذ الفوهات والبراغي باستخدام الخيوط.

في مثالنا ، RandomDigitSpoutسيتم تشغيل مؤشر ترابط واحد فقط ، وسيتم توزيع البيانات المنبعثة من هذا الموضوع على خيطين من خيوط EvenDigitBolt.

لكن الطريقة التي يحدث بها هذا التوزيع ، والتي يشار إليها بتجميع الدفق ، يمكن أن تكون مهمة. على سبيل المثال ، قد يكون لديك تيار من تسجيلات درجات الحرارة من مدينتين ، حيث تبدو المجموعات المنبعثة من الفوهة على النحو التالي:

// City name, temperature, time of recording
(“Atlanta”, 94, “2018–05–11 23:14”)(“New York City”, 75, “2018–05–11 23:15”)(“New York City”, 76, “2018–05–11 23:16”)(“Atlanta”, 96, “2018–05–11 23:15”)(“New York City”, 77, “2018–05–11 23:17”)(“Atlanta”, 95, “2018–05–11 23:16”)(“New York City”, 76, “2018–05–11 23:18”)

لنفترض أننا نعلق مسمارًا واحدًا تتمثل وظيفته في حساب متوسط ​​درجة الحرارة المتغيرة لكل مدينة.

إذا استطعنا أن نتوقع بشكل معقول أنه في أي فترة زمنية معينة سنحصل تقريبًا على عدد متساوٍ من المجموعات من كلتا المدينتين ، فسيكون من المنطقي تخصيص خيطين للمسمار. يمكننا إرسال البيانات الخاصة بأتلانتا إلى أحدهما ونيويورك للآخر.

A المجالات تجميع يخدم غرضنا، التي أقسام البيانات بين المواضيع التي كتبها قيمة الحقل المحدد في التجمع:

// The tuples with the same city name will go to the same thread.builder.setBolt(“avg-temp-bolt”, new AvgTempBolt(), 2) .fieldsGrouping(“temp-spout”, new Fields(“city_name”));

وبالطبع ، هناك أنواع أخرى من التجمعات أيضًا. ومع ذلك ، في معظم الحالات ، قد لا يكون التجميع مهمًا كثيرًا. يمكنك فقط تبديل البيانات ورميها بين خيوط الترباس بشكل عشوائي ( تجميع عشوائي ).

يوجد الآن عنصر مهم آخر لهذا: عدد العمليات العاملة التي ستعمل طوبولوجيا لدينا.

سيتم بعد ذلك تقسيم إجمالي عدد سلاسل الرسائل التي حددناها بالتساوي بين العمليات العاملة. لذلك ، في مثالنا طوبولوجيا الأرقام العشوائية ، كان لدينا خيط صنبور واحد ، واثنين من خيوط الترباس ذات الأرقام الزوجية ، وأربعة خيوط برغي مضاعفة في عشرة (مع إعطاء سبعة في المجموع).

ستكون كل من العمليتين العاملتين مسؤولتين عن تشغيل خيطين من الترباس بضربه في عشرة ، ومسمار برغي واحد ، وستقوم إحدى العمليات بتشغيل مؤشر ترابط صنبور واحد.

بالطبع ، سيكون لعمليتي العمل خيوطهما الرئيسية ، والتي بدورها ستطلق سنون الفوهة والمسامير. إذن الكل في الكل لدينا تسعة خيوط. ويطلق على هؤلاء بشكل جماعي المنفذين .

من المهم أن تدرك أنه إذا قمت بتعيين تلميح توازي صنبور ليكون أكبر من واحد (عدة منفذين) ، فقد ينتهي بك الأمر إلى إصدار نفس البيانات عدة مرات.

لنفترض أن الفوهة تقرأ من واجهة برمجة تطبيقات دفق Twitter العامة وتستخدم منفذين. هذا يعني أن البراغي التي تتلقى البيانات من الفوهة ستحصل على نفس التغريدة مرتين. فقط بعد أن يقوم الفوهة بإصدار المجموعات ، يتم تشغيل توازي البيانات. بمعنى آخر ، يتم تقسيم المجموعات بين البراغي وفقًا لتجميع الدفق المحدد.

سيكون تشغيل عدة عمال على عقدة واحدة بلا فائدة إلى حد ما. ومع ذلك ، سنستخدم لاحقًا مجموعة مناسبة وموزعة ومتعددة العقد ونرى كيف يتم تقسيم العمال على عقد مختلفة.

بناء طوبولوجيا لدينا

ها هي بنية الدليل التي أقترحها:

yourproject/ pom.xml src/ jvm/ packagename/ RandomDigitSpout.java EvenDigitBolt.java MultiplyByTenBolt.java OurSimpleTopology.java

Maven is commonly used for building Storm topologies, and it requires a pom.xml file (The POM) that defines various configuration details, project dependencies, and so on. Getting into the nitty-gritty of the POM will probably be overkill here.

  • First, we’ll run mvn clean inside yourproject to clear any compiled files we may have, making sure to compile each module from scratch.
  • And then mvn package to compile our code and package it in an executable JAR file, inside a newly created target folder. This might take quite a few minutes the first time, especially if your topology has many dependencies.
  • To submit our topology: storm jar target/packagename-{version number}.jar packagename.OurSimpleTopology

Hopefully by now the gap between concept and code in Storm has been somewhat bridged. However, no serious Storm deployment will be a single topology instance running on one server.

What a Storm cluster looks like

To take full advantage of Storm’s scalability and fault-tolerance, any production-grade topology would be submitted to a cluster of machines.

Storm distributions are installed on the primary node (Nimbus) and all the replica nodes (Supervisors).

The primary node runs the Storm Nimbus daemon and the Storm UI. The replica nodes run the Storm Supervisor daemons. A Zookeeper daemon on a separate node is used for coordination among the primary node and the replica nodes.

Zookeeper, by the way, is only used for cluster management and never any kind of message passing. It’s not like spouts and bolts are sending data to each other through it or anything like that. The Nimbus daemon finds available Supervisors via ZooKeeper, to which the Supervisor daemons register themselves. It also carries out other managerial tasks, some of which will become clear shortly.

The Storm UI is a web interface used to manage the state of our cluster. We’ll get to this later.

Our topology is submitted to the Nimbus daemon on the primary node, and then distributed among the worker processes running on the replica/supervisor nodes. Thanks to Zookeeper, it doesn’t matter how many replica/supervisor nodes you run initially, as you can always seamlessly add more. Storm will automatically integrate them into the cluster.

Whenever we start a Supervisor, it allocates a certain number of worker processes (that we can configure). These can then be used by the submitted topology. So in the image above, there are a total of five allocated workers.

Remember this line: conf.setNumWorkers(5)

This means that the topology will try to use a total of five workers. And since our two Supervisor nodes have a total of five allocated workers, each of the 5 allocated worker processes will run one instance of the topology.

If we had run conf.setNumWorkers(4) then one worker process would have remained idle/unused. If the number of specified workers was six and the total allocated workers were five, then because of the limitation only five actual topology workers would’ve been functional.

Before we set this all up using Docker, there are a few important things to keep in mind regarding fault-tolerance:

  • If any worker on any replica node dies, the Supervisor daemon will have it restarted. If restarting repeatedly fails, the worker will be reassigned to another machine.
  • If an entire replica node dies, its share of the work will be given to another supervisor/replica node.
  • If the Nimbus goes down, the workers will remain unaffected. However, until the Nimbus is restored, workers won’t be reassigned to other replica nodes if, say, their node crashes.
  • The Nimbus and Supervisors are themselves stateless. But with Zookeeper, some state information is stored so that things can begin where they were left off if a node crashes or a daemon dies unexpectedly.
  • Nimbus, Supervisor and Zookeeper daemons are all fail-fast. This means that they themselves are not very tolerant to unexpected errors, and will shut down if they encounter one. For this reason they have to be run under supervision using a watchdog program that monitors them constantly and restarts them automatically if they ever crash. Supervisord is probably the most popular option for this (not to be confused with the Storm Supervisor daemon).

Note: In most Storm clusters, the Nimbus itself is never deployed as a single instance but as a cluster. If this fault-tolerance is not incorporated and our sole Nimbus goes down, we’ll lose the ability to submit new topologies, gracefully kill running topologies, reassign work to other Supervisor nodes if one crashes, and so on.

For simplicity, our illustrative cluster will use a single instance. Similarly, the Zookeeper is very often deployed as a cluster but we’ll use just one.

Dockerizing The Cluster

Launching individual containers and all that goes along with them can be cumbersome, so I prefer to use Docker Compose.

We’ll be going with one Zookeeper node, one Nimbus node, and one Supervisor node initially. They’ll be defined as Compose services, all corresponding to one container each at the beginning.

Later on, I’ll use Compose scaling to add another Supervisor node (container). Here’s the entire code and the project structure:

zookeeper/ Dockerfilestorm-nimbus/ Dockerfile storm.yaml code/ pom.xml src/ jvm/ coincident_hashtags/ ExclamationTopology.java storm-supervisor/ Dockerfile storm.yamldocker-compose.yml

And our docker-compose.yml:

Feel free to explore the Dockerfiles. They basically just install the dependencies (Java 8, Storm, Maven, Zookeeper) on the relevant containers.

The storm.yaml files override certain default configurations for the Storm installations. The line ADD storm.yaml /conf inside the Nimbus and Supervisor Dockerfiles puts them inside the containers where Storm can read them.

storm-nimbus/storm.yaml:

storm-supervisor/storm.yaml:

These options are adequate for our cluster. If you are curious, you can check out all the default configurations here.

Run docker-compose up at the project root.

After all the images have been built and all the service started, open a new terminal, type docker ps and you’ll see something like this:

Starting The Nimbus

Let’s SSH into the Nimbus container using its name:

docker exec -it coincidenthashtagswithapachestorm_storm-nimbus_1 bash

And then start the Nimbus daemon: storm nimbus

Starting The Storm UI

Similarly, open another terminal, SSH into the Nimbus again and launch the UI using storm ui:

Go to localhost:8080 on your browser and you’ll see a nice overview of our cluster:

The ‘Free slots’ in the Cluster Summary indicate how many total workers (on all Supervisor nodes) are available and waiting for a topology to consume them.

‘Used slots’ indicates how many of the total are currently busy with a topology. Since we haven’t launched any Supervisors yet, they’re both zero. We’ll get to Executors and Tasks later. Also, as we can see, no topologies have been submitted yet.

Starting A Supervisor Node

SSH into the one Supervisor container and launch the Supervisor daemon:

docker exec -it coincidenthashtagswithapachestorm_storm-supervisor_1 bashstorm supervisor 

Now let’s go refresh our UI:

Note: Any changes in our cluster may take a few seconds to reflect on the UI.

We have a new running Supervisor which comes with four allocated workers. These four workers are the result of specifying four ports in our storm.yaml for the Supervisor node. Of course, they’re all free (four Free slots).

Let’s submit a topology to the Nimbus and put ’em to work.

Submitting A Topology To The Nimbus

SSH into the Nimbus on a new terminal. I’ve written the Dockerfile so that we land on our working (landing) directory /theproject. Inside this is code, where our topology resides.

Our topology is pretty simple. It uses a spout that generates random words and a bolt that just appends three exclamation marks (!!!) to the words. Two of these bolts are added back-to-back, and so at the end of the stream we’ll get words with six exclamation marks. It also specifies that it needs three workers (conf.setNumWorkers(3)).

Run these commands:

1. cd code

2. mvn clean

3. mvn package

4. storm jar target/coincident-hashtags-1.2.1.jar coincident_hashtags.ExclamationTopology

After the topology has been submitted successfully, refresh the UI:

As soon as we submitted the topology, the Zookeeper was notified. The Zookeeper in turn notified the Supervisor to download the code from the Nimbus. We now see our topology along with its three occupied workers, leaving just one free.

And ten word spout threads + three exclaim1 bolt threads + two exclaim bolt threads + the three main threads from the workers = total of 18 executors.

And you might’ve noticed something new: tasks.

What are tasks?

Tasks are another concept in Storm’s parallelism. But don’t sweat it, a task is just an instance of a spout or bolt that an executor uses. They are what actually does the processing.

By default, the number of tasks is equal to the number of executors. In rare cases you might need each executor to instantiate more tasks.

// Each of the two executors (threads) of this bolt will instantiate// two objects of this bolt (total 4 bolt objects/tasks).builder.setBolt(“even-digit-bolt”, new EvenDigitBolt(), 2) .setNumTasks(4) .shuffleGrouping(“random-digit-spout”);

This is a shortcoming on my part, but I can’t think of a good use case where we’d need multiple tasks per executor.

Maybe if we were adding some parallelism ourselves, like spawning a new thread within the bolt to handle a long running task, then the main executor thread won’t block and will be able to continue processing using the other bolt.

However, this can make our topology hard to understand. If anyone knows of scenarios where the performance gain from multiple tasks outweighs the added complexity, please post a comment.

Anyways, returning from that slight detour, let’s see an overview of our topology. Click on the name under Topology Summary and scroll down to Worker Resources:

We can clearly see the division of our executors (threads) among the three workers. And of course all the three workers are on the same, single Supervisor node we’re running.

Now, let’s say scale out!

Add Another Supervisor

From the project root, let’s add another Supervisor node/container:

docker-compose scale storm-supervisor=2

SSH into the new container:

docker exec -it coincidenthashtagswithapachestorm_storm-supervisor_2 bash

And fire up: storm supervisor

If you refresh the UI you’ll see that we’ve successfully added another Supervisor and four more workers (total of eight workers/slots). To really take advantage of the new Supervisor, let’s increase the topology’s workers.

  • First kill the running one: storm kill exclamation-topology
  • Change this line to: conf.setNumWorkers(6)
  • Change the project version number in your pom.xml. Try using a proper scheme, like semantic versioning. I’ll just stick with 1.2.1.
  • Rebuild the topology: mvn package
  • Resubmit it: storm jar target/coincident-hashtags-1.2.1.jar coincident_hashtags.ExclamationTopology

Reload the UI:

You can now see the new Supervisor and the six busy workers out of a total of eight available ones.

Also important to note is that the six busy ones have been equally divided among the two Supervisors. Again, click the topology name and scroll down.

We see two unique Supervisor IDs, both running on different nodes, and all our executors pretty evenly divided among them. This is great.

But Storm comes with another nifty way of doing so while the topology is running — rebalancing.

On the Nimbus we’d run:

storm rebalance exclamation-topology -n 6

Or to change the number of executors for a particular component:

storm rebalance exclamation-topology -e even-digit-bolt=3

Reliable Message Processing

One question we haven’t tackled is about what happens if a bolt fails to process a tuple.

Storm provides us a mechanism by which the originating spout (specifically, the task) can replay the failed tuple. This processing guarantee doesn’t just happen by itself. It’s a conscious design choice, and does add latency.

Spouts send out tuples to bolts, which emit tuples derived from the input tuples to other bolts and so on. That one original tuple spurs an entire tree of tuples.

If any child tuple, so to speak, of the original one fails, then any remedial steps (rollbacks etc) may well have to be taken at multiple bolts. That could get pretty hairy, and so what Storm does is that it allows the original tuple to be emitted again right from the source (the spout).

Consequently, any operations performed by bolts that are a function of the incoming tuples should be idempotent.

A tuple is considered “fully processed” when every tuple in its tree has been processed, and every tuple has to be explicitly acknowledged by the bolts.

However, that’s not all. There’s another thing to be done explicitly: maintain a link between the original tuple and its child tuples. Storm will then be able to trace the origin of the child tuples and thus be able to replay the original tuple. This is called anchoring. And this has been done in our exclamation bolt:

// ExclamationBolt
// ‘tuple’ is the original one received from the test word spout.// It’s been anchored to/with the tuple going out._collector.emit(tuple, new Values(exclamatedWord.toString()));
// Explicitly acknowledge that the tuple has been processed._collector.ack(tuple);

The ack call will result in the ack method on the spout being called, if it has been implemented.

So, say you’re reading the tuple data from some queue and you can only take it off the queue if the tuple has been fully processed. The ack method is where you’d do that.

You can also emit out tuples without anchoring:

_collector.emit(new Values(exclamatedWord.toString())) 

and forgo reliability.

A tuple can fail two ways:

  1. A bolt dies and a tuple times out. Or, it times out for some other reason. The timeout is 30 seconds by default and can be changed using config.put(Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS, 60)
  2. The fail method is explicitly called on the tuple in a bolt: _collector.fail(tuple). You may do this in case of an exception.

In both these cases, the fail method on the spout will be called, if it is implemented. And if we want the tuple to be replayed, it would have to be done explicitly in the fail method by calling emit, just like in nextTuple(). When tracking tuples, every one has to be acked or failed. Otherwise, the topology will eventually run out of memory.

It’s also important to know that you have to do all of this yourself when writing custom spouts and bolts. But the Storm core can help. For example, a bolt implementing BaseBasicBolt does acking automatically. Or built-in spouts for popular data sources like Kafka take care of queuing and replay logic after acknowledgment and failure.

Parting Shots

Designing a Storm topology or cluster is always about tweaking the various knobs we have and settling where the result seems optimal.

There are a few things that’ll help in this process, like using a configuration file to read parallelism hints, number of workers, and so on so you don’t have to edit and recompile your code repeatedly.

Define your bolts logically, one per indivisible task, and keep them light and efficient. Similarly, your spouts’ nextTuple() methods should be optimized.

استخدم Storm UI بشكل فعال. بشكل افتراضي ، لا تظهر لنا الصورة الكاملة ، فقط 5٪ من إجمالي المجموعات المنبعثة. لمراقبة كل منهم ، استخدم config.setStatsSampleRate(1.0d).

راقب قيم Acks و Latency للمسامير والهيكل الفردي عبر واجهة المستخدم. هذا ما تريد النظر إليه عند ضبط المعلمات.