Обнаружение состояния гонки на примере обратного прокси

Условия гонки — это незаметные, но разрушительные дефекты программного обеспечения.

Chat GPT описывает это как:

дефект программного обеспечения, возникающий, когда корректность программы зависит от относительного времени или чередования нескольких параллельных операций.

Этот термин часто используется как мысленный ярлык для объяснения необъяснимого поведения программного обеспечения.

Они — огромная трата времени и бесконечный источник разочарования.

Люди и их однопоточный мозг обычно не в состоянии их обнаружить. Неважно, насколько хорошо, по вашему мнению, вы справляетесь с многозадачностью. Если вы играете с параллелизмом, вас обожгут условия гонки.

Что еще хуже, есть большая вероятность, что вы заметите проблему после того, как развернете изменения в рабочей среде.

Один из таких сценариев разыгрался в 80-х годах с Therac 25 — революционным двухрежимным аппаратом для лучевой терапии. Говоря простым языком, у него были режимы малой и большой мощности.

Квалифицированный техник может ввести команды для настройки машины в мгновение ока.

В один роковой день техник допустил ошибку, настроив машину на режим X (рентген), а не на режим e (электрон). Мастер заметил ошибку и быстро ее устранил.

Машина остановилась и высветила ошибку «Неисправность 54». Технический специалист интерпретировал ошибку как проблему с низким приоритетом и продолжил процесс.

Пациент сообщил, что слышал громкий жужжащий звук, сопровождаемый жжением, как будто кто-то вылил на его кожу горячий кофе.

Через пару дней пациент перенес паралич из-за переоблучения и вскоре умер. Шесть пациентов погибли из-за ошибки «Неисправность 54» в период с 1985 по 1987 год.

Позднее расследование показало, что причиной инцидента стало состояние гонки в программном обеспечении машины.

В поучительной истории о Therac 25 есть что рассказать. Для целей этой статьи мы сосредоточимся на тонком срезе, связанном с условиями гонки.

В следующем разделе мы наденем шляпу отладчика и исследуем условия гонки в гораздо более безопасной среде.

Установка сцены

Мы хотим реализовать обратный прокси-сервер HTTP, который будет перенаправлять запрос в соответствующую систему на основе некоторых условий.

Обратный прокси-сервер — это промежуточный сервер, который получает запросы от клиентов и направляет их на внутренние серверы. Обычно они используются для решения проблем производительности, безопасности и масштабируемости.

Тот, что в нашем примере, перенаправляет запросы либо в систему A, либо в систему B, в зависимости от пути исходного запроса. Исходный путь запроса должен быть сопоставлен с соответствующим восходящим путем.

Например:

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

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

Вариант этой установки обычно используется для:

  • прозрачная замена внутренностей системы (шаблон душителя)
  • затенение трафика
  • Агрегация API

Покажи мне код

Мы будем использовать GO ReverseProxy из пакета httputil из стандартной библиотеки для реализации прокси.

Обратный прокси предоставляет множество перехватчиков, которые позволяют клиентам изменять его поведение. Они поставляются с разумными значениями по умолчанию, поэтому клиентам не нужно реализовывать все вручную.

Нам нужно изменить входящий запрос и изменить путь на основе некоторых правил сопоставления.

Для этого мы реализуем нашу собственную функцию Director, которая определяется как:

Директор — это функция, которая изменяет запрос на новый запрос для отправки с использованием транспорта. Затем его ответ копируется обратно в исходный клиент без изменений.

Наша функция Director изменит входящий запрос, изменив URL-адрес на основе префикса пути URL-адреса. Измененный запрос должен быть нацелен на правильный URL-адрес подсистемы без изменения чего-либо еще.

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

Вот полная реализация прокси:

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
}

Тестирование

Мы можем реализовать тест, чтобы убедиться, что

  • учитывая запрос, URL-адрес должен быть изменен, чтобы соответствовать правильной подсистеме
  • учитывая запрос, метод HTTP не должен быть изменен

Мы реализуем прокси-сервер поверх реального прокси-сервера, чтобы помочь нам в этом.

Во-первых, отправка фактических HTTP-запросов для проверки вышеуказанного поведения не требуется. Мы настроим прокси-транспорт на использование noopRoundTripper, чтобы тесты не выполняли никаких сетевых вызовов.

Во-вторых, мы определим onOutgoing hook, который позволит тестовому коду проверять исходящий запрос.

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
}

Тест создаст экземпляр прокси-сервера приспособления, запустит тестовый запрос и проверит его URL-адрес, чтобы убедиться, что он был правильно изменен.

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

Все тесты прошли, как и ожидалось. Все идет нормально.

Наблюдение за проблемой

Теперь пришло время запустить наш прокси в продакшене.

Чтобы смоделировать рабочие условия, мы реализуем два простых HTTP-сервера для службы A и службы B и запускаем их с помощью Docker Compose.

Обе службы будут иметь одного прослушивателя HTTP, обрабатывающего маршрут, на который нацелен прокси.

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

Далее мы определим Dockerfiles и запустим все с помощью Docker Compose (подробности см. в репозитории GitHub).

Как только система будет запущена, мы можем отправить немного трафика, чтобы посмотреть, как ведет себя проксирование.

Отправка последовательных запросов работает как шарм.

По мере роста популярности нашего сервиса количество поступающих запросов достигает новых высот.

Чтобы смоделировать эти условия с высоким трафиком, мы будем использовать k6.

Сценарий k6 будет случайным образом отправлять HTTP-запросы либо на маршрут, нацеленный на службу A, либо на маршрут, нацеленный на службу 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
  });
}

Оба запроса ожидают, что статус ответа будет 200 OK и правильное ответное сообщение.

После запуска скрипта мы обнаружили, что почти 50% запросов не выполняются. Что дает?

Если мы позволим одновременным запросам выполняться в фоновом режиме и попробуем выполнить пару запросов вручную, мы увидим, что некоторые из наших запросов завершаются с ошибкой 404 Not Found.

Несмотря на то, что наши тесты на ожидаемое поведение прокси-сервера прошли успешно, наша система возвращает ошибку 404 в условиях высокой нагрузки.

Как правило, инженеры теряли рассудок из-за подобных проблем (как это случалось со мной слишком много раз).

Однако это запись в блоге об условиях гонки, поэтому вы, вероятно, догадываетесь, что происходит.

Инструмент обнаружения гонки спешит на помощь

В экосистеме Go есть множество инструментов, которые повышают производительность и помогают инженерам создавать надежное программное обеспечение.

Одним из таких инструментов является Детектор гонок. Как следует из названия, мы будем использовать этот инструмент, чтобы увидеть, есть ли в нашем коде какие-либо условия гонки.

Магия компилятора GO вводит код, который записывает доступ к памяти, в то время как библиотека времени выполнения следит за несинхронизированным доступом к общим переменным.

Согласно документам:

… Детектор гонок может обнаруживать условия гонок только тогда, когда они действительно вызваны работающим кодом

Давайте создадим тест с реалистичным тестовым сценарием, который может привести к возникновению состояния гонки.

Тест отправит 100 одновременных запросов с использованием ранее описанного прокси-фиксатора.

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

Тесты должны запускаться с флагом -race, чтобы включить детектор состояния гонки.

Бинго! Тесты не пройдены с 3 обнаруженными гонками данных. Давайте углубимся в проблему и выясним, что пошло не так.

Расшифровка вывода

Детектор гонки печатает трассировку стека, объясняющую состояние гонки. Вывод можно разделить на две части:

  1. Трассировки стека состояния гонки, указывающие на адрес памяти и строку, где это произошло (Что/Где)
  2. Происхождение горутин, участвующих в состоянии гонки (Who/How)

Что где?

Первая часть вывода сообщает инженерам, какая проблема возникла и где именно она произошла.

==================
# 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
 ...

Инструмент обнаружил одновременную запись в адрес памяти 0x00c0001ccbb0 в строке 47 реализации прокси.

Это строка внутри функции директора, которая копирует исходный фрагмент URL-адреса в URL-адрес прокси.

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

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

Кто как?

Вторая часть вывода сообщает инженерам, какие горутины были задействованы и как они появились:

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

Горутины были созданы тестовым кодом, никаких сюрпризов.

Эти горутины были бы созданы библиотекой HTTP, если бы приложение было развернуто, что привело бы к тому же сбою.

На серверах Go каждый входящий запрос обрабатывается собственной горутиной. ("источник")

Инструмент обнаружения гонки также можно использовать для проверки запущенных приложений, запустив службу с флагом -race.

Будьте осторожны, экспериментируя с этой функцией в рабочей среде, потому что

Стоимость обнаружения гонки зависит от программы, но для типичной программы использование памяти может увеличиться в 5–10 раз, а время выполнения — в 2–20 раз. ("источник")

Устранение проблемы

Теперь, когда мы вооружены пониманием гонки данных, давайте посмотрим, что мы сделали не так в функции директора.

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

Функция директора обновляет части URL-адреса прокси-запроса исходными данными запроса. Параллельная запись в поле структуры Fragment указывает на то, что несколько горутин имеют доступ к одному и тому же URL-адресу.

URL-адреса прокси создаются функцией url.Parse, которая возвращает указатель на URL-адрес, если предоставленная строка является допустимым URL-адресом.

> 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 анализируются только один раз при запуске при создании нового прокси. В результате каждый запрос использует один и тот же указатель URL и изменяет базовые данные.

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
}

Это небольшая проблема. Не нужно паниковать, потому что у нас есть (как минимум) два варианта исправить это:

  1. клонировать URL-адреса прокси
  2. избегайте изменения указателя URL

Давайте перейдем к первому варианту и посмотрим, что из этого получится.

Согласно обсуждению GitHub в официальном репозитории GO, можно безопасно клонировать URL-адрес, разыменовав указатель.

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

После внесения вышеуказанных изменений мы можем с гордостью сказать, что проблема решена! Наши тесты прошли, и мы можем с уверенностью развернуть наш сервис.

Выводы

Условия гонки — это неуловимые и опасные ошибки программирования, которые сохранялись на протяжении десятилетий.

Ущерб, который они могут нанести, варьируется от нулевого вреда в образовательных и игрушечных проектах до смерти в крайних случаях.

Мы показали, что даже служба с не более чем сотней строк кода может иметь проблемы.

Если вы создаете критически важную часть инфраструктуры для своей системы, не позволяйте простоте обмануть вас.

Задайте своей системе кривую, или, что еще лучше, тысячи кривых в секунду.

Хотя лабораторные тесты могут помочь вам выявить некоторые ошибки, не ожидайте, что они будут идеальными. Производство — это совершенно другой зверь, и вам лучше быть готовым к этому.

Когда дела пойдут плохо (а так и будет), убедитесь, что вы правильно настроили наблюдаемость. Слепая отладка — дурацкая затея.

Наконец, возьмите с собой в поездку несколько сверстников. Иногда свежий взгляд — это все, что вам нужно, чтобы заметить сложное состояние гонки. Это отличный опыт общения. Кроме того, с друзьями решать проблемы веселее.

Вкратце:

  • стресс-тест вашей системы
  • посмотрите на крайние случаи
  • использовать специализированные инструменты
  • пригласите сверстников, чтобы помочь вам
  • и не забудьте повеселиться, пока вы на нем. В конце концов, если вы не получаете удовольствия от процесса, в чем смысл?

Примеры кода вы можете найти на GitHub.

Анимированные записи терминала были созданы с помощью terminalizer.