В последнее время я занимался программированием на Go гораздо больше, в основном потому, что в нем есть потрясающие примитивы параллелизма, но также и потому, что в целом это довольно удивительный язык. В отличие от других языков, которые имеют потоки, волокна или управляемые событиями структуры для достижения хорошего параллелизма, Go удается избежать всего этого, но все еще остается читаемым. Вы также можете очень эффективно рассуждать о его поведении благодаря тому, насколько легко понятны и понятны такие понятия, как каналы и программы.
Но хватит о Go (на данный момент). Недавно я обнаружил необходимость быстро дублировать содержимое одной корзины Amazon S3 в другую. Это не было бы проблемой, если бы в ведре не было нескольких миллионов объектов. К счастью, есть два фактора, которые делают это не так страшно:
- S3 масштабируется лучше, чем когда-либо в вашем приложении, так что вы можете отправить столько запросов, сколько захотите.
- Вы можете очень легко скопировать объекты между группами с помощью запроса PUT в сочетании со специальным заголовком, указывающим объект, который вы хотите скопировать (вам не нужно физически получать, а затем помещать данные).
Идеальная работа для программы Go! Ключи объектов представлены в согласованном формате, поэтому мы можем разделить пространство клавиш по префиксам и распределить нагрузку между несколькими программами. Например, если ваши объекты имеют имена от 00000000 до 99999999 с использованием только числовых символов, вы можете довольно легко разделить это на 10 сегментов по 10 миллионов ключей. Используя метод GET, вы можете получить до 1000 ключей в пакете, используя префиксы. Даже если вы разделите на 10 миллионов ключевых сегментов и фактических объектов будет не так много, важно только то, что вы начинаете и заканчиваете в нужных местах (начало и конец сегмента) и продолжаете делать пакетные запросы до тех пор, пока иметь все ключи в этой части пространства ключей.
Так что теперь у нас есть механизм для быстрого извлечения всех ключей. Для миллионов объектов это все равно займет некоторое время, но вы разделили работу между несколькими программами, так что это будет намного быстрее. Для сравнения, Amazon Ruby SDK использует те же самые запросы REST под капотом при использовании итератора корзины bucket.each {| obj | …} Но только поочередно — разделение работы отсутствует.
Теперь, чтобы скопировать все наши объекты, нам просто нужно взять каждый ключ, возвращаемый пакетами GET, и отправить один запрос PUT для каждого. Это приводит к гораздо более медленному процессу — один запрос GET дает до 1000 ключей, но затем нам нужно выполнить 1000 PUT, чтобы их скопировать. Каждый PUT также занимает довольно много времени, поскольку бэкэнд S3 должен физически копировать данные между сегментами — для больших объектов это может все же занять некоторое время.
Давайте воспользуемся еще одним параллелизмом и создадим пул из 100 подпрограмм, ожидающих обработки пакета из 1000 только что полученных ключей. Недавнее обсуждение на golang орехи группы привели некоторые хорошие предложения от других членов сообщества Go и в результате этого кода:
https://gist.github.com/ohookins/5013420
Это не много кода, что заставляет меня думать, что это достаточно идиоматичный и правильный Go. Еще лучше, у него есть возможность масштабироваться до действительно огромного числа работников. Вы можете заметить, что каждый из сотрудников также использует один и тот же http.Client, и это преднамеренно — внутренне http.Client оптимизирует повторное использование соединения, так что вы не подвержены снижению производительности при создании сокетов и TCP-квитировании для каждого запрос. Как правило, это работает довольно хорошо.
Давайте подумаем о системных ограничениях сейчас. Скажем, мы хотим сделать наши операции копирования PUT очень быстрыми и использовать для этих операций 100 процедур. Имея всего 10 процедур загрузки, это означает, что теперь у нас есть 1000 программ, требующих внимания при обработке соединений http.Client. Даже если сборщики бездействуют, если у нас работают все копировщики одновременно, нам может потребоваться 1000 одновременных TCP-соединений. С пользовательским ограничением по умолчанию, равным 1024 дескрипторам открытых файлов (например, в Ubuntu 12.04), это означает, что мы опасно близки к превышению этого предела.
Head http://mybucket.s3.amazonaws.com:80/: lookup mybucket.s3.amazonaws.com: no such host
Когда вы видите ошибку, похожую на приведенную выше, в выходных данных вашей программы, кажется почти уверенным, что вы превысили эти пределы … и вы были бы правы! На данный момент … Первоначально это были ошибки, которые я получал, и хотя было несколько загадочно, что я видел их так много (буквально по одному на каждый неудачный запрос), очевидно, для поиска имен требуются некоторые дополнительные сокеты (даже если они локально кэшированы) , Я все еще ищу ссылку на это, поэтому, если вы знаете об этом, пожалуйста, дайте мне знать в комментариях.
В результате появился второй фрагмент кода Go для проверки моих пользовательских ограничений:
https://gist.github.com/ohookins/5030871
Использование syscall.Getrusage в сочетании с syscall.Getrlimit позволит вам достаточно динамически масштабировать вашу программу, чтобы использовать столько системных ресурсов, сколько у нее есть доступ, но не выходить за эти границы. Но помните, что я говорил об использовании http.Client раньше? В документации пакета net / http говорится, что клиенты должны использоваться повторно, а не создаваться по мере необходимости, и клиенты безопасны для одновременного использования несколькими программами, и оба они действительно точны. Неожиданным побочным эффектом этого является то, что, к сожалению, использование TCP-соединений в настоящее время довольно непрозрачно для нас. Таким образом, наше понимание текущего использования системных ресурсов принципиально отделено от того, как мы используем http.Client . Это станет важным через мгновение.
Таким образом, подняв свои пределы намного выше того, что я ожидал, в чем я на самом деле нуждался (так или иначе, это была единственная программа, запущенная на моем тестовом экземпляре EC2), я перезапустил программу и столкнулся с другой ошибкой:
Error: dial tcp 207.171.163.142:80: cannot assign requested address
Что … я думал, что имел дело с пользовательскими ограничениями? Первоначально я не нашел прямой причины этого, думая, что я должным образом не справился с проблемой ограничений пользователей. Я нашел несколько группы для обсуждения темы , касающейся http.Client повторного подключения, сокеты времени жизни и связанная с ними тему, и я впервые попробовал несколько различных версии Go, заподозрив это была ошибка исправлены в исходном наконечнике (более или менее аналогичен РУКОВОДИТЕЛЬ на origin / master в Git, если вы в основном используете этот VCVS). К сожалению, это не дало никаких решений и никаких дополнительных идей.
Я наблюдал за дескрипторами открытых файлов процесса во время выполнения и заметил, что он никогда не проходил через 150 одновременных соединений. Использование netstat, с другой стороны, показало, что в состоянии TIME_WAIT было значительное количество соединений . Это состояние сокета используется ядром, чтобы оставить след соединения в случае, если в сети ожидают повторяющиеся пакеты (среди прочего). В этом состоянии сокет фактически отсоединен от процесса, который его создал, но ожидает очистки ядра — поэтому он больше не считается дескриптором открытого файла, но это не значит, что он не может вызвать проблемы!
В этом случае я подключался к Amazon S3 с одного IP-адреса — единственного, настроенного на экземпляре EC2. Сам S3 имеет несколько IP-адресов как на восточном, так и на западном побережьях, автоматически вращающихся с помощью механизмов балансировки нагрузки на основе DNS. Однако в любой момент вы разрешите один IP-адрес и, возможно, будете использовать его в течение небольшого периода времени, прежде чем снова запрашивать DNS и, возможно, получить другой IP-адрес. Таким образом, мы можем сказать, что один IP связывается с другим IP — и в этом проблема.
Когда создается сетевой сокет IPv4, ядро использует пять основных элементов, чтобы сделать его уникальным среди всех остальных в системе:
protocol; local IPv4 address : local IPv4 port <-> remote IPv4 address : remote IPv4 port
Учитывая примерно 2 ^ 27 возможностей для локального IP (класс A, B, C), то же самое для удаленного IP и 2 ^ 16 для каждого из локальных и удаленных портов (при условии, что мы можем использовать любые привилегированные порты <1024, если мы используем root счет), что дает нам около 2 ^ 86 различных комбинаций чисел и, таким образом, количество теоретических сокетов IPv4 TCP, которые может отслеживать одна система. Это очень много! Теперь рассмотрим, что у нас есть один локальный IP-адрес в экземпляре, у нас (в течение небольшого промежутка времени) один удаленный IP-адрес для Amazon S3, и мы достигаем его только через порт 80 — теперь три наши переменные сокращены до единственная возможность, и мы можем использовать только диапазон локальных портов.
Хуже того, настройка по умолчанию (по крайней мере для моей машины) диапазона локального порта, доступного для пользователей без полномочий root, составляла только 32768-61000, что уменьшало мои доступные локальные порты до менее половины общего диапазона. После просмотра результатов netstat и grepping для сокетов TIME_WAIT стало очевидно, что я использовал эти нечетные 30000 локальных портов в течение нескольких секунд. Когда нет оставшихся номеров локальных портов, которые нужно использовать, ядро просто не может создать сетевой сокет для программы и возвращает ошибку, как в приведенном выше сообщении — не может назначить запрошенный адрес .
Вооружившись этим знанием, вы можете сделать несколько настроек ядра. Tcp_tw_reuse и tcp_tw_recycle оба связаны с настройками ядра, которые влияют на то, когда оно будет восстанавливать сокеты в состоянии TIME_WAIT, но практически это, похоже, не оказало большого эффекта. Другой параметр, tcp_max_tw_buckets, устанавливает ограничение на общее количество сокетов TIME_WAIT и активно убивает их быстро после того, как число превысит этот предел. Все эти три параметра выглядят и звучат немного опасно, и, несмотря на то, что они не оказали большого влияния, я не хотел их использовать и назвал проблему решенной. В конце концов, если программа убивала соединения и оставляла их для очистки ядра, это не звучало бы как http.Client делал очень хорошую работу по повторному использованию соединений автоматически.
Кстати, Go поддерживает автоматическое повторное использование соединений в TIME_WAIT с опцией сокета SO_REUSEADDR , но это относится только к прослушивающим сокетам (то есть серверам).
К сожалению, это привело меня к концу моего вдохновения, но сотрудник указал мне в направлении параметра MaxIdleConnsPerHost в http.Transportо котором я знал лишь смутно из-за того, что за последние пару дней я искал источник этого пакета, отчаянно пытаясь найти подсказки. Используемое здесь значение по умолчанию — два (2), что кажется разумным для большинства приложений, но, очевидно, ужасно, когда ваше приложение имеет большие пакеты запросов, а не постоянный поток. Я полагаю, что внутренне транспорт создает столько соединений, сколько требуется, запросы обрабатываются и закрываются, а затем все эти соединения (кроме двух) снова завершаются, оставаясь в состоянии TIME_WAIT для ядра, чтобы иметь дело с ним. Это нужно повторить всего за несколько циклов, прежде чем вы соберете десятки тысяч сокетов в этом состоянии.
Изменение значения MaxIdleConnsPerHost примерно до 250 немедленно устранило проблему, и я не видел никаких сокетов в состоянии TIME_WAIT, пока я наблюдал за программой. Вскоре после этого программа перестала функционировать, я полагаю, потому что мой экземпляр был помещен в черный список AWS для отправки слишком большого количества запросов в S3 за короткий период времени — масштабируемость достигнута!
Если в этом есть какие-то уроки, я полагаю, что вам все еще часто нужно знать о том, что происходит на самых низких уровнях системы, даже если ваш язык программирования или приложение абстрагировали достаточно деталей, чтобы вы не могли их иметь. беспокоиться о них. Даже знание того, что было ограничение на соединение в два раза, не дало бы всей картины действующих здесь сил. Go по-прежнему остается моим любимым языком на данный момент, и я был рад, что исправление было относительно простым, и у меня все еще есть очень понятная кодовая база с отличными характеристиками производительности. Однако всякий раз, когда задействованы сеть и удаленные службы с переменными характеристиками производительности, любая проблема может иметь большую сложность.