Deteksi kondisi balapan dengan contoh proksi terbalik

Kondisi balapan merupakan cacat perangkat lunak yang tidak kentara namun sangat merusak.

Obrolan GPT menggambarkannya sebagai:

cacat perangkat lunak yang terjadi ketika kebenaran suatu program bergantung pada waktu relatif atau interleaving dari beberapa operasi bersamaan.

Istilah ini sering digunakan sebagai jalan pintas mental untuk menjelaskan perilaku perangkat lunak yang tidak dapat dijelaskan.

Mereka adalah penyerap waktu yang sangat besar dan sumber frustrasi yang tiada habisnya.

Manusia dan otaknya yang berulir tunggal biasanya gagal mendeteksinya. Tidak peduli seberapa baik Anda dalam melakukan multitasking. Jika Anda bermain dengan konkurensi, Anda akan terpengaruh oleh kondisi balapan.

Yang lebih buruk lagi, ada kemungkinan besar Anda akan melihat masalahnya setelah Anda menerapkan perubahan pada produksi.

Salah satu skenario serupa terjadi pada tahun 80an dengan Therac 25 — mesin terapi radiasi mode ganda yang revolusioner. Dalam istilah awam, ia memiliki mode daya rendah dan daya tinggi.

Teknisi yang terampil dapat mengetikkan perintah untuk menyiapkan mesin dalam sekejap mata.

Suatu hari yang menentukan, teknisi membuat kesalahan saat menyiapkan mesin untuk mode X (x-ray) daripada mode e (elektron). Teknisi menyadari kesalahan tersebut dan segera memperbaikinya.

Mesin berhenti dan menampilkan kesalahan “Malfungsi 54”. Teknisi menafsirkan kesalahan tersebut sebagai masalah dengan prioritas rendah dan melanjutkan prosesnya.

Pasien melaporkan mendengar suara mendengung keras diikuti sensasi terbakar seolah-olah ada yang menuangkan kopi panas ke kulitnya.

Beberapa hari kemudian, pasien tersebut mengalami kelumpuhan akibat paparan radiasi yang berlebihan dan segera meninggal. Enam pasien kehilangan nyawa karena kesalahan “Malfungsi 54” antara tahun 1985 dan 1987.

Investigasi selanjutnya mengungkap bahwa kondisi balapan pada perangkat lunak mesin menyebabkan insiden tersebut.

Ada banyak hal yang perlu dibongkar dalam kisah peringatan Therac 25. Untuk keperluan artikel ini, kami akan fokus pada sedikit hal yang terkait dengan kondisi balapan.

Di bagian selanjutnya, kita akan melakukan debug dan menjelajahi kondisi balapan di lingkungan yang jauh lebih aman.

Menata panggung

Kami ingin menerapkan proxy terbalik HTTP yang akan meneruskan permintaan ke sistem yang sesuai berdasarkan beberapa kondisi.

Proksi terbalik adalah server perantara yang menerima permintaan dari klien dan mengarahkannya ke server backend. Mereka biasanya digunakan untuk mengatasi masalah kinerja, keamanan, dan skalabilitas.

Yang ada di contoh kita meneruskan permintaan ke sistem A atau B tergantung pada jalur permintaan awal. Jalur permintaan asli harus dipetakan ke jalur hulu yang sesuai.

Misalnya:

api.com/a/foo/bar -> system-a.com/v1/foo/bar

api.com/b/baz/14 -> system-b.com/baz/14

Variasi pengaturan ini biasanya digunakan untuk:

  • penggantian internal sistem secara transparan ("pola pencekik")
  • bayangan lalu lintas
  • Agregasi API

Tunjukkan padaku kodenya

Kami akan menggunakan GO ReverseProxy yang ditemukan dalam pakethttputil dari perpustakaan standar untuk mengimplementasikan proxy.

Proksi terbalik memperlihatkan berbagai kaitan yang memungkinkan klien mengubah perilakunya. Mereka hadir dengan default yang masuk akal sehingga klien tidak perlu mengimplementasikan semuanya secara manual.

Kita perlu mengubah permintaan masuk dan mengubah jalur berdasarkan beberapa aturan pemetaan.

Untuk mencapai hal ini, kami akan mengimplementasikan fungsi Director kami sendiri yang didefinisikan sebagai:

Director adalah fungsi yang mengubah permintaan menjadi permintaan baru untuk dikirim menggunakan Transport. Responsnya kemudian disalin kembali ke klien asli tanpa dimodifikasi.

Fungsi Director kami akan mengubah permintaan masuk dengan mengubah URL berdasarkan awalan jalur URL. Permintaan yang dimodifikasi harus menargetkan URL subsistem yang benar tanpa mengubah apa pun.

// director is a function that takes a pointer to http request and modifies it
director := func(req *http.Request) {
  // store original URL
  originalURL := req.URL

  if strings.HasPrefix(originalURL.Path, systemARoutePrefix) {
   req.URL = urlA
  } else if strings.HasPrefix(originalURL.Path, systemBRoutePrefix) {
   req.URL = urlB
  } else {
   return
  }

  // don't forget to take all the URL parts
  req.URL.Fragment = originalURL.Fragment
  req.URL.RawQuery = originalURL.RawQuery
  // map the original path based on some rules
  req.URL.Path = mapPath(originalURL.Path)
 }

Berikut implementasi proxy selengkapnya:

package main

import (
 "fmt"
 "net/http"
 "net/http/httputil"
 "net/url"
 "strings"
)

// URL mapping rules
var subsystemUrlPrefix map[string]string = map[string]string{
 // system A
 "/a/foo/bar": "/v1/foo/bar",
 // system B
 "/b/baz": "/baz",
}

const (
 systemARoutePrefix = "/a"
 systemBRoutePrefix = "/b"
)

// create a new proxy for system A and system B
func NewProxy(systemAURL, systemBURL string) (*httputil.ReverseProxy, error) {
 urlA, urlErr := url.Parse(systemAURL)
 if urlErr != nil {
  return nil, fmt.Errorf("cannot parse system A URL: %w", urlErr)
 }

 urlB, urlErr := url.Parse(systemBURL)
 if urlErr != nil {
  return nil, fmt.Errorf("cannot parse system B URL: %w", urlErr)
 }
 // set up a director function to modify incoming requests
 director := func(req *http.Request) {
  originalURL := req.URL

  if strings.HasPrefix(originalURL.Path, systemARoutePrefix) {
   req.URL = urlA
  } else if strings.HasPrefix(originalURL.Path, systemBRoutePrefix) {
   req.URL = urlB
  } else {
   return
  }

  req.URL.Fragment = originalURL.Fragment
  req.URL.RawQuery = originalURL.RawQuery
  req.URL.Path = mapPath(originalURL.Path)
 }

 return &httputil.ReverseProxy{Director: director}, nil
}

// map path based on the URL prefix
func mapPath(path string) string {
 for apiPrefix, subsystemPrefix := range subsystemUrlPrefix {
  if strings.HasPrefix(path, apiPrefix) {
   return strings.Replace(path, apiPrefix, subsystemPrefix, 1)
  }
 }

 return path
}

Pengujian

Kami dapat menerapkan tes untuk memverifikasi itu

  • mengingat permintaan tersebut, URL harus dimodifikasi agar sesuai dengan subsistem yang benar
  • mengingat permintaan tersebut, metode HTTP tidak boleh diubah

Kami akan menerapkan proxy perlengkapan di atas proxy perlengkapan sebenarnya untuk membantu kami dalam hal ini.

Pertama, mengirimkan permintaan HTTP sebenarnya untuk memverifikasi perilaku di atas tidak diperlukan. Kami akan mengatur transportasi proxy untuk menggunakan noopRoundTripper untuk memastikan pengujian tidak membuat panggilan jaringan apa pun.

Kedua, kita akan mendefinisikan onOutgoing hook yang memungkinkan kode pengujian memeriksa permintaan keluar.

func fixtureProxy(t *testing.T, onOutgoing func(r *http.Request)) *httputil.ReverseProxy {
 p, err := NewProxy(systemABaseUrl, systemBBaseURL)
 require.NoError(t, err)

 originalDirector := p.Director
 p.Director = func(outgoing *http.Request) {
  onOutgoing(outgoing)
  originalDirector(outgoing)
 }
 p.Transport = noopRoundTripper{onRoundTrip: successRoundTrip}
 return p
}

Pengujian akan membuat instance proxy perlengkapan, menjalankan permintaan pengujian, dan memeriksa URL-nya untuk memastikan bahwa URL telah dimodifikasi dengan benar.

func TestProxy(t *testing.T) {
 testCases := []struct {
  desc             string
  originalPath     string
  originalMethod   string
  expectedProxyURL string
 }{
  {
   desc:             "System A POST",
   originalPath:     "/a/foo/bar",
   originalMethod:   "POST",
   expectedProxyURL: fmt.Sprintf("%s/v1/foo/bar", systemABaseUrl),
  },
  {
   desc:             "System B POST",
   originalPath:     "/b/baz/14",
   originalMethod:   "POST",
   expectedProxyURL: fmt.Sprintf("%s/baz/14", systemBBaseURL),
  },
 }
 for _, tC := range testCases {
  t.Run(tC.desc, func(t *testing.T) {
   var proxiedRequest *http.Request
   p := fixtureProxy(t, func(r *http.Request) {
    proxiedRequest = r
   })

   writer := fixtureWriter()
   req := fixtureRequest(t, tC.originalPath, tC.originalMethod)
   p.ServeHTTP(writer, req)
   require.Equal(t, tC.expectedProxyURL, proxiedRequest.URL.String())
   require.Equal(t, tC.originalMethod, proxiedRequest.Method, "HTTP method should not be modified on proxy")
  })
 }
}

Semua tes lulus, seperti yang diharapkan. Sejauh ini bagus.

Mengamati masalahnya

Sekarang saatnya menjalankan proxy kita dalam produksi.

Untuk menyimulasikan kondisi produksi, kami akan menerapkan dua server HTTP sederhana untuk layanan A dan layanan B dan menjalankannya menggunakan Docker Compose.

Kedua layanan akan memiliki satu pendengar HTTP yang menangani rute target proksi.

package main

import (
 "fmt"
 "net/http"
)

func main() {
 // Return "Hello from service A" when any HTTP request reaches /v1/foo/bar URL
 http.HandleFunc("/v1/foo/bar", func(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Hello from service A")
 })
 
 // start the HTTP server running on port 9202
 if err := http.ListenAndServe(":9202", nil); err != nil {
  panic(err)
 }
}

Selanjutnya, kita akan mendefinisikan Dockerfiles dan menjalankan semuanya menggunakan Docker Compose (lihat repositori GitHub untuk detailnya)

Setelah sistem aktif dan berjalan, kami dapat mengirimkan beberapa lalu lintas untuk melihat bagaimana perilaku proxy.

Mengirim permintaan berurutan berfungsi seperti pesona.

Seiring dengan semakin populernya layanan kami, jumlah permintaan yang masuk mencapai tingkat yang baru.

Untuk mensimulasikan kondisi lalu lintas tinggi ini, kami akan menggunakan k6.

Skrip k6 akan secara acak mengirimkan permintaan HTTP ke layanan yang menargetkan rute A atau yang menargetkan Layanan B.

import http from 'k6/http';
import { check } from 'k6';

// Testing constants
const SERVICE_A_URL = 'http://localhost:8080/a/foo/bar'
const SERVICE_A_EXPECTED_RESPONSE = 'Hello from service A'
const SERVICE_A_METHOD = "POST"
const SERVICE_B_URL = 'http://localhost:8080/b/baz/14'
const SERVICE_B_EXPECTED_RESPONSE = 'Hello from service B'
const SERVICE_B_METHOD = "GET"

export default function() {
  // Randomly choose between two URLs
  const url = Math.random() > 0.5 ?  SERVICE_A_URL: SERVICE_B_URL;
  const expectedResponse = url === SERVICE_A_URL ? SERVICE_A_EXPECTED_RESPONSE : SERVICE_B_EXPECTED_RESPONSE
  const method = url === SERVICE_A_URL ? SERVICE_A_METHOD : SERVICE_B_METHOD

  // Make the GET request
  const res = http.request(method, url);

  // Check that the response was successful
  check(res, {
    'status is 200': (r) => r.status === 200,
    'OK response': (r)=> r.body === expectedResponse
  });
}

Kedua permintaan mengharapkan status respons menjadi 200 OK dan pesan respons yang benar.

Setelah menjalankan skrip kami menemukan bahwa hampir 50% permintaan gagal. Apa yang menyebabkannya?

Jika kita membiarkan permintaan serentak dijalankan di latar belakang dan kita mencoba beberapa permintaan manual, kita akan melihat bahwa beberapa permintaan kita gagal dengan 404 Not Found.

Meskipun lulus pengujian kami untuk perilaku proxy yang diharapkan, sistem kami mengembalikan 404 selama kondisi beban berat.

Biasanya, para insinyur akan kehilangan kewarasannya karena masalah seperti ini (seperti yang sering saya alami).

Namun, ini adalah postingan blog tentang kondisi balapan sehingga Anda mungkin punya firasat tentang apa yang terjadi.

Alat pendeteksi balapan untuk menyelamatkan

Ekosistem Go memiliki banyak alat yang meningkatkan produktivitas dan membantu para insinyur membangun perangkat lunak yang tangguh.

Salah satu alat tersebut adalah Go Race Detector. Seperti namanya, kami akan menggunakan alat tersebut untuk melihat apakah kode kami memiliki kondisi balapan.

Keajaiban kompiler GO menyuntikkan kode yang mencatat akses memori sementara perpustakaan runtime mengawasi akses yang tidak disinkronkan ke variabel bersama.

Menurut dokumen:

… Detektor balapan dapat mendeteksi kondisi balapan hanya jika kondisi tersebut benar-benar dipicu oleh kode yang sedang berjalan

Mari kita buat pengujian dengan skenario pengujian realistis yang mungkin menyebabkan kondisi balapan muncul ke permukaan.

Tes ini akan mengirimkan 100 permintaan bersamaan menggunakan proxy perlengkapan yang dijelaskan sebelumnya.

func TestProxy_ConcurrentRequests(t *testing.T) {
 // create a new fixture proxy
 p := fixtureProxy(t, func(r *http.Request) {})
 // define a new WaitGroup that enables testing code to wait for all 
 // goroutines to finish with their work
 wg := sync.WaitGroup{}

 for i := 0; i < 100; i++ {
  // increment the WaitGroup
  wg.Add(1)
  // start a new goroutine
  go func() {
   // don't forget to decrement the WaitGroup
   defer wg.Done()
   writer := fixtureWriter()
   req := fixtureRequest(t, "/a/foo/bar", "GET")
   // serve the test request with fixture proxy
   p.ServeHTTP(writer, req)
  }()
 }
 
 // wait until all goroutines are done
 wg.Wait()
}

Pengujian harus dijalankan dengan tanda -race untuk mengaktifkan pendeteksi kondisi balapan.

Bingo! Pengujian gagal dengan 3 data race terdeteksi. Mari kita perbesar masalahnya dan cari tahu apa yang salah.

Menguraikan keluaran

Detektor balapan mencetak jejak tumpukan yang menjelaskan kondisi balapan. Outputnya dapat dibagi menjadi dua bagian:

  1. Jejak tumpukan kondisi balapan yang menunjuk ke alamat memori dan baris di mana hal itu terjadi (Apa/Di Mana)
  2. Asal goroutine yang terlibat dalam kondisi balapan (Siapa/Bagaimana)

Apa/Dimana?

Bagian pertama dari keluaran memberi tahu para insinyur jenis masalah apa yang terjadi dan di mana tepatnya masalah itu terjadi.

==================
# What happened
WARNING: DATA RACE 
# Where it happened
Write at 0x00c0001ccbb0 by goroutine 14: 
  github.com/pavisalavisa/race-condition-detection.NewProxy.func1()
      /Users/pavisalavisa/repos/race-condition-detection/proxy.go:47 +0x174 #the problematic write by goroutine 14
  ...

Previous write at 0x00c0001ccbb0 by goroutine 13:
  github.com/pavisalavisa/race-condition-detection.NewProxy.func1()
      /Users/pavisalavisa/repos/race-condition-detection/proxy.go:47 +0x174 #the problematic write by goroutine 13
 ...

Alat ini menemukan penulisan bersamaan ke alamat memori 0x00c0001ccbb0 pada baris 47 implementasi proksi.

Ini adalah baris di dalam fungsi direktur yang menyalin fragmen URL asli ke URL proxy.

 director := func(req *http.Request) {
  // Rest of the code

  req.URL.Fragment = originalURL.Fragment // Line 47 DATA RACE
 
  // Rest of the code
 }

Siapa Bagaimana?

Bagian kedua dari keluaran ini memberi tahu para insinyur goroutine mana saja yang terlibat dan bagaimana goroutine tersebut menjadi hidup:

# Goroutine origins
Goroutine 14 (running) created at:
  github.com/pavisalavisa/race-condition-detection.TestProxy_ConcurrentRequests()
      /Users/pavisalavisa/repos/race-condition-detection/proxy_test.go:92 +0x64
...

Goroutine 13 (finished) created at:
  github.com/pavisalavisa/race-condition-detection.TestProxy_ConcurrentRequests()
      /Users/pavisalavisa/repos/race-condition-detection/proxy_test.go:92 +0x64
  ...

Goroutine dibuat berdasarkan kode pengujian, tidak ada kejutan di sana.

Goroutine ini akan dibuat oleh pustaka HTTP seandainya aplikasi diterapkan dan menyebabkan kegagalan yang sama.

Di server Go, setiap permintaan masuk ditangani dalam goroutinenya sendiri. ("sumber")

Alat pendeteksi balapan juga dapat digunakan untuk memeriksa aplikasi yang sedang berjalan dengan menjalankan layanan dengan tanda -race.

Berhati-hatilah saat bereksperimen dengan fitur ini dalam produksi karena

Biaya deteksi ras bervariasi menurut program, namun untuk program pada umumnya, penggunaan memori dapat meningkat sebesar 5–10x dan waktu eksekusi sebesar 2–20x. ("sumber")

Memperbaiki masalah

Sekarang kita sudah dipersenjatai dengan pemahaman perlombaan data, mari kita lihat kesalahan apa yang kita lakukan dalam fungsi direktur.

 director := func(req *http.Request) {
  originalURL := req.URL

  if strings.HasPrefix(originalURL.Path, systemARoutePrefix) {
   req.URL = urlA
  } else if strings.HasPrefix(originalURL.Path, systemBRoutePrefix) {
   req.URL = urlB
  } else {
   return
  }

  req.URL.Fragment = originalURL.Fragment
  req.URL.RawQuery = originalURL.RawQuery
  req.URL.Path = mapPath(originalURL.Path)
 }

Fungsi direktur memperbarui bagian URL permintaan proksi dengan data permintaan asli. Penulisan serentak pada kolom Fragment struct menunjukkan bahwa beberapa goroutine mempunyai akses ke URL yang sama.

URL proxy dibuat oleh fungsi url.Parse yang mengembalikan penunjuk ke URL ketika string yang diberikan adalah URL yang valid.

> go doc net/url URL.Parse

func (u *URL) Parse(ref string) (*URL, error)
    Parse parses a URL in the context of the receiver. The provided URL may be
    relative or absolute. Parse returns nil, err on parse failure, otherwise its
    return value is the same as ResolveReference.

URL ini hanya diurai satu kali saat permulaan saat membuat proksi baru. Akibatnya, setiap permintaan menggunakan penunjuk URL yang sama dan mengubah data yang mendasarinya.

func NewProxy(systemAURL, systemBURL string) (*httputil.ReverseProxy, error) {
 urlA, urlErr := url.Parse(systemAURL)
 if urlErr != nil {
  return nil, fmt.Errorf("cannot parse system A URL: %w", urlErr)
 }

  // Rest of the code
}

Itu adalah sedikit masalah. Tidak perlu panik karena kita punya (setidaknya) dua opsi untuk memperbaikinya:

  1. mengkloning URL proxy
  2. hindari memodifikasi penunjuk URL

Mari lanjutkan dengan opsi pertama dan lihat bagaimana kelanjutannya.

Menurut “diskusi GitHub” di repo resmi GO, aman untuk mengkloning URL dengan melakukan dereferensi penunjuk.

 director := func(req *http.Request) {
  // store the original URL
  originalURL := req.URL
  var proxyURL url.URL

  if strings.HasPrefix(originalURL.Path, systemARoutePrefix) {
   proxyURL = *urlA // dereference the parsed urlA to ensure we get a copy
  } else if strings.HasPrefix(originalURL.Path, systemBRoutePrefix) {
   proxyURL = *urlB // dereference the parsed urlB to ensure we get a copy
  } else {
   return
  }
  
  req.URL = &proxyURL
  req.URL.Fragment = originalURL.Fragment
  req.URL.RawQuery = originalURL.RawQuery
  req.URL.Path = mapPath(originalURL.Path)
 }

Setelah melakukan perubahan di atas, kini kami dapat dengan bangga mengatakan bahwa masalah tersebut telah teratasi! Pengujian kami telah lulus dan kami dapat menerapkan layanan kami dengan percaya diri.

Kesimpulan

Kondisi balapan merupakan kesalahan pemrograman yang sulit dipahami dan berbahaya yang terjadi selama beberapa dekade.

Kerusakan yang diakibatkannya bervariasi, mulai dari tidak menimbulkan kerugian pada proyek pendidikan dan mainan hingga kehilangan nyawa dalam kasus yang ekstrim.

Kami telah menunjukkan bahwa bahkan layanan dengan tidak lebih dari seratus baris kode dapat mengalami masalah.

Jika Anda sedang membangun infrastruktur penting untuk sistem Anda, jangan biarkan kesederhanaan menipu Anda.

Lemparkan bola melengkung ke sistem Anda, atau lebih baik lagi, ribuan bola melengkung per detik.

Meskipun tes kondisi laboratorium dapat membantu Anda menemukan beberapa bug, jangan berharap tes tersebut sempurna. Produksi adalah hal yang sangat berbeda dan Anda sebaiknya bersiap menghadapinya.

Ketika keadaan tidak berjalan baik (dan itu akan terjadi), pastikan Anda telah menyiapkan kemampuan observasi yang tepat. Debug buta adalah tugas orang bodoh.

Terakhir, ajaklah beberapa teman untuk ikut dalam perjalanan. Terkadang, hanya sepasang mata yang segar yang Anda butuhkan untuk melihat kondisi balapan yang sulit. Ini adalah pengalaman ikatan yang luar biasa. Ditambah lagi, menyelesaikan masalah bersama teman lebih menyenangkan.

Singkatnya:

  • uji stres sistem Anda
  • lihat kasus tepinya
  • menggunakan alat khusus
  • ajaklah rekan-rekan untuk membantu Anda
  • dan jangan lupa bersenang-senang saat melakukannya. Lagi pula, jika Anda tidak menikmati prosesnya, apa gunanya?

Anda dapat menemukan contoh kode di GitHub.

Rekaman terminal animasi dibuat dengan terminalizer.