Основы компьютерных сетей и веб-технологий

Разбираться в сетях физику приходится потому, что почти любые данные, необходимые для работы, сегодня получаются через сеть. Установка отдаёт телеметрию по TCP, каталог наблюдений находится за HTTP-интерфейсом, расчёт запускается на кластере по SSH, а результаты отправляются на сервер группы. Когда что-либо из этого перестаёт работать, место разрыва быстро находит тот, кто представляет путь от программы до провода.

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

Сетевые модели: OSI и TCP/IP

Сеть устроена слоями. Каждый слой решает свою узкую задачу и пользуется услугами слоя, лежащего ниже, ничего не зная о его внутреннем устройстве. Приложение формулирует задачу «отправить эти байты на такой-то адрес» и не учитывает ни маршрутов, ни того, медный там кабель или оптика.

Благодаря такому разделению Wi-Fi был изобретён без переписывания браузеров и протокола IP.

Классическая модель OSI, созданная как эталонная, насчитывает семь уровней:

№УровеньЧем занимаетсяЕдиница данных
7Прикладнойпротоколы приложений HTTP, DNS, FTPданные
6Представлениякодировки, сжатие, шифрованиеданные
5Сеансовыйуправление сессиями связиданные
4Транспортныйдоставка между процессами, TCP и UDPсегмент
3Сетевоймаршрутизация между сетями по IPпакет
2Канальныйпередача между соседними узламикадр
1Физическийбиты через провод или эфирбит

На практике же применяется более компактная модель TCP/IP из четырёх уровней. Физический и канальный в ней объединены в «уровень сетевого доступа», а три верхних уровня OSI слиты в один прикладной. Так устроен интернет; далее изложение ведётся по этой модели снизу вверх.

Уровень сетевого доступа

Самый нижний уровень отвечает на вопрос «как физически доставить биты соседнему узлу».

Физический уровень

Средой передачи здесь являются витая пара и коаксиальный кабель, оптоволокно (одномодовое для дальних линий, многомодовое для коротких), радио в виде Wi-Fi по стандарту 802.11, Bluetooth и сотовой связи. Уровень преобразует единицы и нули в сигнал и обратно.

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

Канальный уровень

Уровнем выше принятые биты собираются в кадры (frames) и передаются между устройствами, находящимися внутри одной локальной сети. Здесь работают Ethernet (стандарт IEEE 802.3) и Wi-Fi (IEEE 802.11), а для соединений «точка-точка» PPP.

У каждого сетевого интерфейса есть MAC-адрес — идентификатор, заданный производителем. Программа знает IP-адрес получателя, а отправить кадр необходимо на MAC-адрес; соответствие между ними выясняет протокол ARP, опрашивая локальную сеть. В институтских сетях встречается VLAN — логическое разделение одной физической сети на независимые сегменты, не видящие друг друга, чтобы, например, сеть управления установкой не пересекалась с офисной.

Просмотреть состояние этого уровня можно следующими командами.

ip link show          # список сетевых интерфейсов и их состояние
ethtool eth0          # скорость, режим дуплекса, статистика ошибок
arp -a                # таблица соответствия IP-адресов и MAC-адресов

Сетевой уровень

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

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

Адресация

IPv4 задаёт 32-битный адрес, записываемый четырьмя октетами, как 192.168.1.1. Всего таких адресов около четырёх миллиардов, и они давно исчерпаны, отсюда трансляция адресов в любой домашней или институтской сети.

Часть диапазонов зарезервирована под частные сети: их адреса не маршрутизируются в интернет и могут повторяться в разных организациях:

  • 10.0.0.0/8 для больших корпоративных сетей;
  • 172.16.0.0/12 для сетей средних размеров;
  • 192.168.0.0/16 для домашних роутеров.

Запись вида /24 после адреса называется префиксом и указывает, сколько старших битов адреса отведено под номер сети. Запись 192.168.1.0/24 описывает сеть из 256 адресов, различающихся только последним октетом.

IPv6 решает проблему нехватки адресов. Адрес занимает 128 бит и записывается восемью группами по четыре шестнадцатеричные цифры, как 2001:0db8:85a3:0000:0000:8a2e:0370:7334. Длинные цепочки нулей разрешено сокращать двойным двоеточием, и записанный выше адрес превращается в 2001:db8:85a3::8a2e:370:7334.

Маршрутизация

Маршрутизация отвечает на вопрос «куда отправить принятый пакет дальше». Каждый узел, включая обычный ноутбук, хранит таблицу маршрутов и для каждого пакета выбирает подходящую строку. Просмотреть таблицу можно следующим образом.

$ ip route show
default via 192.168.1.1 dev eth0
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100
10.0.0.0/8 via 192.168.1.254 dev eth0

Выбирается всегда самая конкретная из подходящих строк. Пакет в сеть 192.168.1.0/24 уходит непосредственно в интерфейс eth0, пакет в 10.0.0.0/8 идёт через шлюз 192.168.1.254, а всё, не подошедшее ни под одну строку, попадает в default. Когда сервер соседнего корпуса не отвечает, а до машины, стоящей в той же комнате, ping проходит, проверять нужно именно здесь: чаще всего до нужной сети отсутствует маршрут.

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

$ ip rule
0:    from all lookup local
32766: from all lookup main
32767: from all lookup default

Когда маршрут есть, а пакеты не доходят, требуется выяснить, где они теряются. ip route get показывает маршрут, выбираемый ядром для конкретного адреса; traceroute перечисляет узлы на пути пакета; mtr делает то же непрерывно и подсчитывает потери на каждом переходе — так проще всего показать сетевым администраторам, на каком узле происходит разрыв:

traceroute 8.8.8.8
mtr 8.8.8.8
ip route get 8.8.8.8

Транспортный уровень

IP доставляет пакет до машины, однако на ней работают десятки программ. Какой из них предназначен пакет, указывает номер порта — первая забота транспортного уровня. Вторая — надёжность доставки; здесь выбирают из двух протоколов в зависимости от того, что дороже: потеря данных или задержка.

UDP: быстро и без гарантий

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

Зато у UDP минимальные накладные расходы и задержка. Поэтому на нём работают DNS-запросы (проще переспросить, чем поддерживать соединение), голосовая связь, онлайн-игры и видеотрансляции. Там потерянный фрагмент уже не нужен: пока его запрашивают повторно, разговор уходит вперёд.

TCP: надёжно, но дороже

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

Соединение открывается тройным рукопожатием:

  1. клиент отправляет серверу SYN, означающий «хочу соединиться»;
  2. сервер отвечает SYN-ACK, означающим «согласен, и я тоже хочу»;
  3. клиент отвечает ACK, то есть «принято». Соединение, установленное так, готово к передаче.

Три пакета до первого байта полезных данных и составляют задержку, которой избегает UDP.

Гарантии TCP основаны на полях его заголовка.

| Порт отправителя      | Порт получателя       |
| Номер последовательности (Sequence Number)    |
| Номер подтверждения (Acknowledgment Number)   |
| Смещение | Резерв | Флаги | Размер окна      |
| Контрольная сумма     | Указатель срочности   |

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

Контроль перегрузок

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

  • Tahoe и Reno, классические алгоритмы, реагирующие на потерянный пакет резким снижением скорости;
  • CUBIC, установленный в Linux по умолчанию, восстанавливает потерянную скорость агрессивнее;
  • BBR, современный подход от Google, вместо потерь ориентируется на измеренную полосу и задержку, что заметно лучше работает на длинных линиях между городами.

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

Просмотреть состояние открытых соединений можно следующими командами.

ss -tn                      # открытые TCP-соединения
ss -tlnp                    # какие порты слушают и какие процессы
tcpdump -i any tcp port 80  # перехват пакетов на порту 80

Прикладной уровень

На прикладном уровне работают сами программы и их протоколы: DNS переводит имена в адреса, HTTP переносит данные, TLS их шифрует.

DNS: из имён в адреса

Компьютеры адресуют друг друга числами, а люди именами. Связывает их система доменных имён — распределённая база, возвращающая по имени example.com его IP-адрес.

Чаще всего встречаются четыре типа записей:

ТипЧто содержит
A / AAAAадрес IPv4 / IPv6 для имени
MXпочтовые серверы домена
CNAMEпсевдоним, отсылающий к другому имени
TXTпроизвольный текст; используется для проверки владения доменом

Из Python имя разрешается одной строкой.

import socket

ip_address = socket.gethostbyname("example.com")

DNS является самым частым источником жалоб «ничего не работает». Прежде чем искать ошибку в собственной программе, необходимо проверить командой dig example.com или nslookup example.com, разрешается ли имя вообще.

URL: адрес ресурса

Адрес, вводимый в браузер, устроен по единой схеме.

протокол://хост:порт/путь?запрос#якорь
   https :// api.example.com : 443 /v1/data ?limit=10 #results

Каждая часть предназначена своему уровню: протокол определяет способ взаимодействия, хост разрешается через DNS в IP-адрес, порт указывает транспортному уровню программу, слушающую на сервере, путь и параметры запроса адресуют конкретный ресурс, а якорь не отправляется на сервер и нужен только браузеру, чтобы прокрутить страницу к нужному месту.

Схем существует много, и не все они относятся к вебу: https://api.example.com/v1/data, ftp://ftp.example.com/files, mailto:user@example.com.

HTTP: обмен с сервером

На HTTP основано почти всё сетевое взаимодействие программ. Клиент отправляет текстовый запрос, сервер отвечает текстовым ответом. Состояния между запросами протокол не хранит, каждый запрос самодостаточен.

HTTP-запрос

Запрос представляет собой обычный текст. Первая строка указывает, что и откуда требуется получить, далее следуют заголовки, пары «имя: значение»:

GET /index.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
Connection: keep-alive

Первое слово запроса — метод, он сообщает серверу, что требуется сделать с ресурсом:

МетодДействие
GETполучить ресурс; ничего не меняет на сервере
POSTотправить данные, создать новый ресурс
PUTзаменить ресурс целиком
PATCHизменить часть ресурса
DELETEудалить ресурс

GET считается безопасным и повторяемым, поэтому результат разрешено кешировать, а запрос повторять при обрыве связи. С POST так поступать нельзя: повторённый запрос создаст вторую запись.

Ответ сервера

Ответ устроен так же и содержит статусную строку, заголовки и тело:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
Server: nginx
Date: Mon, 21 Nov 2022 00:18:53 GMT

Самым информативным в ответе является трёхзначный код в первой строке. Достаточно запомнить значение первой цифры:

КодСмыслТипичные представители
1xxинформационные100 Continue
2xxвсё получилось200 OK, 201 Created
3xxперенаправление или ответ из кеша301 Moved Permanently, 304 Not Modified
4xxошибся клиент404 Not Found, 401 Unauthorized, 429 Too Many Requests
5xxошибся сервер500 Internal Server Error, 503 Service Unavailable

При 4xx повторять запрос бессмысленно, пока клиент не исправит его сам; при 5xx имеет смысл подождать и повторить попытку.

Работа с HTTP в Python

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

Использование сокетов

Открывается сокет, в него записываются две строки запроса, читается ответ.

import socket

# TCP-соединение
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect(('example.com', 80))
    request = b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'
    sock.sendall(request)
    response = sock.recv(4096)

Далее требуется найти конец заголовков, разобрать кодировку, дочитать тело по Content-Length, обработать редирект. Поэтому применяют готовое решение.

Библиотека requests

requests берёт на себя соединения, редиректы, кодировки и разбор JSON.

import requests

response = requests.get('https://api.example.com/data')
print(response.status_code)
print(response.headers)
print(response.json())

В коде, работающем ночью без присмотра, добавляют timeout= к каждому запросу — без него зависший сервер остановит сбор данных навсегда — и повтор с нарастающей паузой при ошибках 5xx, чтобы временный отказ сервера не стоил ночи работы.

TLS: буква S в HTTPS

Обычный HTTP передаёт всё открытым текстом, включая пароли и токены; любой узел на пути видит содержимое. TLS (прежде SSL) добавляет к соединению шифрование и проверку подлинности сервера по сертификату: браузер убеждается, что взаимодействует с тем сайтом, за который тот себя выдаёт.

В Python шифрование добавляется обёрткой поверх обычного сокета.

import ssl
import socket

context = ssl.create_default_context()
with socket.create_connection(('example.com', 443)) as sock:
    with context.wrap_socket(sock, server_hostname='example.com') as ssock:
        ssock.sendall(b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n')
        response = ssock.recv(4096)

Веб-скрапинг и парсинг HTML

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

Библиотека BeautifulSoup

Разбирать HTML регулярными выражениями бесполезно: разметка вложенная и часто некорректная. BeautifulSoup строит из страницы дерево и ищет в нём по тегам и атрибутам:

from bs4 import BeautifulSoup
import requests

html = requests.get('http://example.com').text
soup = BeautifulSoup(html, 'html.parser')

# Извлечение данных
title = soup.find('title').text
links = soup.find_all('a', href=True)
for link in links:
    print(link['href'], link.text)

Корректный скрапинг

Скрапингом легко навредить и себе, и чужому серверу. Правила следующие:

  1. Необходимо ознакомиться с robots.txt, файлом в корне сайта, где владелец указывает, что разрешено обходить роботам, а что нет. Технически он ничего не запрещает — это договорённость, но нарушать её не следует.
  2. Между запросами необходимы паузы. Сотня запросов в секунду с одного адреса неотличима от атаки и приводит к блокировке.
  3. Программа должна представляться в заголовке User-Agent. Следует указать, что это за программа и как связаться с её автором: администратору, читающему логи, это упрощает работу, а автору снижает вероятность блокировки.
  4. Ошибки должны обрабатываться. Сеть ненадёжна, и код должен переживать таймаут, 503 и изменившуюся вёрстку, а не завершаться посреди ночного сбора данных.
  5. Скачанное кешируется. Повторный запуск разбора не должен снова загружать те же страницы: так и быстрее, и корректнее по отношению к серверу.

Если у сайта есть API, лучше пользоваться им, а не разбором HTML: вёрстка меняется без предупреждения, объявленный интерфейс гораздо реже.

Работа с API

Если у источника есть API, взаимодействие ведётся на языке данных: запрос с параметрами, ответ в JSON, оговорённый набор полей вместо вёрстки, переделываемой при каждой смене дизайна. Как сервисы договариваются между собой и что в таком договоре нарушается, когда сервисов становится много, подробно рассмотрено у Клеппмана [16].

Публичные API

Устроены такие интерфейсы одинаково независимо от того, что они отдают, курсы валют, переводы слов или каталог наблюдений: адрес сервиса, словарь параметров и ключ доступа. Пример — словарь Яндекса:

import requests
import json

# Пример работы с API словаря
DICTIONARY_API = 'https://dictionary.yandex.net/api/v1/dicservice.json/lookup'
params = {
    'key': 'your_api_key',
    'lang': 'en-ru',
    'text': 'hello'
}

response = requests.get(DICTIONARY_API, params=params)
data = response.json()
translations = data['def'][0]['tr']

Ключ доступа является личным паролем пользователя к сервису, и в коде ему не место: его следует хранить в переменной окружения или в конфигурации вне репозитория. Вторая забота — квота на число запросов. Превысивший её клиент получает 429 Too Many Requests, а при упорстве и блокировку по адресу, поэтому цикл по десяти тысячам записей необходимо замедлять паузой.

OAuth авторизация

Когда речь идёт о личных данных пользователя, простого ключа недостаточно. OAuth описывает схему, при которой пользователь не передаёт программе свой пароль. Вместо него сервис выдаёт ей токен, ограниченный по правам и по времени и отзываемый в любой момент. Токен прикладывается к каждому запросу в заголовке Authorization:

class ApiClient:
    def __init__(self, token):
        self.session = requests.Session()
        self.session.headers['Authorization'] = f'Bearer {token}'
    
    def get_user_info(self):
        response = self.session.get('https://api.example.com/user')
        return response.json()

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

JSON формат

JavaScript Object Notation — текстовый формат обмена данными, почти взаимно однозначно соответствующий словарям и спискам Python.

import json

# Сериализация
data = {'name': 'John', 'age': 30}
json_string = json.dumps(data)

# Десериализация
parsed_data = json.loads(json_string)

В стандарте JSON нет ни NaN, ни бесконечностей. json.dumps запишет их как NaN и Infinity, однако это уже не JSON, и чужой разборщик на такой строке завершится ошибкой. Пропуски в измерениях надёжнее кодировать явным null.

Асинхронные HTTP-запросы

Скачать тысячу файлов из архива по одному означает тысячу раз дождаться ответа сервера. Почти всё время такой программы уходит на ожидание сети: процессор простаивает, канал загружен на проценты. Асинхронный код держит сотню запросов в обработке одновременно и разбирает ответы по мере поступления.

Библиотека httpx

httpx повторяет интерфейс requests, но поддерживает оба режима, синхронный и асинхронный, так что при переходе код почти не переписывается:

import asyncio

import httpx

# Синхронные запросы
with httpx.Client() as client:
    response = client.get('https://example.com')
    print(response.status_code)

# Асинхронные запросы
async def fetch_data():
    async with httpx.AsyncClient() as client:
        response = await client.get('https://example.com')
        return response.json()

print(asyncio.run(fetch_data()))

Устройство async и await рассматривается в главе про асинхронность. Сто одновременных запросов ускоряют сбор данных в десятки раз, тысяча выглядит для чужого сервера как атака. Параллелизм необходимо ограничивать явно, семафором на десяток-другой запросов.

Резюме

Таким образом, запоминать следует не столько детали протоколов, сколько идею слоёв: каждый уровень пользуется услугами нижнего и ничего не знает о его устройстве. Поэтому один и тот же код на requests одинаково работает и по Wi-Fi, и по оптике.

Когда что-либо не работает, уровни проверяются в порядке цены проверки, а не строго снизу вверх: сначала самое дешёвое и самое частое. Разрешение имени стоит в таблице рано по этой причине: оно проверяется одной командой, а нарушается чаще прочего, хотя уровень у него прикладной.

Что проверяемУровеньЧем смотреть
Наличие линкафизический, канальныйip link, ethtool
Разрешение имениприкладной (DNS)dig, nslookup
Прохождение пакетовсетевойping, traceroute, mtr
Открытый порт и слушающий его процесстранспортныйss -tlnp
Содержимое ответа сервераприкладнойcurl -v, tcpdump

Бессмысленно отлаживать код запроса, если имя сервера не разрешается, и проверять DNS, если не поднят сетевой интерфейс.

Из инструментов Python достаточно двух: requests для обычных запросов и httpx там, где требуется асинхронность. Сокеты необходимы для понимания того, что происходит под библиотеками.

Задание. Поставить перед приложением nginx и выяснить, почему запрос не доходит: «Деплой стартапа „Котики в мир“».