도커(Docker)

도커(Docker)

이 문서는 도커에 대한 간단한 설명과 각 프로젝트 팀에서 컨테이너 오케스트레이션 환경에 최적화된 이미지를 만드는 방법에 대한 가이드라인을 제공한다.

도커란

컨테이너 기술은 마이크로서비스 아키텍쳐와 밀접한 관계가 있다. 컨테이너 이미지는 일반적으로 하나의 프로세스를 격리된 환경에서 실행하는 것으로, 동일한 컨테이너를 여러대 구동함으로써 프로세스의 수를 손쉽게 횡적으로 확장가능하다는 장점이 있다. 도커는 컨테이너 기반 기술 중 가장 널리 채택되어 활용되고 있는 구현체 중 하나로 이하에서 다루는 컨테이너는 도커 컨테이너를 의미한다.

컨테이너 모델에서는 컨테이너 이미지를 하나의 프로세스 개념으로 보기 때문에, Dockerfile의 ENTRYPOINT에서 실행하는 프로세스가 컨테이너의 수명 주기를 결정한다. 쿠버네티스같은 컨테이너 관리(orchestration) 환경에서는 프로세스의 종료 코드나 livenessProbe, readinessProbe 등의 각종 애플리케이션 상태 체크 방법을 활용해 애플리케이션의 정상 종료 여부를 판단하고 재시작을 하거나 종료처리 등을 해준다. 컨테이너 오케스트레이션과 관련된 내용은 별도 문서로 다룰 예정이다.

도커의 기반 기술

https://stackoverflow.com/questions/16047306/how-is-docker-different-from-a-virtual-machine

이미지 레이어

도커 이미지를 빌드하면 Dockerfile의 매 커맨드마다 실행된 결과(delta)가 레이어로 추가되며, 매 레이어에는 고유한 식별자가 부여된다. 이 레이어는 동일한 문맥(context) 내에서는 동일한 결과를 만들어내기 때문에 캐싱이 용이하다. 이 이유로 인해 변경이 잦은 커맨드는 가능한 뒤에 실행하는 것이 좋다.

Dockerfile

Dockerfile은 도커 컨테이너 이미지를 생성하는 가장 대표적이고 보편적인 방법이다. 자세한 내용은 Dockerfile 레퍼런스를 참조하도록 한다.

.dockerignore

docker build 커맨드를 실행할 때 .dockerignore에 명시된 패턴의 파일들을 도커 데몬에 문맥으로 전달하지 않도록 하는데 사용된다. .gitignore 파일과 비슷하게 동작한다. 예를 들어 환경마다 그 결과가 달라지는 node_modules 디렉토리나 실제 애플리케이션에서 사용되지 않는 코드(배포용 스크립트나 설정파일 등)나 리소스 등은 가능하면 제외하는 것을 추천한다. 심지어는 Dockerfile이나 .git 디렉토리 등도 제외하는 경우도 꽤 있다.

vendor 디렉토리에 디펜던시를 모두 포함하는 go 프로젝트는 굳이 제외하지 않는 것이 좋다.

네트워킹

TBD

볼륨

TBD

배포용 도커이미지 만들기

프로세스

앞서 언급한 것처럼 도커 컨테이너는 프로세스에 대응된다고 생각하면 되는데, ENTRYPOINT로 컨테이너의 수명주기를 결정하는 프로세스를 정의한다. 하나의 컨테이너 안에서 여러개의 프로세스를 띄우고 supervisor 등을 이용해 다중 프로세스를 운영하는 것도 가능하지만, 일반적인 케이스는 아니며 컨테이너 오케스트레이션의 이점을 누리기 힘들어지는 문제가 있다. 다만 실행 환경에서 얼마든지 ENTRYTPOINT는 재정의할 수 있다(e.g. 쿠버네티스의 command).

환경변수

TBD

이미지 사이즈 최적화

도커 이미지 크기가 커지면 이미지 빌드 후 레지스트리에 푸시할 때나, 배포 환경에서 이미지를 가져올 때 시간이 오래 소요되며, 레지스트리 저장 공간에 따른 비용 및 데이터 전송 트래픽에 대한 비용이 증가한다. 따라서 배포에 사용되는 이미지는 가능한 크기를 줄이는 것을 권장한다.

alpine, slim과 같은 가벼운 기본(base) 이미지 사용하기

알파인 리눅스는 초경량 리눅스 배포판으로, 이미지 사이즈가 5MB 내외다. 알파인을 기반으로 한 파이썬 이미지는 29MB 내외로, 데비안을 기반으로 한 기본 파이썬 이미지가 356MB인 것과 비교하면 10배 이상 차이가 난다.

레이어 캐싱 활용도를 높이는 방법

COPY SOME_FILE .
RUN chown app:app SOME_FILE

위와 같이 파일을 추가하고 다음 레이어에서 권한을 변경하면 추가된 파일 크기만큼의 레이어가 생기고, 권한을 변경한 스텝에서 또 파일 크기만큼의 레이어가 추가된다.

COPY --chown app:app SOME_FILE

대신 위와같이 COPY 커맨드에 권한 설정을 한번에 하는 것과 같은 최적화를 할 수 있다. 여러 라인의 RUN 스텝이 있다면 가능하면 합쳐서 하나의 레이어로 만드는 것이 좋다.

docker history IMAGE 커맨드를 이용해 빌드한 이미지의 레이어별 사이즈를 체크할 수 있다.

멀티스테이지 빌드를 활용한 캐싱

멀티스테이지 빌드를 활용하면 빌드에만 사용되는 라이브러리는 최종 이미지에 포함하지 않고 빌드 결과만 활용할 수 있다. gradle을 포함한 빌드용 이미지에서 jar 파일을 빌드한 후 jdk 컨테이너에 복사해서 사용하는 등의 예시가 있다.

언어별 Dockerfile 작성 요령

golang

go는 빌드된 바이너리만 있으면 소스코드나 의존성이 있는 패키지가 없이 단독으로 배포가 가능하다. 때문에 golang 이미지를 기반으로 해 바이너리를 빌드한 후, 추가적인 레이어를 생성하지 않는(no-op) scratch 이미지에 바이너리만 복사해 배포할 수 있다. 때문에 빌드 시스템이나 배포환경에서는 바이너리 크기에 준하는 가벼운 이미지를 만들고 사용할 수 있다.

FROM golang:1.1-alpine as builder
RUN apk update && apk add --no-cache ca-certificates && update-ca-certificates
RUN adduser -D -g '' app
WORKDIR $GOPATH/src/path/to/package
COPY . .
RUN go build -o /go/bin/binary_name

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /go/bin/binary_name /go/bin/binary_name
USER app
ENTRYPOINT ["/go/bin/binary_name"]

python

파이썬도 알파인 이미지를 쓰는 것을 권장한다. 다만 현재 데비안 기반 컨테이너에서 이미 운영 중인 코드베이스는 알파인 패키지와의 호환성 문제로 slim 이미지를 사용하고 있다. pip install에 소요되는 시간과 그 결과로 추가되는 레이어의 크기가 큰 편으로, 패키지 설치하는 부분을 가능한 캐싱이 가능하도록 코드를 제외한 requirements 파일만 먼저 추가해 패키지를 설치한 이후 나머지 코드를 추가하는 방식으로 이미지를 빌드하는 것을 권장한다.

FROM python:3.7.2-alpine
RUN apk --no-cache add build-base libffi-dev openssl-dev
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . ./

nodejs

FROM node:10.12-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["npm", "start"]

정적 웹사이트

정적 웹사이트는 배포환경에서는 빌드에 필요한 nodejs 환경이나 node_modules이 전혀 필요가 없다. 멀티스테이지 빌드를 이용해 웹사이트를 빌드한 후 결과물만 alpine nginx 베이스 이미지(9MB)에 추가해 정적 컨텐츠를 서빙할 수 있다.

FROM node:8-alpine AS builder

WORKDIR /app
COPY package*.json /app/
RUN npm install

COPY . /app
RUN npm run build

FROM nginx:1.15-alpine
COPY --from=builder /app/build /usr/share/nginx/html

https://docs.docker.com/develop/develop-images/multistage-build/

컨테이너 보안 관련 고려사항

사용자(user) 권한 축소하기

일반적으로 리눅스에서 서비스(nginx, postgres 등)를 구동할 때 서비스 구동에 필수적인 권한만 부여된 사용자를 생성하는 것과 마찬가지로, 구동되는 애플리케이션이 필요 이상의 권한을 획득할 수 없도록 내부에서 서비스를 위한 유저를 생성하는 것을 권장한다.

가능한 경량한 기본 이미지 사용하기

각종 툴이나 라이브러리가 설치돼 있는 이미지는 컨테이너에서 실행권한을 획득했을 때 할 수 있는 행동의 선택지가 높아진다. 애플리케이션 구동에 필수적인 라이브러리만 포함한다.

나중에 디버깅을 위해 curl이나 기능이 풍부한 bash, 각종 네트워크 툴, 심지어는 vim 등을 설치하고 싶을 수 있지만, 애플리케이션 구동에 필수적인 라이브러리가 아닌 이상 제외하는 것이 좋다. 운영계에서는 프로파일러나 APM, 로그 수집 시스템, 분산 트레이싱 등을 최대한 활용해야 한다.

퍼블릭 이미지 사용에 유의

Docker hub 등의 퍼블릭 영역에 있는 이미지들은 각 프로젝트에서 공식적으로 관리되는 이미지 이외의 이미지는 운영계에서 사용하는 것을 삼가야 한다. 아직 base image들은 별도로 관리하지 않고 있으나, 향후 기본 이미지들도 직접 관리하는 것을 고려 중이다.

GCR 취약점 스캔

GCR에 푸시된 데비안, 우분투, 알파인 베이스 이미지에 대한 자동적 취약점 스캔이 이뤄지고 주로 취약점이 있는 시스템 패키지에 대한 알려진 이슈를 자동으로 검증해 준다. 애플리케이션에서 사용하는 패키지나 라이브러리 등에 대한 취약점 스캔은 GitHub에서 제공하는 security alert 서비스를 이용할 수 있다.

구글 클라우드 빌드

구글의 클라우드 빌드(Cloud Build)는 컨테이너 이미지 빌드를 수행할 수 있는 CI 중 간편하고 빠르며, 저장소에 대한 보안 스캔 등의 부가기능들이 제공되어 지속적으로 컨테이너 이미지를 빌드하는데 사용하고 있다.

기본적으로 저장소에 Dockerfile만 포함돼 있다면 간단한 트리거 설정만으로도 코드 저장소와 연동하여 커밋이나 태그 푸시 등을 트리거로 해 컨테이너 이미지를 빌드하고 레지스트리(GCR)에 업로드할 수 있다.

빌드 속도를 높이기 위해서 시스템 패키지나 pip, node module과 같은 패키지들을 설치하는 과정을 별도의 스테이지로 분리해 새로운 이미지를 빌드할 때 --cache-from 플래그를 활용하여 이전 빌드에서 빌드한 베이스 이미지를 캐시로 활용할 수 있다. 해당 기능을 이용하기 위해서는 빌드 스텝을 YAML 파일(cloudbuild.yaml)로 정의할 수 있다. (참고)

steps:
  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args:
    - '-c'
    - |
            docker pull asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}/base || true
  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args:
    - '-c'
    - |
      docker build \
        --cache-from asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}/base \
        --tag asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}/base \
        -f Dockerfile.prod \
        --target base \
        .
      docker build \
        --cache-from asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}/base \
        --tag asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}:$TAG_NAME-$SHORT_SHA \
        -f Dockerfile.prod \
        .      
images:
  - 'asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}/base'
  - 'asia.gcr.io/$PROJECT_ID/${_SVC_BASENAME}:$TAG_NAME-$SHORT_SHA'
timeout: '1200s'

도커 이미지 태깅

도커 이미지를 빌드할 때 별도로 태깅을 하지 않으면 기본적으로 latest라는 태그가 부여된다. 때문에 latest라는 태그를 사용하면 항상 최신의 이미지를 사용할 것으로 생각하기 쉽지만, 동일한 이미지명을 다른 내용을 담고있는 컨테이너 이미지 태그에 재사용할 경우 컨테이너 오케스트레이션이나 배포 관리가 어려워지는 측면이 있다.

때문에 특정 릴리즈 상태를 나타내는 정적인 불변의 태그명(커밋 sha, 브랜치명, 태그명 등)을 매 릴리즈마다 부여하는 것을 추천한다.

도커 레지스트리

퍼블릭/프라이빗 레지스트리

ECR, GCR

이미지 정리

TBD

도커를 이용한 로컬 개발환경

docker-compose

TBD

skaffold

TBD