컨테이너 시작과 중지

도커를 사용한다는 것은 결국 이미지를 이용해서 컨테이너를 생성하고 실행한다는 것이다. docker run 명령어를 사용하면 컨테이너를 실행할 수 있다. 다음 명령어를 보자.

$ docker run -p 8080:80 nginx:latest

이 명령어를 실행하면 hello-world를 실행했을 때와 다르게 nginx 이미지를 실행하면 컨테이너가 구동된 채로 대기한다. 여기서 -p 옵션은 도커를 실행하는 호스트의 8080 포트를 컨테이너의 80 포트로 연결하도록 설정한다. nginx 이미지는 내부적으로 80 포트를 사용해서 웹 서버를 구동하는데 호스트의 8080 포트와 컨테이너의 80 포트를 연결한 것이다.

컨테이너가 실행 중인 상태에서 웹 브라우저를 띄워 http://호스트:8080에 연결해보자. 다음과 같이 nginx 웹 서버의 응답 화면을 볼 수 있다.

docker 명령어를 실행한 콘솔에는 아래와 같이 접근 로그가 출력될 것이다.

vagrant@ubuntu-bionic:~$ docker run -p 8080:80 nginx:latest 
192.168.1.1 - - [21/Sep/2019:14:32:33 +0000] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36" "-"

docker 명령어를 실행한 콘솔에서 Ctrl+C키를 누르면 컨테이너를 종료한다. nginx 이미지는 "nginx" 서버를 포그라운드로 실행하는데 Ctrl+C를 누르면 nginx 서버가 종료된다. 컨테이너를 구동할 때 실행한 프로그램이 종료되면 컨테이너도 함께 종료된다. nginx 이미지의 경우 실행하는 프로그램이 nginx 서버이므로 nginx 서버가 종료되면 컨테이너도 함께 종료된다.

이제 컨테이너를 백그라운드로 실행해보자. 아래와 같이 -d 옵션을 사용한다. -d 옵션은 컨테이너의 프로그램을 실행할 때 터미널에 연결하지 않는다.

vagrant@ubuntu-bionic:~$ docker run -d -p 8080:80 nginx:latest
7d78d9bf30ad21ad021a737886f517cb6cae98cd7b0535307dc583c32bd53e54
vagrant@ubuntu-bionic:~$

명령어 실행 결과로 7d78d9bf로 시작하는 문자열을 출력했는데 이 문자열은 컨테이너를 구분할 때 사용할 고유 ID이다.

컨테이너가 실행중이므로 웹 브라우저에서 http://호스트:8080에 연결하면 응답이 표시된다. docker container -ls 명령어를 사용해서 실제 실행중인 컨테이너 목록을 확인할 수 있다. (docker container 대신 docker ps 명령어를 사용해도 된다.)

vagrant@ubuntu-bionic:/vagrant$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES
7d78d9bf30ad        nginx:latest        "nginx -g 'daemon of…"   4 minutes ago       Up 4 minutes        0.0.0.0:8080->80/tcp   goofy_chaplygin

CONTAINER ID에 표시된 값이 앞서 출력된 컨테이너 ID의 앞 부분과 같은 것을 알 수 있다. STATUS는 Up인데 이는 컨테이너가 실행 중임을 뜻한다.

실행 중인 컨테이너를 중지하려면 docker stop 명령어를 사용하면 된다.

docker stop 7d78d9

docker stop 명령어 뒤에는 컨테이너를 구분할 수 있는 값을 입력한다. 여기서는 7d78d9를 입력했는데 고유 ID 전체가 아니라 다른 컨테이너와 구분되는 앞 글자 일부만 입력해도 된다.

컨테이너 이름

docker container ls -a 명령어를 입력해보자. ls 명령어에 -a 옵션을 붙이면 실행 중인 컨테이너뿐만 아니라 종료된 컨테이너도 모두 표시한다. 결과를 보면 nginx:latest 이미지를 이용해 여러 컨테이너가 생성된 것을 알 수 있다. 여기서 NAMES 값은 도커가 임의로 생성한 컨테이너 이름이다.(비슷하게 docker ps -a 옵션을 사용해도 모든 컨테이너 목록을 보여준다.)

vagrant@ubuntu-bionic:~$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND                  ...생략 NAMES
7d78d9bf30ad        nginx:latest        "nginx -g 'daemon of…"   ...생략 goofy_chaplygin
7cf417035656        nginx:latest        "nginx -g 'daemon of…"   ...생략 condescending_albattani
346023f2afbc        nginx:latest        "nginx -g 'daemon of…"   ...생략 focused_heyrovsky
bb62c718f7a1        nginx:latest        "nginx -g 'daemon of…"   ...생략 peaceful_cerf
f2033c7f6be9        nginx:latest        "nginx -g 'daemon of…"   ...생략 unruffled_greider
dc5e650a1d70        centos:7            "/bin/bash"              ...생략 fervent_cartwright
420592ad5d1e        hello-world         "/hello"                 ...생략 youthful_lovelace
6441f60ab2e2        hello-world         "/hello"                 ...생략 youthful_lamarr

--name 옵션을 사용하면 컨테이너 이름을 직접 지정할 수 있다. 다음은 --name 옵션의 사용 예를 보여준다. --name 값이 web인 컨테이너를 실행한 뒤에 컨테이너 목록을 보면 NAMES 값이 web인 것을 알 수 있다.

vagrant@ubuntu-bionic:~$ docker run -d --name web -p 8080:80 nginx:latest
b91561be1280c8601efab3aa1edb4373b6a3beafc2507df1fc7f274fba341905
vagrant@ubuntu-bionic:~$ docker ps
CONTAINER ID        IMAGE               ...생략  NAMES
b91561be1280        nginx:latest        ...생략  web

컨테이너를 중지할 때는 컨테이너 ID뿐만 아니라 이름을 사용해도 된다.

vagrant@ubuntu-bionic:~$ docker stop web
web

이름이 web인 컨테이너를 주징했다면 다시 같은 이름의 컨테이너를 생성해보자. 다음과 같이 생성에 실패한다.

vagrant@ubuntu-bionic:~$ docker run -d --name web -p 8080:80 nginx:latest
docker: Error response from daemon: Conflict. The container name "/web" is already in use
by container "b91561be1280c8601efab3aa1edb4373b6a3beafc2507df1fc7f274fba341905". 
You have to remove (or rename) that container to be able to reuse that name.
See 'docker run --help'.

컨테이너 ID와 마찬가지로 컨테이너 이름도 고유하기 때문에 같은 이름의 컨테이너를 중복해서 생성할 수 없다.

컨테이너 삭제

매번 동일한 이름으로 컨테이너를 생성해야 한다면 컨테이너를 삭제하고 다시 생성하면 된다. docker rm 명령어를 사용하면 된다. docker rm 명령어에 컨테이너 ID(구분되는 일부)나 이름을 지정하면 해당 컨테이너를 삭제한다.

vagrant@ubuntu-bionic:~$ docker rm web
web

또는 컨테이너를 실행할 때 --rm 옵션을 줄 수도 있다. 이 옵션을 사용하면 컨테이너가 종료될 때 컨테이너를 자동으로 삭제한다.

vagrant@ubuntu-bionic:~$ docker run -d --rm --name web -p 8080:80 nginx:latest

 

컨테이너 상태

컨테이너는 크게 다음의 상태를 가진다.

docker run 명령어는 실제로 다음의 세 명령어를 한 번에 실행하는 것과 같다.

  • docker pull : 이미지를 다운로드한다. docker run 명령어는 이미지가 로컬에 없으면 이미지를 다운로드한다.
  • docker create : 이미지로부터 컨테이너를 생성한다.
  • docker start : 컨테이너를 시작한다.

인터랙티브 컨테이너

컨테이너를 구동하고 컨테이너를 위한 가상 터미널을 할당할 수도 있다. 다음 명령어를 실행해보자.

vagrant@ubuntu-bionic:~$ docker run -it centos:7 /bin/bash
[root@ebe53b326516 /]#

이 명령은 centos:7 이미지를 이용한 컨테이너를 생성하고 컨테이너의 /bin/bash 명령을 실행한다. 여기서 -it 옵션이 중요하다. -i 옵션은 --interactive와 같은 옵션으로 컨테이너의 표준입력을 연결한다. -t는 -tty와 같은 옵션으로 컨테이너를 위한 가상 터미널을 할당한다. 즉 -it 옵션을 이용하면 해당 프로그램을 실행하고 컨테이너에 터미널로 연결한다. 이제 컨테이너 내부에서 ls나 ps와 같은 명령을 사용해서 컨테이너 내부를 확인할 수 있다.

exit 명령어를 bash를 종료하므로 컨테이너가 종료된다.

docker exec는 실행 중인 컨테이너에서 특정 명령을 실행할 때 사용하는데 이를 사용하면 실행 중인 컨테이너에 터미널로 붙을 수도 있다. 다음은 실행 예이다.

vagrant@ubuntu-bionic:~$ docker exec -it web /bin/bash
root@1227ce888767:/#

 

도커는 소프트웨어를 빌드하고 실행하기 위한 소프트웨어다. 도커를 사용하면 웹 서버, 명령행 프로그램 등의 소프트웨어를 설치하고, 출시하고, 실행하고, 삭제하는 과정을 단순화할 수 있다. 이를 위해 도커는 OS의 컨테이너 기술을 사용한다.

docker run hello-world 실행 과정 보기

도커 시작하기 0 : 우분투에 도커 설치하기에서 docker run hello-workd 명령어를 실행했는데 이 명령어를 처음 실행할 때 표시되는 메시지는 다음과 같다.

vagrant@ubuntu-bionic:~$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:b8ba256769a0ac28dd126d584e0a2011cd2877f3f76e093a7ae560f2a5301c00
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

...생략

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

첫 번째 실행하면 "Hello form docker!" 문장이 출력되기 이전에 'hello-world:latest' 이미지를 로컬에서 찾을 수 없어 다운로드했다는 메시지를 볼 수 있다.

docker run hello-world 명령어를 다시 실행해보자.

vagrant@ubuntu-bionic:~$ docker run hello-world

Hello from Docker!
This message shows that your installation appears to be working correctly.
...생략

이번에는 이미지를 다운로드하지 않는다. hello-world는 도커가 소프트웨어를 배포할 때 사용하는 단위인 이미지의 이름으로 docker run 명령을 이용해서 이미지를 실행하면 도커는 다음 과정을 거친다.

docker run 명령어 실행 과정

도커는 실행할 이미지가 로컬에 존재하는지 확인한다. 존재하지 않으면 이미지를 먼저 다운로드한다. 이미지 파일은 도커 허브라는 곳에 위치하며 도커 허브에서 해당 이미지 파일을 다운로드한다. 다운로드가 끝나면 이미지에서 컨테이너를 생성하고 실행한다. 프로그램 파일이 있고 그 프로그램을 실행하면 프로세스가 생기는 것처럼 이미지 파일이 있고 이 이미지를 실행하면 컨테이너가 생성된다.

docker images 명령어를 실행하면 로컬에 설치된 이미지를 표시한다. 실행하면 다음과 같이 hello-world:latest 이미지가 존재하는 것을 확인할 수 있다.

vagrant@ubuntu-bionic:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world         latest              fce289e99eb9        8 months ago        1.84kB

이번에는 docker container ls -a 명령어로 컨테이너 목록을 확인하자. 아래와 같이 두 개의 컨테이너가 표시되는 것을 알 수 있다. docker run 명령어는 실행할 때마다 컨테이너를 생성하므로 docker run hello-world 명령어를 실행한 횟수만큼 컨테이너가 만들어졌다.

vagrant@ubuntu-bionic:~$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
420592ad5d1e        hello-world         "/hello"            24 minutes ago      Exited (0) 24 minutes ago                       youthful_lovelace
6441f60ab2e2        hello-world         "/hello"            39 minutes ago      Exited (0) 39 minutes ago                       youthful_lamarr

 

컨테이너

컨테이너 대한 소개는 https://www.docker.com/resources/what-container 문서를 참고한다. 이 문서는 컨테이너를 다음과 같이 설명하고 있다.

Containers are an abstraction at the app layer that packages code and dependencies together. Multiple containers can run on the same machine and share the OS kernel with other containers, each running as isolated processes in user space. Containers take up less space than VMs (container images are typically tens of MBs in size), can handle more applications and require fewer VMs and Operating systems.

 

컨테이너와 VM 차이

어플리케이션을 구동하는데 필요한 의존은 컨테이너 안에 포함된다. 한 컨테이너에 포함된 의존은 다른 컨테이너에 영향을 주지 않는다. 예를 들어 app 1은 lib 1 버전이 필요하고 app 2는 lib 2 버전이 필요하다고 하자. 한 OS에서 app 1과 app 2를 함께 실행하려면 lib 1과 lib 2를 모두 설치해야 한다. 만약 lib 2를 설치하면 lib 1이 비정상 동작한다면 한 OS에서 app 1과 app 2를 함께 구동할 수 없게 된다. 컨테이너를 사용하면 이런 문제가 발생하지 않는다. 컨테이너는 서로 격리된 환경에서 구동되므로 라이브러리 버전 충돌이 발생하지 않는다.

컨테이너는 격리된 환경에서 돌아가므로 한 컨테이너의 어플리케이션에 문제가 발생하더라도 OS나 다른 컨테이너에 주는 영향을 최소화할 수 있다.

도커, 컨테이너

도커는 이 컨테이너를 사용하는데 필요한 도구를 제공한다. 도커는 cgroup과 네임스페이스에 대한 자세한 이해가 없어도 컨테이너를 사용할 수 있게 만들어 주었다. 이런 이유는 도커는 출시 이후 빠르게 컨테이너를 위한 대세 기반 기술로 자리 잡았다.

베이그런트(Vagrant)를 이용해서 우분투 설치하기

이 글에서는 단일 노드뿐만 아니라 다중 노드에 도커를 설치하고 실행하는 연습을 하기 위해 아래 환경을 사용한다.

  • 버추얼박스
  • 베이그런트

버추얼박스와 베이그런트를 차례대로 설치한 뒤에는 우분투 단일 노드 환경을 위한 Vagrant 파일을 작업할 폴더에 작성한다. 이 글에서는 E:\vn\vagrant\ubuntu 폴더에 Vagrant 파일을 생성했다고 가정한다.

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/bionic64"
  config.vm.network "private_network", ip: "192.168.1.2"
  config.vm.provider "virtualbox" do |vb|
    vb.memory = 2048
    vb.cpus = 2
  end
end

"ubuntu/bionic64"는 우분투 18.04 버전에 해당하는 베이그런트 박스 이미지다. 작업 폴더에서 vagrant up 명령어를 사용해서 우분투 서버를 구동한다.

E:\vm\vagrant\ubuntu>vagrant up
...생략

E:\vm\vagrant\ubuntu>vagrant ssh
Welcome to Ubuntu 18.04.2 LTS (GNU/Linux 4.15.0-54-generic x86_64)
...생략

vagrant ssh로 우분투 서버에 접속하고 도커 설치를 위해 root 계정으로 전환한다. "sudo su -" 명령어를 사용해서 root 계정으로 전환할 수 있다.

vagrant@ubuntu-bionic:~$ sudo su -
root@ubuntu-bionic:~#

우분투에 도커 설치하기

https://docs.docker.com/install/linux/docker-ce/ubuntu/ 사이트를 참고해서 우분투에 도커 커뮤니티 버전을 설치한다. 아래는 설치 과정에서 명령어만 정리한 것이다.

도커 리포지토리 설치

apt 패키지 인덱스 업데이트

# apt-get update

apt가 HTTPS 기반 리포지토리 사용하도록 설정

# apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

도커 공식 GPG 키 추가

# curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -  
# apt-key fingerprint 0EBFCD88   
pub   rsa4096 2017-02-22 [SCEA]
      9DC8 5822 9FC7 DD38 854A  E2D8 8D81 803C 0EBF CD88   
uid           [ unknown] Docker Release (CE deb) <docker@docker.com>   
sub   rsa4096 2017-02-22 [S]

리포지토리 추가

# add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ 
   $(lsb_release -cs) \
   stable"

도커 엔진 설치

apt 패키지 인덱스 업데이트

# apt-get update

도커 설치

# apt-get install docker-ce docker-ce-cli containerd.io

도커 실행 확인(root 계정으로 실행)

root@ubuntu-bionic:~# docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:b8ba256769a0ac28dd126d584e0a2011cd2877f3f76e093a7ae560f2a5301c00
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

docker run 명령어가 어떻게 동작하는지는 뒤에서 설명한다. 일단 지금은 hello-world라는 도커 이미지를 다운로드 받아 실행한다는 정도로만 이해하고 넘어가자.

root 아닌 계정으로 도커 실행하기

docker 명령어는 root 권한을 가진 계정으로 실행해야 한다. 일반 계정은 sudo를 이용해서 docker 명령어를 실행해야 한다. 매번 sudo를 입력하는 귀찮다면 docker 그룹에 사용자를 추가하면 된다. 다음은 현재 사용자를 docker 그룹에 추가하는 명령어 실행 순서를 표시한 것이다. 자세한 내용은 https://docs.docker.com/install/linux/linux-postinstall/ 문서를 참고한다.

  1. 현재 사용자를 docker 그룹에 추가: sudo usermod -aG docker $USER
  2. 그룹 추가를 현재 콘솔에 반영: newgrp docker
  3. 실행 확인: docker run hello-world

 

logs 테이블의 id 칼럼이 자동 증가 칼럼이라고 하자. 최근 id 칼럼 값이 4라고 할때 이 칼럼에 P1, P2, P3과 아래 그림과 같은 순서로 insert 쿼리를 실행한다고 하자.

P1이 세 번의 insert 쿼리를 실행하면 id 값은 5, 6, 7이 된다. P1의 트랜잭션이 끝나지 않은 상태에서 P2, P3가 각각 insert 쿼리를 실행하면 ID는 8, 9가 된다. P2와 P3는 트랜잭션이 끝나고 P1이 아직 트랜잭션 진행 중인 상태에서 P4가 id를 조회하면 [8, 9]를 리턴한다.

일정 간격으로 데이터를 복사할 경우 이 상황은 문제가 될 수 있다. 예를 들어 다음 로직을 사용해서 최신 데이터를 처리하는 로직이 있다고 하자.

  1. 이전에 처리한 최대 ID를 구해 lastProcessedId에 할당한다.
  2. 현재의 최대 ID를 구해 maxId에 할당한다.
  3. lastProcessedId보다 크고 maxId보다 작거나 같은 ID의 값 목록을 구해 ids에 할당한다.
  4. ids에 속한 데이터를 처리한다.

만약 lastProcessedId가 4이고 P4의 조회 시점에 maxId는 9라고 하자. 이 경우 과정 3에서 구하는 ids에는 5, 6, 7은 없고 8, 9만 담긴다. 과정 4를 처리한 뒤 다시 과정 1을 반복하면 이때 lastProcessedId는 9가 되어 5, 6, 7은 처리 대상에서 누락되는 문제가 발생한다.

이런 누락 문제가 발생하지 않도록 하려면 트랜잭션 격리 레벨을 높이거나 데이터 조회 시점과 최대 ID가 증가하는 시점에 차이를 둬야 한다. CDC(Change Data Capture)를 사용하는 방법도 있다.

이 글에서는 메이븐 프로젝트를 이클립스나 인텔리J에서 임포트하는 방법을 살펴보자.

이클립스에서 메이븐 프로젝트 임포트하기

이클립스에서 [File] -> [Import] 메뉴를 실행한다.

[그림1]

실행한 뒤 Import 대화창에서 Maven/Existing Maven Projects를 선택하고 [Next]를 클릭한다.

[그림2]

[그림2]에서 [Browse] 버튼을 클릭해서 pom.xml 파일이 위치한 폴더를 Root Directory로 선택하고 [Finish] 버튼을 클릭한다. 임포트가 끝나면 다음 그림처럼 이클립스에 프로젝트가 표시된다.

[그림3]

인텔리J에서 이클립스 프로젝트 임포트하기

인텔리J의 [File] -> [Open] 메뉴를 실행한다. Welcome 대화창에서는 Open 메뉴를 실행한다.

[그림4]

Open File or Project 대화창에서 메이븐 프로젝트 폴더를 선택하고 [OK] 버튼을 클릭한다. 잠시후 임포트가 끝나면 다음 그림처럼 프로젝트를 임포트한 결과를 확인할 수 있다.

[그림5]

 

MySQL이나 MariaDB에서 inet_aton/inet_ntoa 함수를 사용하면 문자열로 된 IP 주소를 정수로 저장할 수 있어 저장 용량을 줄이는데 도움이 된다. JPA를 사용하면 AttributeConverter를 사용해서 유사한 변환을 처리할 수 있다.

inet_aton과 inet_ntoa 구현

먼저 다음은 문자열과 정수 간 변환을 처리하는 코드이다.

object Inets {
    const val p3_256 = 256L * 256L * 256L
    const val p2_256 = 256L * 256L

    fun aton(ip: String?): Long? {
        if (ip == null) return null
        val vals: List<Int> = ip.split(".").filter { it.isNotEmpty() }.map { Integer.parseInt(it) }
        if (vals.isEmpty()) return null
        if (vals.size == 1) return vals[0].toLong()
        if (vals.size == 2) return vals[0] * p3_256 + vals[1]
        if (vals.size == 3) return vals[0] * p3_256 + vals[1] * p2_256 + vals[2]
        else return vals[0] * p3_256 + vals[1] * p2_256 + vals[2] * 256L + vals[3]
    }

    fun ntoa(num: Long?): String? {
        if (num == null) return null

        val d = num % 256
        val c = num / 256 % 256
        val b = num / (p2_256) % 256
        val a = num / (p3_256) % 256
        return "$a.$b.$c.$d"
    }
}

MySQL의 inet_aton()에 맞춰 구현했다. 예를 들어 inet_aton()은 "1.1"을 "1.0.0.1"과 동일한 값으로 변환한다. 또 "1.1.1"은 "1.1.0.1"과 같게 변환하고 "1"은 "0.0.0.1"과 같게 변환한다. 이 규칙에 맞게 Inets.aton()을 구현했다.

JPA 컨버터

다음은 Inets를 이용해서 구현한 JPA 컨버터이다.

import javax.persistence.AttributeConverter
import javax.persistence.Converter

@Converter
class InetConverter : AttributeConverter<String, Long> {
    override fun convertToDatabaseColumn(ip: String?): Long? {
        return Inets.aton(ip)
    }

    override fun convertToEntityAttribute(num: Long?): String? {
        return Inets.ntoa(num)
    }

}

사용

다음은 InetConverter를 사용한 코드 예이다.

@Convert(converter = InetConverter::class)
@Column(name = "reg_ip")
val ip: String?

 

다음과 같은 요구사항을 처리할 일이 생겨 분산 락이 필요했다.

  • 어플리케이션에 1분 간격으로 실행하는 작업이 있음
  • 이 작업은 한 쓰레드에서만 실행해야 함
  • 여러 장비에서 어플리케이션을 실행할 수 있음

Zookeeper나 Consul 같은 서비스를 사용하고 있지 않아 DB를 이용해서 단순하게 분산 락을 구현했다.

락 정보 저장 테이블

락 정보를 담을 DB 테이블 구조는 다음과 같이 단순하다.

name은 락 이름, owner는 락 소유자를 구분하기 위한 값, expiry는 락 소유 만료 시간이다.

다음은 MySQL을 위한 테이블 생성 쿼리이다.

CREATE TABLE dist_lock
(
    name   varchar(100) NOT NULL COMMENT '락 이름',
    owner  varchar(100) NOT NULL COMMENT '락 소유자',
    expiry datetime     NOT NULL COMMENT '락 만료 시간',
    primary key (name)
)

 

분산 락 동작

분산 락이 필요한 쓰레드(프로세스)는 다음과 같은 절차에 따라 락을 구한다.

  1. 트랜잭션을 시작한다.
  2. select for update 쿼리를 이용해서 구하고자 하는 행을 점유한다.
  3. owner가 다른데 아직 expiry가 지나지 않았으면 락 점유에 실패한다.
  4. owner가 다른데 expiry가 지났으면 락 owner를 나로 바꾸고 expiry를 점유할 시간에 맞게 변경한다.
  5. owner가 나와 같으면 expiry를 점유할 시간에 맞게 변경한다.
  6. 트랜잭션을 커밋한다.
  7. 락 점유에 성공하면(4, 5) 원하는 기능을 실행한다.
  8. 락 점유에 실패하면(3) 원하는 기능을 실행하지 않는다.

DB 락 구현

실제 락을 구현할 차례다. 원하는 코드 형태는 대략 다음과 같다.

lock.runInLock(락이름, 락지속시간, () -> {
    // 락을 구하면 수행할 작업
});

다음은 DB 테이블을 이용한 분산 락 구현 코드이다.

import javax.sql.DataSource;
import java.sql.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;

public class DbLock {
    private final String ownerId;
    private final DataSource dataSource;

    public DbLock(DataSource dataSource) {
        this.dataSource = dataSource;
        this.ownerId = UUID.randomUUID().toString();
    }

    public void runInLock(String name, Duration duration, Runnable runnable) {
        if (getLock(name, duration)) {
            runnable.run();
        }
    }

    private boolean getLock(String name, Duration duration) {
        Connection conn = null;
        boolean owned;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);
            OwnerInfo ownerInfo = getLockOwner(conn, name);
            if (ownerInfo == null) {
                insertLockOwner(conn, name, duration);
                owned = true;
            } else if (ownerInfo.owner.equals(this.ownerId)) {
                updateLockOwner(conn, name, duration);
                owned = true;
            } else if (ownerInfo.expiry.isBefore(LocalDateTime.now())) {
                updateLockOwner(conn, name, duration);
                owned = true;
            } else {
                owned = false;
            }
            conn.commit();
        } catch (Exception e) {
            owned = false;
            if (conn != null) {
                try {
                    conn.rollback();
                } catch (SQLException ex) {
                }
            }
        } finally {
            if (conn != null) {
                try {
                    conn.setAutoCommit(false);
                } catch(SQLException ex) {}
                try {
                    conn.close();
                } catch (SQLException e) {
                }
            }
        }
        return owned;
    }

    private OwnerInfo getLockOwner(Connection conn, String name) throws SQLException {
        try (PreparedStatement pstmt = conn.prepareStatement(
                "select * from dist_lock where name = ? for update")) {
            pstmt.setString(1, name);
            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    return new OwnerInfo(
                            rs.getString("owner"),
                            rs.getTimestamp("expiry").toLocalDateTime());
                }
            }
        }
        return null;
    }

    private void insertLockOwner(Connection conn, String name, Duration duration) 
    throws SQLException {
        try(PreparedStatement pstmt = conn.prepareStatement(
                "insert into dist_lock values (?, ?, ?)")) {
            pstmt.setString(1, name);
            pstmt.setString(2, ownerId);
            pstmt.setTimestamp(3, 
                Timestamp.valueOf(
                    LocalDateTime.now().plusSeconds(duration.getSeconds()))
            );
            pstmt.executeUpdate();
        }
    }

    private void updateLockOwner(Connection conn, String name, Duration duration) 
    throws SQLException {
        try(PreparedStatement pstmt = conn.prepareStatement(
                "update dist_lock set owner = ?, expiry = ? where name = ?")) {
            pstmt.setString(1, ownerId);
            pstmt.setTimestamp(2, 
                Timestamp.valueOf(
                    LocalDateTime.now().plusSeconds(duration.getSeconds()))
            );
            pstmt.setString(3, name);
            pstmt.executeUpdate();
        }
    }
}

https://github.com/madvirus/db-lock-sample 에서 코드를 확인할 수 있다.

추가 고려사항

현재 구현은 당장의 요구를 충족하는데 필요한 만큼만 기능을 구현한 것으로 다음을 고려한 개선이 필요하다.

  • runInLock()에서 실행하는 코드의 실행 시간이 락 지속 시간보다 길면 안 됨
  • 명시적으로 락을 해제하는 기능 없음

 

스프링 데이터 JPA 기능 중에서 Pageable과 Page를 사용하면 쉽게 페이징 처리를 할 수 있어 편리하다. 하지만 특정 행부터 일정 개수의 데이터를 조회하고 싶은 경우에는 Pageable과 Page가 적합하지 않다(예를 들어 21번째 행부터 21개의 데이터를 읽어오고 싶은 경우). 특정 행부터 일정 개수의 데이터를 조회할 수 있는 기능을 모든 리포지토리에 적용할 필요가 생겼는데 이를 위해 다음 작업을 진행했다.

  • Rangeable 클래스 추가 : 조회할 범위 값 저장(Pageable 대체).
  • RangeableExecutor 인터페이스 : Rangeable 타입을 사용하는 조회 메서드 정의.
  • RangeableRepository 인터페이스 : 스프링 데이터 JPA Repository 인터페이스와 RangeableExecutor 인터페이스를 상속.
  • RangeableRepositoryImpl 클래스 : 스프링 데이터 JPA의 기본 구현체를 확장. RangeableRepository 인터페이스의 구현을 제공.

스프링 데이터 JPA에서 모든 리포지토리에 동일 기능을 추가하는 방법은 스프링 데이터 JPA 레퍼런스를 참고한다.

예제 코드 : https://github.com/madvirus/spring-data-jpa-rangeable

 

madvirus/spring-data-jpa-rangeable

init. Contribute to madvirus/spring-data-jpa-rangeable development by creating an account on GitHub.

github.com

Rangeable 클래스

import org.springframework.data.domain.Sort;

public class Rangeable {
    private int start;
    private int limit;
    private Sort sort;

    public Rangeable(int start, int limit, Sort sort) {
        this.start = start;
        this.limit = limit;
        this.sort = sort;
    }

    public int getStart() {
        return start;
    }

    public int getLimit() {
        return limit;
    }

    public Sort getSort() {
        return sort;
    }
}

* start : 시작행, limit : 개수, sort : 정렬

RangeableExecutor 인터페이스

import org.springframework.data.jpa.domain.Specification;

import java.util.List;

public interface RangeableExecutor<T> {
    List<T> getRange(Specification<T> spec, Rangeable rangeable);
}

RangeableRepository 인터페이스

import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.Repository;

import java.io.Serializable;

@NoRepositoryBean
public interface RangeableRepository<T, ID extends Serializable>
        extends Repository<T, ID>, RangeableExecutor<T> {
}

RangeableRepositoryImpl 클래스

import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import java.io.Serializable;
import java.util.List;

public class RangeableRepositoryImpl<T, ID extends Serializable>
        extends SimpleJpaRepository<T, ID>
        implements RangeableRepository<T, ID> {

    public RangeableRepositoryImpl(
            JpaEntityInformation<T, ?> entityInformation, 
            EntityManager entityManager) {
        super(entityInformation, entityManager);
    }

    @Override
    public List<T> getRange(Specification<T> spec, Rangeable rangeable) {
        TypedQuery<T> query = getQuery(
                spec, getDomainClass(), rangeable.getSort());

        query.setFirstResult(rangeable.getStart());
        query.setMaxResults(rangeable.getLimit());

        return query.getResultList();
    }
}

* 기본 구현체인 SimpleJpaRepository 클래스를 확장해서 getRange() 구현

@EnableJpaRepositories로 기본 구현 지정

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@Configuration
@EnableJpaRepositories(repositoryBaseClass = RangeableRepositoryImpl.class)
public class SpringJpaConfiguration {
}

리포지토리에서 RangeableExecutor 인터페이스 사용

import org.springframework.data.repository.Repository;
import rangeable.jpa.RangeableExecutor;

public interface CommentRepository 
        extends Repository<Comment, Long>, RangeableExecutor<Comment> {
}

Rangeable로 일정 범위 조회

List<Comment> comments = repository.getRange(
        someSpec,
        new Rangeable(10, 5, Sort.by("id").descending()));

 

MockK는 코틀린을 위한 Mock 프레임워크이다. 자바에서 주로 사용하는 Mockito와 유사해서 약간만 노력하면 쉽게 적응할 수 있다. 이 글에서는 MockK의 간단한 사용법을 소개하며 더 다양한 사용법은 https://mockk.io/ 사이트에서 확인할 수 있다.

의존 설정

MockK를 사용하려면 먼저 다음 의존을 추가한다.

<dependency>
    <groupId>io.mockk</groupId>
    <artifactId>mockk</artifactId>
    <version>1.9.3</version>
    <scope>test</scope>
</dependency>

코틀린 1.2 버전을 사용하면 1.9.3.kotlin12 버전을 사용한다.

모의 객체 생성

io.mockk.mockk 함수를 이용해서 모의 객체를 생성한다. 다음은 생성 예이다.

// 1. mockk<타입>()
private val mockValidator1 = mockk<CreationValidator>()

// 2. 타입 추론
private val mockValidator2 : CreationValidator = mockk()

mockk 함수는 타입 파라미터를 이용해서 생성할 모의 객체의 타입을 전달받는다. 변수나 프로퍼티의 타입이 명시적으로 정의되어 있으면 타입 추론이 가능하므로 생략해도 된다.

Answer 정의

모의 객체를 생성했다면 모의 객체가 어떻게 동작할지 정의할 차례이다. 아주 간단하다. io.mockk.every 함수를 사용하면 된다. 다음은 예이다.

@Test
fun someMockTest() {
    every { mock.someMethod(1) } returns "OK" // "OK" 리턴
    every { mock.someMethod(2) } throws SomeException() // 익셉션 발생
    every { mock.call() } just Runs // Unit 함수 실행
    
    assertEquals("OK", mock.someMethod(1))
    assertThrows<SomeException> { mock.someMethod(2) }
}

임의의 인자와 일치

임의의 인자 값과 일치하도록 설정하려면 any()를 사용한다.

@Test
fun someMockTest() {
    every { mock.anyMethod(any(), 3) } returns "OK"
    
    assertEquals("OK", mock.anyMethod(10, 3))
}

Relaxed mock

MockK는 호출 대상에 대한 스텁 정의를 하지 않으면 오류를 발생한다. 

val mock = mockk<Some>()

mock.someMethod(1) // --> io.mockk.MockKException: no answer found for: Some(#1).someMethod(1)

이를 완화하는 방법은 Relaxed mock을 생성하는 것이다. mockk()의 relaxed 파라미터 값을 true로 전달하면 Relaxed mock을 생성할 수 있다.

val mock = mockk<Some>(relaxed = true)
mock.someMethod(1) // --> 0 리턴

리턴 타입이 Unit인 함수는 relaxUnitFun 파라미터 값을 true로 전달한다.

호출 여부 검증

io.mockk.verify 함수를 사용해서 호출 여부를 검증할 수 있다.

val mock = mockk<Some>(relaxed = true)

mock.someMethod(1)
mock.anyMethod(1, 3)

verify { mock.someMethod(1) }
verify { mock.anyMethod(any(), 3) }

인자 캡처

인자를 캡처하고 싶을 땐 slot()과 capture()를 사용한다. 다음은 사용 예를 보여준다.

val mock = mockk<Some>()

val argSlot = slot<Int>()
every { mock.someMethod(capture(argSlot)) } returns 3

mock.someMethod(5)

val realArg = argSlot.captured
assertEquals(5, realArg)

이 글에서는 기본적인 MockK의 사용법을 소개했다. MockK는 더 다양한 기능을 제공하므로 https://mockk.io 사이트를 구경해보자. 도움이 되는 기능을 찾을 수 있을 것이다.

졸업 전만 해도 굉장한 개발자가 되고 싶었다. 뛰어난 설계 능력과 코딩 속도를 자랑하는 그런 실력자 말이다. 이런 막연한 목표는 오래가지 않아 사라졌다. 3-4년 정도 경력을 쌓는 동안 '적당히 잘하는 개발자'로 원하는 수준이 바뀌었다. 언제인지도 모르게 '굉장한' 개발자가 되기 어렵다는 걸 깨닫고 나름 노력하면 될 수 있는 '적당히 잘하는'으로 목표를 낮춘 것이다. 회사 생활을 하면서 뭔가 대단한 걸 만들 재주가 없다는 것을 알게 되었고 남이 만든 거라도 잘 쓰면 다행이란 생각을 하시 시작했다.

사회 초년기에 또 하나 깨달은 건 '기술'만으로는 일이 되지 않으며 기술은 일이 되게 하는 여러 요소 중 하나라는 사실이었다. 기술력이 없으면 안 되는 경우도 있겠지만 꽤 많은 프로젝트가 기술 난이도가 아닌 다른 이유로 실패하는 것을 경험했다. 기술에 대한 욕심이 줄고 다가올 일을 수행하는데 필요한 역량에 초점을 맞추기 시작한 것도 이 시기이다.

다다르고 싶은 수준이 내려가고 기술 외에 다른 것도 있다는 걸 알게 되면서 접하는 책의 주제도 다양해졌다. '피플 웨어', '테크니컬 리더(BTL)', '프로젝트 생존 전략', '스크럼'과 같이 구현 기술은 아니지만 개발과 연관된 책을 읽기 시작했다. '썩은사과'나 '인간력'과 같은 사람에 대한 책도 읽기 시작했다. 이런 책은 개발에 대한 시야를 넓히는데 도움이 되었다.

적당히 잘하기 위해 생산성을 높여야 했고 이를 위해 테스트 코드처럼 효율을 높이는 수단을 찾아 학습했다. 남들이 좋다고 하는 지식도 일부 학습했다. 당장 이해할 수 없는 주제가 많았지만 여러 번 책을 읽고 실제로 적용해 보면서 체득하려고 노력했다. 이런 지식은 개발하는 사고의 틀을 제공해 주었고 생산성을 높여주는 밑거름이 되었다.

많은 뛰어난 개발자가 좋다고 알려준 것도 다 못하고 있고, 배틀을 해서 이길 만큼 개발 지식이 넓지도 깊지도 않으며, 개발 리더로서의 자질도 부족해 팀장 역할이 힘겨울 때가 많다. 애초에 높은 경지가 목표가 아니었기에 당연한 모습이다. 그래도 위안을 삼자면 적당히 잘하는 수준은 되었다는 것이다. 최고의 결과를 만들어내는 고수는 아니나 그래도 중간 이상의 결과는 만들 수 있는 개발자는 되었다.

꽤 긴 경력에 이 정도 밖에 도달하지 못했지만 그래도 이게 어딘가! 20대 초반에 상상한 그런 초고수는 아니지만 지금의 모습에 아쉬움은 없다. 부족한 게 많지만 조금 더 갈고닦아 지금보다 조금이라도 나아질 수 있다면 그걸로 족하다.

  1. 빡빡이발레리나 2019.06.25 11:32

    저도 개발을 오래 하면서 생각이 많이 바뀌었습니다.
    저 역시 모든것을 다 알아야 하고 막힘없이 해결하고 다른 사람들에게 기술적으로 인정을 벋는 그런 사람이 되려 했지만 그런점들이 프로젝트를 하면서 보여지든 보여지지 않든 프로젝트의 결과를 향해 가는 항해에 방해가 되는 점이라는 생각이 어느날 들더군요.

    글을 잘 안남기지만 생각이 비슷하고 글을 읽고 머리가 환기되기에 적어 봅니다.

    좋은글 감사합니다.


Centos 7 버전에 쿠버네티스(kubernetes)를 설치하는 과정을 정리한다. 보다 자세한 내용은 다음 문서를 참고한다.

 

0. Centos 7 준비

쿠버네티스 테스트 용도로 세 개의 가상 머신을 준비했다. 각 가상 머신에 Centos 7을 설치했고 IP와 호스트 이름을 다음과 같이 설정했다.

  • 172.16.1.100 k8s-master
  • 172.16.1.101 k8s-node1
  • 172.16.1.102 k8s-node2
각 서버의 /etc/hosts 파일에도 위 내용을 추가했다.

[Centos 7 호스트 이름 변경 명령어]

hostnamectl을 사용하면 Centos에서 호스트 이름을 변경할 수 있다.

# hostnamectl set-hostname 호스트이름


1. 도커 설치

전체 서버에 도커를 설치한다. https://docs.docker.com/install/linux/docker-ce/centos/ 문서를 참고해서 설치했다.


# yum install -y yum-utils device-mapper-persistent-data lvm2


# yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo


# yum install docker-ce


# systemctl start docker && systemctl enable docker


2. kubeadm 설치 준비

kubeadm을 설치하려면 몇 가지 준비를 해야 한다. 전체 서버에서 다음을 진행한다.


SELinux 설정을 permissive 모드로 변경


# setenforce 0


# sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config


iptable 설정


# cat <<EOF >  /etc/sysctl.d/k8s.conf

net.bridge.bridge-nf-call-ip6tables = 1

net.bridge.bridge-nf-call-iptables = 1

EOF

$ sysctl --system


firewalld 비활성화


# systemctl stop firewalld

# systemctl disable firewalld


스왑 오프


스왑 끄기:

# swapoff -a


/etc/fstab 파일에 아래 코드 주석 처리:

#/dev/mapper/centos-swap swap                    swap    defaults        0 0


서버 재시작:

# reboot



3. 쿠버네티스 설치 준비

쿠버네티스 YUM 리포지토리 설정:

# cat <<EOF > /etc/yum.repos.d/kubernetes.repo

[kubernetes]

name=Kubernetes

baseurl=https://packages.cloud.google.com/yum/repos/kubernetes-el7-x86_64

enabled=1

gpgcheck=1

repo_gpgcheck=1

gpgkey=https://packages.cloud.google.com/yum/doc/yum-key.gpg https://packages.cloud.google.com/yum/doc/rpm-package-key.gpg

exclude=kube*

EOF


kubeadm 설치:

# yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes


# systemctl enable kubelet && systemctl start kubelet



[쿠버네티스 구성 요소]

쿠버네티스를 구성하는 컴포넌트에 대해 알고 싶다면 https://kubernetes.io/ko/docs/concepts/overview/components/ 문서를 읽어보자. 이 문서를 빠르게 훑어보고 다음 내용을 진행하면 설치 과정에서 용어나 메시지를 이해하는데 도움이 된다.


4. 마스터 컴포넌트 설치

kubeadm init 명령으로 마스터 노드 초기화


kubeadm init 명령어를 이용해서 마스터 노드를 초기화한다. --pod-network-cidr 옵션은 사용할 CNI(Container Network Interface)에 맞게 입력한다. 이 글에서는 CNI로 Flannel을 사용한다고 가정한다.


# kubeadm init --pod-network-cidr=10.244.0.0/16 --apiserver-advertise-address=172.16.1.100

...생략

[addons] Applied essential addon: CoreDNS

[addons] Applied essential addon: kube-proxy


Your Kubernetes master has initialized successfully!


To start using your cluster, you need to run the following as a regular user:


  mkdir -p $HOME/.kube

  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config

  sudo chown $(id -u):$(id -g) $HOME/.kube/config


You should now deploy a pod network to the cluster.

Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:

  https://kubernetes.io/docs/concepts/cluster-administration/addons/


You can now join any number of machines by running the following on each node

as root:


  kubeadm join 172.16.1.100:6443 --token yrc47a.55b25p2dhe14pzd1 --discovery-token-ca-cert-hash sha256:2a7a31510b9a0b0da1cf71c2c29627b40711cdd84be12944a713ce2af2d5d148



마스터 초기화에 성공하면 마지막에 'kubeadm join ....'으로 시작하는 명령어가 출력된다. 이 명령어를 이용해서 작업 노드를 설치하므로 잘 복사해 놓자.


환경 변수 설정

root 계정을 이용해서 kubectl을 실행할 경우 다음 환경 변수를 설정한다.


# export KUBECONFIG=/etc/kubernetes/admin.conf


CNI 설치

이 글에서는 Flannel을 설치한다. 설치 명령어는 다음과 같다.


# kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml


각 CNI별 설치 명령어는 Creating a single master cluster with kubeadm 문서를 참고한다.


마스터 실행 확인


마스터를 설치했다. 다음 명령어를 실행해서 결과를 확인한다.


# kubectl get pods --all-namespaces

NAMESPACE     NAME                                 READY   STATUS    RESTARTS   AGE

kube-system   coredns-86c58d9df4-78jbg             1/1     Running   0          9m3s

kube-system   coredns-86c58d9df4-q7mwf             1/1     Running   0          9m3s

kube-system   etcd-k8s-master                      1/1     Running   0          13m

kube-system   kube-apiserver-k8s-master            1/1     Running   0          13m

kube-system   kube-controller-manager-k8s-master   1/1     Running   0          13m

kube-system   kube-flannel-ds-amd64-zv8nc          1/1     Running   0          3m11s

kube-system   kube-proxy-xj7hg                     1/1     Running   0          14m

kube-system   kube-scheduler-k8s-master            1/1     Running   0          13m


5. 노드 컴포넌트 설치

kubeadm init 명령을 이용해서 설치할 때 콘솔에 출력된 메시지에 kubeadm join 명령어가 있었다. 이 명령어를 노드 컴포넌트로 사용할 서버에서 실행한다. 이 예에서는 k8s-node1 서버에서 아래 명령어를 실행했다.


# kubeadm join 172.16.1.100:6443 --token yrc47a.55b25p2dhe14pzd1 --discovery-token-ca-cert-hash sha256:2a7a31510b9a0b0da1cf71c2c29627b40711cdd84be12944a713ce2af2d5d148


첫 번째 슬레이브 노드 추가 후 마스터 노드에서 kubectl get nodes 명령을 실행해보자. master 역할을 하는 k8s-master 노드와 방금 추가한 k8s-node1이 노드 목록에 표시된다. 노드를 추가하자 마자 노드 목록을 조회하면 다음 처럼 아직 사용 준비가 안 된 NotReady 상태임을 알 수 있다.


[root@k8s-master ~]# kubectl get nodes

NAME         STATUS     ROLES    AGE   VERSION

k8s-master   Ready      master   18m   v1.13.2

k8s-node1    NotReady   <none>   30s   v1.13.2


k8s-node2 노드에서 두 번째 슬레이브 노드를 추가한 뒤에 다시 노드 목록을 살펴보자. 새로 추가한 노드가 목록에 보인다.


[root@k8s-master ~]# kubectl get nodes

NAME         STATUS     ROLES    AGE     VERSION

k8s-master   Ready      master   21m     v1.13.2

k8s-node1    Ready      <none>   3m28s   v1.13.2

k8s-node2    NotReady   <none>   15s     v1.13.2


kubectl cluster-info 명령어를 실행하면 클러스터 정보를 확인할 수 있다.


[root@k8s-master ~]# kubectl cluster-info

Kubernetes master is running at https://172.16.1.100:6443

KubeDNS is running at https://172.16.1.100:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy


To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.


6. 클러스터 테스트

마스터와 슬레이브를 설치했으니 간단한 예제를 실행해보자.


luksa/kubia 도커 컨테이너 이미지를 이용해서 팟 만들기:


요즘 학습하고 있는 '쿠버네티스 인 액셕' 책의 예제로 테스트했다. 다음 명령을 실행하자.


# kubectl run kubia --image=luksa/kubia --port 8080 --generator=run-pod/v1

replicationcontroller/kubia created


이 명령은 도커 이미지(luksa/kubia)를 이용해서 쿠버네티스 배포 단위인 파드(pod)를 클러스터에서 실행한다. 참고로 luksa/kubia 이미지는 호스트 이름을 응답하는 간단한 웹 서버다.


파드가 포함한 컨테이너에 연결할 수 있도록 서비스를 생성한다.


[root@k8s-master ~]# kubectl expose rc kubia --type=LoadBalancer --name kubia-http

service/kubia-http exposed


kubectl get services 명령어로 생성한 서비스 정보를 보자. 내가 테스트한 환경에서는 LoadBalancer 타입 서비스가 클러스터 IP로 10.101.195.144를 사용하고 있다.


[root@k8s-master ~]# kubectl get services

NAME         TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE

kubernetes   ClusterIP      10.96.0.1        <none>        443/TCP          3h22m

kubia-http   LoadBalancer   10.101.195.144   <pending>     8080:31701/TCP   15s


쿠버네티스 클러스터를 설치한 서버에서 이 클러스터 IP를 이용해서 8080 포트로 연결해보자. 다음과 비슷한 결과가 나오면 쿠버네티스가 정상적으로 동작하고 있는 것이다.


# curl 10.101.195.144:8080

You've hit kubia-ckh8w




  1. 엽이 2019.04.04 16:24

    저도 쿠버네티스 인 액션 책을 통해 공부하려고 주문하였는데 아직 오지를 않아서.. 쿠버네티스 관련하여 인터넷 글을 참고하던차에 여기글을 보고 기본적인 셋팅 및 서비스 테스트까지 성공적으로 해 볼 수 있었습니다. 감사합니다.

  2. 2019.06.25 15:16

    비밀댓글입니다

  3. 동방폐인 2019.07.16 16:23

    사이트 아직 살아 있구나...광균아... 요즘은 어떻게 사는지...?

    나도 이제 K8s 시작한다... 이것도 제대로 쓰려면 골치 아픈 거구낭...ㅠ

  4. 한만큼 2019.09.19 15:53 신고

    덕분에 잘 따라했습니다. 감사합니다. LoadBalancer 는 왜 설정해주는건지 궁금합니다.

최근에 사용하는 프로필이 dev, prod, local, test 4개가 존재하는 스프링 부트 어플리케이션을 개발하고 있다. 로컬에서 'mvn spring-boot:run' 명령어를 실행하면 local 프로필을 사용해서 부트 앱을 실행하고 싶었다. src/main/resources 폴더에 application-prod.properties, application-dev.properties, application-local.properties 파일이 함께 존재해서 src/main/resources의 application.properties 파일에 spring.profiles.active=local 설정을 줄 수 없었다.


'mvn spring-boot:run -Dspring-boot.run.profiles=local'와 같이 로컬에서 실행할 때 마다 프로필을 지정하려니까 귀찮았다. 그래서 프로필을 선택하지 않은 경우 기본으로 local 프로필을 활성화하는 설정을 추가했다.


먼저 EnvironmentPostProcessor 인터페이스를 구현한 클래스를 작성한다.


public class ProfileResolverEnvironmentPostProcessor implements EnvironmentPostProcessor {


    @Override

    public void postProcessEnvironment(ConfigurableEnvironment environment, 

                                                   SpringApplication application) {

        boolean isSomeProfileActive = 

                environment.acceptsProfiles(Profiles.of("prod", "dev", "test", "local"));


        if (!isSomeProfileActive) {

            environment.addActiveProfile("local");

            Resource path = new ClassPathResource("application-local.properties");

            if (path.exists()) {

                try {

                    environment.getPropertySources().addLast(

                            new PropertiesPropertySourceLoader().load("application-local", path).get(0));

                } catch (IOException e) {

                    throw new IllegalStateException(e);

                }

            }

        } else {

            log.info("Some of [prod, dev, test, local] is active: " + environment.getActiveProfiles());

        }

    }

}


이 코드는 ConfigurableEnvironment#acceptsProfiles() 메서드를 이용해서 "prod", "dev", "test", "local" 프로필 중 하나라도 활성화되어 있는지 검사한다. 활성화되어 있지 않으면 활성 프로필을 "local"을 추가하고, 사용할 프로퍼티 소스로 "application-local" 프로퍼티 파일을 추가한다.


다음 할 일은 META-INF/spring.factories 파일에 다음 설정을 추가하는 것이다.


org.springframework.boot.env.EnvironmentPostProcessor=\

demo.ProfileResolverEnvironmentPostProcessor


특정 프로필을 선택하지 않고 부트 어플리케이션을 실행하면 local 프로필이 활성화되는 것을 확인할 수 있다.

스프링 스케줄러를 이용해서 cron 설정을 런타임에 변경하는 방법을 살펴본다.


1. TaskScheduler 설정


먼저 TaskScheduler를 설정한다.


@Configuration

public class SchedulingConfiguration {


    @Bean

    public ThreadPoolTaskScheduler schedulerExecutor() {

        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();

        taskScheduler.setPoolSize(4);

        taskScheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        return taskScheduler;

    }


}


스프링 부트를 사용한다면 부트가 알아서 TaskScheduler를 만들어준다.


2. cron을 사용해서 작업을 스케줄링하는 코드 작성


다음은 cron을 이용해서 스케줄링하는 코드를 작성한다. 예제는 다음과 같다.


@Service

public class SchedulerService {

    private TaskScheduler scheduler;

    private String cron = "*/2 * * * * *";

    private ScheduledFuture<?> future;


    public SchedulerService(TaskScheduler scheduler) {

        this.scheduler = scheduler;

    }


    public void start() {

        ScheduledFuture<?> future = this.scheduler.schedule(() -> {

                    System.out.println("run at " + LocalDateTime.now());

                },

                new CronTrigger(cron));

        this.future = future;

    }


    public void changeCron(String cron) {

        if (future != null) future.cancel(true);

        this.future = null;

        this.cron = cron;

        this.start();

    }

}


scheduler.schedule()은 스케줄링을 취소할 수 있는 ScheduledFuture를 리턴한다. 이 ScheduledFuture를 이용해서 스케줄을 변경할 때 이전 스케줄을 취소하고 새 스케줄을 등록하면 된다. 위 코드에서 changeCron() 메서드는 앞서 생성한 스케줄이 있다면 future.cancel()을 이용해서 스케줄을 취소한다.


3. 스케줄 런타임 변경 확인


테스트 코드를 이용해서 실제 스케줄이 런타임에 바뀌는지 확인해보자.


@RunWith(SpringRunner.class)

@SpringBootTest

public class SchedulerServiceTest {

    @Autowired

    private SchedulerService schedulerService;


    @Test

    public void changeCron() throws InterruptedException {

        schedulerService.start();

        Thread.sleep(10000);

        schedulerService.changeCron("*/3 * * * * *");

        Thread.sleep(20000);

    }

}


SchedulerService의 최초 cron 설정은 "2/* * * * * *"이므로 매 2초마다 작업을 실행한다. 위 코드는 스케줄링을 시작한 뒤에 10초간 쉬고 그 다음에 매 3초마다 작업을 실행하도록 cron 설정을 변경한다. 그리고 20초 동안 쉰다. 실행 결과는 다음과 같다.


run at 2018-12-20T23:03:02.003

run at 2018-12-20T23:03:04.002

run at 2018-12-20T23:03:06.001

run at 2018-12-20T23:03:08.001

run at 2018-12-20T23:03:10.002

run at 2018-12-20T23:03:12.002

run at 2018-12-20T23:03:15.002

run at 2018-12-20T23:03:18.003

run at 2018-12-20T23:03:21.001

run at 2018-12-20T23:03:24.001

run at 2018-12-20T23:03:27.002

run at 2018-12-20T23:03:30.001


위 결과를 보면 2초 마다 실행하다가 changeCron()을 실행한 뒤부터는 3초 마다 실행하는 것을 확인할 수 있다.


예제 코드는 https://github.com/madvirus/spring-scheduler-cron-change 에서 확인할 수 있다.

클라우드 서버에 실수로 용량이 큰 이미지 파일을 올리면 과도한 트래픽 발생으로 높은 비용을 지불할 수도 있다. 이런 상황을 방지하는 방법 중 하나는 아파치 웹 서버 설정에서 응답 파일의 크기를 제한하는 것이다. 아파치 웹 서버에서는 RewirteCond에서 filesize() 식을 사용해서 특정 크기보다 큰 파일에 대한 접근을 거부할 수 있다. 다음은 <Directory> 설정은 1 MB(1048576 바이트) 큰 파일에 접근할 때 403 상태 코드를 응답하도록 설정한 예이다.


<Directory /var/www/html/images/>

  RewriteEngine On

  RewriteCond expr "filesize('%{REQUEST_FILENAME}') -gt 1048576"

  RewriteRule .* - [F]

</Directory>


참고로 filesize()를 이용한 설정은 아파치 2.4부터 지원한다.

다소 동접이 발생하는 간단한 TCP 서버를 구현할 기술을 찾다가 리액터 네티(Reactor Netty)를 알게 되었다. 리액터 네티를 이용하면 네티를 기반으로 한 네트워크 프로그램을 리액터 API로 만들 수 있다. 리액터 네티를 사용하면 네티를 직접 사용하는 것보다 간결한 코드로 비동기 네트워크 프로그램을 만들 수 있는 이점이 있다.


다음은 리액터 네티(Reactor Netty)의 주요 특징이다.

  • 네티 기반
  • 리액터 API 사용
  • 논블로킹 TCP, UDP, HTTP 클라이언트/서버

이 글에서는 리액터 네티를 이용해서 간단한 소켓 서버를 만들어 보겠다.

TcpServer를 이용한 소켓 서버 만들기

리액터 네티는 TcpServer 클래스를 제공한다. 이 클래스를 이용해서 비교적 간단하게 비동기 소켓 서버를 구현할 수 있다. 이 글에서는 간단한 에코 서버를 만들어 본다. 만들 기능은 다음과 같다.

  • 클라이언트가 한 줄을 입력하면 "echo: 입력한 줄\r\n"으로 응답한다.
  • 클라이언트가 exit를 입력하면 클라이언트와 연결을 끊는다.
  • 클라이언트가 SHUTDOWN을 입력하면 서버를 종료한다.
  • 10초 이내에 클라이언트로부터 입력이 없으면 연결을 종료한다.
리액터 네티를 사용하기 위한 메이븐 설정은 다음과 같다.

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>io.projectreactor</groupId>
                <artifactId>reactor-bom</artifactId>
                <version>Californium-SR3</version> <!-- 리액터 네티 0.8.3에 대응 -->
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>io.projectreactor.netty</groupId>
            <artifactId>reactor-netty</artifactId>
        </dependency>

        <dependency>
            <groupId>io.projectreactor.addons</groupId>
            <artifactId>reactor-logback</artifactId>
        </dependency>
    </dependencies>

다음 코드는 리액터 네티로 만든 에코 서버의 전체 코드이다.

package demo;

import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.LineBasedFrameDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
import reactor.netty.tcp.TcpServer;

import java.util.concurrent.CountDownLatch;

public class EchoServer {
    private static Logger log = LoggerFactory.getLogger(EchoServer.class);

    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(1);
        DisposableServer server = TcpServer.create()
                .port(9999) // 서버가 사용할 포트
                .doOnConnection(conn -> { // 클라이언트 연결시 호출
                    // conn: reactor.netty.Connection
                    conn.addHandler(new LineBasedFrameDecoder(1024));
                    conn.addHandler(new ChannelHandlerAdapter() {
                        @Override
                        public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
                            log.info("client added");
                        }

                        @Override
                        public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
                            log.info("client removed");
                        }

                        @Override
                        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
                                       throws Exception {
                            log.warn("exception {}", cause.toString());
                            ctx.close();
                        }
                    });
                    conn.onReadIdle(10_000, () -> {
                        log.warn("client read timeout");
                        conn.dispose();
                    });
                })
                .handle((in, out) -> // 연결된 커넥션에 대한 IN/OUT 처리
                        // reactor.netty (NettyInbound, NettyOutbound)
                        in.receive() // 데이터 읽기 선언, ByteBufFlux 리턴
                          .asString()  // 문자열로 변환 선언, Flux<String> 리턴
                          .flatMap(msg -> {
                                      log.debug("doOnNext: [{}]", msg);
                                      if (msg.equals("exit")) {
                                          return out.withConnection(conn -> conn.dispose());
                                      } else if (msg.equals("SHUTDOWN")) {
                                          latch.countDown();
                                          return out;
                                      } else {
                                          return out.sendString(Mono.just("echo: " + msg + "\r\n"));
                                      }
                                  }
                          )
                )
                .bind() // Mono<DisposableServer> 리턴
                .block();

        try {
            latch.await();
        } catch (InterruptedException e) {
        }
        log.info("dispose server");
        server.disposeNow(); // 서버 종료
    }
}


먼저 전체 코드 구조를 살펴보자.

DisposableServer server = TcpServer.create()
        .port(9999) // 포트 지정
        .doOnConnection(conn -> { // 클라이언트 연결시 호출 코드
            ...
        })
        .handle((in, out) -> // 데이터 입출력 처리 코드
            ...
        )
        .bind() // 서버 실행에 사용할 Mono<DisposableServer>
        .block(); // 서버 실행 및 DisposableServer 리턴

...(서버 사용)

// 서버 중지
server.disposeNow();

전체 코드 구조는 다음과 같다.
  • TcpServer.create()로 TcpServer 준비
  • port()로 사용할 포트 포트
  • doOnConnection() 메서드로 클라이언트 연결시 실행할 함수 설정
    • 이 함수에서 커넥션에 ChannelHandler를 등록하는 것과 같은 작업 수행
  • handle() 메서드로 클라이언트와 데이터를 주고 받는 함수 설정
  • bind() 메서드로 서버 연결에 사용할 Mono<DisposableServer> 생성
  • bind()가 리턴한 Mono의 block()을 호출해서 서버 실행하고 DisposableServer 리턴
서버가 정상적으로 구동되면 block() 메서드는 구동중인 DisposableServer를 리턴한다. DisposableServer의 disposeNow() 메서드는 서버를 중지할 때 사용한다. 이 외에도 서버 중지에 사용되는 몇 가지 dispose로 시작하는 메서드를 제공한다.

doOnConnection()으로 커넥션 초기화

doOnConnection() 메서드의 파라미터는 다음 함수형 타입이다.
  • Consumer<? super Connection>
reactor.netty.Connection 타입은 인터페이스로 네티의 ChannelHandler 등록과 몇 가지 이벤트 연동 기능을 제공한다. 예제 코드의 doOnConnection 설정 부분을 다시 보자.

.doOnConnection(conn -> { // 클라이언트 연결시 호출
    conn.addHandler(new LineBasedFrameDecoder(1024));
    conn.addHandler(new ChannelHandlerAdapter() {
        @Override
        public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
            log.info("client added");
        }
        ...
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
        throws Exception {
            log.warn("exception {}", cause.toString());
            ctx.close();
        }
    });
    conn.onReadIdle(10_000, () -> {
        log.warn("client read timeout");
        conn.dispose();
    });

})

Connection#addHandler()는 네티의 ChannelHandler를 등록한다. 이 외에 addHandlerFirst(), addHandlerLast() 메서드를 제공한다. 이 메서드를 이용해서 필요한 네티 코덱을 등록하면 된다. 예제 코드에서는 한 줄씩 데이터를 읽어오는 LineBasedFrameDecoder를 등록했고 클라이언트 연결 이벤트에 따라 로그를 출력하기 위해 임의 ChannelHandlerAdapter 객체를 등록했다.

Connection#onReadIdle() 메서드는 첫 번째 인자로 지정한 시간(밀리초) 동안 데이터 읽기가 없으면 두 번째 인자로 전달받은 코드를 실행한다. 위 코드는 10초 동안 데이터 읽기가 없으면 연결을 종료한다. 비슷하게 onWriteIdle() 메서드는 지정한 시간 동안 쓰기가 없으면 코드를 실행한다.

handle() 메서드로 데이터 입출력 처리

데이터 송수신과 관련된 코드는 handle() 메서드로 지정한다. handle() 메서드가 전달 받는 함수형 타입은 다음과 같다.

BiFunction<? super NettyInbound, ? super NettyOutbound, ? extends Publisher<Void>>

이 함수는 NettyInbound와 NettyOutbound를 인자로 갖고 Publisher<Void>나 그 하위 타입을 리턴한다. 예제 코드의 handle() 메서드를 다시 보자.

.handle((in, out) -> // 연결된 커넥션에 대한 IN/OUT 처리
        // (NettyInbound, NettyOutbound)
        in.receive() // 데이터 읽기 선언, ByteBufFlux 리턴
          .asString()  // 문자열로 변환 선언, Flux<String> 리턴
          .flatMap(msg -> {
                      log.debug("doOnNext: [{}]", msg);
                      if (msg.equals("exit")) {
                          return out.withConnection(conn -> conn.dispose());
                      } else if (msg.equals("SHUTDOWN")) {
                          latch.countDown();
                          return out;
                      } else {
                          return out.sendString(Mono.just("echo: " + msg + "\r\n"));
                      }
                  }
          )
)


위 코드를 요약하면 다음과 같다.

  • NettyInbound#receive()는 데이터 수신을 위한 ByteBufFlux를 리턴
  • ByteBufFlux#asString()은 데이터를 문자열로 수신 처리
  • flatMap을 이용해서 수신한 메시지 처리

flatMap은 수신한 데이터를 이용해서 알맞은 처리를 한다. 클라이언트에 데이터를 전송할 때에는  NettyOutbound를 이용한다. NettyOutbound#sendString() 메서드를 이용하면 문자열 데이터를 클라이언트에 전송한다. NettyOutbound#sendString()의 파라미터는 Publisher<? extends String> 타입이기 때문에 위 코드에 Mono.just()를 이용했다.


Connection이 필요하면 NettyOutbound#withConnection() 메서드를 사용한다. 위 코드에서는 클라이언트가 "exit"를 전송하면 연결을 끊기 위해 이 메서드를 사용했다.


ByteBufFlux#asString() 메서드는 기본 캐릭터셋을 사용한다. 다른 캐릭터셋을 사용하고 싶다면 asString(Charset) 메서드를 사용한다. 비슷하게 NettyOutbound#sendString() 메서드도 기본 캐릭터셋을 사용하므로 다른 캐릭터셋을 사용하려면 NettyOutbound#sendString(Publisher, Charset) 메서드를 사용한다.


예제 실행

EchoServer를 실행해보자. 로그백을 사용했다면 아래와 비슷한 메시지가 출력되면서 서버가 구동된다.


08:49:10.522 [reactor-tcp-nio-1] DEBUG reactor.netty.tcp.TcpServer - [id: 0x1fb82e53, L:/127.0.0.1:9999] Bound new server


telnet을 이용해서 에코가 제대로 동작하는지 확인해본다. 클라이언트가 전송한 데이터를 굵은 글씨로 표시했고 서버가 응답한 데이터를 파란색으로 표시했다.


$ telnet localhost 9999

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

124124

echo: 124124

wefwef

echo: wefwef

exit

Connection closed by foreign host.

$


위 과정에서 서버에 출력되는 로그는 다음과 같다(리액터 네티가 출력하는 로그는 생략했다.)


08:50:40.187 [reactor-tcp-nio-5] INFO  demo.EchoServer - client added

08:50:37.374 [reactor-tcp-nio-4] INFO  demo.EchoServer - doOnNext: [124124]

08:50:44.506 [reactor-tcp-nio-4] INFO  demo.EchoServer - doOnNext: [wefwef]

08:50:46.218 [reactor-tcp-nio-4] INFO  demo.EchoServer - doOnNext: [exit]

08:50:46.221 [reactor-tcp-nio-4] INFO  demo.EchoServer - client removed


Connection#onReadIdle()을 이용해서 읽기 타임아웃을 10초로 설정했는데 실제로 서버 접속 후 10초 동안 데이터를 전송하지 않으면 연결이 끊기는 것을 확인할 수 있다.


08:56:23.358 [reactor-tcp-nio-2] WARN  demo.EchoServer - client read timeout

08:56:23.360 [reactor-tcp-nio-2] INFO  demo.EchoServer - client removed


마지막으로 SHUTDOWN 명령어를 전송해 보자. 그러면 서버가 종료되는 것도 확인할 수 있을 것이다.


08:57:46.372 [reactor-tcp-nio-3] INFO  demo.EchoServer - doOnNext: [SHUTDOWN]

08:57:46.373 [main] INFO  demo.EchoServer - dispose server


+ Recent posts