👏

TCP/IPモデルでデータが送られる流れ

2024/01/23に公開
  • 目的

    • プライベートでネットワークの勉強をしていたので整理を兼ねてまとめた
    • サーバーにリクエストを飛ばした時に各レイヤーでざっくりどの様なことが起きているのか把握する
  • 書くこと

    • サーバーにリクエストを飛ばしたところからレスポンスが届くまでの流れを書く
      • 今回はソースコードを読んだので、それに沿って説明していこうかなと
      • 読んだコードはこちら
      • https://github.com/keisuke713/microkernel-book
      • 下記をforkしてwebサーバーとしても使えるように改良を加えたもの
      • curl に相当するコマンドが内部でどのように実装されているかを追っていく
  • 前提

    • 以下の4レイヤーに分けられる
      • アプリケーションレイヤー
      • トランスポートレイヤー
      • インターネットレイヤー
      • リンクレイヤー
  • 流れ

    • リクエストがサーバーに届くまで

      • アプリケーションレイヤー

        • 概要
          • アプリ固有の情報を載せるレイヤー
          • HTTPならパス・パラメータなど
        • 具体的にやっていること
          • リクエストを送る
          • 入力されたURLからDNSを使って宛先のIPアドレスを特定する
          • TCPコネクションを貼る
            • ソケットを用意する

              • ホスト内でコネクションを一意に識別するためのもの

              https://github.com/keisuke713/microkernel-book/blob/main/servers/tcpip/tcp.h#L77-L83

              // ソケット管理構造体
              struct socket {
                  bool used;                // 使用中か
                  task_t task;              // 所有するタスク
                  int fd;                   // ソケットID
                  struct tcp_pcb *tcp_pcb;  // TCPコントロールブロック
              };
              

              struct socket *alloc_socket(void) {
                  for (int i = 0; i < SOCKETS_MAX; i++) {
                      if (!sockets[i].used) {
                          sockets[i].fd = i + 1;
                          sockets[i].used = true;
                          return &sockets[i];
                      }
                  }
              
                  return 0;
              }
              
            • やりとりに必要な情報を保存する構造体(TCPコントロールブロック)を作成する

              // TCP通信の管理構造体 (PCB: Protocol Control Block)
              struct tcp_pcb {
                  bool in_use;               // 使用中かどうか
                  enum tcp_state state;      // コネクションの状態
                  uint32_t pending_flags;    // 送信する必要があるフラグ
                  uint32_t next_seqno;       // 次に送信すべきシーケンス番号
                  uint32_t last_seqno;       // 最後に送信したシーケンス番号
                  uint32_t last_ack;         // 最後に受信したシーケンス番号 + 1
                  uint32_t local_winsize;    // 送信ウィンドウサイズ
                  uint32_t remote_winsize;   // 受信ウィンドウサイズ
                  endpoint_t local;          // ソケットに紐付けられたIPアドレスとポート番号
                  endpoint_t remote;         // 相手のIPアドレスとポート番号
                  mbuf_t rx_buf;             // 受信バッファ
                  mbuf_t tx_buf;             // 送信バッファ
                  unsigned num_retransmits;  // 再送回数
                  int retransmit_at;         // 次に再送すべき時刻
                  list_elem_t next;          // 次の要素へのポインタ
                  void *arg;                 // コールバック関数に渡す引数
              };
              

              // 新しいPCBを作成する。
              struct tcp_pcb *tcp_new(void *arg) {
                  // 空いているPCBを探す。
                  struct tcp_pcb *pcb = NULL;
                  for (int i = 0; i < TCP_PCBS_MAX; i++) {
                      if (!pcbs[i].in_use) {
                          pcb = &pcbs[i];
                          break;
                      }
                  }
              
                  // 構造体の各メンバ変数に初期値を代入する
              		// do something
              
                  return pcb;
              }
              
            • ローカルホストで使えるポートを探し、上記で作ったPCBに割り当てる

              • 送信元のポート・宛先のIPとポートの組み合わせが一意になる
                • ちなみに アクティブオープン というのはクライアント側の処理でサーバー側では パッシブオープン というまた違った処理が行われる

              // TCPコネクションを開く (アクティブオープン)。
              error_t tcp_connect(struct tcp_pcb *pcb, ipv4addr_t dst_addr, port_t dst_port, port_t src_port) {
                  // エフェメラルポート (49152-65535) から空いているポートを探す。
                  for (int port = 49152; port <= 65535; port++) {
                      endpoint_t ep;
                      ep.port = port;
                      ep.addr = IPV4_ADDR_UNSPECIFIED;
              
                      if (tcp_lookup_local(&ep) == NULL) {
                          // 使われていないポート番号が見つかった。
                          memcpy(&pcb->local, &ep, sizeof(pcb->local));
                          pcb->remote.addr = dst_addr;
                          pcb->remote.port = dst_port;
                          pcb->state = TCP_STATE_SYN_SENT;
                          pcb->pending_flags |= TCP_PEND_SYN;
                          list_push_back(&active_pcbs, &pcb->next);
                          return OK;
                      }
                  }
              
                  WARN("run out of client tcp ports");
                  return ERR_TRY_AGAIN;
              }
              
            • 最後にトランスポート層に送信を依頼する

      • トランスポートレイヤー

        • 概要

          • データ転送の信頼性を担保すること
            • 例えば上の層から渡されたデータが全て送信されているか・欠損はないかetc
        • 具体的にやっていること

          • 該当のコネクションの特定
            • アプリケーションからはソケットの番号が渡されるので、このレイヤー以降はそこから宛先のIPやポート番号などを判別する

          static struct socket *lookup_socket(task_t task, int sock) {
              if (sock < 1 || sock > SOCKETS_MAX) {
                  return NULL;
              }
          
              struct socket *s = &sockets[sock - 1];
              if (!s->used || s->task != task) {
                  return NULL;
              }
          
              return s;
          }
          
          • 送信したいデータを PCB の送信バッファに追加

          // 送信するデータをバッファに追加する。
          void tcp_write(struct tcp_pcb *pcb, const void *data, size_t len) {
              mbuf_append_bytes(pcb->tx_buf, data, len);
              pcb->retransmit_at = 0;
          }
          
          • 定期的にpcbを確認して送信バッファにデータがあれば PCB のデータをもとにヘッダを構築してインターネット層に渡す

          // TCPヘッダ
          struct tcp_header {
              uint16_t src_port;   // 送信元ポート番号
              uint16_t dst_port;   // 宛先ポート番号
              uint32_t seqno;      // シーケンス番号
              uint32_t ackno;      // 確認応答番号
              uint8_t off_and_ns;  // ヘッダ長など
              uint8_t flags;       // フラグ
              uint16_t win_size;   // ウィンドウサイズ
              uint16_t checksum;   // チェックサム
              uint16_t urgent;     // 緊急ポインタ
          } __packed;
          

          ちなみにTCPヘッダはこのOSの作者が勝手に考えたものではなく予め決まっているものである(プロトコル)

          • シーケンス番号やACK番号を更新することで何バイト目まで送ったのか・受け取ったのかについての情報を共有している
            • これにより時間がかかってもデータが確実に届くようにしている
          • ウィンドウサイズというどれくらいの量のデータを送って良いかという指標の元データ量は決めている
            • つまり一回に送れるデータ量に上限があるのでデータが大きくなるほどやりとりの回数が増える

          // PCBに未送信データ・フラグがあれば送信する。
          static void tcp_transmit(struct tcp_pcb *pcb) {
              // コネクションの状態に応じて送信するデータ・フラグを決定する。
              mbuf_t payload = NULL;
              uint8_t flags = 0;
              if (pcb->state == TCP_STATE_ESTABLISHED) {
                  // 送信バッファにデータがあれば送信する。
                  payload = mbuf_peek(pcb->tx_buf, pcb->remote_winsize);
                  if (mbuf_len(payload) > 0) {
                      flags |= TCP_ACK | TCP_PSH;
                  }
          
                  // 送信するバイト数だけウィンドウサイズを減らすことで、送りすぎないようにする。
                  pcb->remote_winsize -= mbuf_len(payload);
          
                  if (pcb->last_seqno == pcb->next_seqno) {
                      // 再送処理: 再送回数をインクリメントする。
                      pcb->num_retransmits++;
                  }
              }
          
              // 送信待ちフラグをセットする。
              // do something
          
              // 送信するデータ・フラグがなければパケットを送信しない。
              if (!flags) {
                  return;
              }
          
              // TCPヘッダを構築する。
              struct tcp_header header;
              header.src_port = hton16(pcb->local.port);
              header.dst_port = hton16(pcb->remote.port);
              header.seqno = hton32(pcb->next_seqno);
              header.ackno = (flags & TCP_ACK) ? hton32(pcb->last_ack) : 0;
              header.off_and_ns = 5 << 4;
              header.flags = flags;
              header.win_size = hton16(pcb->local_winsize);
              header.urgent = 0;
              header.checksum = 0;
          
              // ペイロードのチェックサムを計算する。
              // do something
          
              // 疑似ヘッダのチェックサムを計算する。
              // do something
          
              // チェックサムをヘッダに書き込む。
              header.checksum = checksum_finish(&checksum);
          
              // パケットを構築する。ペイロードがあれば、ヘッダとペイロードを連結する。
              mbuf_t pkt = mbuf_new(&header, sizeof(header));
              if (payload) {
                  mbuf_append(pkt, payload);
              }
          
              // IPv4の送信処理に回す。
              ipv4_transmit(pcb->remote.addr, IPV4_PROTO_TCP, pkt);
          
              // 最後に送信したシーケンス番号を更新しておき、再送しているのかどうかを判定できるようにする。
              pcb->last_seqno = pcb->next_seqno;
              // 未送信フラグをクリアする。
              pcb->pending_flags = 0;
          }
          
      • インターネット層

        • 概要

          • ネットワークを跨ぎつつデータを宛先まで届ける
        • 具体的にやっていること

          • トランスポート層から渡されたパケットに対してIPヘッダを構築し付与して、イーサネットへ渡す

          // IPv4ヘッダ
          struct ipv4_header {
              uint8_t ver_ihl;          // バージョンとヘッダ長
              uint8_t dscp_ecn;         // DSCPとECN
              uint16_t len;             // IPv4ペイロード長
              uint16_t id;              // 識別子
              uint16_t flags_frag_off;  // フラグとフラグメントオフセット
              uint8_t ttl;              // TTL
              uint8_t proto;            // 上位プロトコル
              uint16_t checksum;        // チェックサム
              uint32_t src_addr;        // 送信元IPv4アドレス
              uint32_t dst_addr;        // 宛先IPv4アドレス
          } __packed;
          

          // IPv4パケットの送信処理
          void ipv4_transmit(ipv4addr_t dst, uint8_t proto, mbuf_t payload) {
              // IPv4ヘッダを構築
              struct ipv4_header header;
              memset(&header, 0, sizeof(header));
              size_t total_len = sizeof(header) + mbuf_len(payload);
              header.ver_ihl = 0x45;                          // ヘッダ長
              header.len = hton16(total_len);                 // 合計の長さ
              header.ttl = DEFAULT_TTL;                       // TTL
              header.proto = proto;                           // 上層のプロトコル
              header.dst_addr = hton32(dst);                  // 宛先IPv4アドレス
              header.src_addr = hton32(device_get_ipaddr());  // 送信元IPv4アドレス
          
              // チェックサムを計算してセット
              checksum_t checksum;
              checksum_init(&checksum);
              checksum_update(&checksum, &header, sizeof(header));
              header.checksum = checksum_finish(&checksum);
          
              // IPv4ヘッダを先頭に付けてイーサーネットの送信処理に回す
              mbuf_t pkt = mbuf_new(&header, sizeof(header));
              mbuf_append(pkt, payload);
              ethernet_transmit(ETHER_TYPE_IPV4, dst, pkt);
          }
          
      • リンク層(ネットワークインターフェイス層)

        • 概要

          • データの物理的な送受信などを行う
        • 具体的にやっていること

          • 次の中継先を決定する
            • 最終的な目的地はインターネット層からIPアドレスが渡されるが、この層でその宛先にたどり着くために次にどこに行けば良いかを決めている
              • 次の宛先には以下のような経路表というマップを見て決める

                Destination に最終的な目的が既に登録されている場合はそれに沿って Gateway を参照しつつ次の宛先を決める

                Destination に最終的な目的地が登録されていない場合は一番上のdefaultが選ばれる

                • 多くの場合デフォルトは繋がっているルーター
                • 上の図で言うと 192.168.100.1

          // イーサーネットフレームを送信する。
          void ethernet_transmit(enum ether_type type, ipv4addr_t dst, mbuf_t payload) {
              // パケットをどこに送るかを決定する。
              ipv4addr_t next_hop = device_get_next_hop(dst);
              if (device_get_next_hop(dst) == IPV4_ADDR_UNSPECIFIED) {
                  WARN("ethernet_transmit: no route to %pI4", dst);
                  mbuf_delete(payload);
                  return;
              }
          
              // 宛先のMACアドレスを取得する。
              macaddr_t dst_macaddr;
              if (!arp_resolve(next_hop, &dst_macaddr)) {
                  // 宛先のMACアドレスがARPテーブルに見つからなかったため、即座に送信できない。
                  // ARPテーブルの応答待ちキューにパケットを挿入し、ARPリクエストを送信して処理を
                  // 終える。
                  arp_enqueue(type, next_hop, payload);
                  arp_request(next_hop);
                  return;
              }
          
              // 宛先MACアドレスが見つかったので、パケットを送信する。
              struct ethernet_header header;
              memcpy(header.dst, dst_macaddr, MACADDR_LEN);
              memcpy(header.src, device_get_macaddr(), MACADDR_LEN);
              header.type = hton16(type);
              mbuf_t pkt = mbuf_new(&header, sizeof(header));
              mbuf_append(pkt, payload);
          
              // ネットワークデバイスドライバにパケット送信を依頼する。
              callback_ethernet_transmit(pkt);
          }
          
    • レスポンスがホストに届くまで

      • リンク層

        • やること

          • ヘッダーを取り出してタイプがIPv4ならインターネット層に渡す

          // イーサーネットフレームの受信処理。
          void ethernet_receive(const void *pkt, size_t len) {
              mbuf_t m = mbuf_new(pkt, len);
          
              // イーサーネットフレームのヘッダを取り出す。
              struct ethernet_header header;
              if (mbuf_read(&m, &header, sizeof(header)) != sizeof(header)) {
                  return;
              }
          
              uint16_t type = ntoh16(header.type);
              switch (type) {
                  case ETHER_TYPE_ARP:
                      // ARPパケット
                      arp_receive(m);
                      break;
                  case ETHER_TYPE_IPV4:
                      // IPv4パケット
                      ipv4_receive(m);
                      break;
                  default:
                      WARN("unknown ethernet type: %x", type);
              }
          }
          
      • インターネット層

        • やること

          • 自分宛のパケットか確認して(違ったらその場で破棄)、そうなら上の層に渡す
            • データが正しく送れているか・中身は何なのかなどを確認するのは上の層の仕事なのでこの層では宛先を確認する以外は何もしない

          // IPv4パケットの受信処理
          void ipv4_receive(mbuf_t pkt) {
              // IPv4ヘッダを読み込む
              struct ipv4_header header;
              if (mbuf_read(&pkt, &header, sizeof(header)) < sizeof(header)) {
                  return;
              }
          
              // ヘッダサイズを取得して、ヘッダの長さ分だけパケットを捨てる。これでpktはIPv4パケットの
              // ペイロードを指すようになる。
              size_t header_len = (header.ver_ihl & 0x0f) * 4;
              if (header_len > sizeof(header)) {
                  mbuf_discard(&pkt, header_len - sizeof(header));
              }
          
              // 宛先が自分でなければ無視
              ipv4addr_t dst = ntoh32(header.dst_addr);
              if (!device_dst_is_ours(dst)) {
                  return;
              }
          
              // 変な長さのパケットは無視
              if (ntoh16(header.len) < header_len) {
                  return;
              }
          
              // イーサーネットフレームのペイロードは最小長制約のためにパディングされる可能性があるので、
              // IPv4ヘッダに記載されているペイロードの長さに合わせてパケットを切り詰める。こうしないと
              // TCPのペイロード長が正しく計算されない (TCPヘッダにはペイロード長が記載されていない)。
              uint16_t payload_len = ntoh16(header.len) - header_len;
              mbuf_truncate(pkt, payload_len);
              if (mbuf_len(pkt) != payload_len) {
                  // 変な長さのパケットは無視
                  return;
              }
          
              // パケットの種類に応じて処理を振り分ける
              ipv4addr_t src = ntoh32(header.src_addr);
              switch (header.proto) {
                  case IPV4_PROTO_UDP:
                      udp_receive(src, pkt);
                      break;
                  case IPV4_PROTO_TCP:
                      tcp_receive(dst, src, pkt);
                      break;
                  default:
                      WARN("unknown ip proto type: %x", header.proto);
              }
          }
          
      • トランスポート層

        • やること

          • 宛先を特定する
          // TCPパケットの受信処理。該当するPCBを探してtcp_process()を呼び出す。
          void tcp_receive(ipv4addr_t dst, ipv4addr_t src, mbuf_t pkt) {
              // TCPヘッダを読み込む
              struct tcp_header header;
              if (mbuf_read(&pkt, &header, sizeof(header)) != sizeof(header)) {
                  return;
              }
          
              // TCPヘッダを pkt から取り除く
              size_t offset = (header.off_and_ns >> 4) * 4;
              if (offset > sizeof(header)) {
                  mbuf_discard(&pkt, offset - sizeof(header));
              }
          
              // 送信元・宛先のIPアドレスとポート番号を取得
              endpoint_t dst_ep, src_ep;
              dst_ep.port = ntoh16(header.dst_port);
              dst_ep.addr = dst;
              src_ep.port = ntoh16(header.src_port);
              src_ep.addr = src;
          
              struct tcp_pcb *pcb = tcp_lookup(&dst_ep, &src_ep);
              if (!pcb) {
                  pcb = tcp_lookup_passive(&dst_ep);
                  if (!pcb) {
                      WARN("tcp: no PCB found for %pI4:%d -> %pI4:%d", src, src_ep.port, dst,
                          dst_ep.port);
                      return;
                  }
              }
          
              tcp_process(pcb, src, src_ep.port, &header, pkt);
          }
          
          • 受信処理
            • ここで初めて正しくデータが受け取れているか確認をする
              • 受け取ったデータは受信バッファに格納してACK番号を更新
                • そうすることで届いたことを相手に知らせることができる
              • またローカルのウィンドウサイズを縮小させることで相手が送りすぎない様にする

          // TCPパケットの受信処理
          static void tcp_process(struct tcp_pcb *pcb, ipv4addr_t src_addr,
                                  port_t src_port, struct tcp_header *header,
                                  mbuf_t payload) {
          
              // RSTパケットの処理
              // do something
          
              // このTCP実装はリオーダリングや SACK (Selective ACK) など面倒な処理をサポートして
              // おらず、TCPパケットが順番に到着することを前提としている。予期したシーケンス番号で
              // なければ同じ確認応答番号のACKパケットを送ることで、次に欲しいデータの再送を促す。
              // パッシブオープンの際も相手から送られる最初のシーケンス番号は予測不可なので外す
              if (pcb->state != TCP_STATE_SYN_SENT && pcb->state != TCP_STATE_WAIT && seq != pcb->last_ack) {
                  WARN("tcp: unexpected sequence number: %08x (expected %08x)", seq,
                       pcb->last_ack);
                  pcb->pending_flags |= TCP_PEND_ACK;
                  return;
              }
          
              // 送信相手のウィンドウサイズを更新する。
              pcb->remote_winsize = ntoh32(header->win_size);
          
              // コネクションの状態に応じた処理を行う。
              switch (pcb->state) {
                  // SYNを送信して、SYN+ACKを待っている状態。
                  case TCP_STATE_SYN_SENT: {
                      // do something
                  }
                  case TCP_STATE_SYN_ACK_SENT: {
                      // do something
                  }
                  // コネクションが確立している状態。データを受信する。
                  case TCP_STATE_ESTABLISHED: {
                      // 相手がどこまで受信したかをチェック。
                      uint32_t acked_len = ack - pcb->next_seqno;
                      if (acked_len > 0) {
                          // 相手に届いたバイト数分だけ送信バッファから削除する。
                          mbuf_discard(&pcb->tx_buf, acked_len);
                          pcb->next_seqno += acked_len;
                      }
          
                      // 受信したデータを受信バッファにコピーする。
                      size_t payload_len = mbuf_len(payload);
                      if (0 < payload_len && payload_len <= pcb->local_winsize) {
                          // 受信したデータに対するACKを返す。また、ローカルのウィンドウサイズを減らす
                          // ことで、相手がデータを送りすぎないようにする。
                          pcb->last_ack += mbuf_len(payload);
                          pcb->local_winsize -= payload_len;
                          pcb->pending_flags |= TCP_PEND_ACK;
                          pcb->retransmit_at = 0;
          
                          mbuf_append(pcb->rx_buf, payload);
                          callback_tcp_data(pcb);
                      }
          
                      // FINパケットの処理 (パッシブクローズ)
                      // do something
                  }
                  case TCP_STATE_FIN_SENT: {
                      // do something
                  }
                  case TCP_STATE_CLOSED: {
                      // do something
                  }
                  default:
                      WARN("tcp: unexpected packet in state=%d", pcb->state);
                      break;
              }
          }
          
      • アプリケーション層

        • やること
          • 受け取ったデータをもとに処理を行う(今回のアプリではログに出しているだけだが、実際のブラウザだと描画はこのレイヤーになる)

            • ここで初めてデータの中身を確認する

            static void received(int sock, uint8_t *buf, size_t len) {
                // char *sep = strstr((char *) buf, "\r\n\r\n");
                buf[len] = '\0';
                DBG("%s", buf);
                return;
            }
            
  • まとめ

    • それぞれの層でやることがはっきり分かれている。
      • アプリケーションレイヤーは内容を確認・トランスポートレイヤーはどこまで送れているか確認するなど
      • 合わせてプロトコル(規約)もはっきりしているのでそれに従う必要あり
        • 例えばトランスポートレイヤーだとACK番号を更新しないことで永遠に同じデータが送られるなんてことも有り得る(体験談)
    • 低レイヤーのコードは難しい印象だったが読めなくもない

Discussion