R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

使用 OpenResty 無痛最佳化圖片體積(AVIF / WebP)

圖片體積最佳化計劃

兩年半之前,我第一次瞭解到 WebP 可以有效減少圖片體積的時候,有一瞬間產生了“把網站中所有資源替換成 WebP”的衝動,但當我開啟 CanIUse 的時候,發現 Safari 遲遲不支援,況且網站中涉及到圖片的地方實在是太多,修改程式碼不太現實。

我當時搜了一些資料,是關於讓 Nginx 針對瀏覽器提供的 Accept 頭來分別提供不同格式的檔案。這個方法確實有,然而我實在是太懶了,以至於寫一段命令將所有圖片轉個格式都不想寫,並且想到以後所有圖片都要提供兩個版本,實在是太麻煩了!於是這個計劃就被擱置了。

後來我更多的接觸了 OpenResty,這是一個集成了 Lua 的 Nginx,可以利用 Lua 指令碼來方便的做一些擴充套件。我還摸索出瞭如何利用 Lua FFI 呼叫 OpenCC API 無痛讓網站支援了繁體版本。既然 FFI 都能支援,那麼如果發現瀏覽器支援 WebP,則呼叫一個 cwebp 命令轉換圖片格式並返回,不是更好實現了?不過我的伺服器已經完全 Docker 化,OpenResty 是使用了現成的 hustshawn/openresty-opencc-docker,如果想安裝 cwebp 進去,需要修改 Dockerfile,因此這個計劃又被懶惰的我給擱置了。

直到前兩天,我無意間看到了這樣一篇文章:AVIF has landed,裡面提到了一個新的圖片格式:AVIF,文章裡提供了很多例子,看起來壓縮率和效果都 WebP 要好。這激起了我的興趣,想試一下能否為支援 AVIF 的瀏覽器提供 AVIF 的圖片。

當然,由於 AVIF 的瀏覽器支援率實在是太低(據說很快會高起來),於是我還是需要順便提供一個 WebP 的轉換。

於是計劃正式開始!

將 AVIF 與 WebP 轉換器打包進 Docker

之前我為了升級 OpenCC 版本,自己 Fork 了一下 hustshawn 的映象:RexSkz/openresty-opencc-docker,剛好在這個上面做修改。我要做的就是將 AVIF 和 WebP 相關的程式打包進去。

WebP 很簡單,用 cwebp 就可以了,我用的 OpenResty 映象是基於 Alpine 的,因此需要找到在 Alpine 下面安裝 cwebp 的方法。在 Alpine Package 中搜索 cwebp,發現需要安裝 libwebp-tools,於是在 Dockerfile 中添加了一句:

RUN apk add libwebp-tools

至於 AVIF 的部分,我找到了一個 Kagami/go-avif 專案。這個專案提供了 Linux 環境的二進位制可執行檔案,我可以直接用 curl 在 Build 的時候下載到映象中,但還需要安裝 libaom-dev。我搜了一下,並沒有發現相關的包,於是只能 Google 了一下 alpine libaom-dev 關鍵字,發現了 這樣一個網站,但裡面只有 Debian 的資料,沒有 Alpine……

熟悉 Linux 的小夥伴可能會知道,libaom-dev 最核心的部分應當是 aom,之前的 lib 表示是一個庫,-dev 表示這會帶上一些必要的 C 語言標頭檔案。於是我在網站搜尋框裡搜尋 aom,發現了一個叫 aom-dev 的包,應該是符合要求的,但需要至少 Alpine 3.12,因此我不得不升級了 OpenResty 映象版本。最終跟 AVIF 相關的部分如下:

FROM openresty/openresty:1.17.8.2-5-alpine

ARG AOM_VERSION="v1.0.0"

# Alpine 是沒有 curl 的,這個也要自己安裝
RUN apk add aom-dev curl \
    && curl -L https://github.com/Kagami/go-avif/releases/download/${GO_AVIF_VERSION}/avif-linux-x64 > /usr/bin/avif \
    && chmod +x /usr/bin/avif \
    && rm -rf /var/cache/apk/*

將 Dockerfile 推到 GitHub,觸發了 Docker Hub 的自動構建。當構建完成後,我在本地輸入了這兩條命令:

docker pull rexskz/openresty-opencc-docker
docker-compose -f local.yaml up -d nginx

然後我又上 Portainer 的 Shell 中看了一下,cwebpavif 兩個命令都已經可以使用,並可以轉換檔案了。

使用 OpenResty 轉換圖片並返回

由於我對 Lua 和 OpenResty 還沒有那麼熟悉,於是還是得參考網上大佬們寫的 OpenResty 配置 WebP 的文章,例如 這篇。關鍵程式碼如下:

-- 作者假定我們訪問 xxx.jpg.webp
-- 才會返回 WebP 格式
local newFile = ngx.var.request_filename
local originalFile = newFile:sub(1, #newFile - 5)

-- 如果原始檔不存在,直接報 404
if not fileExists(originalFile) then
    ngx.exit(404)
    return
end

-- 轉換格式,生成新檔案
os.execute("cwebp -q 75 " .. originalFile .. " -o " .. newFile)

-- 如果轉換成功則返回新檔案,否則報 404
if not fileExists(newFile) then
    ngx.exit(404)
    return
end
ngx.exec(ngx.var.uri)

我稍加改動,又封裝了幾個函式,便使它支援了 AVIF,並且在已有轉換結果的時候不會重複轉換:

-- 當 Accept 中有 image/avif 時才轉換
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
    local newFile = originalFile .. ".converted.avif"
    -- 首先嚐試 serve 新檔案,如果沒有才會做轉換
    if not tryServeFile(newFile, "image/avif") then
        os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
        serveFileOr404(newFile, "image/avif")
    end
end
-- WebP 的配置類似,就不重複寫了

最後我還不忘把 *.converted.avif*.converted.webp 加入了 .gitignore,簡直 Perfect!

轉換的效果如何呢?

我開啟本地的網站試了一下,發現一張 Banner 圖片就等了十幾秒,大部分時間都花在轉換上了……我可是用的外星人啊!

這不是坑爹嗎!

沒關係,大不了之後我每次上傳圖片,都觸發一下自動轉換,然後再把頁面公開化就好了。那麼 AVIF 的壓縮率有多少呢?

我去目錄下 ls 之後發現:對於 Banner 這種 JPG 格式壓縮率還是挺高的,能在之前 75% 的基礎上幾乎無損的再壓縮一半體積;但對於 PNG 檔案來說,由於我需要保證圖片不失真,因此選擇了無損壓縮,結果一個 1.2 KB 的圖示硬是被壓縮成了 2 KB……

這不是坑爹嗎!

難道 AVIF 的無失真壓縮率其實並沒有想象中的好?我又搜了一圈,發現了一個 Google 表格,裡面是 WebP 與 AVIF 的無失真壓縮對比。資料表明,在一半以上的情況下,AVIF 的無失真壓縮會讓圖片體積增大,甚至經常能達到兩三倍!

得了,我用於無損的 PNG 就不要考慮轉換為 AVIF 了,又慢體積還大。

最終的效果與程式碼

部署到伺服器上之後,我發現,一直在提示 404。看了 Nginx 的 Log 才發現,由於 Docker 許可權的原因,無法將生成的圖片寫入到目標路徑。修復了一下許可權問題後,圖片終於顯示了出來。

可以看到圖片的 Type 已經變成了 webp 和 avif

既然都調通了,就放出來造福一下大眾吧。不過寫的比較冗餘,也沒有太多最佳化。

location ~ \.(jpe?g|png|gif)$ {
    # convert using lua code
    content_by_lua_file /etc/nginx/conf.d/image-convert.lua;
}
function tryServeFile(name, contentType)
    if fileExists(name) then
        local f = io.open(name, "rb")
        local content = f:read("*all")
        f:close()
        if contentType ~= "" then
            ngx.header["Content-Type"] = contentType
        end
        ngx.print(content)
        return true
    end
    return false
end

function serveFileOr404(name, contentType)
    if not tryServeFile(name, contentType) then
        ngx.exit(404)
    end
end

-- 根據 https://docs.google.com/spreadsheets/d/1TE5iLE08oV90EqOmFHnzBLwiPtQSs1XAvI3QfoMgKQM/edit#gid=0
-- 不是所有格式都應當轉換為 avif,因為這樣體積可能會更大

if string.find(originalFile, ".png") ~= nil then
    -- 對於 PNG(無損),只嘗試用 webp 的無失真壓縮
    if string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
        local newFile = originalFile .. ".converted.webp"
        if not tryServeFile(newFile, "image/webp") then
            os.execute("cwebp -q 100 -lossless " .. originalFile .. " -o " .. newFile);
            serveFileOr404(newFile, "image/webp")
        end
    else
        serveFileOr404(originalFile, "")
    end
else
    -- 對於其它格式,先嚐試用 avid,再嘗試 webp
    if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
        local newFile = originalFile .. ".converted.avif"
        if not tryServeFile(newFile, "image/avif") then
            os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
            serveFileOr404(newFile, "image/avif")
        end
    elseif string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
        local newFile = originalFile .. ".converted.webp"
        if not tryServeFile(newFile, "image/webp") then
            os.execute("cwebp -q 80 " .. originalFile .. " -o " .. newFile);
            serveFileOr404(newFile, "image/webp")
        end
    else
        serveFileOr404(originalFile, "")
    end
end

參考資料

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3901 天