| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- pmd
- dpdk
- DPDK EAL
- Descriptor Ring
- mbuf
- redis
- Pubsub
- Mempool
- lcore
- Kernel Bypass
- Publish
- redisGetReply
- SR-IOV
- kni
- DPDK Architecture
- hiredis
- Subscribe
- redisReply
- DPDK 초기화
- rte_eth_dev
- polling
- nic
- HugePage
- Today
- Total
우당탕탕 개발일지
[K8S] C 기반 HTTP 통신 구현 및 Kubernetes 배포 본문
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 이름을 사용하도록 변경하였다.
'Server > Linux, C' 카테고리의 다른 글
| [C언어] Redis를 활용한 Pub/Sub 기능 (0) | 2026.07.27 |
|---|---|
| [C언어] libcurl 기반 FTP/SFTP 다운로드 구현 (0) | 2026.07.07 |
| [C언어] IPC(2) (feat. 메세지 큐) (0) | 2025.10.19 |
| [C언어] IPC(1) (feat. 공유 메모리) (0) | 2025.10.19 |
| [C언어] 시그널 (feat. Sigaction) (0) | 2025.09.30 |