티스토리 뷰

728x90
반응형

서론

마이크로서비스 환경에서 서비스를 잘 모델링하는 것 만큼 중요한 것이 바로 컨테이너 이미지를 설계하는 것이다. 어플리케이션을 잘 설계하여 소스도 경량화 되고, 확장성을 확보했다고 생각할 수 있지만, 사실은 어플리케이션을 감싸고 있는 컨테이너 이미지가 최적화되어 있지 않을 경우 어플리케이션의 경량화는 의미가 퇴색될 수 있다.

 

 

지금부터 살펴볼 내용은 컨테이너 이미지를 생성할때 고려해야 할 사항에 대해 본인의 노하우를 섞어 알아보도록 하자.


첫번째, 경량화

역시나 첫번째로 고려해야 할 부분은 바로 컨테이너 이미지의 경량화이다. 이미 수도 없이 강조했지만, 컨테이너 이미지는 작으면 작을 수록 좋다. Scalability를 강조하기 위해서는 무엇보다 가벼운 이미지가 중요하며 경량화를 위한 노력을 무엇보다 지속해 나가야 한다. 그 대상은 이미지를 구성하는 모든 구성 요소가 될 것이다. 

1) 샌드박스 컨테이너 만들기

IT 분야에서 샌드박스라는 의미는 일반적으로 실험적으로 활용하는 영역을 의미한다. 놀이터의 모래밭과 같이 성을 쌓고 무너뜨리고 하면서 원하는 놀이를 하듯 본인의 환경에는 영향을 주지 않고 샌드박스 환경을 구성하여 테스트하기 어려운 환경을 구성하여 대체하는 영역이 바로 샌드박스라고 볼 수 있다.

예를 들어 디스크 부족 환경을 테스트 하고 싶은데 내 PC에는 디스크의 여유가 있어 더미 파일을 무작위로 만들어 디스크를 채운다면? 그 자체도 정말 무의미한 작업이지만, 이로 인해 시스템 자체의 장애를 발생 시켜 원하는 검증 자체를 수행하지 못하는 경우가 발생할 수 있다. 이를 대신 테스트 하기 위한 환경. 기존에는 VM이 이러한 환경의 대체가 되었었지만, 최근에는 Container가 바로 샌드박스가 되고 있다.

샌드박스 환경의 필수 조건은 바로 빈 깡통에서 시작할 수 있어야 한다는 점이다. 이는 경량화 컨테이너를 만들어 가는 과정과 비슷하여 이를 샌드박스 컨테이너 만들기라고 정의했다.

경량화 컨테이너를 만들기 위해서는 이미지의 기반이되는 Base Image의 선택이 중요하다. 대표적인 경량화 이미지로 Alpine과 Debian이 있다.

반드시 이러한 이미지를 사용해야 하는 것은 아니지만, 가능한 Minimal 이미지를 기반으로 Base Image를 생성하는 것이 컨테이너 사이즈를 줄일 수 있는 방법이다.

다음을 살펴보자.

node:onbuild 이미지는 Dockerfile을 아래와 같이 매우 간단하게 생성할 수 있고, 이미 컴파일과 실행에 필요한 라이브러리를 패키징하고 있어 손쉽게 컨테이너를 구성할 수 있다.

FROM node:onbuild
EXPOSE 8080

node:alpine 이미지는 아래와 같이 Dockerfile을 직접 작성해야 하며, 이외 필요한 라이브러리들도 직접 구성해야 한다.

FROM node:alpine
WORKDIR /app
COPY package.json /app/package.json
RUN npm install --production
COPY server.js /app/server.js
EXPOSE 8080
CMD npm start

컴파일한 이미지의 사이즈는 아래와 같다.

[root@ip-192-168-78-195 docker]# docker images
nodealpine                            latest                      8a9ab21306d4   5 seconds ago        173MB
nodeonbuild                           latest                      a0cc599f0c87   About a minute ago   682MB
node                                  alpine                      eb56d56623e5   6 days ago           168MB
node                                  onbuild                     ed2506e2e522   3 years ago          673MB
[root@ip-192-168-78-195 docker]#

위와 같이 Alpine 이미지로 빌드한 이미지의 사이즈는 173MB로 Onbuild 이미지로 빌드한 이미지는 682MB이다. 이미지의 사이즈를 줄이면 구축 시간과 이미지 Pull 시간을 단축할수 있다는 측면에서 의미가 있다. (대체로 빌드 + push 보다는 pull에 이점이 더 있다.)

다만, Alpine, Debian을 기반으로 이미지를 생성해 나가는 것은 생각보다 어려운 작업이 될 수 있다. CentOS나 Ubuntu와 같이 기본으로 설치되어 있는 라이브러리들을 하나씩 추가해 나가며 어플리케이션이 동작되게 만드는 것은 다소 시간이 걸리는 작업이기 때문이다. 이러한 작업에 익숙하지 않은 경우에는 CentOS나 Ubuntu를 기반으로 하되 이미지를 Mininal 또는 필수패키지 만 Install된 버전으로 선택하여 구성해 나가는 것이 좋다.

2) Package Manager 패키지 관리

Package 매니저로 패키지를 설치할 경우 불필요한 패키지가 함께 설치되는 경우가 많이 있다. 이를 방지하기 위해 --no-install-recommends 옵션을 추가하여 설치하는 것이 이미지를 경량화 하는 방법이다. 또한 설치가 완료되면 패키지 매니저의 캐시 리스트를 함께 제거하는 것도 중요하다.

ex) Dockerfile RUN

RUN apt-get update && apt-get install -y --no-install-recommends ssh vim && rm -rf /var/lib/apt/lists/*

3) 도커 이미지 Layer 관리

도커 이미지에는 Layer라는 Stack이 존재한다. 도커 이미지를 효과적으로 관리하기 위해 Stack 형태로 이미지가 누적되어 쌓이는 방식이다. 동일한 stack이 동일한 Repository에 존재할 경우 굳이 다시 해당 layer를 생성하거나, push하거나 pull 하지 않도록 매커니즘이 구현되어 있다. 이후 도커 이미지는 컨테이너의 사이즈를 결정하기도 하지만, 관리 측면에서 Local Repository나 Docker Registry의 사이즈를 결정하기도 한다. 따라서 Docker Layer 관리는 굉장히 중요한 부분이라 할 수 있다.

다음과 같은 예를 들어 설명해 보도록 하자.

[CASE 1]

FROM debian:experimental-20210311
RUN apt-get update && apt-get install -y wget
RUN wget https://download.sonatype.com/nexus/oss/nexus-latest-bundle.tar.gz
RUN rm next-latest-bundle.tar.gz

[CASE 2]

FROM debian:experimental-20210311
RUN apt-get update && apt-get install -y wget
RUN wget https://download.sonatype.com/nexus/oss/nexus-latest-bundle.tar.gz && rm next-latest-bundle.tar.gz

이를 각각 빌드한 결과 이미지 사이즈는 다음과 같다.

[root@ip-192-168-114-198 layer]# docker images | grep debiansize
debiansize          case2                   b54fe5f58286        4 seconds ago       151MB
debiansize          case1                   26ab4f733fdb        42 seconds ago      234MB
[root@ip-192-168-114-198 layer]# 

위와 같이 case1이 234MB, case2가 151MB임을 알 수 있다. 동일한 동작을 수행하였음에도 이와 같이 사이즈에 차이가 나는 이유는 Layer 별 참조 관계 때문이다. Layer를 구성하는 요소는 바로 Dockerfile을 구성하는 Command의 차이에서 확인할 수 있다. CASE 1의 세번째 라인인 wget이 실행되는 Layer에는 이미 Nexus 바이너리가 포함되어 있고, 네번째 라인에서 삭제를 한들 세번째 Layer에는 영향을 주지 않기 때문에 이미지 사이즈의 차이가 발생하는 것이다. 두 케이스의 이미지 사이즈 차이는 결국

[root@ip-192-168-114-198 layer]# ls -lah
total 80M
drwxr-xr-x  2 root root   82 Mar 14 03:58 .
dr-xr-x--- 20 root root 4.0K Mar 14 03:47 ..
-rw-r--r--  1 root root  189 Mar 14 03:51 Dockerfile
-rw-r--r--  1 root root  190 Mar 14 03:50 Dockerfile_split
-rw-r--r--  1 root root  80M Dec 30 16:44 nexus-latest-bundle.tar.gz
[root@ip-192-168-114-198 layer]# 

다운로드 받은 nexus-latest-bundle.tar.gz 사이즈의 차이라고 볼 수 있다. 따라서 삭제가 필요한 파일을 관리할 경우에는 반드시 같은 Layer에서 처리해야 한다. 이 부분은 많은 Dockerfile 작성자 들이 놓치는 부분이라 볼 수 있다.
Docker Layer는 Union Mount라는 Linux Mount 기술을 활용하여 여러 Layer를 통합하여 하나의 이미지로 관리한다.

4) 임시 파일 삭제

불필요한 임시 파일은 삭제하는 것이 좋다. 위와 같이 파일 삭제가 필요한 경우 같은 Layer에서 처리하도록 구성하는 것을 권고했으며, 다음과 같은 방법으로도 이를 사전에 방지할 수 있다. wget을 사용해서 다운로드 받을 경우 파이프라이닝과 조합하여 사용하면 파일 다운로드 시 압축파일은 생성하지 않고 압축이 해제된 파일만 남겨 둘 수 있다.

wget -O - https://download.sonatype.com/nexus/oss/nexus-latest-bundle.tar.gz | tar zxf -

5) 외부 볼륨 활용

이미지의 사이즈는 굉장히 중요한 요소임은 이미 충분히 인지했을 거라 생각한다. 컨테이너의 이미지를 경량화 시키기 위해 로그 파일이나 Runtime에 생성되는 Dynamic Class, 업로드 파일 등은 외부 볼륨을 활용하여 저장한다. 특히 대용량 볼륨을 요구하는 사진, 파일 등이 저장되는 공간을 로컬 볼륨에 둘 경우 이미지의 사이즈 증가는 물론 기본 컨테이너 이미지의 내부 공간은 휘발성을 가정하기 때문에 삭제 될 수 있다는 위험을 감수하고 사용해야 한다. 따라서 Network 볼륨을 사용하거나, HostPath 볼륨을 사용하여 관리하는 것이 좋다. 대체로 로그 파일은 HostPath, 모든 Pod에서 공유해서 관리되는 사진이나, 파일은 Network 볼륨에 저장한다.

6) 어플리케이션 소스 경량화 

어플리케이션 소스를 경량화 해야 한다느 점은 아무리 강조해도 부족하다. WAR, EAR, JAR 등의 Archive를 배포한다 하더라도 archive 내부에는 불필요한 libaray의 중복, JDK의 중복, .svn과 같은 메타 파일 또는 소스 파일 등이 포함되어 있을 수도 있기에 직접 압축을 해제해서 검증 과정을 한번쯤은 거치는 것이 좋다.

두번째, 1 Pod - 1 Container

컨테이너는 하나의 독립적인 동작을 정의하는 단위라고 볼 수 있다. 이를 Kubernetes와 같은 컨테이너 관리 플랫폼(PaaS)에 배치할 경우 배포 단위는 Container에서 Pod로 변경된다.

여기서 주의할 점은 하나의 Pod에는 여러개의 Container가 포함될 수 있지만, Main이 되는 어플리케이션은 하나만 구성해야 한다는 점이다. 여기서 Main이라 함은 비즈니스를 포함하는 어플리케이션 정도로 생각할 수 있다. SideCar에 의해 배치되는 Logging Bits, Prometheus의 exporter와 같은 Container는 하나의 Pod에 여러 컨테이너로 합류할 수 있지만, 야구 어플리케이션과 농구 어플리케이션이 하나의 Pod 안에 동작하게 해서는 안된다는 것이다.

OS가 기동되면 Init Process라는 1번 부모 프로세스가 기동되면서 하위 프로세스를 Fork하여 관리하는 형태로 되어 있다. (물론 Kernel 2.4 이후 버전에서는 System과 사용자 영역이 구분되어 모든 프로세스가 Init 하위에 있는 것은 아니다. 자세한 내용은 [Docker] Kubernetes 보안 (Cgroup)를 참고하자.)

이는 1번 프로세스가 종료되었을 경우 OS가 종료된다는 의미이며, Pod의 LifeCycle을 관리하는 주체가 된다는 의미로 볼 수 있다. 1번 프로세스는 Dockerfile에 정의된 ENTRYPOINT와 CMD가 그 대상이며, Container 이미지에 포함되어 있는 어플리케이션을 기동하기 위해 미들웨어를 기동하는 Script나 Command가 대체로 이에 포함된다. 즉 미들웨어가 종료되면 컨테이너 이미지는 종료된다고 볼 수 있다.

이때, 야구 어플리케이션과 농구 어플리케이션이 서로 다른 마이크로 서비스로 구성되어 있고 각각 WAS 인스턴스로 기동된다고 볼 때 둘 중 하나는 종료에 대한 프로세스로 지정될 수가 없다. 즉 야구 어플리케이션 인스턴스가 init으로 기동될 경우 농구 어플리케이션 인스턴스에 이상이 발생해도 해당 Pod는 정상상태를 유지한다는 것이다. 우회 방안으로 이를 해결할 수 있겠지만, 근본적인 원칙으로는 이와 같이 구성하는 것은 지양하는 것을 권고한다.

세번째, 가독성 확보

1) Dockerfile 공개

Dockerfile은 항상 공개해 두는 것이 좋다. 공개의 의미는 외부의 공개가 아닌 같은 작업자 간의 공유가 가능하도록 하라는 의미이다. 공개 방식은 디스크에 저장하는 방식을 사용하지 말고, 리포지토리를 구성하여 Dockerfile을 관리할 수 있도록 한다. 대표적으로 Docker 이미지를 저장하는 Nexus, Harbor를 함께 활용하거나, Source 형상관리와 묶어 관리하는 방법 등이 있다. Dockerfile은 해당 컨테이너 이미지의 구성을 한눈에 확인할 수 있고 그 의도를 명확히 할 수 있어야 한다. 특히 tag를 통해 버전관리가 되도록 구성하는 것이 반드시 필요하다. 이는 Agility 배포 환경에서 불필요한 확인 시간을 줄이는 방법이라 이미지 수정 시간을 단축할 수 있다.

2) 태그 활용하기

도커 파일은 때로는 수 라인만으로도 생성이 가능하지만, 복잡한 환경의 경우 수십 또는 수백라인으로 구성될 수도 있다. 따라서 자바 코드를 작성하듯 도커 파일의 가독성은 굉장히 중요하다. 이를 통해 이후 유지보수 관점에서 얼마나 신속하고 정확하게 변경할 수 있는지 결정되기 때문이다.

다음 예를 보며 살펴보자.

RUN apt-get update && \
    apt-get install -y \
    wget=1.13.4 \
    ca-certificates=20130119
COPY run_wildfly.sh /root
RUN cd /root && sh run_wildfly.sh

위는 가독성을 높여주기 위해 초기 설치되어야하는 패키지 설치과정, 복사, 그리고 복사된 파일을 실행하는 과정이 각각 하나의 라인으로 묶여 있다.

만약 이 과정이 다음과 같이 구분되어 있다고 가정해 보자.

RUN apt-get update && \
    apt-get install -y \
RUN wget=1.13.4 \
COPY run_wildfly.sh /root
RUN cd /root && sh run_wildfly.sh
RUN ca-certificates=20130119

위는 모든 라인을 풀어서 라인별로 구성되어 있으며, 각 라인의 동작하고자 하는 목적을 해석하기가 번거롭다. 명확히 도커파일의 각 라인이 동작하고자 하는 목적을 쉽게 파악할 수 있게 라인을 묶어서 구성하는 것과 필요하다면 주석을 다는 것도 가독성을 높이는 방법이 될 것이다.

보다 높은 가독성을 확보하고자 할 경우 ENV 환경 변수를 사용하는 것이 좋다. 환경 변수를 효과적으로 사용하면 가독성은 물론 작업 효율성과 반복에 의한 실수를 줄일 수 있다는 장점까지 가져 갈 수 있다.

3) COMMAND 적극 활용

RUN, ADD, COPY를 제외한 Command는 가독성을 높이기 위해 사용을 권장한다. 이 3개의 Command 이외의 나머지 명령어들은 Layer에 포함되지 않는다. 즉 나머지 명령어들은 가독성을 높이기 위해 사용될 수 있는 명령어라고 볼 수 있다.

예를 들어 보자.

FROM debian:experimental-20210311
RUN mkdir -p /home/nrson/test && \
    cd /home/nrson/test

위는 디렉토리 이동을 cd로 동작하도록 구성했으며,

FROM debian:experimental-20210311
RUN mkdir -p /home/nrson/test
WORKDIR /home/nrson/test

위는 WORKDIR을 이용하여 디렉토리를 이동하였다. 이때 별도의 Layer가 생성되는 것이 아닌 위와 동일한 사이즈의 이미지와 동일한 Layer 수를 갖게 된다.

따라서 RUN, ADD, COPY를 제외한 Command의 경우 가독성을 위해 적극 사용하는 것이 좋다.

네번째, 빌드/배포 속도 고려

1) 어플리케이션 빌드

컴파일을 위한 jdk, gcc, python 등 사이즈가 제법 되는 라이브러리들이 함께 설치 되어야 하며 이미지 빌드 시간이 늘어나는 현상이 발생된다. OS 환경의 영향을 덜 받는 Java의 경우 컴파일이 완료된 Class를 패키징하여 업로드 하며, C와 같이 OS 환경의 영향을 많이 받는 경우 Base Image와 동일한 환경을 VM으로 구성해 두고 해당 환경에서 Pre-Compile 후 바이너리만 업로드 하는 것이 좋다.

위와 같이 서버 상에서 컴파일이 필요하지 않을 경우 Java는 JDK가 아닌 JRE, C의 경우 GCC 라이브러리를 설치 하지 않아도 되기 때문에 이미지 경량 효과까지 함께 볼 수 있다.

2) 패키지 매니저 관리

yum, apt-get과 같은 패키지 매니저의 update & upgade와 같은 동작은 빌드 시간을 대폭 증가 시키게 된다. 필수적으로 필요한 패키지만 설치하도록 하며, 부득이 하게 update & upgrade가 필요할 경우 Custom Image에서 매번 동작하도록 하지 말고, Base Image 생성 시 패키지 매니저 구성을 완료해 두고 이후 Custom Image에서는 어플리케이션만 변경되도록 구성하는 것이 빌드/배포 시간을 단축하는 방법이다.

3) Dockerfile Command 순서

Dockerfile은 Layer로 관리되기 때문에 각 Layer는 상위 Layer를 참조하는 형태를 띈다. 다음과 같은 Dockerfile을 두고 고민해 보자.

FROM debian:experimental-20210311
RUN uname -a
COPY . /home/nrson/webhome
RUN apt-get update && apt-get install -y wget ssh

위와 같이 빌드가 실행되면, RUN uname -a는 Layer A, COPY는 Layer B, RUN apt-get은 Layer C로 각각 생성된다. 이때, COPY할 파일이 변경되었다고 가정해보자.

Layer는 참조 관계가 있어 하위 Layer가 변경되면 상위 Layer도 함께 변경된다. 따라서 COPY의 변경에 의해 RUN apt-get 역시 다시 이미지 빌드를 수행하는 것을 볼 수 있다. 다시 이미지 빌드를 수행하는 이유는 Layer를 재생성하기 위함이다. 이는 결국 빌드 시간이 늘어나고, 이미지의 Layer가 누적되어 변경되므로 디스크 낭비가 발생한다고 볼 수 있다.이를 효과적으로 관리하기 위해서는 다음과 같은 두가지 방침을 따라야 한다.

첫번째는 변경 가능한 라인은 최대한 아래로 내리는 것이 좋다. 즉 어플리케이션 Layer는 반드시 제일 아래로 내려주는 것이 좋다.

FROM debian:experimental-20210311
RUN uname -a
RUN apt-get update && apt-get install -y wget ssh
COPY . /home/nrson/webhome

이렇게 변경될 경우 RUN uname -a와 RUN apt-get은 COPY와 상관없이 고정되게 된다.

두번째는 COPY 명령어를 사용할 경우 디렉토리 형식을 사용하지 말고 가능한 파일을 지정하는 것이 좋다. COPY를 사용할 경우 빌드하는 위치에 따라 불필요한 파일이 함께 업로드 될 수 있어 이를 방지하는 차원에서 파일 명을 지정해 주는 것이 좋다.

다섯번째, 태그 관리

도커 파일에 지정되는 이미지의 태그를 latest로 무심결에 저장하는 경우가 많이 있다. 이미지의 최신 버전을 latest로 저장하는 것은 잘못된 습관이며, release 되는 대상이 있을 경우 latest로 저장하여 관리하도록 한다. dev / test 과정의 이미지는 반드시 이미지의 용도나 버전을 확인할 수 있는 tag 명을 정의하여 붙이도록 하며, 같이 작업하는 개발자가 많을 수록 이미지의 Naming을 정의하는 것이 중요하다.

또한 해당 이미지는 Local Repository에 관리하는 것이 아닌 Docker Repository에 Registry 별로 구분하여 관리하도록 구성한다. 특히 어플리케이션에 대한 정보뿐 아니라 이미지의 변경 정보를 함께 tag를 통해 명시적으로 알아볼 수 있게 작성해 주는 것이 중요하다.


결론

컨테이너 이미지는 마이크로 서비스 기술의 완성을 위해 반드시 고민해서 작성되어야 한다. 컨테이너의 agility, resilience, reliability, scalability 모두 컨테이너 이미지의 완성도 있는 작성에서부터 그 이점을 가져갈 수 있을 것이라는 점을 기억해야 하며, 전체를 모두 적용할 수 있다면 좋겠지만, 가능한 많은 부분을 검토하여 적용할 수 있도록 어느 정도의 애포트를 들여 적용하는 것은 반드시 필요할 것이다.

728x90
반응형