Dockerfile的文法非常簡單,然而如何加快鏡像建構速度,如何減少Docker鏡像的大小卻不是那麼直覺,需要積累實踐經驗。這篇文章可以幫助你快速掌握編寫Dockerfile的技巧。
目标
- 更快的建構速度
- 更小的Docker鏡像大小
- 更少的Docker鏡像層
- 充分利用鏡像緩存
- 增加Dockerfile可讀性
- 讓Docker容器使用起來更簡單
總結
- 編寫.dockerignore檔案
- 容器隻運作單個應用
- 将多個RUN指令合并為一個
- 基礎鏡像的标簽不要用latest
- 每個RUN指令後删除多餘檔案
- 選擇合适的基礎鏡像(alpine版本最好)
- 設定WORKDIR和CMD
- 使用ENTRYPOINT (可選)
- 在entrypoint腳本中使用exec
- COPY與ADD優先使用前者
- 合理調整COPY與RUN的順序
- 設定預設的環境變量,映射端口和資料卷
- 使用LABEL設定鏡像中繼資料
- 添加HEALTHCHECK
- 多階段建構
示例
示例Dockerfile犯了幾乎所有的錯(當然我是故意的)。接下來,我會一步步優化它。假設我們需要使用Docker運作一個Node.js應用,下面就是它的Dockerfile(CMD指令太複雜了,是以我簡化了,它是錯誤的,僅供參考)。
FROM ubuntu
ADD . /app
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y nodejs ssh mysql
RUN cd /app && npm install
# this should start three processes, mysql and ssh
# in the background and node app in foreground
# isn't it beautifully terrible? <3
CMD mysql & sshd & npm start
建構鏡像:
docker build -t wtf . |
---|
你能發現上面Dockerfile所有的錯誤嗎? 不能? 那接下來讓我們一步一步完善它。
優化
1. 編寫.dockerignore檔案
建構鏡像時,Docker需要先準備
context
,将所有需要的檔案收集到程序中。預設的
context
包含Dockerfile目錄中的所有檔案,但是實際上,我們并不需要.git目錄,node_modules目錄等内容。
.dockerignore
的作用和文法類似于
.gitignore
,可以忽略一些不需要的檔案,這樣可以有效加快鏡像建構時間,同時減少Docker鏡像的大小。示例如下:
.git/node_modules/ |
---|
2. 容器隻運作單個應用
從技術角度講,你可以在Docker容器中運作多個程序。你可以将資料庫,前端,後端,ssh,supervisor都運作在同一個Docker容器中。但是,這會讓你非常痛苦:
- 非常長的建構時間(修改前端之後,整個後端也需要重新建構)
- 非常大的鏡像大小
- 多個應用的日志難以處理(不能直接使用stdout,否則多個應用的日志會混合到一起)
- 橫向擴充時非常浪費資源(不同的應用需要運作的容器數并不相同)
- 僵屍程序問題 - 你需要選擇合适的init程序
是以,建議大家為每個應用建構單獨的Docker鏡像,然後使用 Docker Compose 運作多個Docker容器。
現在,我從Dockerfile中删除一些不需要的安裝包,另外,SSH可以用docker exec替代。示例如下:
FROM ubuntu
ADD . /app
RUN apt-get update
RUN apt-get upgrade -y
# we should remove ssh and mysql, and use
# separate container for database
RUN apt-get install -y nodejs # ssh mysql
RUN cd /app && npm install
CMD npm start
3. 将多個RUN指令合并為一個
Docker鏡像是分層的,下面這些知識點非常重要:
- Dockerfile中的每個指令都會建立一個新的鏡像層。
- 鏡像層将被緩存和複用
- 當Dockerfile的指令修改了,複制的檔案變化了,或者建構鏡像時指定的變量不同了,對應的鏡像層緩存就會失效
- 某一層的鏡像緩存失效之後,它之後的鏡像層緩存都會失效
- 鏡像層是不可變的,如果我們再某一層中添加一個檔案,然後在下一層中删除它,則鏡像中依然會包含該檔案(隻是這個檔案在Docker容器中不可見了)。
Docker鏡像類似于洋蔥。它們都有很多層。為了修改内層,則需要将外面的層都删掉。記住這一點的話,其他内容就很好了解了。
現在,我們将所有的RUN指令合并為一個。同時把
apt-get upgrade
删除,因為它會使得鏡像建構非常不确定(我們隻需要依賴基礎鏡像的更新就好了)
FROM ubuntu
ADD . /app
RUN apt-get update \
&& apt-get install -y nodejs \
&& cd /app \
&& npm install
CMD npm start
記住一點,我們隻能将變化頻率一樣的指令合并在一起。将node.js安裝與npm子產品安裝放在一起的話,則每次修改源代碼,都需要重新安裝node.js,這顯然不合适。是以,正确的寫法是這樣的:
FROM ubuntu
RUN apt-get update && apt-get install -y nodejs
ADD . /app
RUN cd /app && npm install
CMD npm start
4. 基礎鏡像的标簽不要用latest
當鏡像沒有指定标簽時,将預設使用
latest
标簽。是以,
FROM ubuntu
指令等同于
FROM ubuntu:latest
。當時,當鏡像更新時,latest标簽會指向不同的鏡像,這時建構鏡像有可能失敗。如果你的确需要使用最新版的基礎鏡像,可以使用latest标簽,否則的話,最好指定确定的鏡像标簽。
示例Dockerfile應該使用
16.04
作為标簽。
FROM ubuntu:16.04 # it's that easy!
RUN apt-get update && apt-get install -y nodejs
ADD . /app
RUN cd /app && npm install
CMD npm start
5. 每個RUN指令後删除多餘檔案
假設我們更新了apt-get源,下載下傳,解壓并安裝了一些軟體包,它們都儲存在
/var/lib/apt/lists/
目錄中。但是,運作應用時Docker鏡像中并不需要這些檔案。我們最好将它們删除,因為它會使Docker鏡像變大。
示例Dockerfile中,我們可以删除
/var/lib/apt/lists/
目錄中的檔案(它們是由apt-get update生成的)。
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y nodejs \
# added lines
&& rm -rf /var/lib/apt/lists/*
ADD . /app
RUN cd /app && npm install
CMD npm start
6. 選擇合适的基礎鏡像(alpine版本最好)
在示例中,我們選擇了
ubuntu
作為基礎鏡像。但是我們隻需要運作node程式,有必要使用一個通用的基礎鏡像嗎?
node
鏡像應該是更好的選擇。
FROM node
ADD . /app
# we don't need to install node
# anymore and use apt-get
RUN cd /app && npm install
CMD npm start
更好的選擇是alpine版本的
node
鏡像。alpine是一個極小化的Linux發行版,隻有4MB,這讓它非常适合作為基礎鏡像。
FROM node:7-alpine
ADD . /app
RUN cd /app && npm install
CMD npm start
apk是Alpine的包管理工具。它與
apt-get
有些不同,但是非常容易上手。另外,它還有一些非常有用的特性,比如
no-cache
和
--virtual
選項,它們都可以幫助我們減少鏡像的大小。
7. 設定WORKDIR和 CMD
WORKDIR指令可以設定預設目錄,也就是運作
RUN
/
CMD
/
ENTRYPOINT
指令的地方。
CMD指令可以設定容器建立是執行的預設指令。另外,你應該講指令寫在一個數組中,數組中每個元素為指令的每個單詞(參考官方文檔)。
FROM node:7-alpine
WORKDIR /app
ADD . /app
RUN npm install
CMD ["npm", "start"]
8. 使用ENTRYPOINT (可選)
ENTRYPOINT指令并不是必須的,因為它會增加複雜度。
ENTRYPOINT
是一個腳本,它會預設執行,并且将指定的指令當成參數接收。它通常用于建構可執行的Docker鏡像。entrypoint.sh如下:
#!/usr/bin/env sh_# $0 is a script name, # 2, $3 etc are passed arguments# 1case "$CMD" in "dev" ) npm install export NODE_ENV=development exec npm run dev ;; "start" ) _# we can modify files here, using ENV variables passed in _ # "docker create" command. It can't be done during build process. echo "db: $DATABASE_ADDRESS" >> /app/config.yml export NODE_ENV=production exec npm start ;; * ) _# Run custom command. Thanks to this line we can still use _ # "docker run our_image /bin/bash" and it will work exec {@:2} ;;esac |
---|
示例Dockerfile:
FROM node:7-alpine
WORKDIR /app
ADD . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
可以使用如下指令運作該鏡像:
_# 運作開發版本_docker run our-app dev _# 運作生産版本_docker run our-app start _# 運作bash_docker run -it our-app /bin/bash |
---|
9. 在entrypoint腳本中使用exec
在前文的entrypoint腳本中,我使用了
exec
指令運作node應用。不使用
exec
的話,我們則不能順利地關閉容器,因為SIGTERM信号會被bash腳本程序吞沒。
exec
指令啟動的程序可以取代腳本程序,是以所有的信号都會正常工作。
這裡擴充介紹一下docker容器的停止過程:
(1). 對于容器來說,
init
系統不是必須的,當你通過指令
docker stop mycontainer
來停止容器時,docker CLI 會将
TERM
信号發送給 mycontainer 的
PID
為 1 的程序。
- 如果 PID 1 是 init 程序 - 那麼 PID 1 會将 TERM 信号轉發給子程序,然後子程序開始關閉,最後容器終止。
- 如果沒有 init 程序- 那麼容器中的應用程序(Dockerfile 中的
或ENTRYPOINT
指定的應用)就是 PID 1,應用程序直接負責響應CMD
信号。這時又分為兩種情況:TERM
- 應用不處理 SIGTERM - 如果應用沒有監聽
信号,或者應用中沒有實作處理SIGTERM
信号的邏輯,應用就不會停止,容器也不會終止。SIGTERM
- 容器停止時間很長 - 運作指令
之後,Docker 會等待docker stop mycontainer
,如果10s
後容器還沒有終止,Docker 就會繞過容器應用直接向核心發送10s
,核心會強行殺死應用,進而終止容器。SIGKILL
(2).如果容器中的程序沒有收到
SIGTERM
信号,很有可能是因為應用程序不是
PID 1
,PID 1 是
shell
,而應用程序隻是
shell
的子程序。而 shell 不具備
init
系統的功能,也就不會将作業系統的信号轉發到子程序上,這也是容器中的應用沒有收到
SIGTERM
信号的常見原因。
問題的根源就來自
Dockerfile
,例如:
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT ./popcorn.sh
CMD ["start"]
ENTRYPOINT
指令使用的是 **shell 模式**,這樣 Docker 就會把應用放到
shell
中運作,是以
shell
是 PID 1。
解決方案有以下幾種:
方案 1:使用 exec 模式的 ENTRYPOINT 指令
與其使用 shell 模式,不如使用 exec 模式,例如:
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT ["./popcorn.sh"]
這樣 PID 1 就是
./popcorn.sh
,它将負責響應所有發送到容器的信号,至于
./popcorn.sh
是否真的能捕捉到系統信号,那是另一回事。
舉個例子,假設使用上面的 Dockerfile 來建構鏡像,
popcorn.sh
腳本每過一秒列印一次日期:
#!/bin/sh
while true
do
date
sleep 1
done
建構鏡像并建立容器:
docker build -t truek8s/popcorn .
docker run -it --name corny --rm truek8s/popcorn
打開另外一個終端執行停止容器的指令,并計時:
time docker stop corny
因為
popcorn.sh
并沒有實作捕獲和處理
SIGTERM
信号的邏輯,是以需要 10s 左右才能停止容器。要想解決這個問題,就要往腳本中添加信号處理代碼,讓它捕獲到
SIGTERM
信号時就終止程序:
#!/bin/sh
# catch the TERM signal and then exit
trap "exit" TERM
while true
do
date
sleep 1
done
注意:下面這條指令與 shell 模式的 ENTRYPOINT 指令是等效的:
ENTRYPOINT ["/bin/sh", "./popcorn.sh"]
方案 2:直接使用 exec 指令
如果你就想使用
shell
模式的 ENTRYPOINT 指令,也不是不可以,隻需将啟動指令追加到
exec
後面即可,例如:
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT exec ./popcorn.sh
這樣
exec
就會将 shell 程序替換為
./popcorn.sh
程序,PID 1 仍然是
./popcorn.sh
。
方案 3:使用 init 系統
如果容器中的應用預設無法處理
SIGTERM
信号,又不能修改代碼,這時候方案 1 和 2 都行不通了,隻能在容器中添加一個
init
系統。init 系統有很多種,這裡推薦使用 tini,它是專用于容器的輕量級 init 系統,使用方法也很簡單:
- 安裝
tini
- 将
設為容器的預設應用tini
- 将
作為popcorn.sh
的參數tini
具體的 Dockerfile 如下:
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--", "./popcorn.sh"]
現在 ``` tini
就是 PID 1,它會将收到的系統信号轉發給子程序 ```
popcorn.sh
。
10. COPY與ADD優先使用前者
COPY指令非常簡單,僅用于将檔案拷貝到鏡像中。ADD相對來講複雜一些,可以用于下載下傳遠端檔案以及解壓壓縮包(參考官方文檔)。
FROM node:7-alpine
WORKDIR /app
COPY . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
11. 合理調整COPY與RUN的順序
我們應該把變化最少的部分放在Dockerfile的前面,這樣可以充分利用鏡像緩存。
在建構鏡像的時候,docker 會按照
dockerfile
中的指令順序來一次執行。每一個指令被執行的時候 docker 都會去緩存中檢查是否有已經存在的鏡像可以複用,而不是去建立一個新的鏡像複制。
如果不想使用建構緩存,可以使用
docker build
參數選項
--no-cache=true
來禁用建構緩存。在使用鏡像緩存時,要弄清楚緩存合适生效,何時失效。建構緩存最基本規則如下:
- 如果引用的父鏡像在建構緩存中,下一個指令将會和所有從該父程序派生的子鏡像做比較,如果有子鏡像使用相同的指令,那麼緩存命中,否則緩存失效。
- 在大部分情況下,通過比較
中的指令和子鏡像已經足夠了。但是有些指令需要進一步的檢查。Dockerfile
- 對于
和ADD
指令, 檔案的内容會被檢查,并且會計算每一個檔案的校驗碼。但是檔案最近一次的修改和通路時間不在校驗碼的考慮範圍内。在建構過程中,docker 會比對已經存在的鏡像,隻要有檔案内容和中繼資料發生變動,那麼緩存就會失效。COPY
- 除了
和ADD
指令,鏡像緩存不會檢查容器中檔案來判斷是否命中緩存。例如,在處理COPY
指令時,不會檢查容器中的更新檔案以确定是否命中緩存,這種情況下隻會檢查指令字元串是否相同。RUN apt-get -y update
示例中,源代碼會經常變化,則每次建構鏡像時都需要重新安裝NPM子產品,這顯然不是我們希望看到的。是以我們可以先拷貝
package.json
,然後安裝NPM子產品,最後才拷貝其餘的源代碼。這樣的話,即使源代碼變化,也不需要重新安裝NPM子產品。
FROM node:7-alpine
WORKDIR /app
COPY package.json /app
RUN npm install
COPY . /app
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
同樣舉一反三,Python項目的時候,我們同樣可以先拷貝requerements.txt,然後進行pip install requerements.txt,最後再進行COPY 代碼。
ROM python:3.6
# 建立 app 目錄
WORKDIR /app
# 安裝 app 依賴
COPY src/requirements.txt ./
RUN pip install -r requirements.txt
# 打包 app 源碼
COPY src /app
EXPOSE 8080
CMD [ "python", "server.py" ]
## 12. 設定預設的環境變量,映射端口和資料卷 運作Docker容器時很可能需要一些環境變量。在Dockerfile設定預設的環境變量是一種很好的方式。另外,我們應該在Dockerfile中設定映射端口和資料卷。示例如下: ```dockerfile FROM node:7-alpine ENV PROJECT_DIR=/app WORKDIR
PROJECT_DIR RUN npm install COPY .
MEDIA_DIR EXPOSE $APP_PORT ENTRYPOINT ["./entrypoint.sh"] CMD ["start"] ``` [ENV](https://docs.docker.com/engine/reference/builder/#env)指令指定的環境變量在容器中可以使用。如果你隻是需要指定建構鏡像時的變量,你可以使用[ARG](https://docs.docker.com/engine/reference/builder/#arg)指令。
13. 使用LABEL設定鏡像中繼資料
使用LABEL指令,可以為鏡像設定中繼資料,例如鏡像建立者或者鏡像說明。舊版的Dockerfile文法使用MAINTAINER指令指定鏡像建立者,但是它已經被棄用了。有時,一些外部程式需要用到鏡像的中繼資料,例如nvidia-docker需要用到
com.nvidia.volumes.needed
。示例如下:
FROM node:7-alpine
LABEL maintainer "[email protected]"
...
14. 添加HEALTHCHECK
運作容器時,可以指定
--restart always
選項。這樣的話,容器崩潰時,Docker守護程序(docker daemon)會重新開機容器。對于需要長時間運作的容器,這個選項非常有用。但是,如果容器的确在運作,但是不可(陷入死循環,配置錯誤)用怎麼辦?使用HEALTHCHECK指令可以讓Docker周期性的檢查容器的健康狀況。我們隻需要指定一個指令,如果一切正常的話傳回0,否則傳回1。對HEALTHCHECK感興趣的話,可以參考這篇部落格。示例如下:
FROM node:7-alpine
LABEL maintainer "[email protected]"
ENV PROJECT_DIR=/app
WORKDIR $PROJECT_DIR
COPY package.json $PROJECT_DIR
RUN npm install
COPY . $PROJECT_DIR
ENV MEDIA_DIR=/media \
NODE_ENV=production \
APP_PORT=3000
VOLUME $MEDIA_DIR
EXPOSE $APP_PORT
HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
當請求失敗時,
curl --fail
指令傳回非0狀态。
15. 多階段建構
參考文檔《https://docs.docker.com/develop/develop-images/multistage-build/》
在docker不支援多階段建構的年代,我們建構docker鏡像時通常會采用如下兩種方法:
方法A.将所有的建構過程編寫在同一個Dockerfile中,包括項目及其依賴庫的編譯、測試、打包等流程,可能會有如下問題:
- - Dockerfile可能會特别臃腫
- - 鏡像層次特别深
- - 存在源碼洩露的風險
方法B.事先在外部将項目及其依賴庫編譯測試打包好後,再将其拷貝到建構目錄中執行建構鏡像。
方法B較方法A略顯優雅一些,而且可以很好地規避方法A存在的風險點,但仍需要我們編寫兩套或多套Dockerfile或者一些腳本才能将其兩個階段自動整合起來,例如有多個項目彼此關聯和依賴,就需要我們維護多個Dockerfile,或者需要編寫更複雜的腳本,導緻後期維護成本很高。
為解決以上問題,**Docker v17.05 開始支援多階段建構 (multistage builds)**。使用多階段建構我們就可以很容易解決前面提到的問題,并且隻需要編寫一個 Dockerfile。
你可以在一個 Dockerfile 中使用多個 FROM 語句。每個 FROM 指令都可以使用不同的基礎鏡像,并表示開始一個新的建構階段。你可以很友善的将一個階段的檔案複制到另外一個階段,在最終的鏡像中保留下你需要的内容即可。
預設情況下,建構階段是沒有指令的,我們可以通過它們的索引來引用它們,第一個 FROM 指令從0開始,我們也可以用AS指令為建構階段命名。
案例1
FROM golang:1.7.3
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
通過
docker build
建構後,最終結果是産生與之前相同大小的 Image,但複雜性顯著降低。您不需要建立任何中間 Image,也不需要将任何編譯結果臨時提取到本地系統。
哪它是如何工作的呢?關鍵就在
COPY --from=0
這個指令上。Dockerfile 中第二個 FROM 指令以 alpine:latest 為基礎鏡像開始了一個新的建構階段,并通過
COPY --from=0
僅将前一階段的建構檔案複制到此階段。前一建構階段中産生的 Go SDK 和任何中間層都會在此階段中被舍棄,而不是儲存在最終 Image 中。
使用多階段建構一個python應用。
案例2
預設情況下,建構階段是未命名的。您可以通過一個整數值來引用它們,預設是從第 0 個 FROM 指令開始的。 為了友善管理,您也可以通過向 FROM 指令添加 as NAME 來命名您的各個建構階段。下面的示例就通過命名各個建構階段并在 COPY 指令中使用名稱來通路指定的建構階段。
這樣做的好處就是即使稍後重新排序 Dockerfile 中的指令,COPY 指令一樣能找到對應的建構階段。
FROM golang:1.7.3 as builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
案例3
停在特定的建構階段
建構鏡像時,不一定需要建構整個 Dockerfile 中每個階段,您也可以指定需要建構的階段。比如:您隻建構 Dockerfile 中名為 builder 的階段
$ docker build --target builder -t alexellis2/href-counter:latest .
此功能适合以下場景:
- 調試特定的建構階段。
- 在 Debug 階段,啟用所有程式調試模式或調試工具,而在生産階段盡量精簡。
- 在 Testing 階段,您的應用程式使用測試資料,但在生産階段則使用生産資料。
案例4
使用外部鏡像作為建構階段
使用多階段建構時,您不僅可以從 Dockerfile 中建立的鏡像中進行複制。您還可以使用
COPY --from
指令從單獨的 Image 中複制,支援使用本地 Image 名稱、本地或 Docker 注冊中心可用的标記或标記 ID。
COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
案例5
把前一個階段作為一個新的階段
在使用 FROM 指令時,您可以通過引用前一階段停止的地方來繼續。同樣,采用此方式也可以友善一個團隊中的不同角色,如何使用類似流水線的方式,一級一級提供基礎鏡像,同樣更友善快速的複用團隊其他人的基礎鏡像。例如:
FROM alpine:latest as builder
RUN apk --no-cache add build-base
FROM builder as build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp
FROM builder as build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp
# ---- 基礎 python 鏡像 ----
FROM python:3.6 AS base
# 建立 app 目錄
WORKDIR /app
# ---- 依賴 ----
FROM base AS dependencies
COPY gunicorn_app/requirements.txt ./
# 安裝 app 依賴
RUN pip install -r requirements.txt
# ---- 複制檔案并 build ----
FROM dependencies AS build
WORKDIR /app
COPY . /app
# 在需要時進行 Build 或 Compile
# --- 使用 Alpine 釋出 ----
FROM python:3.6-alpine3.7 AS release
# 建立 app 目錄
WORKDIR /app
COPY --from=dependencies /app/requirements.txt ./
COPY --from=dependencies /root/.cache /root/.cache
# 安裝 app 依賴
RUN pip install -r requirements.txt
COPY --from=build /app/ ./
CMD ["gunicorn", "--config", "./gunicorn_app/conf/gunicorn_config.py", "gunicorn_app:app"]
公衆号:運維開發故事
github:https://github.com/orgs/sunsharing-note/dashboard
部落格:https://www.devopstory.cn
愛生活,愛運維
我是冬子先生,《運維開發故事》公衆号團隊中的一員,一線運維農民工,雲原生實踐者,這裡不僅有硬核的技術幹貨,還有我們對技術的思考和感悟,歡迎關注我們的公衆号,期待和你一起成長!
關注我,不定期維護優質内容
溫馨提示
如果我的文章對你有所幫助,還請幫忙一下,你的支援會激勵我輸出更高品質的文章,非常感謝!
你還可以把我的公衆号設為「星标」,這樣當公衆号文章更新時,你會在第一時間收到推送消息,避免錯過我的文章更新。
........................