티스토리 뷰

반응형

ssh 커넥션을 자주 열고 닫는 환경에서는 매번 TCP 연결, 키 교환, 인증, 세션 초기화를 반복하는 비용이 꽤 크다.
이 글에서는 OpenSSH portable의 실제 구현을 참고하여 TCP 커넥션 위의 논리적인 SSH session과 channel을 OpenSSH가 어떻게 관리하고 재사용하는지 정리해 본다.


1. TCP connection, SSH session, channel의 관계

ssh를 쓰다 보면 “세션을 연다”, “커넥션을 유지한다”, “터널을 만든다” 같은 말을 섞어 쓰게 된다. 실제 구현을 볼 때는 이 단어들을 조금 분리해서 보는 편이 좋다.

먼저 전체 구조를 보면 대략 이렇다.

TCP connection
└─ SSH transport: 암호화, MAC, key exchange, rekey
   └─ SSH connection protocol
      ├─ channel 0: session:shell
      ├─ channel 1: direct-tcpip 또는 forwarded-tcpip
      ├─ channel 2: x11-connection
      └─ ...

이 구조를 위에서부터 내려오며 정리하면 다음과 같다.

  1. TCP connection은 가장 바깥의 실제 네트워크 연결
    ssh user@host를 실행하면 클라이언트는 서버의 22번 포트, 또는 설정된 포트로 TCP 연결을 만든다.
  2. SSH transport는 TCP connection 위에 올라가는 암호화된 전송 계층
    TCP 연결이 만들어진 뒤에는 SSH 버전 교환, key exchange, host key 검증, 사용자 인증이 진행된다. 여기까지 끝나면 “암호화되고 인증된 SSH 연결”이 준비된다. 이 글에서 이후 재사용된다고 말하는 핵심 대상도 바로 이 SSH transport connection이다.
  3. SSH connection protocol은 그 위에 여러 channel을 올림
    이 레이어에서 등장하는 핵심 추상화가 channel이다. OpenSSH 구현에서 channel은 로컬 fd, 원격 channel id, window size, max packet size, close 상태 등을 가진 하나의 논리 스트림으로 관리된다.
  4. session은 channel의 한 종류
    우리가 흔히 “터미널 세션”이라고 부르는 것은 보통 session 타입 channel이다. 즉 SSH 프로토콜 관점에서 session은 TCP connection이나 SSH transport와 같은 별도 계층이 아니라, channel type 중 하나로 보는 편이 정확하다. OpenSSH 코드에서 channel_new(..., "session", ...) 같은 호출을 볼 때도 “새로운 SSH 연결 전체를 만든다”기보다 “이미 만들어진 SSH connection 위에 session 타입 channel 하나를 연다”라고 읽는 것이 자연스럽다.
  5. channel은 session만 있는 게 아님
    일반적인 터미널 접속이나 원격 명령 실행은 session channel을 쓰지만, port forwarding은 direct-tcpip/forwarded-tcpip channel을 쓰고, X11 forwarding은 x11 계열 channel을 쓴다. 즉 하나의 SSH connection 위에는 목적이 다른 여러 논리 스트림이 함께 올라갈 수 있다.

session channel은 우리가 가장 자주 보는 형태다. 일반적인 ssh user@host 접속, ssh host command 실행, sftp 같은 subsystem 실행은 모두 이 session channel 위에서 shell, exec, subsystem, pty-req 같은 channel request를 보내는 식으로 구성된다. 다만 대부분의 사용자는 터미널 접속이나 원격 명령 실행을 통해 session 타입 channel을 가장 먼저 접하므로 기본 흐름은 session channel을 중심으로 설명한다.

 

이 구조를 이해하면 OpenSSH multiplexing이 무엇을 재사용할지가 명확해진다. multiplexing은 이미 인증까지 끝난 하나의 SSH transport connection을 마스터 프로세스가 유지하고, 이후의 ssh 실행은 그 위에 새 channel만 추가로 요청하는 방식이다.


2. 새 session channel 생성과 연결 비용의 반복

기본 옵션으로 다음 명령을 실행한다고 해보자.

ssh user@example.com

 

이 경우에는 보통 하나의 ssh 프로세스가 하나의 TCP connection과 하나의 대표 session channel을 가진다. OpenSSH 클라이언트 쪽 흐름을 구현 관점에서 단순화하면 다음과 같다.

 

ssh main()
  └─ 설정 파일/CLI 옵션 파싱
  └─ TCP 연결 생성
  └─ SSH transport 초기화: 버전 교환, KEX, 인증
  └─ ssh_session2()
     └─ ssh_session2_open(): 논리적 채널 생성
        └─ channel_new("session", SSH_CHANNEL_OPENING, ...)
        └─ channel_send_open()
     └─ open confirm 이후 client_session2_setup(): 채널을 어떤 용도로 쓸지 협상
        └─ pty 할당 요청 / 환경 변수 세팅 / 단순 명령 exec / shell 할당 요청
  └─ client_loop()
     ├─ network socket
     ├─ channel fd
     ├─ window adjust
     ├─ channel data/eof/close
     └─ server alive / rekey / signal 처리

 

위 흐름에서 짧은 요청 하나를 보내기 위해 매번 TCP 연결 생성부터 SSH transport 초기화, KEX, 인증까지 반복하는 것은 꽤 비싸다. 이미 인증까지 끝난 SSH transport connection을 유지할 수 있다면, 이후 실행에서는 이 앞단을 생략하고 기존 connection 위에 새로운 session channel만 열면 된다.

 

이 글에서 말하는 “세션 채널 캐싱”은 개별 session channel 자체를 저장해 두고 재사용한다는 뜻은 아니다. OpenSSH가 실제로 재사용하는 것은 더 비싼 단위인 SSH transport connection이고, 필요할 때마다 그 위에 새 session channel을 빠르게 추가한다.

OpenSSH의 multiplexing은 이 지점에서 등장한다.


3. Multiplexing의 핵심 아이디어

OpenSSH의 multiplexing은 여러 ssh 프로세스가 하나의 TCP socket을 직접 나눠 쓰는 구조가 아니다. 더 정확히는 마스터 프로세스가 서버와의 SSH 연결을 독점하고, 슬레이브 프로세스들은 마스터에게 작업을 위임하는 구조다.

 

마스터가 필요한 이유는 SSH transport가 단순한 byte stream이 아니기 때문이다. 하나의 SSH connection에서는 packet sequence, MAC/cipher 상태, rekey 타이밍, channel id 공간이 모두 하나의 순서 안에서 관리된다. 여러 프로세스가 같은 TCP socket에 직접 SSH 패킷을 쓰면 이 상태를 일관되게 직렬화하기 어렵다. 그래서 OpenSSH는 TCP socket과 SSH transport 상태를 마스터 하나만 소유하게 만든다.

master ssh process
  ├─ 서버와의 TCP socket 보유
  ├─ SSH transport 암호화 상태 보유
  ├─ channel id / window / rekey 상태 관리
  └─ 기존 SSH connection 위에 새 session channel 생성

slave ssh process
  └─ ControlPath의 Unix domain socket으로 master에 접속
      └─ MUX_C_NEW_SESSION 요청 `이 command로 session channel 하나 열어줘`

 

Unix domain socket은 multiplexing을 자동으로 해주는 소켓이 아니라, 같은 머신 안의 slave와 master가 대화하기 위한 로컬 통로다. 이 소켓은 master가 ControlPath 경로에 listener로 만들어두고, slave는 같은 경로로 connect 해 master를 찾는다.

 

mux.c의 클라이언트 경로는 이 소켓으로 master에 접속한 뒤 메시지를 보낸다. 일반적인 새 명령 실행이라면 이 메시지는 MUX_C_NEW_SESSION이고, master는 이벤트 루프에서 control socket 이벤트를 받아 요청을 해석한 뒤 자신이 가진 SSH connection 위에 새 session channel을 연다.


4. 슬레이브가 붙는 과정: muxclient()

이제 두 번째 ssh를 실행했다고 해보자.

ssh user@example.com hostname

 

ControlMaster auto이고 같은 ControlPath에 이미 마스터가 있으면, 새 ssh 프로세스는 TCP 연결을 만들기 전에 mux.cmuxclient() 경로를 시도한다. 여기서 슬레이브가 붙는 대상은 원격 서버가 아니라, 이미 떠 있는 로컬 마스터 프로세스다.

 

muxclient(ControlPath)
  ├─ AF_UNIX socket 생성
  ├─ ControlPath로 로컬 master에 connect()
  ├─ 실패하면:
  │   ├─ auto 계열이면 일반 SSH 연결로 fallback
  │   └─ stale socket처럼 보이면 정리
  └─ 성공하면:
      ├─ master와 mux protocol HELLO 교환
      └─ 요청 종류에 따라 session / forward / check / exit / proxy 수행

 

HELLO 교환은 슬레이브가 ControlPath의 Unix domain socket으로 마스터에 붙은 뒤, 로컬 마스터와 mux protocol 버전이 맞는지 확인하는 단계다. 이 확인이 끝나야 슬레이브는 “이 마스터에게 세션 생성을 위임해도 된다”라고 판단하고 다음 요청으로 넘어간다.

일반적인 새 쉘/명령 실행은 MUX_C_NEW_SESSION 요청으로 이어진다. 여기서 중요한 점은 슬레이브가 단순히 명령어 문자열만 보내는 것이 아니라, 자신이 가진 세션 제어권을 마스터에게 넘긴다는 점이다.

 

특히 fd 전달이 핵심이다. Unix domain socket의 프로세스 간 file descriptor 전달 기능을 이용, 슬레이브의 stdin, stdout, stderr fd를 마스터에게 보낸다. 그래서 마스터는 슬레이브의 터미널 입출력 통로를 직접 가진 상태로 새 session channel을 만들 수 있다.

 

이후 슬레이브는 네트워크 패킷을 직접 만들지 않는다. 마스터가 받은 fd를 session channel의 입출력에 연결하고, 서버와의 실제 SSH protocol exchange를 대신 수행한다. 슬레이브 프로세스는 foreground에 남아 있다가 마스터가 보내주는 session open 결과, 종료 상태, 에러 메시지를 기다린다.

 

그래서 사용자는 두 번째 ssh도 독립 프로세스처럼 느낀다. 터미널도 있고, 종료 코드도 받고, 표준 입/출력도 분리되어 있다. 하지만 실제 TCP 연결, 암호화, channel 생성과 관리는 모두 마스터가 이미 가지고 있던 하나의 SSH connection 위에서 처리된다.


5. 종료 과정: 세션 종료와 마스터 종료는 다르다

multiplexing을 이해할 때 종료 과정을 분리해서 봐야 한다.

 

첫 번째는 개별 session channel의 종료다. 예를 들어 슬레이브로 실행한 ssh host hostname 명령이 끝나면, 서버는 session channel에 exit status를 보낸다. 마스터는 이 정보를 control socket을 통해 슬레이브에게 알려주고, 슬레이브는 그 exit status로 종료한다. mux_client_request_session()MUX_S_SESSION_OPENED를 받은 뒤 계속 control socket을 읽다가 MUX_S_EXIT_MESSAGE 등을 처리한다.

 

두 번째는 마스터의 종료다. 마스터는 여러 이유로 끝날 수 있다.

1. 일반적인 ssh session이 끝나고 더 유지할 이유가 없을 때
2. ControlPersist timeout이 지났을 때
3. 사용자가 `ssh -O exit host`를 보냈을 때
4. 네트워크 연결이 끊겼을 때
5. 마스터 프로세스가 죽었을 때

 

ssh -O exit는 mux protocol의 MUX_C_TERMINATE 요청으로 간다. 마스터는 이를 받으면 종료 플래그를 세우고 loop를 빠져나간다. 반면 ssh -O stop은 조금 다르다. 이것은 새 multiplexing 요청을 더 받지 않도록 control socket을 닫는 쪽에 가깝다. client_stop_mux()ControlPath를 unlink 하고, persist 모드이거나 shell이 없는 경우 모든 active channel이 닫힌 뒤 종료되도록 표시한다.


6. ControlPersist: 연결을 언제까지 들고 있을까

ControlMaster는 이미 만들어진 SSH transport connection을 마스터 프로세스가 들고 있게 만든다. 그렇다고 해서 마스터가 자신의 원래 session이 끝나는 즉시 항상 죽는 것은 아니다. clientloop.c의 메인 루프는 session_closed만 보지 않고 channel_still_open(ssh)도 함께 본다. 즉 마스터가 실행한 원래 명령이 끝났더라도 아직 슬레이브가 만든 channel이 하나라도 열려 있으면 마스터는 계속 살아서 대행을 이어간다.

 

ControlMaster만 있는 경우
  ├─ 마스터의 원래 session 종료
  ├─ 그래도 열린 slave channel이 있으면 유지
  └─ 모든 channel이 닫히면 즉시 종료

 

ControlPersist는 여기서 한 걸음 더 나간다. 모든 session channel이 닫혀 더 이상 처리할 슬레이브가 없어도, 마스터를 일정 시간 더 살려둔다. 이 로직은 clientloop.cset_control_persist_exit_time()에 드러난다. 열린 channel이 있으면 종료 예약을 취소하고, 열린 channel이 없으면 ControlPersist 설정값에 따라 종료 시각을 잡는다.

 

ControlPersist가 있는 경우
  ├─ 열린 channel이 있으면 계속 유지
  ├─ 열린 channel이 없어도 바로 종료하지 않음
  ├─ ControlPersist yes / 0 이면 계속 대기
  └─ ControlPersist 10m 이면 마지막 channel 종료 후 10분 뒤 종료

 

그래서 ControlMaster만으로도 “이미 붙어 있는 슬레이브”는 보호된다. 하지만 모든 슬레이브가 잠깐이라도 0개가 되는 순간 마스터가 사라지기 때문에, 다음 짧은 요청은 다시 TCP 연결과 인증부터 시작해야 한다. ControlPersist는 바로 이 공백을 메우기 위한 옵션이다. Ansible, 배포 스크립트, 짧은 ssh host command를 반복하는 환경에서 효과가 큰 이유도 여기에 있다.


7. 왜 이렇게 설계했을까?

OpenSSH multiplexing의 핵심은 공유할 것과 위임할 것을 분리한 것이다. TCP socket과 SSH transport 상태는 마스터가 계속 들고 있고, 슬레이브는 매번 새로 생기는 사용자 입출력과 명령 실행 요청만 마스터에게 넘긴다.

마스터가 관리하는 범위
  ├─ TCP socket
  ├─ SSH transport 암호화 상태
  ├─ rekey 상태
  ├─ channel id / window 상태
  └─ 서버와의 실제 protocol exchange

슬레이브가 관리하는 범위
  ├─ 사용자 터미널/stdio
  ├─ 실행하고 싶은 command
  ├─ 이번 실행의 옵션
  └─ 종료 상태를 기다리는 foreground 프로세스

 

이렇게 나누면 SSH transport의 복잡한 상태는 한 프로세스 안에서만 직렬화할 수 있다. packet sequence, MAC/cipher 상태, rekey, channel window 같은 값은 마스터가 일관되게 관리하고, 슬레이브는 Unix domain socket을 통해 “새 session channel을 열어달라”는 고수준 요청만 보낸다.

 

동시에 사용자 경험은 기존 ssh와 크게 달라지지 않는다. 슬레이브는 자기 stdin, stdout, stderr fd를 마스터에게 넘기고 foreground에서 종료 상태를 기다린다. 사용자는 독립적인 ssh 명령을 실행한 것처럼 느끼지만, 실제로는 마스터가 기존 SSH connection 위에 새 channel을 열어 대신 처리한다.

 

결국 ControlMaster는 “TCP socket을 여러 프로세스가 같이 만지는 기능”이 아니라, 마스터가 연결 상태를 중앙에서 관리하고 슬레이브의 요청을 channel 단위로 대행하는 구조다. 마스터가 소유한 암호화나 시퀀스 상태를 자신의 명령이 끝날 때 살아 있는 슬레이브에게 원자적으로 넘겨주는 까다로운 방식을 택하지도 않는다. 이 덕분에 TCP handshake, key exchange, 인증 같은 비싼 단계를 반복하지 않으면서도 SSH transport의 상태를 안전하게 유지할 수 있다.


Reference

반응형

'기타' 카테고리의 다른 글

git diff, patch 사용법  (0) 2024.02.04
[오류] M1 Mac에서 Python MediaPipe 설치 중 OpenCV 관련 오류  (0) 2022.11.24