WebSocketit skaalautuvassa sovelluksessa

Buutilla konsulttimme joutuvat usein ratkomaan skaalautuvaan arkkitehtuuriin liittyviä haasteita. Nykypäivänä web-sovellusten yksi yleisistä vaatimuksista on skaalautua joustavasti suurelle määrälle käyttäjiä. Hyvin tavanomainen tapa toteuttaa tällainen skaalautuminen on niin sanottu horisontaalinen skaalautuminen, jossa yhden palvelimen rinnalle lisätään käyttäjämäärän kohotessa lisää identtisiä palvelininstansseja. Tässä blogitekstissä tutkitaan tosimaailman esimerkin kautta, miten tällaiseen järjestelmään toteutetaan WebSocket-yhteyden välityksellä käyttäjien välinen kommunikaatio: millaisia ongelmia kohdataan ja miten niitä ratkotaan?


Mistä on kyse WebSocketeissa?

WebSocketit ovat protokolla, joka mahdollistaa kaksisuuntaisen kommunikaation asiakasohjelmiston (client) sekä palvelimen välillä yhden pysyvän yhteyden kautta. Toisin kuin perinteinen HTTP-protokolla, jossa asiakas tekee pyynnön ja palvelin vastaa, WebSocket-yhteydessä asiakas ja palvelin voivat molemmat lähettää ja vastaanottaa dataa milloin tahansa ilman, että yhteys katkeaa. Tämä tekee WebSocketeista tehokkaan reaaliaikaiseen tiedonsiirtoon, jossa palvelimen täytyy lähettää tietoa asiakkaalle ilman erillisiä pyyntöjä.

WebSocketien hyötyinä ovat matalampi viive sekä viestikuorma kuin HTTP-protokollassa, sillä jatkuvien erillisten pyyntöjen sijaan yhteys pidetään jatkuvasti auki. Pysyvä yhteys säästää kaistaa ja resursseja verrattuna perinteisiin HTTP-pyyntöihin, joita pitää lähettää toistuvasti.

Myös HTTP-protokollalla on mahdollista toteuttaa WebSocketeja vastaava käytös niin sanotulla long polling -taktiikalla. Siinä lähetetään HTTP-pyyntö ja jätetään se pitkäksi aikaa odottamaan, kunnes palvelimella on dataa palautettavanaan. Tämä ratkaisu on kuitenkin WebSocketeja raskaampi, korkealatenssisempi sekä kaistankäytöltään epäoptimaalisempi.

HTTP- ja Websocket-protokollien asiakas-palvelin-kommunikaation vertailu.

 

Yleinen WebSocketien käyttötapaus on esimerkiksi reaaliaikaisen chatin toteuttaminen. Toteuttamassamme asiakasprojektissa WebSocketteja käytettiin juuri tähän tarkoitukseen. Projektissa haluttiin välttää HTTP-pollaamista, sillä chatin tapauksessa clientien jatkuvat HTTP-pyynnöt olisivat olleet WebSocketeja raskaampi ratkaisu sekä palvelimelle että tietokannalle. Viestien haluttiin myös välittyvän käyttäjältä mahdollisimman reaaliaikaisesti.

Kääntöpuolenaan WebSocketit ovat HTTP:tä monimutkaisempi protokolla, mikä tekee niiden toteutuksesta ja seurannasta monimutkaisempaa. Esimerkiksi epävakaissa verkoissa yhteys voi katketa, minkä huomioon ottaminen vaatii uudelleenyhdistämislogiikan toteutusta. WebSocketit vaativatkin yhteyden auki pitämistä koko istunnon ajan, mikä puolestaan syö palvelinresursseja, kuten muistia. Koska ne ovat HTTP-pyyntöjä uudempi teknologia, vanhemmat selaimet eivät myöskään välttämättä tue niitä. Lisäksi WebSocketteja on haastavampi skaalata kuin esimerkiksi REST-arkkitehtuurin mukaisia HTTP-pyyntöjä, sillä ne rikkovat perinteisen tilattoman arkkitehtuurin mallin.


Skaalautuva toteutus

Buutin työstämässä web-sovelluksessa WebSocket-pohjainen chat toteutettiin hyvin yleisesti hyödynnetyllä Socket.IO-kirjastolla. Sovelluksen frontend lähettää chat-viestit palvelimelle, joka tallentaa viestit tietokantaan ja lähettää puolestaan tiedon saapuneista viesteistä kaikille niille clienteille, joille kyseisten viestien tulee näkyä. Socket.IO:n tukemaa long polling -ominaisuutta käytettiin varavaihtoehtona vanhoille selaimille, jotka eivät tue WebSocketeja.

Yhden palvelimen tapauksessa WebSocket-pohjaisen chatin toteutus oli suoraviivainen. Enemmän suunnittelua vaati kuitenkin sovelluksen skaalaaminen horisontaalisesti siten, että se pystyi vastaamaan suuriin käyttäjämääriin lisäämällä joustavasti uusia palvelininstansseja alkuperäisen rinnalle. Sovellus toteutettiin serverless-mallisesti Google Cloud Run -palveluun, joka hallinnoi instanssien skaalaamista automatisoidusti kasvavan käyttäjäkuorman mukaan. Cloud Run huolehtii kokonaan palvelininfran pyörittämisestä, kun taas itse sovellus ajautuu Docker-konteissa.

Yksinkertaistettu havainnekuva toteutetusta horisontaalisesti skaalautuvasta sovelluksesta.

 

WebSocketien tapauksessa ongelmaksi muodostuu tällaisessa skaalautuvassa ympäristössä tiedon synkronoiminen palvelimien välillä. Kun clientit luovat WebSocket-yhteyksiä eri palvelimiin, nämä eri palvelimille päätyvät yhteydet eivät nimittäin jaa millään tapaa tietoa keskenään. Jos esimerkiksi client lähettää chat-viestin palvelimelle A, tieto saapuneesta viestistä välitetään WebSocketin kautta ainoastaan muille palvelimeen A yhdistäneille clienteille. Palvelimeen B yhdistäneet clientit eivät saa lainkaan tietoa palvelimeen A yhdistäneen clientin viestistä. 

HTTP-pollauksessa vastaavaa ongelmaa ei synny. Viestin lähettäminen etenee silloin kuten WebSocketejakin käytettäessä: palvelimeen A yhdistänyt client lähettää chat-viestin palvelimelle, ja palvelin päivittää viestin mukaiset tiedot tietokantaan. Palvelimeen B yhdistänyt client kuitenkin lähettää palvelimelle tasaisin väliajoin kyselyn, jolloin palvelin B palauttaa tälle clientille päivittyneen chat-keskustelun tietokannasta. 

WebSocketien tapauksessa palvelimella B ei sen sijaan ole mitään keinoa tietää, että palvelin A on päivittänyt tietokantaa. Siksi se ei osaa myöskään informoida siihen yhdistäneitä clienttejä.

Palvelinten välisen kommunikaation vertailu käytettäessä HTTP-pollausta ja WebSocketteja ilman erillistä synkronointia.

 

Palvelinsynkronointiongelman ratkaisemiseksi sovellukseen toteutettiin viestivälityskerros hyödyntäen Redistä ja sen Pub/Sub-mekanismia. Redisin Pub/Sub-ominaisuus mahdollistaa viestikanavien luomisen, joihin yhdistyy joukko julkaisijoita (publisher) sekä tilaajia (subscriber). 

Pub/Subin välityksellä sovelluksen palvelimet voivat synkronoida tietoa niille saapuvista chat-viesteistä keskenään. Jokainen sovelluksen palvelininstanssi liittyy Pub/Sub-kanavalle tilaajaksi ja pystyy toimimaan myös julkaisijana silloin, kun niille saapuu chat-viesti clientiltä. Koska Redisin Pub/Sub toimii hyvin matalin viivein, palvelimet pystyvät synkronoimaan viestit tehokaasti keskenään ja informoidaan WebSocket-yhteyksiensä kautta niihin kytkeytyneitä clienttejä uusista chat-viesteistä. Pub/Sub-mekanismi ei takaa viestien säilyvyyttä, mutta siitä pitää huolen sovelluksen tietokanta, johon saapuvat viestit tallennetaan.

Palvelimien ja clientien synkronointi Redisin Pub/Sub-mekanismin välityksellä.


WebSocket-ratkaisun testaaminen

WebSocket-ratkaisun luotettava testaaminen vaati kehitystiimiltä enemmän työtä ja pähkäilyä kuin pelkän HTTP-pohjaisen sovelluksen. Siinä missä Cypressillä toteutetut E2E-testit esimerkiksi soveltuivat hyvin perinteisten HTTP-pyyntöjen pohjalta toimivien ominaisuuksien testaamiseen, reaaliaikaisen viestinnän verifioiminen osoittautui vaikeammaksi tehtäväksi. Cypress ei nimittäin pysty käsittelemään yhtä aikaa kahta välilehteä tai selainikkunaa. Tästä syystä muun muassa chat-viestin vastaanottamista ei aluksi pystytty automatisoidusti varmentamaan toisella käyttäjällä sen lähettämisen jälkeen.

Ongelman selvittämiseksi backendiin toteutettiin erillinen WebSocket-rajapintapiste (endpoint) E2E-testejä varten. Tämä endpoint on sovelluksessa aktiivinen ainoastaan testejä ajettaessa, ja sille välitetyt viestit ohjataan eteenpäin chattiin kytkeytyneille clienteille. Cypres-testejä varten luotiin puolestaan oma kustomoitu komentonsa, joka lähettää simuloidun chat-viestin tähän endpointtiin. Näin E2E-testeissä pystyttiin kyseistä Cypress-komentoa ja WebSocket-endpointtia hyödyntäen lähettämään yhtä aikaa simuloituja käyttäjän A viestejä sekä tarkistamaan vastaavasti Cypressillä sovelluksen frontendistä, että viestit saapuivat perille käyttäjälle B.

WebSocket-pohjaisen chatin reaaliaikaisen kommunikaation testaaminen Cypressillä.

Lisäksi sovelluksen backendin rajapinnoille toteutettiin omat testinsä. Nämä testit liittyvät tilaajaksi Redisin Pub/Sub-kanavalle, lähettävät backendille chat-rajapintakutsun ja kuuntelevat, saapuuko vastaava chat-viesti Pub/Sub-kanavaan.

Sovelluksen jatkokehityksessä testaamisella on WebSocketien suhteen suuri painoarvo. CI/CD-pipelineen suunnitellaan otettavaksi käyttöön muun muassa Artillery-työkalua, jolla pystyttäisiin suorittamaan monimutkaisempaa kuormitustestausta testiympäristössä.

Samoin tarkoituksena on luoda ajastettuja testejä, jotka simuloivat todellisia käyttäjiä testiympäristössä ja tarkastelevat tasaisin väliajoin, toimivatko chat-ominaisuudet yhä halutulla tavalla ja raportoivatko ne virheistä. Näin voidaan verifioida ja debugata sovelluksen sekä WebSocket-kommunikaation pitkän tähtäimen epävakauksia.


Mitä muita haasteita kohdattiin?

Skaalautuvuuden ja testaamisen ohella WebSocket-ratkaisussa törmättiin myös muihin haasteisiin. Oman ongelmansa WebSockettien käyttöön lokaalissa ympäristössä tuotti create-react-app-projektityökalu, jonka pohjalle sovellus oli alun perin rakennettu. Lokaalisti sovellusta kehitettäessä create-react-app käynnistää clientin ja palvelimen eri portteihin. Tästä syystä lokaalisti kehitettäessä tulee aktivoida proxy-palvelin, joka uudelleenohjaa clientin kutsut palvelimelle sekä välttää samalla CORS-ongelmat.

Proxy-konfiguraatiota ei kuitenkaan saatu toimimaan Websocket Secure -protokollan kanssa. Oletuksena package.jsonin proxy-asetus koskee nimittäin vain HTTP- ja HTTPS-protokollia, eikä create-react-appin kehitysserveri mahdollista alla olevan Webpack Dev Serverin proxyn uudelleenkonfiguroimista. Ongelmaa ei saatu selvitettyä edes http-proxy-middleware-pakettia käyttämällä, ja seurauksena jouduttiin käyttämään epäkäytännöllisiä käsin konfiguroituja WebSocket-polkuja, jotka aktivoitiin vain sovellusta lokaalisti ajettaessa. Vasta migraatio create-react-appista Viteen ratkaisi ongelman täysin.

Sovellusta tuotannossa ajettaessa havaittiin puolestaan, että chat-ominaisuus lakkasi toimimasta kaikilla yksittäiseen palvelimeen kytkeytyneillä clienteillä aina sen jälkeen, kun palvelin oli ollut ajossa yhteensä noin puolitoista kuukautta. Syyksi epäiltiin jonkilaista WebSocketien tai Socket.IO:n yhteydessä tapahtuvaa resurssivuotoa, jonka juurisyytä ei ole toistaiseksi saatu selville. Ongelman kuitenkin havaittiin korjaantuvan palvelimien uudelleenkäynnistyksen yhteydessä. Myöhemmin palvelininstanssit asetettiin skaalautumaan ajastetusti ylös- ja alaspäin tiettyinä kellonaikoina sovelluksen yleisimpien käyttöaikojen mukaan, jolloin Google Cloud Run käynnistää myös ne uudelleen.

Erään haasteen WebSocketien suhteen tuottaa edelleen niiden monitorointi Google Cloud Runin Metrics-näkymässä. Se ei nimittäin erittele WebSocket- ja HTTP-kutsuja, vaan se laskee molemmat yleisiin request-lukemiin. Tämä aiheuttaa sen rajoituksen, että Request latency -näkymä näyttää aina maksimiarvoa eli palvelimelle asetettua request timeoutia, sillä WebSocket-yhteydet ovat jatkuvasti auki.

Blogiartikkelin ovat kirjoittaneet Buutin konsultti Ali Abdollahi sekä CTO Miikka Salmi.