본문 바로가기
기타/Docker&k8s

Docker

by oneny 2024. 7. 31.

Docker

Docker는 오픈소스 컨테이너화된 플랫폼으로 개발자는 어떤 환경에서도 해당 코드를 실행하는 데 필요한 애플리케이션 소스 코드와 운영 체제(OS) 라이브러리 및 종속성을 결합한 앱을 컨테이너로 패키지화할 수 있다. 즉, 어플리케이션을 개발하면서 여러 언어버전, 환경변수, 프레임워크 및 도구 간의 복잡한 인터페이스는 엄청난 복잡도를 해결하기 위해 Docker가 나오게 되었다. 예를 들어, 개발자가 로컬에서 어플리케이션을 개발하여 서버에 배포할 때, 로컬에서 잘 작동했는데 서버 환경에서는 잘 작동하지 않는 문제와 같은 문제를 Docker가 해결해줄 수 있다.

 

Docker를 사용하는 이유

가상화 환경 vs 컨테이너 환경

출처: https://medium.com/@darkrasid/docker%EC%99%80-vm-d95d60e56fdd

처음 가상머신을 사용하는 이유 중 하나로는 언뜻 보기에 모순되어 보이는 조건인 격리(isolation)밀집(density)을 동시에 충족하기 위한 것이라고 할 수 있다. 여기서 밀집이란 컴퓨터에 CPU와 메모리가 허용하는 한 되도록 많은 수의 애플리케이션을 실행하는 것을 의미한다. 하지만 서로 다른 여러 애플리케이션을 동시에 실행하게 되면 하나의 애플리케이션이 과다한 리소스를 필요로 해서 다른 애플리케이션의 리소스가 부족하지기 떄문에 각각의 애플리케이션은 서로 독립된 환경에서 실행되어야 하는데 그러면 한 컴퓨터에 여러 애플리케이션을 실행할 수 없기 때문에 밀집을 달성할 수 없게 된다.

위 그림처럼 가상 머신은 애플리케이션이 실행될 독립적 환경이 생긴다는 점에서는 컨테이너와 큰 차이가 없다. 하지만 VM 구조를 살펴보면 컨테이너와 달리 호스트 컴퓨터의 운영체제를 공유하지 않고 하이퍼바이저 소프트웨어를 이용해서 그 위에 별도의 운영체제를 필요로 한다. 이 운영체제는 애플리케이션이 사용해야 할 CPU와 메모리 자원을 상당량 차지한다. 또한, 운영체제의 라이선스 비용과 운영체제 업데이트 설치 부담이 추가로 생긴다. 결국 가상 머신은 격리는 달성할 수 있지만 밀집은 제공하지 못한다.

컨테이너는 이러한 단점을 해결할 수 있다. 각각의 컨테이너는 호스트 컴퓨터의 운영체제를 공유하므로 필요한 리소스가 크게 경감된다. 덕분에 그만큼 실행도 빠르고 같은 호스트 컴퓨터에서 가상 머신에 비해 더 많은 수의 애플리케이션을 실행할 수 있다. 또한, 컨테이너 역시 외부와 독립된 환경을 제공하므로 밀집과 격리가 동시를 달성할 수 있다. 그리고 도커 자체는 호스트 컴퓨터의 아키텍터나 운영체제와 상관없이 동일하게 동작하지만, 컨테이너에 들어 있는 애플리케이션은 운영체제나 아키텍처를 가릴 수 있따. 결국 컨테이너의 내용물이 무엇이든 컨테이너를 다루는 방법은 환경과 상관없이 동일하다.

 

Docker Life Cycle  

Docker Container에서 원하는 어플리케이션을 돌리기 위해서는 Docker Image가 필요하다. Docker Image 안에는 dependencies, libraries, frameworks 등 어플리케이션을 구동하기 위한 요소들이 포함되어 있는 스냅샷이라고 할 수 있다. 그리고 Docker Image를 만들기 위해서는 Dockerfile이라는 텍스트 파일을 통해서 만들 수 있다. Dockerfile 안을 보면 베이스 이미지가 뭐고, run, copy 등 원하는 명령어들을 순차적으로 적어놓은 파일이라고 할 수 있다. 그래서 도커 파일을 이용해서 빌드를 시켜 도커 이미지를 만들고, 도커 이미지를 통해서 런하게 되면 도커 컨테이너를 구동시킬 수 있다. 도커는 이런 라이프 사이클을 가지며, 각각의 요소들을 하나씩 살펴보자.

 

Dockerfile

Dockerfile은 도커 이미지를 빌드하는데 사용되는 스크립트 파일로, 이미지 구성 및 환경을 정의한다. Dockerfile은 일련의 명령어(인스트럭션)로 구성되어 있으며, 명령어를 실행한 결과로 도커 이미지가 만들어진다. 또한, 각 명령어는 Docker 이미지의 레이러를 생성하는데 레이어는 파일 시스템의 스냅샷으로 도커는 각 레이어를 캐싱하여 재사용한다.

Docker의 캐싱은 Docker 이미지 빌드 시간을 단축하기 위한 매커니즘으로 이미지 빌드 프로세스에서 이전에 실행된 명령어들의 결과를 캐시로 저장하여 동일한 명령어가 다시 실행될 때 재사용하여 이미지 빌드 속도를 향상시킨다. 또한, Dockerfile에서 자주 명령어가 바뀔만한 것들은 뒤쪽에 명시해줘야 더 적은 변화된 레이어로 다시 빌드하기 때문에 캐싱 기능을 더 효율적으로 이용할 수 있다.

 

Docker Image

도커 이미지는 어플리케이션을 실행하는데 필요한 코드, 런타임 환경, 시스템 라이브러리 등 컨테이너 실행에 필요한 파일과 설정값 등을 포함하고 있으며, 실행이 필요한 어플리케이션 상태를 하나의 SNAPSHOT 형식으로 이미지화 시켜서 만들어진 후 상태값이 변화하지 않는다.

도커 이미지는 이미지 레이어가 모인 논리적 대상이다. 레이어는 도커 엔진의 캐시에 물리적으로 저장된 파일이다. 이 점이 중요한 이유는 이미지 레이어는 여러 이미지와 컨테이너에서 공유되기 때문이다. 만약 Node.js 애플리케이션이 실행되는 컨테이너를 여러 개 실행한다면 이들 컨테이너는 모두 Node.js 런타임이 들어 있는 이미지 레이어를 공유한다.

이미지 레이어를 여러 이미지가 공유한다면, 공유되는 레이어는 수정할 수 없어야 한다. 만약 이미지의 레이어를 수정할 수 있다면 그 수정이 레이어를 공유하는 다른 이미지에도 영향을 미치기 때문이다. 도커는 이미지 레이어를 읽기 전용으로 만들어 이러한 문제를 방지하고 있다. 즉, 이미지를 빌드하면서 레이어가 만들어지면 레이어는 다른 이미지에서 재사용될 수 있지만 레이어는 수정할 수 없다.

 

oneny/node 이미지는 최소한의 운영체제 레이어와 Node.js 런타임을 포함한다. 리눅스 이미지는 약 75MB의 디스크 용량을 차지한다. 그리고 node-app1 이미지는 oneny/node 이미지를 기반 이미지로 하므로 기반 이미지의 모든 레이어를 포함한다. Dockerfile 스크립트의 FROM 인스트럭션의 의미가 바로 이것이라고 할 수 있다. 기반 레이어 위에 추가한 app.js나 server.js는 불과 몇 KB에 지나지 않기 때문에 두 이미지 모두 75.5MB인 것을 확인할 수 있다.

 

그러면 docker image ls 명령어로 알아낸 두 이미지를 합치면 모두 대략 151MB의 디스크 용량을 점유하는 것으로 오해할 수 있다. 이는 이미지의 논리적 용량이지 해당 이미지가 실제로 차지하는 디스크 용량을 나타내는 것은 아니다. docker system df 명령어를 이미지의 용량 총합을 할 수 있는데 아까 말한 크기보다 디스크 용량을 훨씬 덜 차지하는 것을 확인할 수 있다.

 

도커의 이미지 레이어가 특정한 순서대로만 배치가 되는데 이 순서 중간에 있는 레이어가 변경되면 변경된 레이어보다 위에 오는 레이어는 재사용할 수 없다. 위에서 app.js를 수정하고 난 후 새로운 버전의 도커 이미지를 빌드하면 WORKDIR까지는 CACHED로 캐시된 레이어를 재사용하지만 그 뒤의 레이어는 새로운 레이어가 만들어졌다. 도커는 캐시에 일치하는 레이어가 있는지 확인하기 위해 해시값을 이용하고 기존 이미지 레이어에 해시값이 일치하는 것이 없다면 캐시 미스가 발생하고 해당 명령어가 실행된다. 그리고 한 번 명령어가 실행되면 그 다음에 오는 명령어는 수정된 것이 없더라도 모두 실행된다.

 

이러한 이유로 Dockerfile 스크립트의 명령어는 잘 수정하지 않는 명령어가 앞으로 오고 자주 수정되는 명령어는 뒤에 오도록 배치해서 캐시에 저장된 이미지 레이어를 되도록 많이 재사용할 수 있게 해야 한다. 예를 들어, CMD 명령어는 스크립트 마지막에 위치할 필요가 없다. FROM 명령어 뒤라면 어디에 배치해도 무방하고, 수정할 일이 잘 없기 때문에 보통 초반부에 배치한다. 그리고 ENV 명령어도 하나로 정의하여 레이어의 단계를 줄일 수 있다. 이러한 이미지를 공유하는 과정에서 시간음 룰론이고 디스크 용량, 네트워크 대역폭을 모두 절약할 수 있는 방법이다

 

Docker Conatiner

Docker Container는 한 마디로 도커 이미지를 기반으로 실행되는 프로세스다. 도커 이미지만 있다면 환경의 영향을 받지 않고 다양한 환경에서 컨테이너를 기동시킬 수 있기 때문에 이식성이 높다. 자바에 WORA(Write Once, Run Anywhere)라는 특징이 있는데 도커 컨테이너에도 한 번 빌드한 도커 이미지는 어느 환경에서나 동일한 동작을 보장한다는 BORA(Build Once, Run Anywhere) 특징이 있다고 할 수 있다.

또한, 도커 컨테이너는 위에서 말했듯이 가상 머신에 비해 '가볍다', '시작과 중지가 빠르다' 등과 같은 장점이 있다. 가상 머신은 하이퍼바이저를 이용하여 게스트 OS를 동작시키지만, 도커 컨테이너는 호스트 머신의 커널을 이용하면서 네임스페이스 분리와 cgroups를 이용한 제어를 통해 독립적인 OS와 같은 환경을 만들 수 있다. 따라서 게스트 OS 기동을 기다릴 필요가 없으므로 프로세스를 빠르게 시작하고 중지할 수 있다.

 

Docker가 컨테이너를 실행하는 원리

도커 엔진은 도커의 관리 기능을 맡는 컴포넌트다. 로컬 이미지 캐시를 담당하므로 새로운 이미지가 필요하면 이미지를 내려받으며, 기존 이미지가 있다면 전에 내려받은 이미지를 사용한다. 호스트 운영체제와 함께 컨테이너와 가상 네트워크 등 도커 리소스를 만드는 일도 담당한다. 도커 엔진은 항상 동작하는 백그라운드 프로세스로 containerd라는 컴포넌트를 통해 컨테이너를 실제로 관리하는데, containerd는 호스트 운영체제가 제공하는 기능을 통해 컨테이너, 즉 가상 환경을 만든다.

도커 엔진의 기능에 접근하려면 도커 API를 통해야 한다. 도커 API는 표준 HTTP 기반 REST API이다. 도커 엔진의 설정을 수정하면 이 API를 네트워크를 경유해 외부 컴퓨터로부터 호출할 수 없도록 차단하거나 허용할 수 있다. 그리고 도커 API는 운영체제와 상관없이 동일하므로 라즈베리 파이나 클라우드 환경, 리눅스 서버 등 어느 곳에 위치한 도커 엔진이라도 윈도가 설치된 노트북에서 이들을 제어할 수 있다.

도커 명령형 인터페이스(Docker command-line interface)는 도커 API의 클라이언트다. 우리가 docker 명령을 사용할 때 실제로 도커 API를 호출하는 것이 바로 도커 CLI이다. 즉, 도커 엔진과 상호 작용할 수 있는 유일한 방법은 API를 통하는 방법뿐인데 CLI는 API에 요청을 전달하는 역할을 한다.

 

Dockerfile 이미지 최적화 심화

 

FROM diamol/maven

WORKDIR /web

COPY pom.xml .
RUN mvn -B dependency:go-offline

COPY . .
RUN mvn package

EXPOSE 80

CMD ["java", "-jar", "/web/target/***-service-0.1.0.jar"]

위와 같은 Dockerfile이 있다고 가정하자. 해당 Dockerfile이 하는 일은 다음과 같다.

  • 기반 이미지는 diamol/maven이다. 이 이미지는 메이븐과 OpenJDK를 포함한다.
  • 작업 디렉터리를 만든 다음 이 디렉터리에 pom.xml 파일을 복사하면서 시작된다.
  • RUN 명령어에서 메이븐이 실행에 필요한 라이브러리 모듈을 내려받는다. 이 과정은 상당한 시간이 걸리기 때문에 별도의 단계로 분리해 레이어 캐시를 활용하고, 새로운 의존 모듈이 추가될 경우에만 이 단계까 다시 실행되도록 한다.
  • 그 다음 COPY . . 명령어를 통해 나머지 소스 코드가 복사된다. 이 명령어는 도커 빌드가 실행 중인 디렉터리에 포함된 모든 파일과 서브 디렉터리를 현재 이미지 내 작업 디렉터리로 복사하라는 의미다.
  • 그 다음 애플리케이션을 빌드하고 JAR 포맷으로 패키징하여 java 명령으로 JAR 파일을 실행한다.

 

FROM diamol/maven AS builder

WORKDIR /web
COPY pom.xml .
RUN mvn -B dependency:go-offline

COPY . .
RUN mvn package

# app
FROM diamol/openjdk

EXPOSE 80
CMD ["java", "-jar", "/app/iotd-serice-0.1.0.jar"]

WORKDIR diamol/openjdk
COPY --from=builder /web/target/iotd-service-0.1.0.jar .

위 Dockerfile은 멀티스테이지 빌드를 사용하여 이미지 크기를 줄이고 최적화한 파일이다. 멀티스테이지 빌드를 사용하면, 빌드에 필요한 모든 도구와 종속성을 포함한 이미지를 사용하여 애플리케이션을 빌드한 후, 최종 실행 이미지에는 빌드 도구 없이 필요한 파일만 포함된다. 해당 Dockerfile이 하는 일은 다음과 같다.

  • RUN 명령어를 통해 빌드하는 과정은 위와 같다. builder 단계를 정상적으로 마쳤다면, 다음 과정을 수행하는 마지막 단계를 실행해 애플리케이션 이미지를 생성한다. 하지만 메이븐을 이용한 빌드 과정에 (네트워크 문제로 의존 모듈을 받아오지 못했거나 소스 코드에 컴파일 에러가 있는 등의) 문제가 있었다면 RUN 명령어는 실패하면서 전체 빌드도 실패하게 된다.
  • 기반 이미지는 diamol/openjdk이다. 이 이미지는 자바 11 런타임을 포함하지만, 메이븐은 포함하지 않는다.
  • 똑같이 웹 어플리케이션 서버가 주시하는 포트는 80번이기 때문에 EXPOSE 명령어를 통해 외부로 공개해야 한다.
  • 이번에도 이미지에 작업 디렉터리를 만든 다음, 여기에 앞서 builder 단계에서 만든 JAR 파일을 복사한다. 이 JAR 파일은 모든 의존 모듈과 컴팡리된 애플리케이션을 포함하는 단일 파일이다. 그러므로 builder 단계의 파일 시스템에서 이 파일만 가져오면 된다.
  • CMD 명령어는 컨테이너가 실행되면 도커가 이 명령어에 정의된 명령을 실행하기 때문에 java 명령으로 빌드된 JAR 파일을 실행한다.
    • 여기서 의문이 말한 순서와 다르게 CMD 명령이 위에 있는 것을 확인할 수 있다. CMD 명령어는 FROM 명령어 뒤라면 마지막에 위치할 필요 없이 어디에 배치해도 무방하다. 또한, 수정할 일이 잘 없으므로 보통 초반부에 배치하면 된다.

 

결과

이미지를 최적화하기 전과 후를 비교했을 때 516MB 정도가 차이나는 것을 확인할 수 있다. 이처럼 멀티 스테이지 빌드를 사용하여 소스 코드 컴파일에 필요한 도구를 실제 애플리케이션을 기동시키는 컨테이너에 포함하지 않아도 된다. 기동용 컨테이너에 불필요한 소프트웨어를 설치하는 것은 보안 측면에서도 좋다. 또한, 최근에서 빌드킷(BuildKit) 등을 사용해 빌드 단계의 의존 관계를 자동으로 파악하고 빌드 단계를 병렬로 처리하는 것도 가능하다. 그래서 멀티 스테이지 빌드를 사용해 도커 이미지의 빌드 처리를 전체적으로 빠르게 할 수도 있다.

 

도커는 빌드할 때 뿐만 아니라 도커 레지스트리에 이미지를 푸시할 때 실제로 업로드 대상이 되는 것은 이미지 레이어다. 아래 사진처럼 push할 때 출력되는 내용을 보면 일련의 레이어 식별자와 해당 레이어의 업로드 진행 상황이 표시된다. 레지스트리 역시 도커 엔진과 같은 방식으로 이미지 레이어를 다루면 그만큼 Dockerfile 스크립트의 최적화가 더욱 중요하다는 것을 알 수 있다. 도커 엔진의 레이어 캐시와 완전히 같은 방식이지만, 레지스트리상의 전체 이미지를 대상으로 한다는 점이 다르다. 레이어의 90%의 레이어는 레지스트리의 캐시를 재사용할 수 있다. 이렇듯 최적화된 Dockerfile 스크립트는 빌드 시간, 디스크 용량을 넘어 네트워크 대역폭까지 영향을 미치는 중요한 요소다.



 

출처

도커 교과서

쿠버네티스 완벽 가이드