斜槓工程師的問題筆記

如何使用容器來協助進行開發(上)

透過Docker將環境建置時間縮短100倍

問題情境

去年加入了新的後端團隊中,實際在工作上最常接觸到的專案中,最主要在維運的專案使用的環境是2019年發佈的版本。雖說要跟一些上古專案比,版本不算太舊,但在相對迭代領域較快的軟體領域中,也已經是頗為陳舊的存在。且加上過去設計的架構比較複雜,疊床架屋的情形相對嚴重。雖然團隊持續的在解構,將一些不同的功能單獨拆出來運行,但在此之前,他就是一個很重要所以不太能做太大改動的專案。因此造成幾個問題。

  1. 新人上手時間很長:每個人剛入職時,在拿到電腦後首先要面對的就是啟動上面提到的主專案。而這一切要參照前人陸陸續續整理下來的環境建置文件一步一步地進行,雖然沒有實際測試過,但整份文件應該有十張A4只那麼多,再加上不同系統(尤其是m系列的mac)所產生的不同問題,快則三天,慢的話需要一個星期才能將專案成功啟動

  2. 工程師不敢更新:由於曾發生電腦開啟自動更新,更新之後專案就跑不動的狀態,造成大家PTSD發作,變得不敢輕易更新自己的電腦,整台電腦的環境為了一個專案被迫停留在過去。

  3. 墊高跨專案的開發成本:上面提到的問題其實不只在一個專案上發生,同時期開發的專案或多或少都有類似的問題。造成工程師們在開發時,跨專案開發或支援的時間成本會被墊高。

要解決這些問題,我想到了在轉職後的第一份工作上,遇到過一位堅決不讓任何環境污染自己電腦的OP同事,拜他所賜,我那時也初窺容器的門道。

現在的新人只需要半天就可以開始進行開發囉!

容器的使用是一條很深的學問,但在這裡會紀錄的,不是多深的學問,而是如果想要建置一套屬於某個專案的特定環境時,需要的知識以及步驟。指令的操作也基本聚焦在最必要的幾個,能夠使用GUI的盡量使用GUI,避免太多新指令模糊了學習的焦點。

那我們開始吧

要開始使用Docker,首先當然是要在自己的電腦上安裝Docker,這件事很簡單,滑鼠點一點就完成了。去Docker desktop官網。根據自己的系統下載相對應的檔案來安裝。

接下來的步驟很簡單

搭建特定的環境(鏡像) > 將要執行的檔案放進去(容器) > 開始使用。

搭建特定的環境:幫我燒一片光碟

或許有點透露年紀,但我喜歡將容器的使用比喻成光碟的概念,小時候在玩單機遊戲時,一定都需要插入光碟片,才有辦法將遊戲在電腦中運行。而無論你在遊戲進行什麼樣的操作,存了多少檔案,都不會對那片光碟造成任何改動。因此同樣的光碟片拿去借給朋友的時候,他也可以在他的電腦中,從頭開始體會其中的美好。

而容器當中一個重要的概念 - 鏡像,就是這樣的存在。我現在說的「構建環境」對應的就是 「build一個鏡像」,而用前面的例子來說就是「燒一片光碟」的概念。 而鏡像的構建並不是透過燒錄機,而是透過一條指令

docker build

這條指令下去之後會做的事情,是去找到相對應的Dockerfile,按照裡面的步驟去做一條一條執行。Dockerfile中的步驟常見的有幾個: FROM / WORKDIR / COPY / RUN / EXPOSE / CMD

由於主要是為了用於自己的本地開發,一些底層邏輯、權限、安全相關的設定我們就先不考慮。以達成本地順利啟動為目標。 同樣以燒光碟為例子,我現在要將自己電腦中的遊戲燒到光碟中,我需要

拿一片新的光碟片(FROM)
將電腦裡的馬力歐資料夾複製(COPY)到光碟片中的指定資料夾(WORKDIR)
複製進去之後,我要在裡面新增一個文件,紀錄這片光碟的序號(RUN)
接著我指定一個啟動指令(CMD),讓之後拿到光碟的人一放進電腦中就會自動打開遊戲。

這就是一般build鏡像實在做的事情。 這裡準備了一個簡單的專案,可以透過它來練習(也可以不用),所以先透過以下指令將專案複製下來。

git clone https://github.com/YINWEIHSU/docker-exercise.git

現在以實際的案例來說,我現在要將電腦中的一個ruby專案放到鏡像中,需要ruby3.2.2版本的環境,我想要讓其他沒有安裝ruby的人也可以使用這個專案。我需要

下載ruby3.2.2的鏡像作為基礎鏡像(FROM)
將電腦裡的專案資料夾複製(COPY)到鏡像中的指定資料夾(WORKDIR)
複製進去之後,我要他安裝各種需要的套件(RUN)
接著我指定一個啟動指令(CMD),讓之後執行鏡像的人可以直接運行專案。

實際來看,就是backend資料夾下的Dockerfile(檔案名稱就叫做Dockerfile,沒有任何副檔名)在做的事,裡面的內容長這樣。

FROM ruby:3.2.2
WORKDIR /src
COPY . .
RUN bundle install
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]

每一步實際執行的內容如下。

  1. FROM ruby:3.2.2
    從 Docker Hub 下載 Ruby 3.2.2 官方鏡像作為基礎鏡像。用作構建自定義鏡像的起點。等於是拿一片已經安裝好特定版本的Ruby的光碟。
  2. WORKDIR /src
    設定容器內的工作目錄為 /src。如果該目錄不存在,Docker 會自動創建它。接下來的指令(如 COPY 和 RUN)都會在這個目錄下執行。
  3. COPY . .
    將 Dockerfile 所在目錄的所有文件和子目錄複製到容器內的當前工作目錄(即 /src)。
  4. RUN bundle install
    運行 bundle install 命令安裝所有專案中需要的套件。
  5. EXPOSE 3000
    告訴 Docker 在運行時打開容器的 3000 端口。這行其實不寫也沒關係,他算是一個提醒的功能讓其他人知道。
  6. CMD ["rails", "server", "-b", "0.0.0.0"]
    定義容器啟動時執行的命令。這裡啟動了一個 Rails 服務器,並綁定到所有網絡接口上,使得容器可以接受來自 Docker 主機的任何 IP 地址的請求。
由於容器本身是個隔離的環境,可以將它想像是一台獨立的主機,所以如果沒有寫 0.0.0.0,則容器只會限定接收容器內部發起的請求。

創建完這個Dockerfile之後,在專案底下執行這行指令。

docker build -t project:1.0.0 .

他會在專案路徑底下(最後的點.)尋找Dockerfile檔案,並且執行裡面的步驟,建立一個名叫project,標籤為1.0.0的鏡像。
如果有不同的Dockerfile存在,可以透過-f來指定執行的檔案。例如,如果我不想要執行Dockerfile檔案,而是想要執行Dockerfile.dev這個檔案,我可以這樣寫。(目前的專案底下沒有這個檔案,所以執行會出現錯誤)

docker build -f Dockerfile.dev -t project:1.0.0 .

我們執行docker build可以看到build的進程。 執行完後,打開Docker desktop可以看到一個名為project的鏡像已經被成功創建。

由於在Dockerfile已經設定了CMD,因此容器啟動時就會自動執行這行指令,所以我們來將容器啟動。

docker run -p 3001:3000 project:1.0.0

接著在自己的瀏覽器輸入 localhost:3001 就可以看到rails 的啟動畫面。

這裡解釋一下這行指令代表的意思: 我要從project:1.0.0啟動(docker run)一個執行的容器,對外開啟的PORT(-p)號為3001,對應到我在容器內的PORT為3000。

當然可以寫3000:3000去映射同樣的PORT,但這裡為了方便理解兩者的關聯,所以寫不同的PORT

到此為止可以成功的透過容器建置環境並運行專案,但如果我是開發需要使用的。我不可能專案啟動了之後就放著不管。

同步開發我的專案

接著需要解決的問題就是,如何讓我的程式碼更新也可以即時反映在容器中。在我們上面執行的鏡像構建時,已經將現有程式碼作為固定的部分放入,所以即使我現在做了任何更改,都不會反映到我在執行的容器中。

為了解決這個問題,我們可以使用 Docker 的一個功能 —— Volume。Volume允許我們將本地目錄或檔案掛載到容器中,這樣容器內的應用就可以直接讀取或寫入到本地檔案系統中,從而實現資料的即時同步。不用想得太複雜,就當他是一個設定容器內容跟電腦資料夾的連結功能。

在我們的 backend 路徑下,有一個 Dockerfile-env 檔案

FROM ruby:3.2.2
WORKDIR /backend
COPY Gemfile Gemfile.lock ./
RUN bundle install
EXPOSE 3000

會發現他跟上面的Dockerfile有些不同,我複製的東西不同了,原本是整個專案資料夾複製進去,現在只複製了紀錄套件的檔案。 我們一樣執行 docker build,這次我們透過-f指定要使用的Dockerfile。

docker build -f Dockerfile-env -t project-dev:1.0.0 .

執行完後會在 Docker Desktop 中看到 build 完成後的鏡像 project-dev。如果我們用他執行上面的 docker run 指令時,由於我們僅僅複製了套件設定(COPY Gemfile Gemfile.lock ./),他其實是沒有任何可執行的程式碼的,創建的只有一個可執行該程式碼的環境(安裝好套件的 ruby 環境)。因此我們在執行 docker run 時要加上一個 -v 設定。告訴 docker 要將哪個資料夾透過 Volume 掛載進去容器中的環境。

下面的 route-to-project 記得改為自己專案的絕對路徑,由於無法使用相對路徑,所以不確定的話可以透過在專案終端機打上pwd來確認專案的絕對路徑。

docker run -it -d -v /${route-to-project}/docker-exercise/backend:/backend -p 3001:3000 project-dev:1.0.0 bash

有沒有發現不只多了 -v,還有很多地方做了調整。在實際執行時都可以試試看如果沒有加上這些指令會發生什麼事。

  1. -it:只是容器在啟動後為我啟動一個可以用來交互的終端機。如果沒有加上這個指令,容器會在啟動後直接停止,因為我們並沒有給容器一個持續運行的 process 或交互式的接口,Docker 認為容器已經完成了其任務並退出。
  2. -d:這一個可以不加,用來指示將容器設置為後台運行(detached mode),讓它在背景中運行,不會阻塞當前終端機的操作,這在需要長期運行的服務或應用中很有用。,讓他不會佔用一個終端機頁面。
  3. .換成bash:改變容器的預設啟動 process。將 . 替換為 bash 允許在容器啟動後立即進入一個交互式的 bash shell,讓我們可以在裡面像在外面終端機一樣執行各種命令和操作。

先看結果,在Docker Desktop 中的 container 頁籤,會看到新增了一個容器。是透過我們剛剛build出來的 project-dev 鏡像來創建的。

只是如果我們按照之前的步驟,在瀏覽器輸入localhost:3001,卻會發現網頁不會正常顯示。因此我們在 Docker Desktop 中選取剛剛間裡起來正在執行中的容器,並且選擇 CLI。會看到一個很熟悉的終端機畫面。在裡面輸入

rails s -b '0.0.0.0'

之後,就會看到rails專案啟動的內容。 此時再重新到瀏覽器輸入localhost:3001就會看到熟悉的畫面。

如果剛剛沒有使用 -d 的話,就不用再特別進去 Docker Desktop 中執行 CLI,因為容器會在前台運行(也就是會直接在你下 docker run 指令的終端),並且佔用 CLI 的輸出,只有當容器停止運行時,CLI 才會恢復正常操作。

為什麼這次需要我自己手動進去啟動應用程式呢?

主要差別在於兩者的 Dockerfile 設定的不同,在第一個 Dockerfile 中,我們設定了

CMD ["rails", "server", "-b", "0.0.0.0"]

他等於指定了 docker run 之後要執行的指令,因此不用再自己手動啟動伺服器。 這次特別也以兩者的不同來展現 CMD 的作用。

到現在,應該已經可以使用容器來啟動任何專案需要的環境,與本機的環境完全隔離開來。並且還可以透過掛載進行同步的開發作業。但如果我現在有複數的專案要進行開發,同樣的步驟要做很多遍也是非常麻煩。因此在下一篇文章中,會再持續朝這個痛點邁進,透過 docker-compose,讓我們實現一件啟動多個專案的目標。

All rights reserved.