Add week 7 materials

This commit is contained in:
2026-10-21 23:03:28 +03:00
parent 4bb38fb0a8
commit a987b82834
22 changed files with 1265 additions and 0 deletions
@@ -0,0 +1,56 @@
"""A small real HTTP service which registers its reachable address in Consul."""
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen
INSTANCE = os.environ["INSTANCE_ID"]
CONSUL = os.environ.get("CONSUL_URL", "http://consul:8500")
ADDRESS = socket.gethostbyname(socket.gethostname())
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/", "/health"):
self.send_error(404)
return
body = json.dumps({"instance": INSTANCE, "address": ADDRESS, "port": 8080}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_):
pass
def register():
payload = {
"ID": INSTANCE, "Name": "orders", "Address": ADDRESS, "Port": 8080,
"Check": {"HTTP": f"http://{ADDRESS}:8080/health", "Interval": "2s", "Timeout": "1s"},
}
# No automatic deregistration: the failed service stays in the catalog for comparison.
for attempt in range(30):
try:
request = Request(CONSUL + "/v1/agent/service/register",
data=json.dumps(payload).encode(), method="PUT",
headers={"Content-Type": "application/json"})
with urlopen(request, timeout=2) as response:
response.read()
print(f"REGISTERED orders / {INSTANCE} -> {ADDRESS}:8080", flush=True)
return
except OSError:
time.sleep(1)
raise RuntimeError("Consul registration failed after 30 attempts")
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
register()
thread.join()
@@ -0,0 +1,67 @@
"""Discover instances, save one address, and compare cached/fresh requests."""
import argparse
import json
from pathlib import Path
import sys
import time
from urllib.request import urlopen
BASE = "http://consul:8500"
CACHE = Path("/work/consul-address.json")
def get(url):
with urlopen(url, timeout=2) as response:
return json.load(response)
def discover():
rows = get(BASE + "/v1/health/service/orders?passing=true")
return sorted([{"id": r["Service"]["ID"], "address": r["Service"]["Address"],
"port": r["Service"]["Port"]} for r in rows], key=lambda r: r["id"])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["list", "save", "cached", "fresh", "wait"])
parser.add_argument("--ids", default="app-a,app-b", help="Expected passing IDs for wait")
args = parser.parse_args()
if args.action == "wait":
expected = sorted(args.ids.split(","))
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
try:
rows = discover()
if [r["id"] for r in rows] == expected:
print(json.dumps(rows, indent=2))
return
except OSError:
pass
time.sleep(0.5)
raise RuntimeError("Passing instances did not reach " + str(expected))
if args.action == "list":
print(json.dumps(discover(), indent=2))
return
if args.action == "cached":
endpoint = json.loads(CACHE.read_text())
else:
rows = discover()
if not rows:
raise RuntimeError("No passing instances")
endpoint = rows[0]
if args.action == "save":
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps(endpoint), encoding="utf-8")
print("SAVED", endpoint)
return
url = f"http://{endpoint['address']}:{endpoint['port']}/"
print(("CACHED" if args.action == "cached" else "FRESH"), endpoint["id"], url, flush=True)
print(json.dumps(get(url), indent=2))
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError) as error:
print("REQUEST FAILED:", error, file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,90 @@
# Consul: от имени до ответа приложения
Consul хранит сведения об экземплярах сервисов: их идентификаторы, адреса и результаты проверок работоспособности. Клиент может найти экземпляр по имени сервиса, а затем отправить запрос непосредственно ему.
В стенде у сервиса `orders` два экземпляра: `app-a` и `app-b`. **Предскажите:** если остановить один из них, изменятся ли каталог Consul, список здоровых экземпляров и сохранённый клиентом адрес одновременно?
Все команды выполняются из каталога `seminar`; [подготовка стенда](../readme.md#самостоятельный-запуск-опытов).
## 1. Найти экземпляры
Запустите сервисы и дождитесь, пока оба экземпляра пройдут проверки:
~~~powershell
docker compose up -d --wait consul app-a app-b
docker compose run --rm -T tools python3 consul/client.py wait
~~~
Сравните каталог регистраций и список здоровых экземпляров:
~~~powershell
docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/catalog/service/orders"
docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/health/service/orders?passing=true"
~~~
Каталог содержит зарегистрированные экземпляры независимо от их текущего состояния. Второй запрос с `passing=true` возвращает только экземпляры, прошедшие проверки. В ответе каталога найдите `ServiceName`, `ServiceID`, `ServiceAddress` и `ServicePort`: это имя сервиса, ID экземпляра, его адрес и порт.
Адреса экземпляров можно получить и через DNS-интерфейс Consul:
~~~powershell
docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul A +short
docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul SRV +short
~~~
Запись **A** содержит IPv4-адрес. Строка **SRV** имеет вид `приоритет вес порт имя-узла`, поэтому позволяет узнать и порт приложения — в нашем случае `8080`. Аргумент `-p 8600` задаёт порт самого DNS-сервера Consul.
**Вопрос:** откуда клиент узнает порт приложения, если запросил только A-запись?
## 2. Сохранить адрес и выполнить запрос
~~~powershell
docker compose run --rm -T tools python3 consul/client.py save
docker compose run --rm -T tools python3 consul/client.py cached
~~~
Команда `save` выбирает первый здоровый экземпляр по ID — `app-a` — и сохраняет его адрес. Команда `cached` обращается по сохранённому адресу. В HTTP-ответе поле `instance` показывает, кто обработал запрос.
Таким образом, клиент сначала получает адрес через HTTP API Consul, затем обращается к приложению. DNS-запросы выше иллюстрируют другой способ поиска адреса.
## 3. Остановить приложение и сравнить результаты
Остановите `app-a` и дождитесь, пока Consul обнаружит сбой:
~~~powershell
docker compose stop app-a
docker compose run --rm -T tools python3 consul/client.py wait --ids app-b
~~~
Теперь сравните каталог, список здоровых экземпляров и DNS-ответ:
~~~powershell
docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/catalog/service/orders"
docker compose run --rm -T tools python3 consul/client.py list
docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul A +short
~~~
В каталоге всё ещё есть `app-a`: остановка приложения сама по себе не удаляет регистрацию. Список здоровых экземпляров содержит только `app-b`, а DNS возвращает лишь его адрес.
Сравните запрос по сохранённому адресу с новым поиском:
~~~powershell
docker compose run --rm -T tools python3 consul/client.py cached
docker compose run --rm -T tools python3 consul/client.py fresh
~~~
`cached` завершается с `REQUEST FAILED` и кодом 1 — это ожидаемый результат. `fresh` заново ищет здоровый экземпляр и получает ответ от `app-b`.
Проверки выполняются периодически, поэтому Consul замечает остановку с задержкой. Но даже после обнаружения сбоя сохранённый клиентом адрес не меняется сам. В этом опыте клиент хранит его в файле без срока действия; автоматическое истечение записей рассмотрим в [разделе DNS](../dns/readme.md).
**Вопросы для самопроверки:** гарантирует ли свежий поиск успех следующего запроса? Зачем клиенту тайм-аут и возможность выбрать другой экземпляр? Почему повтор операции с побочным эффектом требует отдельной защиты от повторного выполнения?
## 4. Восстановить исходное состояние
~~~powershell
docker compose start app-a
docker compose run --rm -T tools python3 consul/client.py wait
~~~
В этом стенде один агент Consul и два приложения. Отказоустойчивость самого Consul здесь не проверяется.
Исходники: [приложение и регистрация](app.py), [клиент](client.py). Документация: [регистрация сервиса](https://developer.hashicorp.com/consul/api-docs/agent/service), [DNS-интерфейс](https://developer.hashicorp.com/consul/docs/discover/dns).