Add week 7 materials
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user