IanChenAboutPosts

使用 Docker 設置開發環境

avatar奕安Feb 15, 2020

這套流程最初是為了交大資工游逸平老師的編譯器課程作業所設計,目的是讓對 docker 完全沒有經驗的大學部同學也能快速使用我們的作業環境,並且作為未來作業全自動化批改的重要里程碑。

這門課程作業正在被助教群快速翻新來跟上時代中。如果你對課程有興趣且你是交大學生,可以來修看看,體驗看看這門我有自信成為交大資工教材最完整跟紮實的課程(助教自己說),並用你的人頭數來實際增加我的助教薪水。(拜託)

標準工作流程

  1. 進入專案目錄
  2. 執行 activate_docker.sh (實際上就是呼叫 activate_docker.py)
  3. 直接開始開發

特性介紹

這個工作流程就是模仿一般有虛擬話開發環境的使用模式,但實際上是直接進入一個乾淨的 docker 系統,我們會直接把呼叫 activate_docker.sh 時的工作目錄掛載到 docker 中的家目錄之下,並且同時完成使用者身份對應,讓你避免掉奇怪的檔案權限問題處理。

這裡的所有檔案都放在 Github 上,你可以從上面拿到我的程式碼。

接下來我會解釋這個東西的設計方法,並且如果你想把這份程式碼當範本的話,你該怎麼做

Makefile

第一份要看的資訊是 Makefile ,所有可以客製化的變數都在 Makefile 中,比如說進入 container(容器)中的使用者/群組名稱、主機名稱等,透過這個 Makefile 來集中管理並在下指令時傳入。

all: build
.PHONY: build activate

# Do not named user and group the same, this would cause error in entrypoint.sh
#	because we create the group before user exist which allowing name-crash in useradd command
CONTAINER_USERNAME = ian
CONTAINER_GROUPNAME = iang
CONTAINER_HOSTNAME = dev-env
IMAGE_NAME ?= my-dev-env

HOMEDIR = /home/${CONTAINER_USERNAME}

build: Dockerfile
	docker build \
		--build-arg CONTAINER_USERNAME=${CONTAINER_USERNAME} \
		--build-arg CONTAINER_GROUPNAME=${CONTAINER_GROUPNAME} \
		--build-arg CONTAINER_HOMEDIR=${HOMEDIR} \
		-t $(IMAGE_NAME) .

activate:
	python3 activate_docker.py \
		--username ${CONTAINER_USERNAME} \
		--homedir ${HOMEDIR} \
		--imagename ${IMAGE_NAME} \
		--hostname ${CONTAINER_HOSTNAME}

在知道 Makfile 的用處之後,就可以讀一下實作功能的檔案們, 第一位: dockerfile。

dockerfile && entrypoint.sh

以製作 python3.8 的環境當作範例,首先你會需要一份可以動的 dockerfile

可以注意一下這個檔案除了安裝 Python 外有哪些不一樣的地方(還有 Makfile 傳入的變數)

FROM ubuntu:18.04

ARG PYTHON3_VERSION=3.8

RUN apt-get update \
  && apt-get install gosu \
  && apt-get install -y software-properties-common && add-apt-repository -y ppa:deadsnakes/ppa \
      && apt-get install -y python${PYTHON3_VERSION} \
      && ln -sfn /usr/bin/python${PYTHON3_VERSION} /usr/bin/python3 \
      && ln -sfn /usr/bin/python${PYTHON3_VERSION} /usr/bin/python
RUN apt-get install -y gosu make

ARG CONTAINER_USERNAME=dummy
ARG CONTAINER_GROUPNAME=dummyg
ARG CONTAINER_HOMEDIR=/home/dummy

ENV DOCKER_USERNAME_PASSIN ${CONTAINER_USERNAME}
ENV DOCKER_GROUPNAME_PASSIN ${CONTAINER_GROUPNAME}
ENV DOCKER_HOMEDIR_PASSIN ${CONTAINER_HOMEDIR}

# indicate we are inside docker
ENV STATUS_DOCKER_ACTIVATED 1

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

CMD ["/bin/bash"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

這份 dockerfile 最重要的是最後兩行指令 :

CMD ["/bin/bash"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

這裏一次用到了 CMDENTRYPOINT,在同時用到這兩個指令時會在 Container( 容器 ) 啟動時強制執行 entrypoint.sh 後才會執行預設的 CMD 指令

entrypoint.sh 的存在是為了動態將使用者在 host system (原本的系統環境) 跟 container 中檔案系統所顯示的使用者身份關聯起來。

簡單來說,如果使用者在原本系統中的 uid (user id)跟 pimary gid (group id) 是 1008 跟 1009,那他進入這份 entrypoint.sh 的 docker container 之後所創建的所有檔案的所有權仍是 1008 跟 1009,當使用者離開環境之後看見所有在 container 下創建的檔案所有權都會是自己。

會這樣做的背後原因很簡單,就是在 contianer image 中系統無法被修改的情況下追求最大的使用彈性,讓各式各樣的人都能在自己的系統中使用相同的 docker image 作為開發環境(原本的目的就是課程使用)。 所以如果想要建立的 docker image 不是屬於這種情況的話可以參考 Handling-permissions-with-docker-volumes 中所提到的其他方式。

除了 entrypoint 本身之外, apt-get install gosu 也是與他一起連動的,這是為了解決 Linux 的 su 指令無法正確達成 container 下的 signal 處理需求。

在介紹完這個 dockerfile 之後 就要介紹一下輔助我們進入環境的腳本文件: activate_docker.py

activate_docker.py

import sys
import subprocess
import pathlib
import argparse
from pathlib import Path
import os

parser = argparse.ArgumentParser(
  description='Activate homework environment for compiler-s20')
parser.add_argument( '-u','--username',default="student",)
parser.add_argument( '--hostname', default='compiler-s20')
parser.add_argument( '--homedir', default='/home/student')
parser.add_argument( '-i','--imagename', default='compiler-s20-env')

args = parser.parse_args()
DOCKER_USER_NAME = args.username
DOCKER_HOST_NAME = args.hostname
DOCKER_IMG_NAME = args.imagename
dk_home = args.homedir

dirpath = os.path.dirname(os.path.abspath(__file__))
def main():
   if "STATUS_DOCKER_ACTIVATED" in os.environ:
      print("You are already inside our docker environment, see?")
      sys.exit(0)

   cwd = os.getcwd()

   bash_his = Path(f'{dirpath}/.history/docker_bash_history')
   bash_his.parent.mkdir(exist_ok=True)
   bash_his.touch(exist_ok=True)

   docker_options = [
      'docker', 'run',
      '--rm', '-it',
      '--hostname', DOCKER_HOST_NAME,
      '-e', f'LOCAL_USER_ID={os.getuid()}',
      '-e', f'LOCAL_USER_GID={os.getgid()}',
      '-v', f'{os.getcwd()}:/home/{DOCKER_USER_NAME}',

      # bash history file
      '-v', f'{dirpath}/.history/docker_bash_history:/{dk_home}/.bash_history',
      DOCKER_IMG_NAME,
   ]
   os.system(' '.join(docker_options))

if __name__ == "__main__":
  main()

./activate_docker.py 的主要用處有幾個:

  • 使用 docker volume 將當前目錄對應到使用者家目錄下
  • 將使用者身份對應到系統內部
  • 保存必要的指令輸入紀錄,讓你在進出 docker 環境時還能快速搜尋過去歷史
  • 避免重複呼叫 ./activate_docker.py 來遞迴進入 docker 環境

大部分的程式碼其實並不難,但你可能會比較在意我們是如何判斷使用者已經進入環境了的。也就是 STATUS_DOCKER_ACTIVATED 這個變數到底是從哪裡冒出來的呢?

回頭看看 Dockerfile 就能找到了!

# indicate we are inside docker
ENV STATUS_DOCKER_ACTIVATED 1

我們使用 ENV 指令在系統環境中直接設置一個環境變數,這樣做的目的是避免對 docker 不熟悉的學生誤觸指令,能省去新手學生掉入不知名 bug 的時間。

好的,接下來就是 docker image 的進入點 : entrypoint.sh

entrypoint.sh

這份的原始碼大部分是從 denibertovic 的文章 中所修改而成。 我會大概介紹一下他在做什麼,還有我做了哪些修改。

#!/bin/bash

# reference : https://denibertovic.com/posts/handling-permissions-with-docker-volumes/

USER_ID=${LOCAL_USER_ID:-9001}
USER_GID=${LOCAL_USER_GID:-9001}

USER_NAME=`echo $DOCKER_USERNAME_PASSIN`
GROUP_NAME=`echo $DOCKER_GROUPNAME_PASSIN`
HOMEDIR=`echo $DOCKER_HOMEDIR_PASSIN`

groupadd -g ${USER_GID} ${GROUP_NAME}
if [[ -d ${HOMEDIR} ]]
then
    # supress home already exist warning
    useradd --shell /bin/bash -u $USER_ID -o -c "" -m ${USER_NAME}  2>/dev/null
else
    useradd --shell /bin/bash -u $USER_ID -o -c "" -m ${USER_NAME}
fi
usermod -g ${GROUP_NAME} ${USER_NAME}

cd ${HOMEDIR}
exec /usr/sbin/gosu ${USER_NAME} "$@"

這份程式碼的目的就是在系統中使用 host system 的 uid 跟 gid 來即時新增使用者。 唯一不同的是,我使用了 DOCKER_USERNAME_PASSIN, DOCKER_GROUPNAME_PASSIN, DOCKER_HOMEDIR_PASSIN 三個變數。 這些環境變數一樣也是從 Dockerfile 中設置而來。而 Dockerfile 也只是擔任轉介 Makefile 中的內容而已。

最後,我們可以看看 activate_docker.sh。

activate_docker.sh

#! /bin/bash
make activate

只有兩行。

兩行的背後用意

其實 activate_docker.sh 的唯一目的就只是呼叫 make activate 指令,而 make activate 也就只是呼叫 activae_docker.py 而以,一個簡單的想法是,為什麼不要直接把 activate_docker.py 設定成可以直接呼叫,然後就直接呼叫 activate_docker.py 就好了呢?

第一也是最重要的原因:方便管理 - 我們為了把變數共享在 makefile 中,讓開發者在未來想修改時只修改 makefile 的單一變數就能完成。 第二點是 make activate 指令本身難以用 tab 自動補全,只要不是 fish shell 用戶基本上就一定需要打字,使用另外一個 shell script 讓我們可以只輸入 ./<tab> 就完成效果。 這樣在使用的體驗上會更加流暢。

第三點則跟專案架構有關 : 我們為了課程作業的新手導向需求,故意修改過專案架構,所以跟這裏擺出來的畫面有些不同。 在課程的作業設計之中,我採取把對於完成作業不需要知道的知識隱藏起來,降低新手們的認知負擔。

推薦的專案架構模式

同上所述,如果你想把專案打包給新手使用,你可以嘗試把大部分跟 docker 有關的程式碼都藏進另一個資料夾中(/docker),專案主目錄中只留下 activae_docker.sh

在這裡把我設計的作業資料夾架構放在這裏給你參考。

compiler-hw1/
├── Makefile
├── activate_docker.sh
├── docker
│   ├── Dockerfile
│   ├── Makefile
│   ├── activate_docker.py
│   └── entrypoint.sh
├── src
│   └── main.cpp
└── test
    ├── test.py
    └── testcase
        ├── answer
        └── output

更多優化建議

在理解完基本的架構之後,這裡提供一些想法來讓你將 docker 應用到你的開發環境中。

如果你想縮小 docker image 編譯出的大小,我會考慮使用 Multistage build 並盡可能在 dockerfile 中減少 RUN 指令的使用次數。(在這份 dockerfile 中為了清楚並沒有這麼做)。甚至是採用 alpine linux 當作基本發行版。只不過要注意的是, alpine linux 因為只是在系統功能上實踐最基本介面,這麼做的後果有可能會拖慢你開發環境系統的效能。

如果你想要使用不同的 shell,你會需要自己理解這些 shell 有哪些 history file 並自行修改 activate_docker.py 來掛載到目標 container 之中。

推薦閱讀與參考

如果你也想要用 docker 當作自己的開發環境,這裏推薦我在設計時讀過的一些文章以及 dockerfile 參考,讀完之後相信會對你很有幫助。

  1. docker container 身份對應
  2. 推薦的 makefile 以及 dockerfile

結語

分享完畢,有任何想法的話也希望你可以到 Github 上回饋給我,又或是寫 email 告知 我們下次再見~