## Dockerize Rails Best Practice slides: https://reurl.cc/VE5ad5 ![](https://i.imgur.com/0s1vL10.png) [@5xRuby](https://5xruby.com) Cindy note: 大家好,這是今天演講的投影片,大家可以掃 qr code 或輸入短網址,跟我一起看投影片,今天的題目是"Rails 容器化最佳實踐",這場演講會跟大家分享五倍紅寶石軟體開發如何針對 Rails 專案進行容器化,並且製作出約 100MB 的 Production Ready 容器鏡像。 --- ## Cindy ![](https://i.imgur.com/EBxWfl3.jpg) - github:https://github.com/cindyliu923 - blog:https://cindyliu923.com note: 我是 Cindy,這是我的 github 跟 blog,小雞是我的代表圖,是我自己畫的,還在努力持續學習中 --- ![](https://i.imgur.com/cqPfx5M.png) note: 對於部署用的 image 來說,會是越輕量越好,因為越輕量部署時間會越快,部署時間越快,越可以快速迭代,今天會示範用 GitLab CI 搭配撰寫 Dockerfile 打包出輕量的 Rails app image,會用 CI 主要也是希望有一個乾淨的環境,打包出的 image 不會有多餘的檔案,所以可以比 local build 的 image 更輕量 --- ![](https://docs.gitlab.com/ee/ci/introduction/img/gitlab_workflow_example_extended_v12_3.png) <font size="2">source from: [GitLab CI docs](https://docs.gitlab.com/ee/ci/introduction)</font> note: 這張圖是 gitlab 官網的 CI/CD workflow,今天主要的重點會在中間 build package 的部分,不會講到打包完之後的 release,也不會特別講到通常在打包之前會做的測試或 lint 的檢查,先讓大家了解一下今天演講的範圍 --- ![](https://i.imgur.com/3Lyb1fW.jpg) <font size="2">source from: [Docker](https://in.pinterest.com/sankarvs/docker/)</font> note: 這邊是 docker 的 flow,我們今天說明的部分就是前面的 dockerfile 的部分,透過在 CI 執行 docker buils 來產生 docker image。 --- ## Docker + GitLab CI - Docker: Dockerfile - GitLab CI: .gitlab-ci.yml note: 我會先說明如何撰寫 Dockerfile 達成輕量化,接著說明利用 GitLab CI 做持續性整合,將打包 image 的動作變成自動化完成,所以最主要會有兩個 file,大家可以把這兩個 file 當作是執行的腳本檔案,dockerfile 是 for docker,gitlab ci yml 是 for gitlab --- ## Dockerfile draft for Rails ```dockerfile # default arguments # install gem # delete unnecessary file # prepare rails app # entrypoint & command ``` note: 這邊是我們 dockerfile 的草稿,我們在 dockerfile 撰寫的指令會在執行 docker build 的時候一一被執行,可以先定義一些預設的參數,接著安裝 rails 需要的套件,然後清理不需要的檔案來減少 image 的大小,準備 rails image,最後會寫我們 image 的進入點跟 docker image 在 run 的時候會執行的指令 --- ### [Use multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build) ```dockerfile ... # install gem FROM ruby:${RUBY_VERSION}-alpine AS gem ... # prepare rails app FROM ruby:${RUBY_VERSION}-alpine ... COPY --from=gem ... ... ``` note: dockerfile 中有兩個 From 表示的是這是有使用到 docker multi stage build 的功能,這裡表示的是,最下面的 stage 才會是最後產生的 image,而前面的 image 會是中間過程產生而不會被記錄下來,這樣的使用方式會讓 image 更小,這邊有個觀念簡單說就是在 Dockerfile 的每個指令都會產生一個 layer 紀錄,層層堆疊,所以如果說我們使用了越多的指令 image 其實是會越大 --- ### default arguments <!-- [ARG](https://docs.docker.com/engine/reference/builder/#arg): defines variables with default value --> ```dockerfile # default arguments ARG SERVER_ROOT=/srv/app ARG RUBY_VERSION=2.7.1 ... ``` change value when build ``` docker build --build-arg RUBY_VERSION=3.0.0 ``` note: 接下來我們會先在 dockerfile 的最上面設定參數的預設值,想要變更的時候可以直接在 build image 的時候傳入,這邊先設定之後 rails app 要放置的位置,和 ruby 的版本,這邊 app 要放哪裡,沒有一定,大家可以看習慣,那我們設定變數的好處是,在 build image 的時候可以帶參數做變更,例如我們想要換 ruby 的版本,就可以直接將版本當作參數傳入,如畫面上這樣 docker build build arg 放 ruby version。 --- ### install library & copy gem <!-- [FROM](https://docs.docker.com/engine/reference/builder/#from): Base Image ; [RUN](https://docs.docker.com/engine/reference/builder/#run): execute commands ; [COPY](https://docs.docker.com/engine/reference/builder/#copy) --> ```dockerfile ... FROM ruby:${RUBY_VERSION}-alpine AS gem ARG SERVER_ROOT RUN apk add --no-cache postgresql-dev build-base ca-certificates zlib-dev libressl-dev git shared-mime-info ... RUN mkdir -p ${SERVER_ROOT} COPY Gemfile Gemfile.lock ${SERVER_ROOT}/ ... ``` [Alpine Linux packages](https://pkgs.alpinelinux.org/packages) note: From 這條指令會初始化一個新的 build stage,build stage 這邊的話會是開始一個產生 image 的階段,然後設定 base image,接下來會以這個 base image 為基底開始製造最後要產生的 image。那這邊 docker 官網有建議我們可以使用 alpine 這個 image 來當作 base image,而 alpine image 是具有完整 linux 作業系統又不會檔案太大的選擇。而 SERVER_ROOT 是在這一個階段會用到的參數。我們第一個階段要做的事情是安裝 Ruby gem,所以我們將這個 stage 0 命名為 gem,之後可以用複製的方式將 gem 複製到後面的 stage。首先我們在 alpine linux 的環境下使用 apk add 指令來安裝需要的套件,而這種 -dev 類型的套件(具有 header 檔案),通常會用在 compile gem,而 alpine 需要 build-base 是 compile 相關的工具,那想要查有什麼 package 的話可以直接去官網看,這邊有官網的連結,這邊要裝的套件也是沒有一定,大家會需要依據自己的需求修改,主要就是說會需要看 rails 使用的 gem 在編譯的時候是不是有依賴其他套件,有的話就會需要把依賴的套件都安裝進來。利用 mkdir -p 的指令建立 SERVER_ROOT 的目錄,用 COPY 將 Gemfile 和 Gemfile.lock 複製到 container 的 SERVER_ROOT 下,因為我們在第一個步驟只是要安裝 gem,所以不需要花時間複製所有的檔案。 --- ### install gem & delete unnecessary file <!-- [WORKDIR](https://docs.docker.com/engine/reference/builder/#workdir): sets the working directory for any instructions that follow it in the Dockerfile --> ```dockerfile ... WORKDIR ${SERVER_ROOT} RUN gem install bundler:2.1.4 \ && bundle config --local deployment 'true' \ && bundle config --local frozen 'true' \ && bundle config --local no-cache 'true' \ && bundle config --local system 'true' \ && bundle config --local without 'development test' \ && bundle install -j "$(getconf _NPROCESSORS_ONLN)" \ && find /${SERVER_ROOT}/vendor/bundle -type f -name '*.c' -delete \ && find /${SERVER_ROOT}/vendor/bundle -type f -name '*.o' -delete \ && find /usr/local/bundle -type f -name '*.c' -delete \ && find /usr/local/bundle -type f -name '*.o' -delete \ && rm -rf /usr/local/bundle/cache/*.gem ... ``` [bundle config](https://bundler.io/v2.1/bundle_config.html) note: 用 WORKDIR 切到專案資料夾,然後 apline 版本的 bundler 可能不是我們要的,所以這邊先安裝我們要的版本,接著做 bundle 的設定,是部署用且要 frozen,frozen 的目的是為了防止套件被駭客修改,安裝 gem 的操作不要有 cache,且安裝到系統裡,這邊是看習慣,有些人會裝在 project,那我們只要安裝 production 的套件就好,那下面有附 bundle config 設定的官網說明,大家有興趣也可以官網看一下。接著開始安裝套件,而 -j 是 job 的意思,如果說我們 build 的 ci 環境可以多核心的話,那他可以平行去安裝,所以就讓它打開,`getconf _NPROCESSORS_ONLN` 這個指令可以確認目前的 linux 環境是幾核心,會是一個數字。但安裝完之後發現檔案還是很大,所以這邊要去找有什麼是我們在裝完之後不需要且可以刪除的,`.c` 的檔案是 C 語言的 source 檔案,包含了 header,而 `.o` 的檔案則是在 compile 過程中產生的檔案,都是我們最後不需要的,而 `.gem` 的檔案是從 ruby gem 下載來的壓縮檔,最後通通刪掉,而到這邊我們 gem 的目錄已經是最小化了。(Cindy 的筆記:A file ending in .o is an object file. The compiler creates an object file for each source file, before linking them together, into the final executable.) --- ### prepare rails app - install library ```dockerfile ... FROM ruby:${RUBY_VERSION}-alpine ARG SERVER_ROOT RUN apk add --no-cache tzdata libstdc++ git postgresql-libs imagemagick shared-mime-info ... ... ``` note: 這邊開始是我們要做 rails production image 的步驟,那我們安裝需要的最小套件,postgresql-libs 跟先前提到 dev 的差別就是這個套件不會包含 header 檔案,且依賴的套件比較少,所以會比較小,這邊要裝的套件也是沒有一定,大家可以依據自己的需求修改,主要是有些套件在執行的時候會動態的需要依賴一些其他套件,這邊要注意的是就算不安裝套件,docker image 還是可以成功 build 出來的,所以我們還是需要真正去 run image 來確認這個 image 是可以成功執行的。 --- ### prepare rails app - add user <!-- [adduser [OPTIONS] USER [GROUP]](https://wiki.alpinelinux.org/wiki/Setting_up_a_new_user#adduser) --> ```dockerfile ... RUN adduser -h ${SERVER_ROOT} -D -s /bin/nologin rails rails ... ``` Create new user, or add USER to GROUP ``` -h --home DIR Home directory -s --shell SHELL Login shell named SHELL by example /bin/bash -D --disabled-password Don't assign a password so cannot login in ``` note: 接下來做一個 user,設定不可以登入,create 名叫 rails 的 user 並加到 rails 的 group --- ### prepare rails app - copy gem ```dockerfile ... COPY --from=gem /usr/local/bundle/config /usr/local/bundle/config COPY --chown=rails:rails --from=gem /usr/local/bundle /usr/local/bundle COPY --chown=rails:rails --from=gem /${SERVER_ROOT}/vendor/bundle /${SERVER_ROOT}/vendor/bundle ... ``` ``` COPY [--chown=<user>:<group>] <src>... <dest> COPY [--chown=<user>:<group>] ["<src>",... "<dest>"] ``` note: COPY --from 是 multi stage 的特性,讓我們可以複製上面 stage 產生的東西,先前有設定 config 是不可變更的,我們用 root 權限複製 config 的設定,所以他除非拿到 root 權限,不然是不可以更改 config 的,而剩下的就用 rails 的權限將已經安裝好的套件複製到這個 stage 裡 --- ### prepare rails app - set environment ```dockerfile ... RUN mkdir -p ${SERVER_ROOT} ENV RAILS_ENV=production ENV RAILS_LOG_TO_STDOUT=true ENV SERVER_ROOT=$SERVER_ROOT COPY --chown=rails:rails . ${SERVER_ROOT} ... ``` note: 利用 mkdir -p 的指令建立 SERVER_ROOT 的目錄,設定 rails 的環境變數,設定為 production,log manage 由 docker 去處理,docker 統一接 log manage 再丟到 CloudWatch 之類的服務,直接將 log stdout 設定為 true,這樣我們可以更清楚看到 log 訊息,然後用 rails 的權限把專案目錄下的東西複製過來。 --- ### [.dockerignore](https://docs.docker.com/engine/reference/builder/#dockerignore-file) ``` node_modules npm-debug.log Dockerfile* docker-compose* .dockerignore .git .gitignore README.md LICENSE .vscode log/* tmp/* vendor/ruby vendor/data coverage ``` note: 這邊要注意一個地方就是我們可以設定 dockerignore,讓我們在 Dockerfile 做 Copy 的時候不會完全複製所有的檔案,被設定在 dockerignore 的檔案就不會複製了,假設我們專案很大的時候,像是 git 也會占很多的空間的,但在 production 我們根本不需要 git 的紀錄。 --- ### set user ```dockerfile ... USER rails WORKDIR ${SERVER_ROOT} ``` note: 設定執行的使用者是 rails,預設的目錄是 SERVER_ROOT <!-- --- (感覺可以跳過) [VOLUME](https://docs.docker.com/engine/reference/builder/#volume) ```dockerfile # Dockerfile ... RUN mkdir -p /${SERVER_ROOT}/public/system VOLUME ["/${SERVER_ROOT}/public/system"] ... ``` 因為他有要上傳檔案的資料夾,然後把他註冊一個 VOLUME,意思是只要 VOLUME 還在,裡面的檔案就不會消失 --> --- ### entrypoint & command <!-- [EXPOSE](https://docs.docker.com/engine/reference/builder/#expose)、[ENTRYPOINT](https://docs.docker.com/engine/reference/builder/#entrypoint)、[CMD](https://docs.docker.com/engine/reference/builder/#cmd) --> ```dockerfile ... EXPOSE 3000 ENTRYPOINT ["bin/entrypoint"] CMD ["server"] ``` note: rails port 預設是 3000,expose 可以設定是要 TCP 或 UDP 連線,預設是 TCP 連線,ENTRYPOINT 會是 image 執行的進入點,那 server 是執行的 comman,這邊注意一點是在 Dockerfile 只需要一個 CMD,如果我們寫了很多的 CMD 指令,但實際上只有最後一個 CMD 指令會生效,另外因為實質進入點是 ENTRYPOINT,所以我們也可以在 dockerfile 中不寫 command,可以直接執行 ENTRYPOINT。 --- ### bin/entrypoint ```script #!/usr/bin/env ruby ... require 'bundler/setup' ... cmd = ARGV.shift case cmd when 'server', 'migrate', 'rake', 'rails' ... ensure_connection return send("run_#{cmd}") if respond_to?("run_#{cmd}", true) exec("bundle exec #{cmd} #{ARGV.join(' ')}") else exec("#{cmd} #{ARGV.join(' ')}") end ``` docker run xxx (bin/entrypoint server) note: 我們在 dockerfile 中指定了 docker run rails image 的進入點,接著就會需要寫我們客製化進入點的執行程序,entrypoint 會在 CMD 的前面,這邊依照 dockerfile 寫的話預設就會是 bin/entrypoint server,寫 entrypoint 有個好處是,可以限制 docker run 的指令,我們可以在 entrypoint 中阻擋一些不可以執行的指令,或只限定可以執行特定的指令,進行 docker run 的客製化。而畫面上的程式碼表示的是我們將 command line argument 中先用 shift 取出第一個指令,然後依照傳入的指令決定要執行哪些方法,else 這邊的話會是 when 那行以外的指令,就直接執行看看,如果想要限制可執行的指令的話下面也可以刪掉,就不會執行。另一件要注意的是我們 Rails server 在跑起來的時候,DB server 是不是已經跑起來,是不能保證的,所以我們可以在 entrypoint 執行指令之前先做一個 db connection 的檢查。 --- ### bin/entrypoint - check connection ```ruby require 'timeout' require 'pg' def ensure_connection puts 'Check database connection...' Timeout.timeout(30) do PG.connect(ENV['DATABASE_URL']) rescue StandardError sleep 1 retry end rescue Timeout::Error puts 'Unable connect database' exit 1 end ``` note: 檢查 connection 的 code 可以類似像這樣,我們設定 timeout 30 秒,利用 PG connect 確認 pg 是不是已經初始化完成,DATABASE_URL 是 rails 預設如果 database yml 沒有特別設定,會先去看 DATABASE_URL,所以我們現在都可以不用特別設定 database yml 檔,可以讓 rails 直接去找 DATABASE_URL --- ### bin/entrypoint - run server ```ruby require 'bundler/setup' ... def run_migrate system('bundle exec rake db:migrate') end def run_server run_migrate unless ENV['AUTO_MIGRATION'].nil? exec("bundle exec rails server #{ARGV.join(' ')}") end ... ``` note: 畫面上這邊是 run_server 執行的指令,ruby 的 system 是執行完後會回到 ruby,exec 就直接 return 出去不會再回來了,所以我們在 run server 之前做的 migrate 會用 system 執行 migrate ,目的是為了回來接著執行 rails server <!-- ```script #!/usr/bin/env ruby # frozen_string_literal: true require 'timeout' require 'bundler/setup' require 'pg' def ensure_connection puts 'Check database connection...' Timeout.timeout(30) do PG.connect(ENV['DATABASE_URL']) rescue StandardError sleep 1 retry end rescue Timeout::Error puts 'Unable connect database' exit 1 end def run_migrate system('bundle exec rake db:migrate') end def run_seed system('bundle exec rake db:seed') end def run_server run_migrate unless ENV['AUTO_MIGRATION'].nil? exec("bundle exec rails server #{ARGV.join(' ')}") end def run_console exec("bundle exec rails console #{ARGV.join(' ')}") end command = ARGV.shift case command when 'server', 'seed', 'console', 'sidekiq', 'migrate', 'rake', 'rails' ensure_connection return send("run_#{command}") if respond_to?("run_#{command}", true) exec("bundle exec #{command} #{ARGV.join(' ')}") else exec("#{command} #{ARGV.join(' ')}") end ``` --> --- ## GitLab CI Pipelines ![](https://i.imgur.com/3fAbDCc.png) note: 這邊是我們預計一組自動化的工作流程,就像前面說過的我們專注在打包 image 這件事情,所以才會只有這樣,一般應該會有更多的 Job,每個圈圈會是一個 Job --- ## .gitlab-ci.yml ```yaml default: image: ruby:2.7.1 stages: - build - package ... ``` note: gitlab ci yml 檔的前面會類似這樣,default 的 image 會是所有 Job 的 default image,stage 會是執行一組任務時候的順序,例如 build stage 會平行執行,都執行成功後才會接著執行 package stage --- ### .gitlab-ci.yml - install nodejs & gem ```yaml variables: NODE_VERSION: 14.2.0 BUNDLER_VERSION: 2.1.4 .install_ruby_gems: &install_ruby_gems - gem install bundler -v ${BUNDLER_VERSION} - bundle install --path vendor .install_nodejs: &install_nodejs - curl -SLO https://nodejs.org/dist/v$NODE_VERSION/node-v${NODE_VERSION}-linux-x64.tar.xz && tar -xJf node-v${NODE_VERSION}-linux-x64.tar.xz -C /usr/local --strip-components=1; - curl -o- -L https://yarnpkg.com/install.sh | bash - export PATH=$HOME/.yarn/bin:$HOME/.config/yarn/global/node_modules/.bin:$PATH ... ``` note: 在 gitlab ci yml 中每個不是 gitlab ci 的 keyword 開頭的話就會被視為一個要執行的 Job,而 點 開頭的是被隱藏的 Job 不會被執行,但是可以被擴充到其他要執行的 Job 之中,所以這邊有個小技巧就是可以把會重複使用的整理成 點 開頭的 Job,而畫面上要做的就是安裝套件。 <!-- ```yaml default: image: ruby:2.7.1 stages: - build - package variables: NODE_VERSION: 14.2.0 BUNDLER_VERSION: 2.1.4 .install_ruby_gems: &install_ruby_gems - gem install bundler -v ${BUNDLER_VERSION} - bundle install --path vendor .install_nodejs: &install_nodejs - curl -SLO https://nodejs.org/dist/v$NODE_VERSION/node-v${NODE_VERSION}-linux-x64.tar.xz && tar -xJf node-v${NODE_VERSION}-linux-x64.tar.xz -C /usr/local --strip-components=1; - curl -o- -L https://yarnpkg.com/install.sh | bash - export PATH=$HOME/.yarn/bin:$HOME/.config/yarn/global/node_modules/.bin:$PATH - yarn install .common: before_script: - export LANG=C.UTF-8 - export LC_ALL=C.UTF-8 - *install_ruby_gems cache: key: files: - Gemfile.lock - yarn.lock paths: - vendor/ruby - node_modules .docker: &docker image: docker:stable cache: {} services: - docker:18.06.1-dind variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 before_script: - mkdir -p $HOME/.docker - echo $DOCKER_AUTH_CONFIG > $HOME/.docker/config.json - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - apk -Uuv add bash git curl tar sed grep docker:assets: extends: .common stage: build variables: RAILS_ENV: production before_script: - *install_nodejs - *install_ruby_gems - yarn install services: - postgres:12-alpine script: - bundle exec rake assets:precompile artifacts: paths: - public/packs - public/assets only: - master - tags docker:nginx: extends: .docker stage: package script: - docker build --tag $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG --file .gitlab-ci/nginx/Dockerfile . - docker push $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG - docker tag $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_SHORT_SHA - docker push $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_SHORT_SHA - | VERSION=$( curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ grep '"tag_name":' | \ sed -E 's/.*"v([^"]+)".*/\1/' \ ) && curl -L -o dockle.tar.gz https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz && \ tar zxvf dockle.tar.gz - ./dockle --exit-code 1 $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG - if [ "$CI_COMMIT_REF_SLUG" == "master" ]; then docker tag $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG $CI_REGISTRY_IMAGE/nginx:latest; fi - if [ "$CI_COMMIT_REF_SLUG" == "master" ]; then docker push $CI_REGISTRY_IMAGE/nginx:latest; fi needs: - job: docker:assets artifacts: true only: - master - tags docker:rails: extends: .docker stage: package script: - docker pull $CI_REGISTRY_IMAGE:gems || true - docker pull $CI_REGISTRY_IMAGE:latest || true - docker build --target gem --cache-from $CI_REGISTRY_IMAGE:gems --tag $CI_REGISTRY_IMAGE:gems . - docker build --cache-from $CI_REGISTRY_IMAGE:gems --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG . - docker push $CI_REGISTRY_IMAGE:gems - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - | VERSION=$( curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \ grep '"tag_name":' | \ sed -E 's/.*"v([^"]+)".*/\1/' \ ) && curl -L -o dockle.tar.gz https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz && \ tar zxvf dockle.tar.gz - ./dockle --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG - if [ "$CI_COMMIT_REF_SLUG" == "master" ]; then docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG $CI_REGISTRY_IMAGE:latest; fi - if [ "$CI_COMMIT_REF_SLUG" == "master" ]; then docker push $CI_REGISTRY_IMAGE:latest; fi needs: - job: docker:assets artifacts: true only: - master - tags ``` --> --- ### .gitlab-ci.yml - assets precompile <!-- [extends](https://docs.gitlab.com/ee/ci/yaml/#extends): reuse configuration sections [Create job artifacts](https://docs.gitlab.com/ee/ci/pipelines/job_artifacts.html#create-job-artifacts) --> ```yaml docker:assets: stage: build before_script: - *install_nodejs - *install_ruby_gems - yarn install services: - postgres:12-alpine script: - bundle exec rake assets:precompile artifacts: paths: - public/packs - public/assets ... ``` note: 在 assets 這個 Job 的 rails env 我們會設定為 production,另外也可以做 cache,那我們在 dockerfile 其實都沒有處理靜態類型的檔案,這會需要安裝 node js,但在 webpacker 的時代中,我們只有在處理靜態類型檔案的時候才需要 node js,所以就不在 Dockerfile 安裝了,而是交給 CI 幫我們處理,如此一來就不會在 image 中製造更多不需要的檔案,在我們執行完 assets:precompile 後,在 Rails 6 之後的 webpack 和本來就有的 assets pipeline 會在 public 資料夾下產生 packs 和 assets 的資料夾,而我們利用 CI 中的 artifacts 指令,保留執行完 script 後產生的檔案,讓我們在 CI 後面的步驟中可以取用 --- ### .gitlab-ci.yml - Nginx [Artifact downloads with needs](https://docs.gitlab.com/ee/ci/yaml/#artifact-downloads-with-needs) ```yaml docker:nginx: extends: .docker stage: package script: - docker build --tag $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_SLUG --file .gitlab-ci/nginx/Dockerfile . - docker push ... ... needs: - job: docker:assets artifacts: true only: - master - tags ``` note: rails 用 ruby 去讀靜態檔案會比較慢,所以我們會將這件事情交給 nginx 去 host 這些 static 的檔案,因為 nginx 會用 C 去讀取檔案,所以會比較快,而這邊的重點在於我們 用 needs 這個 keyword 告訴 CI 我們會需要前一個 stage 產生的靜態檔案,而這邊 build nginx 的 dockerfile 我們另外寫在 .gitlab-ci 的資料夾下。 --- ### Dockerfile - Nginx ```dockerfile ARG NGINX_VERSION=1.19 FROM nginx:$NGINX_VERSION-alpine-perl RUN apk add --no-cache nginx-mod-http-perl RUN chown -R nginx:nginx /etc/nginx/conf.d RUN rm -rf /usr/share/nginx/html/index.html USER nginx COPY .gitlab-ci/nginx/nginx.conf /etc/nginx/nginx.conf COPY .gitlab-ci/nginx/templates /etc/nginx/templates COPY public/ /usr/share/nginx/html ENV PUBLIC_PORT=80 ``` note: 這邊是我們另外為 nginx 寫的 dockerfile,要用哪個 nginx 當 base image 沒有一定,大家可以看情況使用,而最主要要做的事情是要將 public 的資料夾複製到 nginx 裡,且將已經寫好的設定複製進去,而我們會在設定檔中撰寫,如果是靜態檔案要直接從 nginx 讀取我們放進去的 assets 和 packs 裡的檔案,其餘的請況再去找 rails。 --- ### Nginx - default.conf.template ```nginx server { listen ${PUBLIC_PORT}; ... location ~ "^/assets/.+-([0-9a-f]{32}|[0-9a-f]{64})\..+" { ... } location ~ "^/packs/.+" { ... } ... } ``` note: 這邊是 ngnix 的設定,主要是要告訴 nginx 如果是 assets 或 packs 的話就直接去找複製到 nginx 的靜態檔案,其餘情況再去找 rails。 --- ### .gitlab-ci.yml - Rails <!-- [Specifying target build stage (--target)](https://docs.docker.com/engine/reference/commandline/build/#specifying-target-build-stage---target) [dockle](https://github.com/goodwithtech/dockle): Check image --> ```yaml docker:rails: extends: .docker stage: package script: - docker pull $CI_REGISTRY_IMAGE:gems || true - docker pull $CI_REGISTRY_IMAGE:latest || true - docker build --target gem ... - docker build --cache-from $CI_REGISTRY_IMAGE:gems --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG . - docker push ... ... needs: - job: docker:assets artifacts: true ``` note: 那接下來我們來看 CI 如何產生 rails image,先 pull 上一次產生的 image 做 cache,可以加快 CI 執行的速度,當 CI 執行完之後,image 就會被堆到 gitlab 的 Container Registry 或其他 image 的 registry --- ## Rails image ### business project ![](https://i.imgur.com/KPbst4H.png) note: 公司專案大概可以壓到 100 MB 左右 --- ## Rails image ### side project ![](https://i.imgur.com/BYsvRfC.png =280x200) -> ![](https://i.imgur.com/kqsxn2I.png =280x200) :alarm_clock: 00:05:30 :alarm_clock: -> :alarm_clock: 00:01:20 :alarm_clock: note: 我自己另外測試比較小的專案,還不是用 webpacker,需要在 rails image 中下載 nodejs 的情況,可以壓到不到 100 MB,而一般我們如果沒有特別處理,大概是 400 多 MB,所以從 400 多 MB 變成不到 100 MB,rails image package 的 CI 速度從 5 分多鐘變成只要 1 分鐘左右,CI 執行時間降低 75%。 --- ## Thank you! :baby_chick: - [What is Docker?](https://aws.amazon.com/tw/docker) - [Best practices for writing Dockerfiles](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) - [Enable Docker commands in your CI/CD jobs](https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#enable-docker-commands-in-your-cicd-jobs) - [A deeper look into the CI/CD workflow](https://docs.gitlab.com/ee/ci/introduction/#a-deeper-look-into-the-cicd-workflow) - [Keyword reference for the .gitlab-ci.yml file](https://docs.gitlab.com/ee/ci/yaml/) - [Container Linter for Security and Best Practices](https://github.com/goodwithtech/dockle) - [nginx docker image](https://hub.docker.com/_/nginx) - [Use multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) note: 附上一些可以參考的資料給大家 <!-- 其他筆記 [What is a Docker build stage?](https://stackoverflow.com/questions/49875295/what-is-a-docker-build-stage?noredirect=1&lq=1) docker-compose 預設會讀 .env 的環境變數 -->
{"metaMigratedAt":"2023-06-16T00:30:02.368Z","metaMigratedFrom":"YAML","title":"Dockerize Rails Best Practice","breaks":true,"description":"speech","slideOptions":"{\"theme\":\"simple\",\"spotlight\":{\"enabled\":true}}","contributors":"[{\"id\":\"293b5e6e-e5eb-4311-9c14-9ef76e059dd3\",\"add\":45616,\"del\":22982}]"}
    758 views