## Dockerize Rails Best Practice
slides: https://reurl.cc/VE5ad5 
[@5xRuby](https://5xruby.com) Cindy
note: 大家好,這是今天演講的投影片,大家可以掃 qr code 或輸入短網址,跟我一起看投影片,今天的題目是"Rails 容器化最佳實踐",這場演講會跟大家分享五倍紅寶石軟體開發如何針對 Rails 專案進行容器化,並且製作出約 100MB 的 Production Ready 容器鏡像。
---
## Cindy

- github:https://github.com/cindyliu923
- blog:https://cindyliu923.com
note: 我是 Cindy,這是我的 github 跟 blog,小雞是我的代表圖,是我自己畫的,還在努力持續學習中
---

note: 對於部署用的 image 來說,會是越輕量越好,因為越輕量部署時間會越快,部署時間越快,越可以快速迭代,今天會示範用 GitLab CI 搭配撰寫 Dockerfile 打包出輕量的 Rails app image,會用 CI 主要也是希望有一個乾淨的環境,打包出的 image 不會有多餘的檔案,所以可以比 local build 的 image 更輕量
---

<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 的檢查,先讓大家了解一下今天演講的範圍
---

<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

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

note: 公司專案大概可以壓到 100 MB 左右
---
## Rails image
### side project
 -> 
: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}]"}