우당탕탕 개발일지

[K8S] C 기반 HTTP 통신 구현 및 Kubernetes 배포 본문

Server/Linux, C

[K8S] C 기반 HTTP 통신 구현 및 Kubernetes 배포

YUDENG 2026. 7. 12. 20:32

 

 

GitHub - TirTir/ZamTok: C#을 활용한 채팅 프로그램

C#을 활용한 채팅 프로그램. Contribute to TirTir/ZamTok development by creating an account on GitHub.

github.com

 

C언어로 HTTP 통신을 이용해 클라이언트와 서버간 간단한 통신을 구현했다. 중심적으로 봐야할 부분은 다음과 같다.

 

소켓 생성

 

 

먼저 클라이언트와 서버가 통신할 수 있도록 IPv4 기반의 TCP 소켓을 사용하였다.
소켓에서 IPv4 주소를 표현할 때는 일반적으로 다음과 같은 struct sockaddr_in 구조체를 사용한다.

struct sockaddr_in {
    sa_family_t    sin_family; // AF_INET
    in_port_t      sin_port;   // 포트 (네트워크 바이트 오더)
    struct in_addr sin_addr;   // IP 주소
};

 

처음에는 인자로 서버 주소를 직접 넘기도록 설정했다. 로컬 환경에서는 문제가 없지만, 쿠버네티스 환경에서는 서버 파드의 IP 주소가 계속 바뀌기 때문에 접속에 실패했다.

 

그래서 서비스 이름과 포트 번호를 "호스트 문자열 + 포트" 형태로 전달한 뒤 getaddrinfo()를 통해 실제 접속 가능한 sockaddr 주소 정보로 변환하도록 구현하였다.

 

getaddrinfo()
int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);
  • "127.0.0.1" → 숫자 IPv4 주소
  • "localhost" → 로컬 이름 해석
  • "example.com" → DNS 조회
  • 경우에 따라 "::1" → IPv6 주소

 

getaddrinfo()의 결과는 하나의 주소만 반환되는 것이 아니라, 해석된 주소 후보가 여러 개일 수도 있다. 이 후보들은 struct addrinfo 연결 리스트 형태로 구성되며, 각 노드는 ai_next를 통해 다음 후보 주소를 가리킨다. 

 

res
 ↓
+-------------------+      +-------------------+      +-------------------+
| ai_family         |      | ai_family         |      | ai_family         |
| ai_socktype       |      | ai_socktype       |      | ai_socktype       |
| ai_addr --------- |----> | ai_addr --------- |----> | ai_addr --------- |
| ai_next --------- |----> | ai_next --------- |----> | ai_next = NULL    |
+-------------------+      +-------------------+      +-------------------+

 

 

따라서 이 리스트를 순회하면서 각 후보 주소에 대해 connect()를 하나씩 시도하도록 구성되어 있다. 여기서 ai_family를 AF_INET으로 설정해두었기 때문에 getaddrinfo()는 IPv4 주소 후보만 반환하도록 동작한다.

  • hints.ai_family = AF_INET;
snprintf(port_str, sizeof(port_str), "%d", port);

memset(&hints, 0, sizeof(hints));
hints.ai_family   = AF_INET;
hints.ai_socktype = SOCK_STREAM;

rc = getaddrinfo(host, port_str, &hints, &res);

...

for (rp = res; rp != NULL; rp = rp->ai_next) {
	if (connect(socket, rp->ai_addr, rp->ai_addrlen) == 0) {
		rc = SOCKET_OK;
		break;
	}
}

 

 

데이터 송수신

 

HTTP 요청은 문자열 형태의 바이트 데이터로 구성되며, 클라이언트는 이를 소켓에 write()를 호출해 전송한다.

n = (int)write(socket, req_buf, (size_t)len);

 

 

커맨드 컨트롤러

 

TCP 소켓은 보통 블로킹 모드가 기본이다. main 스레드에서 read() 를 호출했을 때 서버 응답을 바로 받지 못하면, 데이터가 들어올 때까지 사용자의 입력을 처리하지 못한다. 그래서 소켓은 논블로킹으로 바꾸고, 입력 처리 전용 스레드를 띄웠다.

 

소켓 논블로킹

 

연결된 소켓에 대해 SET_NONBLOCKING(socket)을 호출해 O_NONBLOCK 플래그를 설정한다.

fcntl(F_GETFL)로 기존 플래그를 읽어온 뒤, fcntl(F_SETFL, flags | O_NONBLOCK)로 논블로킹 속성을 추가하는 방식이다.

client_fd = accept( socket, (struct sockaddr *)&t_client_addr, &un_socket_len );
	
    ...
    
SET_NONBLOCKING(client_fd);
tEv.events = EPOLLIN;
tEv.data.fd = client_fd;

rc = epoll_ctl( epfd, EPOLL_CTL_ADD, client_fd, &tEv );

 

이렇게 논블로킹으로 바꾸면 아직 읽을 데이터가 없으면 read()가 블로킹되지 않고 즉시 반환하며, EAGAIN 또는 EWOULDBLOCK 을 통해 현재 수신 가능한 데이터가 없음을 알 수 있다. 

 

또한, 통신용 소켓 FD를 epoll에게 감시 대상으로 등록하여, 클라이언트가 데이터를 전송해 소켓이 읽기 가능한 상태가 되면 EPOLLIN 이벤트를 통해 이를 처리하도록 구현하였다.

 

커맨드 처리 스레드

 

pthread_create()로 컨트롤러 핸들러를 띄우고, 해당 스레드에서는 컨트롤러 핸들러가 표준 입력으로부터 fgets()를 사용해 사용자 명령을 읽고 처리한다. fgets()는 입력이 들어올 때까지 대기하는 블로킹 함수이기에, 이 스레드는 사용자 입력을 기다리는 동안 블로킹 상태로 동작한다.

 

while (1) 
{
	printf("> ");
	fflush(stdout);

	if (!fgets(line_buf, sizeof(line_buf), stdin))
    	break;

	nargs = parse_cmd_line(line_buf, argv, MAX_ARGC);
	CTRL_proc(nargs, argv);
}

 

이제 해당 프로그램을 쿠버네티스 환경에 배포할 예정이다.

 

 

쉘 스크립트

 

앞으로 수정할 때 코드 수정, 바이너리 파일 확인, yaml 파일 수정 등 여러 디렉토리에서 수정해야 할 부분이 많아져서 한꺼번에 창이 켜지는 쉘 스크립트를 생성해두었다.

 

[open-windows.sh]

#!/bin/bash

cmd.exe /c wt -w 0 \
new-tab wsl -d Ubuntu-24.04 -e bash -c "cd /home/user/dev/ZamTok/src/CLIENT && exec bash" \; \
new-tab wsl -d Ubuntu-24.04 -e bash -c "cd /home/user/dev/ZamTok/src/SERVER && exec bash" \; \
new-tab wsl -d Ubuntu-24.04 -e bash -c "cd /home/user/dev/ZamTok/src/release && exec bash" \; \
new-tab wsl -d Ubuntu-24.04 -e bash -c "cd /home/user/dev/ZamTok/pkg && exec bash" \; \
new-tab wsl -d Ubuntu-24.04 -e bash -c "cd /home/user/dev/ZamTok && exec bash"

 

sh로 직접 실행하면 다음과 같이 설정한 대로 다섯개의 탭과 지정된 디렉토리 위치로 열린다.

$ sh open-windows.sh

 

 

C로 만든 바이너리 이미지 만들기

 

MAC OS는 리눅스 환경이기 때문에 그대로 진행했지만 윈도우는 WSL을 설치해서 우분투 환경에서 진행했다. 회사 노트북과 집 노트북이 달라서 두 개로 진행했지만, 환경만 다를 뿐 진행 과정은 일치한다.

 

[ Dockerfile-client ]

FROM rockylinux:9

WORKDIR /app

COPY CLIENT /app/client

RUN chmod +x /app/client

CMD ["./client"]

 

[ Dockerfile-server ]

# ----- [Stage 1: Build] -----
FROM rockylinux:9 AS builder

RUN dnf install -y --allowerasing gcc make tar curl

# hiredis 1.1.0 소스 설치
RUN curl -L https://github.com/redis/hiredis/archive/refs/tags/v1.1.0.tar.gz -o hiredis.tar.gz && \
    tar xvzf hiredis.tar.gz && \
    cd hiredis-1.1.0 && \
    make && make install


# ----- [Stage 2: Runtime] -----
FROM rockylinux:9

WORKDIR /app

COPY --from=builder /usr/local/lib/libhiredis.so* /usr/local/lib/

RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf && \
    ldconfig

COPY SERVER /app/server
RUN chmod +x /app/server

EXPOSE 8080

CMD ["/app/server", "8080"]

 

서버는 Redis를 사용해서 데이터를 저장하기 때문에 도커 파일이 약간 다르다.

 

Git Action으로 CI/CD 구축

 

코드를 수정할 때마다 이미지를 빌드하고, 이미지 버전에 맞게 yaml 파일 수정하고, 다시 재배포 해야 하는 과정을 거쳐야 한다. 비효율적이라 판단을 해서 만능 Git 으로 CI/CD를 구축했다.

 

name: Docker Image CI

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Install build dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc make libhiredis-dev
      - name: Build client
        run: make -C src/CLIENT rb

      - name: Build server
        run: make -C src/SERVER rb

      - name: Prepare binaries for Docker
        run: |
          cp src/release/CLIENT src/CLIENT
          cp src/release/SERVER src/SERVER
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push client image
        uses: docker/build-push-action@v6
        with:
          context: ./src
          file: src/release/Dockerfile-client
          push: ${{ github.event_name != 'pull_request' }}
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/zamtok-client:latest
            ${{ secrets.DOCKER_USERNAME }}/zamtok-client:${{ github.sha }}
      - name: Build and push server image
        uses: docker/build-push-action@v6
        with:
          context: ./src
          file: src/release/Dockerfile-server
          push: ${{ github.event_name != 'pull_request' }}
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/zamtok-server:latest
            ${{ secrets.DOCKER_USERNAME }}/zamtok-server:${{ github.sha }}

 

 

쿠버네티스 환경 구축

 

일반 노트북 환경에서 쿠버네티스를 사용하기 위해서는 도커 데스크탑으로 먼저 설치를 해주어야 한다. 도커로 설치한 이유는 OS에 kubelet을 설치 등을 서비스로 설치하면 부팅할 때마다 구성 요소가 올라온다. 즉, 사용하지 않을 때에도 백그라운드 CPU, 메모리를 쓰기 때문이다. 도커 데스크탑으로 설치하면 도커 데스크탑을 실행시켜야 클러스터가 동작한다.

 

 

도커 데스크탑 설정에서 Kubernetes 를 활성화해주면 자동으로 쿠버네티스 클러스터가 설치되고 시작된다. 정상적으로 설치가 되었다면 다음과 같이 뜰 것이다.

 

Kubernetes 문서상 kubectl은 기본적으로 $HOME/.kube/config의 context를 사용해서 클러스터와 통신한다. context는 cluster, user, namespace 정보를 묶은 항목이다.

 

위에서 CI/CD로 구축했기 때문에 도커 허브에서 이미지를 Pull 해오는 방식이다.

 

 

Deployment

 

Deployment는 파드를 직접 하나씩 관리하는 객체가 아니라, 원하는 파드 개수와 상태가 유지되도록 관리하는 객체이다. 보통 쿠버네티스에서는 Deployment → ReplicaSet → Pod 구조로 동작한다.

 

1. Deployment가 생성됨.

2. Deployment가 ReplicaSet을 만듦

3. ReplicaSet이 지정된 개수만큼 Pod를 유지함.

 

StatefulSet과의 차이점은 파드의 집합을 관리하느냐, 고유한 식별성을 유지하느냐의 차이이다. StatefulSet은 각 파드가 고유한 식별자와 순서를 가지고 생성이 된다. 

 

zamtok의 경우, 서버는 응답을 내려주고 로그를 기록하는 역할만 담당할 것이기 때문에 Deployment로 구성하였고, 클라이언트의 경우 각 파드에 접속해 채팅을 직접 진행할 것이기 때문에 StatefulSet 으로 구성하였다.

 

 

[ client.yaml ]

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-client
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test-client
  template:
    metadata:
      labels:
        app: test-client
    spec:
      containers:
        - name: client
          image: test-client:1.0
          imagePullPolicy: Never
          command: ["./client"]
          args: ["128.0.0.1", "9000"]
          ports:
            - containerPort: 8080
          env:
            - name: SERVER_HOST
              value: client-service
            - name: SERVER_PORT
              value: "9000"

 

  • 이 YAML은 test-client라는 이름의 Deployment를 만든다.
  • Pod는 2개를 유지하며, spec.template에 만들 Pod의 spec을 정의한다.
  • Pod는 test-client의 Image를 사용한다. Client를 실행시키기 위해서는 args에 ip와 port를 넘겨줘야 한다.
    • 실행 명령어: ./client <IP> <Port>

 

StatefulSet

 

 

다음 restart 명령어는 현재 클러스터에 저장된 StatefulSet 설정으로 다시 시작만 하는 명령이기 때문에, 로컬에서의 yaml 파일이 수정되었다고 해도 자동으로 반영되지 않는다.

 

$ kubectl rollout restart statefulset test-client

 

그래서 apply를 먼저 해주어야 설정이 바뀐다.

$ kubectl apply -f client.yaml

이 상태에서 기존 파드를 삭제해야 기존 이미지가 삭제되고, replica 수의 설정대로 파드가 자동으로 다시 생성된다.

$ kubectl delete pod test-client-0

 

Service

Service는 다음과 같은 이유로 사용한다.

  • Server Pod가 죽었다가 살아나면 Pod IP가 바뀌기 때문에 Client Pod에서 접근하지 못한다.
  • Server Pod를 여러 개 띄울 경우, 어느 Pod에 접근해야 할지 모른다.

Service 를 사용하게 되면 다음 역할을 해주게 된다.

  • 고정된 접근 이름 제공
    Service는 Pod IP 대신 Service의 고정된 이름으로 접근을 한다.
  • 로드밸런싱
    같은 Pod가 Replica로 여러 개가 뜰 경우, 로드밸런싱을 해준다.
  • Pod 교체 대응
    Pod가 죽고 새로 떠도 Service 주소는 그대로 유지된다.
  • 내부 통신 단순화

 

[ server.yaml ]

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: test-server
  template:
    metadata:
      labels:
        app: test-server
    spec:
      containers:
        - name: server
          image: test-server:1.0
          imagePullPolicy: Never
          command: ["./server"]
          args: ["9000"]
          ports:
            - containerPort: 9000
---
apiVersion: v1
kind: Service
metadata:
  name: test-server-service
spec:
  selector:
    app: test-server
  ports:
    - port: 9000
      targetPort: 9000
  type: ClusterIP

 

 

Deployment에서 Pod에 test-server 라벨을 붙여두고, Service에서 해당 라벨을 사용한다.

이렇게 Server Pod 들을 test-server-service로 묶어두었기 때문에, Client 인자로 <IP> <Port> 대신 <Service Name> <Port>로 념겨주어야 한다.

 

. . .
    spec:
      containers:
        - name: client
          image: test-client:1.0
          imagePullPolicy: Never
          command: ["./client"]
          args: ["test-server-service", "9000"]
          ports:
            - containerPort: 9000

 

YAML 수정 후 다시 다음 명령어로 배포해주면 된다.

$ kubectl apply -f server.yaml 

 

replica를 2개로 지정했지만 다음과 같이 롤링 업데이트 중에는 잠깐 더 많이 보일 수도 있다. Deployment 에서는 정상적으로 2개가 뜨는것을 볼 수 있다.

 

STATUS 의미 자주 있는 원인
Pending Pod는 생성됐지만 아직 실행되지 않음 스케줄링 대기, 이미지 다운로드 중, 볼륨 마운트 대기
ContainerCreating 컨테이너 생성 중 이미지 준비, 네트워크 설정, 볼륨 연결 중
Running Pod가 정상 실행 중 컨테이너가 살아 있음
Completed 작업이 정상 종료됨 배치 작업, Job, 실행 후 종료되는 프로그램
Error 컨테이너가 에러로 종료됨 프로그램 내부 오류, 실행 실패
CrashLoopBackOff 실행 직후 계속 죽어서 재시작 반복 앱 에러, 잘못된 args, 연결 실패, 프로세스 즉시 종료
ImagePullBackOff 이미지 pull 재시도 중 이미지 이름 오류, 레지스트리 접근 실패
ErrImagePull 이미지 pull 실패 이미지 없음, 권한 문제, 태그 오타
CreateContainerConfigError 컨테이너 설정 문제 Secret/ConfigMap/env 설정 오류
CreateContainerError 컨테이너 생성 실패 잘못된 command, 실행 파일 없음
RunContainerError 컨테이너 실행 시작 실패 실행 권한 문제, 바이너리 문제
Terminating Pod 삭제 중 kubectl delete pod 실행, 롤링 업데이트 중
Init:0/1, Init:1/2 init container 진행 중 init container 아직 미완료
OOMKilled 메모리 부족으로 강제 종료 메모리 limit 부족, 메모리 누수
Unknown Pod 상태를 정확히 알 수 없음 노드 문제, kubelet 통신 문제

 

 

ISSUE : Connection refused (111)

  • test-server-service:9000 으로 접속을 시도했고
  • 그 주소/포트에 연결을 받아주는 프로세스가 없음
[INFO] Connecting to 128.0.0.1:9000
[SOCKET_Connect] Connect Fail <111:Connection refused>
[ERROR] SOCKET_Connect Fail

 

처음에는 인자로 서버 주소를 직접 넘기도록 설정했다. 로컬 환경에서는 문제가 없지만, 쿠버네티스로 배포된 환경에서는 서버 파드의 IP 주소가 계속 바뀌기 때문에 접속에 실패했다.

 

그래서 클라이언트 측의 코드에 서버의 IP 대신 Service 이름을 사용하도록 변경하였다.

728x90