Chào các bạn hôm nay mình sẽ hướng dẫn các bạn cách để tạo một docker container cho Single Page Application mà environment có thể truyền vào ở runtime (khi start một container). Cách này có thể áp dụng cho SPA React, Angular hoặc Vue, trong phạm vi bài viết này mình xin lấy React làm ví dụ. Bản thân mình đã từng gặp phải vấn đề này trong quá trình làm việc và cũng đã mất cả ngày trời google, hiểu idea rồi implement. Có thể nó chưa phải là cách tốt nhất nhưng trong trường hợp của mình nó work và giúp cho công việc quản lý biến môi trường nói riêng và DevOps nói chung dễ dàng hơn.
Vấn đề.
Trong một số trường hợp ứng dụng web SPA (gọi nôm na là con Front end) của mình phải đóng gói theo dạng container (Docker) để phục vụ cho nhu cầu cloud hóa giúp dễ dàng cài đặt và triển khai hơn. Điều này kéo theo việc set up biến môi trường phải khác đi một chút. Thông thường biến môi trường sẽ được inject vào trong source ở build time bằng các plugin của webpack như DotEnv hoặc WebpackDefinePlugin và mỗi lần muốn cập nhật biến môi trường lại phải build lại image. Điều này rất mất thời gian và lại khó quản lý vì mình ko thể biết được image nào đang chứa những environment nào dẫn đến rất dễ nhầm lẫn và nhập nhằng về version. Vậy thì làm thế nào? Đừng lo, giải pháp ngay bên dưới này đây, chỉ cần nhìn xuống là thấy.
Cách giải quyết
Trước hết mình sẽ nói về ý tưởng. Bạn đang không muốn build lại image mỗi lần cập nhật biến môi trường, vậy thì đơn giản thôi, mình sẽ không inject biến môi trường ở build time nữa. Hợp lý chưa? ^^ quá là hợp lý luôn. Thay vào đó chúng ta sẽ cần làm được các công việc sau:
- Tạo được một file env-config.js ở runtime khi start docker container
- Load file này vào khi initial-load và inject environment vào application
- Sử dụng các environment đó.
Tạo file env-config.js ở runtime
Bạn hãy xem qua file docker-compose.yml này
version: "3.2"
services:
my-spa-example:
image: my-spa-example
ports:
- "5000:80"
environment:
- "API_URL=production.example.com"
Khi chúng ta run container này với lệnh docker-compose up -d environment
sẽ được truyền vào như trên theo mô tả của file docker-compose. Biến API_URL sẽ nhận giá trị production.example.com. Mục tiêu của chúng ta là tạo được một file env-config có nội dung như thế này mỗi lần container khởi chạy thành công.
window.env = {
API_URL: "production.example.com",
}
Vậy làm sao ta có thể làm được điều này? Chúng ta cần có một bash script để làm điều đó. Bash script này có nhiệm vụ là lấy environment để ghi thành file. Nội dung bash script đó như sau:
#!/bin/bash
# Recreate config file
rm -rf ./env-config.js
touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
while read -r line || [[ -n "$line" ]];
do
# Split env variables by character `=`
if printf '%s\n' "$line" | grep -q -e '='; then
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
fi
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
echo "}" >> ./env-config.js
Mình không rành lắm về các lệnh của Linux nhưng đại ý cái script này là nó sẽ tìm đọc một file môi trường .env do chúng ta định nghĩa các biến môi trường (kiểu như template file), scan để lấy danh sách các biến đó ra, sau đó nó tìm trong global environment nếu có biến nào tồn tại thì ưu tiên lấy giá trị của biến trong global còn không thì lấy cái giá trị định trong file .env. Ví dụ ở đây chúng ta có biến global là API_URL có giá trị production.example.com thì nó sẽ ưu tiên lấy giá trị đó mà điền vào. Thế thì câu hỏi đặt ra là cái bash script này chạy ở đâu? Câu trả lời sẽ có sau khi bạn đọc Dockerfile này.
# => Build container
FROM node:alpine as builder
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN yarn
COPY . .
RUN yarn build
# => Run container
FROM nginx:1.15.2-alpine
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY conf /etc/nginx
# Static build
COPY --from=builder /app/build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY ./env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
Đây là Dockerfile mô tả image mà chúng ta sẽ build. Đối với SPA ta sẽ áp dụng kiểu 2 stage build, với bước 1 là build static assets (html/css/js...) , bước 2 copy cái đống asset đó qua một con web server (nginx) để serve, với cách làm này thì Docker Image build ra có size nhỏ và chỉ chứa những thứ cần thiết.
Và điểm nhấn ở đây nằm ở dòng này
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
Dòng này giúp con container của chúng ta sẽ run cái script lúc nãy khi khởi chạy và tạo ra file env-config.js như chúng ta mong muốn. Rồi bây giờ chúng ta sẽ tìm hiểu cách load và sử dụng file này như thế nào.
Load file env-config.js
Công việc bây giờ trở nên đơn giản hơn. Chúng ta chỉ cần thêm dòng này vào thẻ <head>
của file index.html
<script src="./env-config.js"></script>
Sử dụng các environment
Bây giờ mình tạo một thẻ p như thế này:
<p>API_URL: {window.env.API_URL}</p>
Các bạn sẽ thấy giá trị từ docker-compose.yml đã được hiển thị lên đây, giờ tha hồ mà change environment nhé. Trên đây là những công việc cơ bản để có thể set up runtime environment cho SPA. Ngoài ra có một số lưu ý như sau, mình muốn chia sẻ thêm.
Một số lưu ý
- Nhớ chỉnh sửa file .gitignore nhé
# Temporary env files
/public/env-config.js
env-config.js
- Để tránh mismatch giữa development và production bạn nên set up thêm command line cho dev server thế này nhé. Dưới đây là ví dụ dùng boilerplate create-react-app, nếu bạn tự set up build tool thì chế cháo lại nhé.
"scripts": {
"dev": "chmod +x ./env.sh && ./env.sh && cp env-config.js ./public/ && react-scripts start",
"test": "react-scripts test",
"eject": "react-scripts eject",
"build": "react-scripts build'"
},
- Chúng ta đang dùng image nginx để serve đống static assets này, và chúng ta có thể config như sau:
# Create directory for Nginx configuration / Tạo Nginx config
mkdir -p conf/conf.d
touch conf/conf.d/default.conf conf/conf.d/gzip.conf
Nội dung conf/conf.d/default.conf.
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
expires -1; # Set it to different value depending on your standard requirements
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
Nội dung conf/conf.d/gzip.conf
gzip on;
gzip_http_version 1.0;
gzip_comp_level 5; # 1-9
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
# MIME-types
gzip_types
application/atom+xml
application/javascript
application/json
application/rss+xml
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/svg+xml
image/x-icon
text/css
text/plain
text/x-component;
Hãy đọc lại file Dockerfile ở trên một lần nữa và chú ý dòng này
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY conf /etc/nginx
Tổng kết.
Thế là xong xuôi rồi đó, mình đã áp dụng và thành công chúc các bạn cũng áp dụng thành công nhé, nó cũng có một số trường hợp không mong muốn nhưng không sao từ từ rồi các bạn có thể chế cháo và hoàn thiện thêm, nếu có góp ý gì thì cứ liên hệ với mình nhé. Chào tạm biệt và hẹn gặp lại. Dưới đây là bài viết nguồn mình lượm của một thánh ở trên Internet:
See you next time!
Nếu bạn thấy hay thì ủng hộ mình ly cà phê nhé.