배경

여러 버전의 OS에서 돌아가는 여러 언어로 만든 시스템을 개발하려다 보니 개발 환경을 구축하는 것도 쉬운 일이 아닙니다. Vagrant도 사용을 해봤지만, 그렇게 획기적으로 편리하다는 인상은 못 받았습니다. 개발 환경을 배포하는 측면에서 좋았지만, 소규모 개발팀에서 배포를 위해 이미지를 빌드하는 과정이 오버헤드처럼 느껴졌습니다.

그러던 차에 Docker를 이용해서 개발 환경을 구축하는 것이 편리하다는 것을 느끼고, 본격적으로 Docker를 통해서 개발 환경을 구축 해보았습니다.

제약 사항

개발 환경 구축에 있어 아래의 제약 사항을 설정하고 접근했습니다.

  1. 환경(Development, QA, Stage, Production)에 따른 설정 변경 등의 수작업을 최소화 합니다.
  2. 최대한 Docker 기본의 기능을 활용하여 동작합니다.
  3. Local/Remote, Vim+Tmux/VS Code 조합에서 동일한 코드 트리로 설정 변경 없이 작업 가능하도록 합니다.
  4. 모범사례(Best Practice)를 찾아내서 최대한 그에 맞춰 구성하도록 합니다.

Dockerfile & Docker Compose

개발 환경을 구축하면서 Dockerfile과 Docker Compose의 관계에 대한 감을 잡는 것이 어려웠습니다. 왜냐하면 특정 작업을 양쪽 어디에 넣어도 동작하기 때문입니다. 여러 문서와 StackOverflow의 답변을 참고로 해서 다음과 같은 기준을 만들어 낼 수 있었습니다.

  1. 가능하면 최대한 넣을 수 있는 설정/작업(RUN)은 Dockerfile에 넣습니다.
  2. Dockerfile은 1 개만 사용합니다. 환경에 따라 동작하는 여러 개의 Dockerfile을 두지 않습니다. 즉, 이 말을 바꿔 말하면 환경에 따라 다르게 동작해야 하는 설정/작업은 Dockerfile에 넣으면 안 됩니다.
  3. 나머지 설정/작업은 Docker Compose에 맡깁니다.
  4. 환경에 따라 변화하는 설정/작업은 Docker Compose에서 처리하도록 합니다. 자세한 내용은 docker-compose.yml 설명 할 때 다루겠습니다.

Dockerfile

Docker 기본 기능 및 설정에 대한 설명은 생략하고, 이슈와 해결방법에 초점을 두어 설명하도록 하겠습니다.

Multi-stage builds

Go와 같은 언어는 컴파일 과정이 필수적이지만, 실행을 할 때 Go의 빌드 환경이 필요하지는 않습니다. 실행 환경이 빌드 환경을 포함한다면 쓸데없이 컨테이너 크기만 커집니다. 이런 문제를 해결하기 위해 Mult-stage builds를 사용했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
ARG GOLANG_DOCKER_IMAGE=debian:stretch-slim

FROM golang:1.12-stretch AS builder

COPY . /go/src/github.com/sangheonhan/foobar/
WORKDIR /go/src/github.com/sangheonhan/foobar/
RUN go get "github.com/gorilla/mux" &&
    go build -o foobar main.go

FROM ${GOLANG_DOCKER_IMAGE}

COPY --from=builder
    /go/src/github.com/sangheonhan/foobar/
    /go/src/github.com/sangheonhan/foobar/
WORKDIR /go/src/github.com/sangheonhan/foobar/
EXPOSE 80
CMD ["./foobar"]

GOLANG_DOCKER_IMAGE 환경변수를 사용한 이유는 Production 환경에서는 Go 빌드 환경이 필요없기 때문에 debian 이미지를 사용하고, 개발 환경에서는 Docker Compose를 통해 이미지를 사용하도록 지정하여 실행시에도 Go 빌드 환경을 통한 디버깅이 용이하도록 했습니다.

SSH Key

컨테이너 내부에서 외부로 SSH 연결이 필요 했습니다. 이를 위해서 Docker Screts 사용하는 것을 권하지만, 아직 Docker Swarm을 사용하지 않기 때문에, 기존처럼 SSH Key를 빌드 과정에서 컨테이너에 포함 시키는 방법을 사용하기로 했습니다.

다만 SSH Key를 Docker 설정에 포함 시키지 않고, 빌드 과정에서 시스템의 것을 가져오도록 만들었습니다. 이 때 한가지 문제가 있는데 외부의 SSH Key 문자열을 Docker에서 제대로 처리하지 못 한다는 점입니다. 이를 회피하기 위해서 Base64 인코딩을 하여 인자로 넣은 후, 다시 빌드 과정에서 디코딩을 하도록 했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
FROM python:3.7.4-slim-stretch

ARG ssh_private_key_base64
ARG ssh_public_key_base64

COPY . /usr/src/foobar/
WORKDIR /usr/src/foobar/
RUN pip install --no-cache-dir -r requirements.txt &&
mkdir -p /root/.ssh &&
chmod 0700 /root/.ssh &&
echo $ssh_private_key_base64 | base64 -d > /root/.ssh/id_rsa &&
echo $ssh_public_key_base64 | base64 -d > /root/.ssh/id_rsa.pub &&
chmod 600 /root/.ssh/id_rsa /root/.ssh/id_rsa.pub &&
echo "Host 192.168.0.* foobar*\ntStrictHostKeyChecking no\nUser root\nPort 22\n" >> /root/.ssh/config

CMD [ "python", "./foobar.py" ]
1
2
3
4
5
6
7
8
version: "3.3"
services:
    foobar:
        build:
            context: .
            args:
                ssh_private_key_base64: "${SSH_PRIVATE_KEY_BASE64:?Missing environment value - SSH private key}"
                ssh_public_key_base64: "${SSH_PUBLIC_KEY_BASE64:?Missing environment value - SSH public key}"

SSH_PRIVATE_KEY_BASE64, SSH_PUBLIC_KEY_BASE64 환경 변수는 .env 파일을 통해서 자동으로 읽어 들이도록 했고, .env 파일은 스크립트를 사용해서 미리 빌드 해둘 수 있게 만들었습니다.

 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
32
33
34
35
36
37
38
39
#! /bin/bash

if [ "$1" == "help" ] || [ "$1" == "-h" ]; then
    echo "Usage: mkenv [private key file] [public key file]"
    exit 1
fi

if [ "$1" != "" ]; then
    SSH_PRIVATE_KEY_FILE=$1
else
    SSH_PRIVATE_KEY_FILE=~/.ssh/id_rsa
fi

if [ "$2" != "" ]; then
    SSH_PUBLIC_KEY_FILE=$1
else
    SSH_PUBLIC_KEY_FILE=~/.ssh/id_rsa.pub
fi

if [ ! -f $SSH_PRIVATE_KEY_FILE ]; then
    echo $SSH_PRIVATE_KEY_FILE is not found.
    exit 1
fi

if [ ! -f $SSH_PUBLIC_KEY_FILE ]; then
    echo $SSH_PUBLIC_KEY_FILE is not found.
    exit 1
fi

echo Private key = $SSH_PRIVATE_KEY_FILE
echo Public key = $SSH_PUBLIC_KEY_FILE

SSH_PRIVATE_KEY_BASE64=`cat ${SSH_PRIVATE_KEY_FILE} | base64 | paste -s -d ''`
SSH_PUBLIC_KEY_BASE64=`cat ${SSH_PUBLIC_KEY_FILE} | base64 | paste -s -d ''`

echo SSH_PRIVATE_KEY_BASE64=${SSH_PRIVATE_KEY_BASE64} > .env
echo SSH_PUBLIC_KEY_BASE64=${SSH_PUBLIC_KEY_BASE64} >> .env

echo Done.

위 스크립트를 통해서 Base64로 인코딩 한 값을 .env 파일에 생성 할 수 있습니다. .env는 Docker Compose에서 자동으로 읽어서 적용하므로 docker-compose.ymlDockefile에서 해당 값을 사용 할 수 있습니다.

Ubuntu Repository

Docker가 아닌 환경에서도 간혹 격는 문제인데 Md5sum 값이 다르다면서 패키지 저장소를 업데이트 하는 과정에 오류가 발생하는 경우가 있습니다. 이번 개발 환경 구축 시에도 동일한 문제를 겪었고, Dockerfile에 저장소 주소를 초기화 하는 방법으로 해당 문제를 해결했습니다.

1
2
3
4
5
6
RUN rm -rf /etc/apt/sources.list && (echo "deb mirror://mirrors.ubuntu.com/mirrors.txt bionic main restricted universe multiverse" &&
echo "deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates main restricted universe multiverse" &&
echo "deb-src mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates main restricted universe multiverse" &&
echo "deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-backports main restricted universe multiverse" &&
echo "deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security main restricted universe multiverse") > /etc/apt/sources.list &&
rm -rf /var/lib/apt/lists/* && apt clean && apt-get update && apt-get install -y libsm6 libxext6 libxrender-dev

컨테이너 이미지 안의 패키지 저장소 주소를 초기화 시키고, 우분투 서버의 미러 사이트 주소를 가져와서 동작하도록 합니다. 이렇게 처리를 하면 지역이 바뀌거나 미러 사이트 주소에 변화가 생겨도 대응이 되기 때문에 소스 코드 트리에 넣기에 부담이 줄어듭니다.

docker-compose.yml

환경 별 설정

위에서 언급했듯이 환경에 따른 빌드의 변화를 Dockerfile이 아닌 Docker Compose, 즉 docker-compose.yml을 통해서 처리하도록 했습니다.

환경에 따라 달라지는 변화에 대응하는 방법을 크게 두 가지로 나뉩니다.

  1. docker-compose.yml과 환경 별로 나눈 docker-compose.{environment}.yml들을 이용하는 방법
  2. docker-compose.ymldocker-compose.override.yml 심볼릭 링크를 이용하는 방법

첫번째 방법은 docker-compose.yml에 공통적인 설정을 넣고, 환경에 따라 docker-compose.development.yml, docker-compose.qa.yml, docker-compose.stage.yml, docker-compose.production.yml을 만들어서 실행 시 명령행 인자를 통해서 적절한 파일을 사용하는 방법입니다.

1
2
# 개발 환경
$ docker-compose -f docker-compose.yml -f docker-compose.development.yml
1
2
# 운영 환경
$ docker-compose -f docker-compose.yml -f docker-compose.production.yml

두번째 방법은 첫번째와 동일하게 환경에 따라 파일은 나누되 명령행 인자로 지정하는 것이 아니라 docker-compose.override.yml 파일로 심볼릭 링크를 하는 방법입니다.

1
2
# 개발 환경
$ ln -s docker-compose.development.yml docker-compose.override.yml
1
2
# 운영 환경
$ ln -s docker-compose.production.yml docker-compose.override.yml

Docker Compose를 docker-compose.yml을 읽은 후 docker-compose.override.yml을 추가적으로 읽습니다.

여기서 환경 별로 구축을 할 때 주의해야 할 점이 있습니다. docker-compose.yml에서 한 설정을 덮어 쓰거나 새로운 설정을 추가하는 것은 가능하지만, 지우는 기능은 없으므로, 만약 특정 환경에서는 필요하지 않는 어떤 설정이 있다면 절대로 docker-compose.yml에 넣으면 안 됩니다. 번거롭더라도 해당 환경을 특정 환경을 제외한 나머지 환경을 위한 설정 파일에 모두 해당 내용을 추가해야 합니다.

아울러 환경 별로 필요한 어떤 환경 설정이 있다면 .env가 아닌 Docker Compose 설정에서 env_file 기능을 사용하여 분리하여 지정 할 수 있습니다.

devcontainer.yml

지금까지 설정으로 Vim을 이용한 작업에는 문제가 없습니다. 여기에 VS Code를 이용한 작업도 가능하도록 설정을 더 추가했습니다.

Dockerfile이 있는 프로젝트 루트에 .devcontainer 디렉토리를 생성하고, 그 안에 devcontainer.jsondocker-compose.extend.yml 파일을 생성합니다.

 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
{
  // See https://aka.ms/vscode-remote/devcontainer.json for format details.
  "name": "Foobar",
  // Update the 'dockerComposeFile' list if you have more compose files or use different names.
  // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
  "dockerComposeFile": [
    "../../docker-compose.yml",
    "../../docker-compose.override.yml",
    "./docker-compose.extend.yml"
  ],
  // The 'service' property is the name of the service for the container that VS Code should
  // use. Update this value and .devcontainer/docker-compose.yml to the real service name.
  "service": "foobar",
  // The optional 'workspaceFolder' property is the path VS Code should open by default when
  // connected. This is typically a file mount in .devcontainer/docker-compose.yml
  "workspaceFolder": "/usr/src/foobar/",
  // Uncomment the next line if you want to keep your containers running after VS Code shuts down.
  // "shutdownAction": "none",
  // Uncomment the next line if you want to add in default container specific settings.json values
  // "settings":  { "workbench.colorTheme": "Quiet Light" },
  // Uncomment the next line to run commands after the container is created - for example installing git.
  "postCreateCommand": "apt-get update && apt-get install -y git && pip install pylint black rope",
  // Add the IDs of any extensions you want installed in the array below.
  "extensions": ["ms-python.python"]
}

dockerComposeFile 설정에서 나온 순서대로 설정 파일을 읽습니다. service 설정은 꼭 docker-compose.yml와 이름이 같도록 해야 합니다.

VS Code를 이용 할 때 IDE 기능이 로컬 머신이 아닌 컨테이너 내부에서 동작하므로, 개발에 필요한 도구들도 설치하도록 해야 합니다.

1
"postCreateCommand": "apt-get update && apt-get install -y git && pip install pylint black rope"

아울러 VS Code의 확장 또한 컨테이너 내부에서 실행하므로, 이미 로컬 머신에 해당 확장을 설치했다고 하여도 컨테이너에 설치하도록 하는 설정을 추가해줘야 합니다.

1
"extensions": ["ms-python.python"]

docker-compose.extend.yml에는 VS Code로 개발 할 때만 필요한 Docker Compose 설정을 적습니다.

1
2
3
4
5
6
7
8
version: "3.3"
services:
  foobar:
    cap_add:
      - SYS_PTRACE
    security_opt:
      - seccomp:unconfined
    command: sleep infinity

cap_addsecurity_opt 설정은 디버깅을 위한 권한을 부여하기 위한 것입니다. command는 컨테이너 실행(run) 시 프로그램을 실행하지 않도록 하여, 개발자가 원할 때 실행 할 수 있도록 하기 위한 설정입니다.

설정 후 해당 프로젝트 폴더를 Remote-Containers: Open Folder in Container로 열면 됩니다. 만약 Dockerfile이 있는 프로젝트 루트를 그냥 폴더 열기로 열면 우측 하단에 컨테이너로 열지 물어보는데, Reopen in Container 버튼을 눌러 컨테이너로 여는 것도 가능합니다.

마무리

개발 환경을 Docker로 구축하는 방법에 대해 이야기를 해보았습니다. 이미 Docker를 어느 정도 사용 할 줄 아는 분들을 기준으로 했기 때문에 처음 Docker를 쓰는 분들은 잘 와닿지 않으실 수도 있을 것 같습니다.

하지만 개발 환경을 Docker로 구축을 하시다 보면 여기서 이야기한 문제를 경험하고 해결 방법을 고민하게 될 때를 분명히 경험하리라 생각합니다. 그 때 이 글이 조금이라도 도움 됐으면 합니다.