챕터 7 유닉스 도메인 소켓!
// INDEX
GNPS (Go Network Programming School)의 내용을 정리한 글입니다.
Go 언어를 활용한 네트워크 프로그래밍 - 책으로 스터디를 진행합니다.

유닉스 도메인 소켓이란?
유닉스 도메인 소켓은 파일 시스템을 이용하여 패킷의 목적지 주소를 결정하는 통신 방법입니다. 동일한 노드 내에서 동작하는 프로세스 간 통신(IPC, Inter-Process Communication)을 가능하게 하여 데이터를 주고받을 수 있도록 합니다.
일반적인 네트워크 소켓은 IP 주소와 포트 번호로 정의됩니다. 이를 통해 동일한 IP를 가진 노드 내의 여러 서비스가 서로 다른 포트로 구분되어 수신되는 트래픽을 처리할 수 있습니다.
유닉스 도메인 소켓은 이러한 소켓 주소 정의 원칙을 파일 시스템에 적용한 것입니다. 유닉스 도메인 소켓에는 파일 시스템 내에 연관된 파일이 존재하며, 이 파일이 네트워크 소켓의 IP 주소와 포트 번호에 대응합니다. 해당 파일에 네트워크 연결을 맺고 데이터를 읽고 쓰면서 해당 소켓에서 대기 중인 서비스와 통신할 수 있습니다.
유닉스 도메인 소켓의 장점
파일 시스템 기반이기 때문에 유닉스의 파일 시스템 권한으로 접근을 제어할 수 있습니다. 또한 운영체제의 네트워크 스택을 지나지 않고 파일로 직접 접근하기 때문에 트래픽 라우팅에 대한 오버헤드가 존재하지 않아 효율적입니다. 패킷 단편화나 패킷 순서에 대해서도 걱정할 필요가 없습니다.
따라서 로컬 서비스와 통신하는 경우 유닉스 도메인 소켓을 사용하지 않으면 보안상 이점과 성능상 이점을 놓치게 됩니다.
다만 네트워크 스택을 지나지 않기 때문에 다른 노드와의 통신이 아닌, 소켓을 사용하는 노드 내에서만 적용됩니다.
유닉스 도메인 소켓 파일에 바인딩
유닉스 도메인 소켓 타입
유닉스 도메인 소켓은 세 가지 종류가 있습니다.
- 스트리밍 소켓(unix): TCP처럼 동작합니다.
- 데이터그램 소켓(unixgram): UDP처럼 동작합니다.
- 시퀀스 패킷 소켓(unixpacket): 두 요소를 조합하여 동작합니다. 하이브리드 특성과 읽지 않은 데이터를 버리는 특성 때문에 대부분의 경우 사용하지 않습니다.
스트리밍 소켓 - unix
스트리밍 유닉스 도메인 소켓은 TCP의 메시지 확인, 체크섬, 혼잡 제어 등의 오버헤드 없이 TCP처럼 작동합니다.
일반적인 스트림 기반의 에코 서버를 생성하는 함수는 다음과 같습니다. streamingEchoServer의 매개변수인 network와 addr을 전달하는 방식에 따라 다른 노드와의 통신을 위한 tcp 타입의 연결을 생성할 수도 있고, 유닉스 소켓 주소와 통신하기 위한 unix 타입의 도메인 소켓 연결을 생성할 수도 있습니다.
네트워크 타입이 tcp라면 주소 문자열은 127.0.0.1:80과 같이 IP와 포트의 조합이어야 합니다. 네트워크 타입이 unix이거나 unixpacket인 경우 주소 문자열은 존재하지 않는 파일의 경로여야 합니다. 만약 해당 파일 경로에 유닉스 도메인 소켓 파일이 존재하는 경우 운영체제는 주소가 이미 사용 중이라는 에러를 반환합니다.
package uxnidomainsock
import (
"context"
"net"
)
func streamingEchoServer(ctx context.Context, network string, addr string) (net.Addr, error) {
s, err := net.Listen(network, addr)
if err != nil {
return nil, err
}
go func() {
// 별도 고루틴에서 서버 종료를 알리는 시그널을 수신 대기 -> 리스너를 종료한다.
go func() {
<-ctx.Done()
_ = s.Close()
}()
for {
conn, err := s.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
// 수신 메시지 에코잉
for {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return
}
_, err = conn.Write(buf[:n])
if err != nil {
return
}
}
}()
}
}()
return s.Addr(), nil
}
net.Listen 또는 net.ListenUnix 함수로 생성된 리스너가 종료되면 유닉스 소켓 파일을 자동으로 삭제합니다. 반면 net.ListenPacket으로 생성된 유닉스 도메인 소켓은 리스너 종료 시 자동으로 소켓 파일을 삭제하지 않습니다.
소켓이 네트워크 소켓이든 유닉스 도메인 소켓이든 상관없이 추상화된 net.Conn 인터페이스의 Read와 Write 메서드를 사용하여 클라이언트와 통신합니다.
package uxnidomainsock
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
)
func TestEchoServerUnix(t *testing.T) {
dir, err := os.MkdirTemp("", "echo_unix")
if err != nil {
t.Fatal(err)
}
t.Logf("This is temp dir: %s", dir)
defer func() {
if rErr := os.RemoveAll(dir); rErr != nil {
t.Error(rErr)
}
}()
ctx, cancel := context.WithCancel(context.Background())
sock := filepath.Join(dir, fmt.Sprintf("%d.sock", os.Getpid()))
rAddr, err := streamingEchoServer(ctx, "unix", sock)
if err != nil {
t.Fatal(err)
}
defer cancel()
// /tmp/echo_unix/123.sock 에 모든 사용자가 읽기 쓰기 권한 있는지 확인
err = os.Chmod(sock, os.ModeSocket|0666)
if err != nil {
t.Fatal(err)
}
conn, err := net.Dial("unix", rAddr.String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
msg := []byte("ping")
for i := 0; i < 3; i++ {
_, err = conn.Write(msg)
if err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
t.Fatal(err)
}
expected := bytes.Repeat(msg, 3)
if !bytes.Equal(expected, buf[:n]) {
t.Fatalf("expected reply %q; actual reply %q", expected, buf[:n])
}
}
스트리밍 방식의 유닉스 소켓으로 데이터를 읽고 쓰는 테스트 코드입니다. 테스트 코드는 앞서 다룬 TCP, UDP 서버 테스트 코드와 비슷한 흐름을 보입니다. 다만 유닉스 도메인 소켓 파일에 모든 사용자의 읽기 및 쓰기 권한이 있도록 변경하는 코드가 추가되어 있습니다.
데이터그램 소켓 - unixgram
udp 혹은 unixgram과 같은 데이터그램 기반의 네트워크 타입과 통신하는 에코서버는 아래와 같이 작성할 수 있습니다.
package uxnidomainsock
import (
"context"
"net"
"os"
)
func datagramEchoServer(ctx context.Context, network string, addr string) (net.Addr, error) {
s, err := net.ListenPacket(network, addr)
if err != nil {
return nil, err
}
go func() {
// 별도 고루틴에서 서버 종료를 알리는 시그널을 수신 대기 -> 리스너를 종료한다.
go func() {
<-ctx.Done()
_ = s.Close()
if network == "unixgram" {
_ = os.Remove(addr)
}
}()
buf := make([]byte, 1024)
for {
n, clientAddr, err := s.ReadFrom(buf)
if err != nil {
return
}
_, err = s.WriteTo(buf[:n], clientAddr)
if err != nil {
return
}
}
}()
return s.LocalAddr(), nil
}
주요한 차이점은 net.ListenPacket으로 생성된 유닉스 도메인 소켓 리스너는 리스너 종료 시 자동으로 소켓 파일을 삭제하지 않는다는 점입니다. 따라서 리스너 종료 시 소켓 파일을 수동으로 제거해야 합니다.
다음은 데이터그램 에코 서버를 테스트하는 코드입니다.
// +build darwin linux
// 아래는 자동생성됩니다.
//go:build darwin || linux
package uxnidomainsock
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
)
주석으로 작성된 부분은 Go가 패키지를 빌드할 때 확인하는 빌드 제약 조건(build constraint)입니다. unixgram 네트워크 타입이 Windows 운영체제에서는 동작하지 않기 때문에 빌드 제약 조건을 이용하여 unixgram 에코 서버의 테스트 코드가 macOS와 Linux에서만 동작하도록 설정합니다.
func TestEchoServerUnixDatagram(t *testing.T) {
dir, err := os.MkdirTemp("", "echo_unixgram")
if err != nil {
t.Fatal(err)
}
defer func() {
if rErr := os.RemoveAll(dir); rErr != nil {
t.Error(err)
}
}()
ctx, cancel := context.WithCancel(context.Background())
serverSocketAddr := filepath.Join(dir, fmt.Sprintf("%d.sock", os.Getpid()))
serverAddr, err := datagramEchoServer(ctx, "unixgram", serverSocketAddr)
if err != nil {
t.Fatal(err)
}
defer cancel()
// /tmp/echo_unixgram/123.sock 에
err = os.Chmod(serverSocketAddr, os.ModeSocket|0622)
if err != nil {
t.Fatal(err)
}
clientSocketAddr := filepath.Join(dir, fmt.Sprintf("c%d.sock", os.Getpid()))
client, err := net.ListenPacket("unixgram", clientSocketAddr)
if err != nil {
t.Fatal(err)
}
defer client.Close()
err = os.Chmod(clientSocketAddr, os.ModeSocket|0622)
if err != nil {
t.Fatal(err)
}
msg := []byte("ping")
for range 3 {
_, err = client.WriteTo(msg, serverAddr)
if err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 1024)
for range 3 {
n, addr, err := client.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if addr.String() != serverAddr.String() {
t.Fatalf("received reply from %q instead of %q", addr, serverAddr)
}
if !bytes.Equal(msg, buf[:n]) {
t.Fatalf("expected reply %q; actual reply %q", msg, buf[:n])
}
}
}
전체 흐름은 unix 타입과 동일하지만, 서버와 클라이언트가 각각 소켓 파일을 가지고 있습니다. 각 소켓 파일에 대해 권한을 변경하며, 클라이언트를 이용해 서버 주소로 ping 메시지를 보내고 에코잉 된 메시지를 읽어옵니다.
클라이언트와 인증하는 서비스 작성
유닉스 도메인 소켓은 피어의 인증 정보를 수신하여 프로세스에 대한 상세 정보를 알 수 있습니다. 따라서 프로세스에 접근하려는 사용자에 대한 접근 권한 설정이 가능합니다.
특정 사용자 또는 /etc/group 내의 그룹 사용자로부터 서비스 연결을 허용할 수 있습니다. 클라이언트가 유닉스 도메인 소켓으로 연결하는 경우 서버는 클라이언트의 그룹 ID를 확인하여 허용된 그룹 ID 목록에 있는지 비교할 수 있습니다.
피어 인증 정보 요청
클라이언트와 서버의 연결 객체로부터 연결된 프로세스의 정보를 얻기 위해서는 golang.org/x/sys/unix 패키지를 이용해야 합니다. 다음 명령어를 사용해 패키지를 설치합니다.
go get -u golang.org/x/sys/unix
유닉스 도메인 소켓 연결을 받아 피어가 특정 그룹에 속하지 않는 경우 연결을 거부하는 코드는 다음과 같습니다.
package auth
import (
"log"
"net"
"os/user"
"golang.org/x/sys/unix"
)
func Allowed(conn *net.UnixConn, groups map[string]struct{}) bool {
if conn == nil || groups == nil || len(groups) == 0 {
return false
}
file, _ := conn.File()
defer file.Close()
var (
err error
ucred *unix.Ucred
)
for {
ucred, err = unix.GetsockoptUcred(int(file.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
if err == unix.EINTR {
continue
}
if err != nil {
log.Println(err)
return false
}
break
}
u, err := user.LookupId(string(ucred.Uid))
if err != nil {
log.Println(err)
return false
}
gids, err := u.GroupIds()
if err != nil {
log.Println(err)
return false
}
for _, gid := range gids {
if _, ok := groups[gid]; ok {
return true
}
}
return false
}
유닉스 도메인 소켓 연결 객체를 나타내는 net.UnixConn의 파일 객체를 저장합니다. 이후 unix.GetsockoptUcred 함수의 매개변수로 파일 디스크립터, 프로토콜 계층의 상수 unix.SOL_SOCKET, 그리고 옵션 값인 unix.SO_PEERCRED를 전달합니다.
리눅스 커널에서 소켓 옵션 값을 얻어오기 위해서는 해당 옵션과 옵션이 존재하는 계층 값이 모두 필요하기 때문에 해당 함수는 프로토콜의 계층과 옵션을 모두 인자로 받습니다.
리눅스 커널이 유닉스 도메인 소켓 계층의 피어 인증 정보를 확인하면 unix.Ucred 객체 포인터를 정상적으로 반환합니다. 해당 객체에는 피어 프로세스의 정보와 사용자 ID, 그룹 ID 정보가 포함되어 있습니다. os/user 패키지의 user.LookupId 함수를 수행하여 사용자 정보를 얻어옵니다. 이를 통해 그룹 ID를 검증하여 올바른 사용자의 접근인지 확인합니다.
서비스 작성하기
명령줄 매개변수로 전달한 그룹에 속한 클라이언트만 유닉스 도메인 소켓을 이용한 연결이 가능하도록 서비스를 작성합니다. 서비스 코드는 다음과 같습니다.
package cred
import (
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"os/user"
"path/filepath"
)
// init 에서는 클라이언트의 연결을 허용하는 그룹 ID의 목록을 flag 로 전달받습니다.
func init() {
flag.Usage = func() {
_, _ = fmt.Fprintf(flag.CommandLine.Output(), "Usage:\n\t%s \n", filepath.Base(os.Args[0]))
flag.PrintDefaults()
}
}
// GroupID 를 사용하여 user 패키지에서 그룹을 조회하고 group id 여부에 대해서 map 으로 저장합니다.
func parseGroupNames(args []string) map[string]struct{} {
groups := make(map[string]struct{})
for _, arg := range args {
grp, err := user.LookupGroup(arg)
if err != nil {
log.Println(err)
continue
}
groups[grp.Gid] = struct{}{}
}
return groups
}
GroupID를 사용하여 user 패키지에서 그룹을 조회하고 그룹 ID를 맵으로 저장합니다.
// 이어서 작성..
func main() {
flag.Parse()
groups := parseGroupNames(flag.Args())
socketAddr := filepath.Join(os.TempDir(), "creds.sock")
addr, err := net.ResolveUnixAddr("unix", socketAddr)
if err != nil {
log.Fatal(err)
}
s, err := net.ListenUnix("unix", addr)
if err != nil {
log.Fatal(err)
}
// 시그널 대기 및 고루틴을 통한 우아한 리스너 종료
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
_ = s.Close()
}()
fmt.Printf("Listening on %s ...\n", socketAddr)
for {
conn, err := s.AcceptUnix()
if err != nil {
break
}
if auth.Allowed(conn, groups) {
_, err = conn.Write([]byte("Welcome\n"))
if err == nil {
continue
}
} else {
_, err = conn.Write([]byte("Access denied\n"))
}
if err != nil {
log.Println(err)
}
_ = conn.Close()
}
}
명령줄 매개변수를 파싱하여 허용된 그룹 ID의 맵을 생성 합니다. 임시 디렉터리에 creds.sock 소켓 리스너를 생성하고 auth.Allowed 함수를 호출합니다.
Ctrl+C 키를 이용한 인터럽트 시그널로 서비스를 종료하는 경우 net.ListenUnix 함수를 사용했음에도 Go가 소켓 파일을 정리하지 못하게 되므로, 시그널을 대기하고 별도의 고 루틴에서 시그널을 수신한 후 리스너를 종료할 수 있도록 처리합니다.