foundations · Stage 1

ネットワーク遅延と部分失敗を層別に診断する

DNS、TCP、TLS、HTTPの時系列を分け、期限内に失敗を扱う予算を設計する。

学習時間
270分
難易度
foundation
更新日
2026-07-30
到達証拠
成果物・説明・判断根拠・転用

到達目標

  1. DNS問い合わせからHTTP応答完了までを少なくとも六イベントの時系列として図示できる

    • DNSからHTTPまでの時刻、期限、再試行条件、未観測区間を含むトレース
    • 輸送成功とアプリケーション成功を区別する5分説明
  2. 利用者期限から接続、TLS、応答、再試行へ時間を配分し、最悪時の合計を計算できる

    • DNSからHTTPまでの時刻、期限、再試行条件、未観測区間を含むトレース
    • 損失、名前解決、接続、依存遅延の仮説を反証する診断
  3. パケット損失と依存サービス遅延の仮説を層別の時刻と再送観測で反証し、未知経路へ診断を移せる

    • 損失、名前解決、接続、依存遅延の仮説を反証する診断
    • 未知の多段依存で損失と依存遅延を切り分けた調査計画

能力の進行

  1. recognize

    DNS、TCP、TLS、HTTPの開始・完了イベントと各層の失敗を時系列上で識別できる

    証拠: DNSからHTTPまでの時刻、期限、再試行条件、未観測区間を含むトレース

  2. explain

    TCP到達、TLS認証、HTTP応答、業務処理成功が別の契約である理由を説明できる

    証拠: 輸送成功とアプリケーション成功を区別する5分説明

  3. apply

    総期限から各段の予算と再試行回数を逆算し、合計が期限を超えないトレースを作成できる

    証拠: DNSからHTTPまでの時刻、期限、再試行条件、未観測区間を含むトレース

  4. diagnose

    DNS時間、接続時間、再送、server timingを比較し、損失と依存遅延を反証できる

    証拠: 損失、名前解決、接続、依存遅延の仮説を反証する診断

  5. lead

    冪等性、deadline伝播、retry stormを含む障害レビューを未知の依存グラフで主導できる

    証拠: 未知の多段依存で損失と依存遅延を切り分けた調査計画

なぜ重要か

「APIが遅い」という一つの観測には、名前解決、接続、暗号handshake、request送信、server処理、response転送という異なる区間が含まれる。総時間だけを見てnetwork担当またはserver担当へ責任を移すと、原因を反証できず、timeoutとretryで負荷を増幅し得る。

networkでは、相手が処理して応答だけ失われた状態と、相手へ届かなかった状態をclientから完全には区別できないことがある。特に状態を変えるPOSTを無条件にretryすると二重処理を生む。deadline、冪等key、結果照会を一つの契約として設計する必要がある。

メンタルモデル

一要求を層ではなくイベント列として観測する。各イベントに単調時計の開始・完了、残りdeadline、request IDを付ける。層の境界を越えるたびに「何が完了したと言えるか」を更新する。

timeoutは各層へ同じ値を配るのではなく、利用者の総deadlineから逆算する。再試行を許すなら、一回目のtimeout、backoff、二回目、後処理の合計が総deadline以下でなければならない。下流にも残りdeadlineを伝えないと、clientが諦めた後もserverが仕事を続ける。

DNSから業務結果までの時系列

説明用の総deadline 300msで、どのeventが最初の超過点となり、残りbudgetはどう変化したか。

  1. 接続準備 budget 120ms

    説明用の総deadline 300msのうちDNS 30ms、TCP 40ms、TLS 50msを割り当てる。observed合計105ms。普遍値ではない。

    1. 名前解決(説明用budget)

      DNS queryを送り、候補addressとTTLを得る。budget 30ms、observed 25ms、累積25ms・残り275ms。

      順序: 0

    2. 輸送接続(説明用budget)

      TCP SYN、SYN-ACK、ACKで接続状態を確立する。budget 40ms、observed 35ms、累積60ms・残り240ms。

      順序: 1

    3. 暗号接続(説明用budget)

      TLSでversion、鍵、相手のidentityを検証する。budget 50ms、observed 45ms、累積105ms・残り195ms。

      順序: 2

  2. HTTP交換 budget 160ms

    request 10ms、server 100ms、first-byte 20ms、body 30ms。first-byte observedで総deadlineを初めて超える。

    1. request送信(説明用budget)

      HTTP method、target、header、bodyを送る。budget 10ms、observed 10ms、累積115ms・残り185ms。

      順序: 3

    2. server処理(説明用budget)

      handlerと依存serviceが状態を読み書きする。budget 100ms、observed 120ms、累積235ms・残り65ms。

      順序: 4

    3. 最初のbyte(説明用budget)

      statusとheaderを受け始める。budget 20ms、observed 75ms、累積310ms。ここが最初のdeadline超過点(10ms超過)。

      順序: 5

    4. 本文完了(説明用budget)

      responseを読み終える。ただし業務結果はstatusとbodyの契約で判定する。budget 30ms、observed 25ms、累積335ms・残り-35ms。

      順序: 6

  3. 業務結果 budget 20ms

    transport完了後の結果永続化へ20msを割り当てる。数値はtraceの読み方を示す説明用。

    1. 結果確定(説明用budget)

      clientが結果を永続化し、必要なら冪等keyで後から照会できる。budget 20ms、observed 10ms、累積345ms・残り-45ms。

      順序: 7

各eventのbudgetと累積observedを追い、first-byteの310msを最初の超過点として説明できる。これは普遍的なlatency値ではない。

動く例で考える

2000msの利用者期限へ一回のretryを収める

前提
総deadlineは2000ms。UI更新と結果保存に200ms、retry間のjitter付きbackoffに100msを確保する。状態変更操作は冪等key対応済み。
入力
一要求についてDNS、connect、TLS、time to first byte、body完了を単調時計で記録する。
操作
一回目へ800ms、backoffへ100ms、二回目へ700ms、後処理へ200ms、cancel伝播へ100msを配り、残り100ms未満では新しい試行を開始しない。
観測
localhost simulatorでcold/warmを各7回測り、制御したDNS/TLS delayを含む単調時刻を保存する。最悪時は800 + 100 + 700 + 200 + 100 = 1900msで期限内に収まる。
結論
各段へ2000msを設定すると、二試行で4000ms以上になり得る。試行ごとに残りdeadlineを再計算し、未開始区間を明示する。

次のtrace-fixture.csv相当のlocalhost simulatorは、外部network、秘密情報、curlを必要としない。DNS cacheとTLS handshakeは制御可能な待ち時間として模擬し、実protocol測定ではないことを出力へ明記する。

python3.13 - <<'PY'
import json
import re
import socket
import statistics
import threading
import time

HARNESS = "network_lab_v2"
MAX_LINE_BYTES = 256
SOCKET_DEADLINE_SECONDS = 0.25
MAX_REQUEST_ID_BYTES = 64
MAX_DELAY_MS = 1000
REQUEST_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")

class ProtocolError(Exception):
    def __init__(self, code):
        super().__init__(code)
        self.code = code

def receive_line(
    connection,
    timeline=None,
    deadline_seconds=SOCKET_DEADLINE_SECONDS,
):
    connection.settimeout(deadline_seconds)
    payload = bytearray()
    while True:
        try:
            part = connection.recv(min(64, MAX_LINE_BYTES + 1))
        except TimeoutError as error:
            raise ProtocolError("receive_timeout") from error
        if not part:
            raise ProtocolError("newline_required")
        newline = part.find(b"\n")
        fragment = part if newline == -1 else part[:newline]
        payload.extend(fragment)
        if len(payload) > MAX_LINE_BYTES:
            raise ProtocolError("line_too_long")
        if timeline is not None and "first_byte" not in timeline:
            timeline["first_byte"] = time.monotonic_ns()
        if newline != -1:
            if part[newline + 1:]:
                raise ProtocolError("multiple_lines")
            break
    if timeline is not None:
        timeline["body_finish"] = time.monotonic_ns()
    try:
        return payload.decode("ascii")
    except UnicodeDecodeError as error:
        raise ProtocolError("non_ascii_line") from error

def validate_request_id(request_id):
    if (
        len(request_id.encode("ascii", errors="ignore"))
        > MAX_REQUEST_ID_BYTES
        or REQUEST_ID_PATTERN.fullmatch(request_id) is None
    ):
        raise ProtocolError("invalid_request_id")
    return request_id

def parse_command(line):
    fields = line.split()
    if not fields:
        raise ProtocolError("empty_command")
    command = fields[0]
    if command == "APPLY":
        if len(fields) != 4:
            raise ProtocolError("invalid_arity")
        request_id = validate_request_id(fields[1])
        if not fields[2].isascii() or not fields[2].isdecimal():
            raise ProtocolError("invalid_delay_ms")
        delay_ms = int(fields[2])
        if not 0 <= delay_ms <= MAX_DELAY_MS:
            raise ProtocolError("invalid_delay_ms")
        if fields[3] not in {"reply", "drop"}:
            raise ProtocolError("invalid_mode")
        return command, request_id, delay_ms, fields[3]
    if command == "LOOKUP":
        if len(fields) != 2:
            raise ProtocolError("invalid_arity")
        return command, validate_request_id(fields[1])
    if command == "STOP":
        if len(fields) != 1:
            raise ProtocolError("invalid_arity")
        return (command,)
    raise ProtocolError("unknown_command")

class LocalFixture:
    def __init__(self, injected_failure_id):
        self.results = {}
        self.injected_failure_id = injected_failure_id
        self.injected_exceptions = []
        self.stopped = threading.Event()
        self.terminated = threading.Event()
        self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            self.listener.setsockopt(
                socket.SOL_SOCKET,
                socket.SO_REUSEADDR,
                1,
            )
            self.listener.bind(("127.0.0.1", 0))
            self.listener.listen()
            self.listener.settimeout(0.05)
        except Exception:
            self.listener.close()
            raise
        self.port = self.listener.getsockname()[1]
        self.started = False
        # A non-daemon thread makes cleanup failures visible instead of hiding
        # a leaked server at interpreter shutdown.
        self.server = threading.Thread(
            target=self.serve,
            name="localhost-fixture",
            daemon=False,
        )

    def start(self):
        try:
            self.server.start()
            self.started = True
        except Exception:
            self.listener.close()
            raise

    def handle(self, connection, command):
        if command[0] == "APPLY":
            _, request_id, delay_ms, mode = command
            if request_id == self.injected_failure_id:
                raise RuntimeError("controlled handler failure")
            self.results.setdefault(request_id, "applied")
            time.sleep(delay_ms / 1000)
            if mode == "reply":
                connection.sendall(
                    f"RESULT {request_id} applied\n".encode("ascii")
                )
        elif command[0] == "LOOKUP":
            _, request_id = command
            value = self.results.get(request_id, "missing")
            connection.sendall(
                f"RESULT {request_id} {value}\n".encode("ascii")
            )
        else:
            self.stopped.set()
            connection.sendall(b"STOPPED\n")

    def serve(self):
        try:
            while not self.stopped.is_set():
                try:
                    connection, _ = self.listener.accept()
                except TimeoutError:
                    continue
                except OSError:
                    if self.stopped.is_set():
                        break
                    raise
                # The accepted socket receives its own deadline immediately;
                # a slow client therefore cannot retain the sole worker.
                connection.settimeout(SOCKET_DEADLINE_SECONDS)
                with connection:
                    try:
                        self.handle(
                            connection,
                            parse_command(receive_line(connection)),
                        )
                    except ProtocolError as error:
                        connection.sendall(
                            f"ERROR {error.code}\n".encode("ascii")
                        )
                    except Exception as error:
                        self.injected_exceptions.append(type(error).__name__)
                        connection.sendall(b"ERROR internal\n")
        finally:
            self.terminated.set()

    def close(self):
        self.stopped.set()
        try:
            self.listener.close()
        finally:
            if self.started:
                self.server.join(timeout=1)
        if self.started and self.server.is_alive():
            raise RuntimeError("localhost fixture did not stop")
        if self.started and not self.terminated.is_set():
            raise RuntimeError("localhost fixture termination not observed")

def open_connection(port, connector=socket.create_connection):
    return connector(("127.0.0.1", port), timeout=1)

def send_line(port, payload, *, shutdown_write=False, send=True):
    with open_connection(port) as connection:
        if send:
            connection.sendall(payload)
        if shutdown_write:
            connection.shutdown(socket.SHUT_WR)
        return receive_line(connection, deadline_seconds=1)

def rejection_code(port, payload=b"", **options):
    response = send_line(port, payload, **options)
    prefix, separator, code = response.partition(" ")
    assert prefix == "ERROR" and separator
    return code

def request(port, request_id, resolver_mode, delay_ms=0, drop=False,
            connector=socket.create_connection):
    timeline = {}
    timeline["dns_start"] = time.monotonic_ns()
    socket.getaddrinfo("localhost", port, type=socket.SOCK_STREAM)
    # These injected waits make the fixture deterministic enough to compare;
    # they do not claim to flush or observe the operating-system DNS cache.
    time.sleep(0.002 if resolver_mode == "cold" else 0.0001)
    timeline["dns_end"] = time.monotonic_ns()
    timeline["connect_start"] = time.monotonic_ns()
    with open_connection(port, connector) as connection:
        timeline["connect_end"] = time.monotonic_ns()
        timeline["tls_start"] = time.monotonic_ns()
        time.sleep(0.0002)
        timeline["tls_end"] = time.monotonic_ns()
        mode = "drop" if drop else "reply"
        connection.sendall(
            f"APPLY {request_id} {delay_ms} {mode}\n".encode("ascii")
        )
        try:
            body = receive_line(connection, timeline)
        except ProtocolError as error:
            if not drop or error.code != "newline_required":
                raise
            now = time.monotonic_ns()
            timeline.setdefault("first_byte", now)
            timeline["body_finish"] = now
            body = ""
    return {
        "request_id": request_id,
        "resolver_mode": resolver_mode,
        "monotonic_ns": timeline,
        "body": body,
        "duration_ms": (
            timeline["body_finish"] - timeline["dns_start"]
        ) / 1_000_000,
    }

def main():
    fixture = LocalFixture(injected_failure_id="inject-error")
    baseline = {"cold": [], "warm": []}
    protocol_rejections = {}
    connection_failure = "not_injected"
    real_connector_calls = [0]
    real_connector_calls_at_refusal = None
    injector_calls = [0]
    ambiguous = None
    lookup_result = None
    delayed = None
    baseline_median_ms = None
    try:
        fixture.start()
        for mode in baseline:
            for repeat in range(7):
                baseline[mode].append(
                    request(fixture.port, f"{mode}-{repeat}", mode)
                )
        baseline_values = [
            sample["duration_ms"]
            for samples in baseline.values()
            for sample in samples
        ]
        baseline_median_ms = statistics.median(baseline_values)

        delayed = request(
            fixture.port,
            "delay-fixture",
            "warm",
            delay_ms=25,
        )
        protocol_rejections = {
            "unterminated": rejection_code(
                fixture.port,
                b"LOOKUP never-finished",
                shutdown_write=True,
            ),
            "oversized": rejection_code(
                fixture.port,
                b"X" * (MAX_LINE_BYTES + 1) + b"\n",
            ),
            "timeout": rejection_code(fixture.port, send=False),
            "invalid_id": rejection_code(
                fixture.port,
                b"LOOKUP bad/id\n",
            ),
            "excessive_delay": rejection_code(
                fixture.port,
                b"APPLY too-slow 1001 reply\n",
            ),
            "invalid_arity": rejection_code(
                fixture.port,
                b"APPLY missing-mode 0\n",
            ),
            "invalid_mode": rejection_code(
                fixture.port,
                b"APPLY wrong-mode 0 maybe\n",
            ),
        }
        assert send_line(
            fixture.port,
            b"APPLY inject-error 0 reply\n",
        ) == "ERROR internal"

        # The fault injector raises before the real connector can create a
        # socket. The counters prove the refusal did not call the real path.
        def real_connector(address, timeout):
            real_connector_calls[0] += 1
            return socket.create_connection(address, timeout=timeout)

        def refused_connector(address, timeout):
            del address, timeout
            injector_calls[0] += 1
            raise ConnectionRefusedError(
                "controlled refusal before socket creation"
            )

        try:
            request(
                fixture.port,
                "refused-fixture",
                "warm",
                connector=refused_connector,
            )
        except ConnectionRefusedError:
            connection_failure = "injected_before_socket_creation"
        real_connector_calls_at_refusal = real_connector_calls[0]
        assert real_connector_calls_at_refusal == 0
        assert injector_calls[0] == 1

        ambiguous = request(
            fixture.port,
            "order-001",
            "warm",
            drop=True,
            connector=real_connector,
        )
        lookup_result = send_line(
            fixture.port,
            b"LOOKUP order-001\n",
        ).split()[-1]
        assert send_line(fixture.port, b"STOP\n") == "STOPPED"
    finally:
        fixture.close()

    budget = {
        "deadline_ms": 2000,
        "first_attempt_ms": 800,
        "backoff_ms": 100,
        "second_attempt_ms": 700,
        "post_processing_ms": 200,
        "cancel_propagation_ms": 100,
    }
    budget["worst_case_ms"] = sum(
        value
        for name, value in budget.items()
        if name != "deadline_ms"
    )
    assert budget["worst_case_ms"] <= budget["deadline_ms"]
    assert ambiguous is not None
    assert delayed is not None
    assert baseline_median_ms is not None

    return {
        "harness": HARNESS,
        "fixture": "trace-fixture.csv",
        "bind_address": "127.0.0.1",
        "external_network_used": False,
        "tls_simulated": True,
        "dns_cache_simulated": True,
        "baseline": baseline,
        "baseline_median_ms": baseline_median_ms,
        "budget": budget,
        "retry_stop_conditions": [
            "remaining deadline is below 100ms",
            "non-idempotent APPLY has an unknown result",
        ],
        "faults": {
            "dependency_delay_ms": delayed["duration_ms"],
            "connection_failure": connection_failure,
            "real_connector_calls": real_connector_calls_at_refusal,
            "injector_calls": injector_calls[0],
        },
        "non_idempotent": {
            "request_id": "order-001",
            "first_response": ambiguous["body"] or "response-lost",
            "automatic_retry": "stopped",
            "lookup_result": lookup_result,
        },
        "protocol_security": {
            "max_line_bytes": MAX_LINE_BYTES,
            "socket_deadline_ms": int(
                SOCKET_DEADLINE_SECONDS * 1000
            ),
            "request_id_ascii_allowlist": REQUEST_ID_PATTERN.pattern,
            "max_request_id_bytes": MAX_REQUEST_ID_BYTES,
            "max_delay_ms": MAX_DELAY_MS,
            "rejections": protocol_rejections,
        },
        "cleanup": {
            "injected_exception": (
                "observed"
                if fixture.injected_exceptions == ["RuntimeError"]
                else "not_observed"
            ),
            "server_stopped": fixture.terminated.is_set(),
            "thread_alive": fixture.server.is_alive(),
            "listener_closed": fixture.listener.fileno() == -1,
        },
    }

if __name__ == "__main__":
    print(json.dumps(main(), ensure_ascii=False, indent=2))
PY

cold/warmは各7回だが、このfixtureのcoldは注入delay、warmは短い注入delayであり、OS resolver cacheのflushを証明しない。TLS区間も暗号処理のsimulationである。実環境へ移すときだけ管理下の検証先とcurl等を用い、同じrequest IDでclient/serverを相関する。

同じrequest IDでclientとserverを相関する。clientのconnect区間と再送が増え、serverにrequestが届いていなければ損失説を支持する。connectは平常でserver内部のDB spanだけが伸びれば依存遅延説を支持する。どちらも観測できなければ原因確定ではなく観測不足である。

ラボ成果物: 「DNSからHTTPまでの時系列トレースとタイムアウト予算」には、coldとwarmの両経路、単位、単調時計、request ID、最悪時の足し算、retry停止条件を含める。

failure注入点ごとのrequest pathとretry判断

注記

図を読む際の補足情報です。

  1. retry budget fixture。primaryの300ms phase breakdown fixtureとは別。実packet captureや普遍的latencyではない。

DNS、TCP、TLS、request、response喪失を分けると、再試行前に何を観測すべきか。

選択肢の比較
項目完了した段階failureまでに完了したDNS/TCP/TLS/request段階。観測clientとserverのtraceで確認する事実。retry判断残りdeadlineと冪等性を確認してから再試行する。
failureなし全phaseを完了。DNS→TCP→TLS→request→responsestatusとbodyまで観測retry不要
DNS failureDNSで停止。DNS→failure observation→retry decision/recoveryresolver結果とTTL残りdeadline内で再解決
TCP failureTCPで停止。DNS→TCP→failure observation→retry decision/recoveryconnect timeoutとaddress別addressとbudgetを確認
TLS failureTLS設定不整合。DNS→TCP→TLS failure→retry blocked/config repairversion、証明書、identity設定不整合なら盲目的retry禁止
request failurerequest timeout。DNS→TCP→TLS→request timeout→retry decision/recovery送信量とserver trace冪等性と残りdeadlineを確認
response lostresponse未観測。DNS→TCP→TLS→request sent→response unobserved→side-effect inquiry→retry decisionside effectとresponseを別々に照会同じ冪等keyで結果照会またはretry

接続準備とHTTP交換を混同せず、failure点、観測結果、retry条件を有限scenarioごとに説明する。

パラメータと選択肢
パラメータ選択肢既定値
failure注入点なし、DNS、TCP、TLS、request、response喪失ok
latency profilenormal-budget: elapsed=1900ms, remaining=100ms、tight-budget(+1ms overhead): elapsed=1901ms, remaining=99msn
  1. DNS: DNS→TCP→TLS→request。total=2000ms; first=800ms+backoff=100ms+second=700ms+post=200ms+cancel=100ms=planned=1900ms。retry開始条件 remaining>=100ms。; 条件 常時; node completed-path; edge なし
  2. TCP: DNS完了、TCP接続を確立。; 条件 fault=ok; node healthy; edge healthy-path
  3. TLS: TCP完了、TLS identityを検証。; 条件 fault=ok; node healthy; edge healthy-path
  4. request: TLS完了、request送信。; 条件 fault=ok; node healthy; edge healthy-path
  5. response: elapsed=1900ms; remaining=100ms; budget-gate=allowed。response observed。; 条件 budget=nfault=ok; node healthy; edge healthy-pathhealthy-observation
  6. no deadline: elapsed=1901ms; remaining=99ms; budget-gate=blocked。response待ち。; 条件 budget=tfault=ok; node healthy; edge healthy-pathhealthy-retry
  7. DNS failure: DNS failure観測。TCP以降は未開始。; 条件 fault=dns; node dns-point; edge dns-pathdns-observation
  8. DNS retry: elapsed=1900ms; remaining=100ms; budget-gate=allowed。DNS recovery。; 条件 budget=nfault=dns; node dns-point; edge dns-pathdns-retry
  9. DNS blocked: elapsed=1901ms; remaining=99ms; budget-gate=blocked。DNS retry禁止。; 条件 budget=tfault=dns; node dns-point; edge dns-pathdns-retry
  10. TCP failure: DNS後にTCP failure観測。TLS以降は未開始。; 条件 fault=tcp; node tcp-point; edge tcp-pathtcp-observation
  11. TCP retry: elapsed=1900ms; remaining=100ms; budget-gate=allowed。TCP recovery。; 条件 budget=nfault=tcp; node tcp-point; edge tcp-pathtcp-retry
  12. TCP blocked: elapsed=1901ms; remaining=99ms; budget-gate=blocked。TCP retry禁止。; 条件 budget=tfault=tcp; node tcp-point; edge tcp-pathtcp-retry
  13. TCP before TLS: DNS後にTCP確立。; 条件 fault=tls; node tls-point; edge tls-path
  14. TLS failure: TLS設定不整合を観測。; 条件 fault=tls; node tls-point; edge tls-pathtls-observation
  15. TLS repair: elapsed=1900ms; remaining=100ms; budget-gate=allowed。設定不整合なのでretry禁止、config repair。; 条件 budget=nfault=tls; node tls-point; edge tls-pathtls-retry
  16. TLS blocked: elapsed=1901ms; remaining=99ms; budget-gate=blocked。TLS retry禁止。; 条件 budget=tfault=tls; node tls-point; edge tls-pathtls-retry
  17. request TCP: DNS後にTCP確立。; 条件 fault=req; node request-point; edge request-path-cell
  18. request TLS: TCP後にTLS完了。; 条件 fault=req; node request-point; edge request-path-cell
  19. request timeout: timeout観測。statusとside effectを照会。; 条件 fault=req; node request-point; edge request-path-cellrequest-observation
  20. request retry: elapsed=1900ms; remaining=100ms; budget-gate=allowed。status確認後retry decision。; 条件 budget=nfault=req; node request-point; edge request-path-cellrequest-retry
  21. request blocked: elapsed=1901ms; remaining=99ms; budget-gate=blocked。status照会、retry禁止。; 条件 budget=tfault=req; node request-point; edge request-path-cellrequest-retry
  22. response TCP: DNS後にTCP確立。; 条件 fault=resp; node response-point; edge response-path
  23. response TLS: TCP後にTLS完了。; 条件 fault=resp; node response-point; edge response-path
  24. request sent: TLS後にrequest送信。; 条件 fault=resp; node response-point; edge response-path
  25. unobserved: response未観測。side effectは不明。; 条件 fault=resp; node response-point; edge response-pathresponse-observation
  26. inquiry: 冪等keyでside effect/statusを照会。; 条件 fault=resp; node response-point; edge response-observation
  27. response retry: elapsed=1900ms; remaining=100ms; budget-gate=allowed。side-effect照会後retry。; 条件 budget=nfault=resp; node response-point; edge response-pathresponse-retry
  28. response blocked: elapsed=1901ms; remaining=99ms; budget-gate=blocked。side-effect照会、retry禁止。; 条件 budget=tfault=resp; node response-point; edge response-pathresponse-retry
完全な遷移
イベント開始終了条件
parameter-changedns-lookupdns-lookupfault=ok
parameter-changedns-lookupdns-lookupfault=dns
parameter-changedns-lookupdns-lookupfault=tcp
parameter-changedns-lookupdns-lookupfault=tls
parameter-changedns-lookupdns-lookupfault=req
parameter-changedns-lookupdns-lookupfault=resp
nextdns-lookuph-tcpfault=ok
timerdns-lookuph-tcpfault=ok
previoush-tcpdns-lookupfault=ok
reseth-tcpdns-lookupfault=ok
nexth-tcptls-readyfault=ok
timerh-tcptls-readyfault=ok
previoustls-readyh-tcpfault=ok
resettls-readydns-lookupfault=ok
nexttls-readyh-reqfault=ok
timertls-readyh-reqfault=ok
previoush-reqtls-readyfault=ok
reseth-reqdns-lookupfault=ok
nexth-reqh-okbudget=nfault=ok
timerh-reqh-okbudget=nfault=ok
previoush-okh-reqbudget=nfault=ok
reseth-okdns-lookupbudget=nfault=ok
nexth-reqh-retry-blockedbudget=tfault=ok
timerh-reqh-retry-blockedbudget=tfault=ok
previoush-retry-blockedh-reqbudget=tfault=ok
reseth-retry-blockeddns-lookupbudget=tfault=ok
nextdns-lookupd-failfault=dns
timerdns-lookupd-failfault=dns
previousd-faildns-lookupfault=dns
resetd-faildns-lookupfault=dns
nextd-faild-retrybudget=nfault=dns
timerd-faild-retrybudget=nfault=dns
previousd-retryd-failbudget=nfault=dns
resetd-retrydns-lookupbudget=nfault=dns
nextd-faild-retry-blockedbudget=tfault=dns
timerd-faild-retry-blockedbudget=tfault=dns
previousd-retry-blockedd-failbudget=tfault=dns
resetd-retry-blockeddns-lookupbudget=tfault=dns
nextdns-lookupt-failfault=tcp
timerdns-lookupt-failfault=tcp
previoust-faildns-lookupfault=tcp
resett-faildns-lookupfault=tcp
nextt-failt-retrybudget=nfault=tcp
timert-failt-retrybudget=nfault=tcp
previoust-retryt-failbudget=nfault=tcp
resett-retrydns-lookupbudget=nfault=tcp
nextt-failt-retry-blockedbudget=tfault=tcp
timert-failt-retry-blockedbudget=tfault=tcp
previoust-retry-blockedt-failbudget=tfault=tcp
resett-retry-blockeddns-lookupbudget=tfault=tcp
nextdns-lookupl-tcpfault=tls
timerdns-lookupl-tcpfault=tls
previousl-tcpdns-lookupfault=tls
resetl-tcpdns-lookupfault=tls
nextl-tcpl-failfault=tls
timerl-tcpl-failfault=tls
previousl-faill-tcpfault=tls
resetl-faildns-lookupfault=tls
nextl-faill-retry-blockedbudget=nfault=tls
timerl-faill-retry-blockedbudget=nfault=tls
previousl-retry-blockedl-failbudget=nfault=tls
resetl-retry-blockeddns-lookupbudget=nfault=tls
nextl-faill-retry-blocked-tightbudget=tfault=tls
timerl-faill-retry-blocked-tightbudget=tfault=tls
previousl-retry-blocked-tightl-failbudget=tfault=tls
resetl-retry-blocked-tightdns-lookupbudget=tfault=tls
nextdns-lookupq-tcpfault=req
timerdns-lookupq-tcpfault=req
previousq-tcpdns-lookupfault=req
resetq-tcpdns-lookupfault=req
nextq-tcpq-tlsfault=req
timerq-tcpq-tlsfault=req
previousq-tlsq-tcpfault=req
resetq-tlsdns-lookupfault=req
nextq-tlsq-failfault=req
timerq-tlsq-failfault=req
previousq-failq-tlsfault=req
resetq-faildns-lookupfault=req
nextq-failq-retrybudget=nfault=req
timerq-failq-retrybudget=nfault=req
previousq-retryq-failbudget=nfault=req
resetq-retrydns-lookupbudget=nfault=req
nextq-failq-retry-blockedbudget=tfault=req
timerq-failq-retry-blockedbudget=tfault=req
previousq-retry-blockedq-failbudget=tfault=req
resetq-retry-blockeddns-lookupbudget=tfault=req
nextdns-lookups-tcpfault=resp
timerdns-lookups-tcpfault=resp
previouss-tcpdns-lookupfault=resp
resets-tcpdns-lookupfault=resp
nexts-tcps-tlsfault=resp
timers-tcps-tlsfault=resp
previouss-tlss-tcpfault=resp
resets-tlsdns-lookupfault=resp
nexts-tlss-reqfault=resp
timers-tlss-reqfault=resp
previouss-reqs-tlsfault=resp
resets-reqdns-lookupfault=resp
nexts-reqdeadline-exceededfault=resp
timers-reqdeadline-exceededfault=resp
previousdeadline-exceededs-reqfault=resp
resetdeadline-exceededdns-lookupfault=resp
nextdeadline-exceededs-inquiryfault=resp
timerdeadline-exceededs-inquiryfault=resp
previouss-inquirydeadline-exceededfault=resp
resets-inquirydns-lookupfault=resp
nexts-inquirys-retrybudget=nfault=resp
timers-inquirys-retrybudget=nfault=resp
previouss-retrys-inquirybudget=nfault=resp
resets-retrydns-lookupbudget=nfault=resp
nexts-inquirys-retry-blockedbudget=tfault=resp
timers-inquirys-retry-blockedbudget=tfault=resp
previouss-retry-blockeds-inquirybudget=tfault=resp
resets-retry-blockeddns-lookupbudget=tfault=resp
観測結果
結果状態
elapsed=1900ms; remaining=100ms; budget-gate=allowedh-ok
elapsed=1901ms; remaining=99ms; budget-gate=blockedh-retry-blocked
elapsed=1900ms; remaining=100ms; budget-gate=allowedd-retry
elapsed=1901ms; remaining=99ms; budget-gate=blockedd-retry-blocked
elapsed=1900ms; remaining=100ms; budget-gate=allowedt-retry
elapsed=1901ms; remaining=99ms; budget-gate=blockedt-retry-blocked
elapsed=1900ms; remaining=100ms; budget-gate=allowedl-retry-blocked
elapsed=1901ms; remaining=99ms; budget-gate=blockedl-retry-blocked-tight
elapsed=1900ms; remaining=100ms; budget-gate=allowedq-retry
elapsed=1901ms; remaining=99ms; budget-gate=blockedq-retry-blocked
elapsed=1900ms; remaining=100ms; budget-gate=alloweds-retry
elapsed=1901ms; remaining=99ms; budget-gate=blockeds-retry-blocked

現在の状態: DNS — DNS→TCP→TLS→request。total=2000ms; first=800ms+backoff=100ms+second=700ms+post=200ms+cancel=100ms=planned=1900ms。retry開始条件 remaining>=100ms。

このモデルは例示的かつ決定的であり、実システムの完全な再現ではありません。

トレードオフと失敗モード

失敗時のclient actionを選ぶdecision table
観測と操作 action 必要な契約 避ける失敗
接続前失敗、read-only 予算内で別addressへretry 残りdeadlineとbackoff 同時retryによる集中
送信後timeout、状態変更 まず結果照会 冪等keyと結果endpoint 処理済みrequestの二重実行
依存serviceの持続的遅延 縮退またはfail fast 機能別の許容損失 queue滞留とdeadline超過の連鎖
名前解決結果が疑わしい TTLとresolver経路を確認 cache更新規則 stale addressへの接続継続

もっともらしい誤診と反証

  1. 誤診: 「HTTP p99悪化はpacket loss」。反証: TCP再送、connect時間、複数地点の同時刻を確認し、serverの依存spanと比較する。再送が平常でDB区間だけ増えていれば損失説を退ける。
  2. 誤診: 「DNS時間が0msなのでDNSに問題はない」。反証: warm cacheとcold queryを分け、TTL、負のcache、複数addressを確認する。測定がresolver callを含んでいない可能性も検証する。

retryは成功率を上げる可能性と、障害中の負荷を増やす可能性を同時に持つ。全clientが同時に再試行しないようjitterを入れ、総試行数を制限し、server側のcapacityと期限を共有する。TLS 0-RTTを使う場合はreplay可能性に適した操作かを確認する。

知識チェック

  1. TCP ACKを受けたら注文は確定済みか。分からない。byteの輸送確認とapplicationの状態遷移は別である。
  2. HTTP 200なら利用者操作は成功か。契約次第である。body内の業務結果、後続処理、client保存まで別の失敗点がある。
  3. DNS cacheは常に遅延を減らすか。通常はqueryを省けるが、stale addressや負のcacheにより回復を遅らせることがある。
  4. timeoutを長くすれば成功率は上がるか。待てば成功する原因には効くが、queueとresource占有を増やし、利用者deadlineを破り得る。

5分teach-back

八イベントの時系列を使い、各段で保証されたことと未確定なことを説明する。2000ms予算の足し算と、POST timeout後に直ちにretryしない理由を一分ずつ含める。

未知へのtransfer

パケット損失と依存遅延を区別する診断を、CDN、API gateway、二つの内部service、databaseからなる未知経路で行う。観測点が欠ける区間を明示し、責任を断定する前に追加する時刻とrequest IDを設計する。

出典と次の学習

RFC 1035はDNS、RFC 9293はTCP、RFC 6298はTCP再送timeout、RFC 8446はTLS 1.3、RFC 9110はHTTP semanticsの正準として使う。各層の仕様を横断して要約し、長い引用や原図の転載はしていない。正確なURLはlesson metadataのsourcesに集約した。

次の学習では、要求とdomain modelへ進み、networkの結果不明状態をapplication契約として表現する。1日後は時系列、7日後は予算計算、30日後は実trace、90日後はretryによる障害増幅を再レビューする。

実践ラボ

名前解決から応答完了までを期限へ収める

提出成果物: DNSからHTTPまでの時系列トレースとタイムアウト予算

  1. cold DNSとwarm DNSで、名前解決、接続、TLS、最初のbyte、本文完了の単調時計時刻を記録する
  2. 利用者期限2000msから各段の上限、後処理余白、キャンセル伝播時間を配分する
  3. 一回の再試行を含む最悪時合計を計算し、期限を超える場合は試行予算を修正する
  4. 制御可能な遅延依存と接続失敗を別々に注入し、時系列上の差を観測する
  5. 非冪等操作では再試行を止める条件と、結果不明を照会するための識別子を記録する

説明して理解を確かめる

5分で、DNS、TCP、TLS、HTTPの役割、TCP成功が業務成功を保証しない理由、exactly-onceを安易に主張できない理由を説明する。

アセスメント

  1. 問い: HTTPのp99が800msへ悪化した。『パケット損失だ』という診断と『依存DBが遅い』という診断をどう区別するか。

    期待する証拠: TCP再送または接続時間、server-side区間、依存span、同時刻比較による反証

  2. 問い: POSTの応答がtimeoutした。即時retryが常に安全でない理由と必要な契約を示す。

    期待する証拠: 処理済みだが応答喪失の状態、冪等key、結果照会、期限とbackoffを含む説明

別問題へ転用する

パケット損失と依存遅延を区別する診断

復習スケジュール

  1. 1日後

    総期限2000msへ一回の再試行を入れるなら、各試行へ何ms配分するか

  2. 7日後

    TCP成功から業務成功までに残る結果不明状態は何か

  3. 30日後

    パケット損失説を反証するclient側とserver側の観測を一つずつ示す

  4. 90日後

    総期限2000msへ一回の再試行を入れるなら、各試行へ何ms配分するか

評価ルーブリック

4段階の評価基準
観点未達発展途上熟達卓越
technical-correctnessTCP接続、TLS、HTTP成功、業務処理完了を同じ成功として扱う層は区別するが、再送またはtimeout後の結果不明状態を説明できない各層のイベント、期限、失敗状態、retryの影響を正しい順序で説明するDNS cache、TLS 0-RTT、TCP再送、冪等性の境界と反例まで扱う
judgment各段へ総期限と同じtimeoutを設定し、無条件に再試行する予算を分けるが、後処理余白、deadline伝播、操作の冪等性が抜ける利用者期限から逆算し、操作種別に応じて試行回数と停止条件を選ぶretry stormと部分成功を考慮し、適応的縮退と結果照会を設計する
evidence総所要時間だけでネットワーク層の原因を断定するclient時刻はあるが、serverまたは再送の観測と時刻基準が揃わない単調時計の層別時刻、再送、server区間を同じ要求IDで相関するcoldとwarm経路、複数地点、欠測区間を比較し、観測限界を結論へ反映する
communicationネットワークが遅いという表現だけで、層と時間帯が分からない時系列はあるが、期限超過への寄与と次の反証操作が不明である要求ID、層別区間、予算、仮説、反証、次の操作を一枚で追跡できるclient、network、service各担当へ責任断定を避けつつ同じ証拠を提示する

出典

以下の外部資料は利用者が選択したときだけ開きます。