前導知識

  1. 瀏覽器的 POST 表格上傳是切片上傳的。

    瀏覽器在發送請求時,如果請求內容過大,就會將其改為 chucked request, 如這篇 Stackoverflow 的問題 中, 發問者所貼的那幾張圖那樣,會將同一個檔案在同一個請求中分成數個不小的片段上傳,也就是說並不會大檔案一上傳,就直接往伺服器的記憶體中塞,而是分片上傳, 由網站伺服器接收每一個片段然後寫到暫存檔案中,直到分片全部到來完成上傳後,再將暫存檔的位置傳給 PHP Script。

    順帶一提,在 PHP Script 被執行時,用戶上傳的檔案已經完整上傳了,因此也許上傳需要花費 1 小時的時間,但是 php.ini 中的設定 execution_time 只有 10 秒, 一樣不會觸發錯誤,因為 execution_time 在指的是 PHP Script 在執行時所花的總時間。

  2. Apache 或 Nginx 接收到檔案上傳時,並不是把上傳的檔案放到記憶體裡面,而是寫入指定的佔存資料夾中。

    如上一點所提到,網站伺服器收到上傳的檔案時,會將其放到指定的資料夾中,以 Apache 為例,暫存資料夾的位置是被 php.ini 中的 sys_temp_dir 指定:

    sys_temp_dir = /path/to/temp_dir
    

    而 Nginx 則透過 client_body_temp_path 所指定:

    client_body_temp_path /spool/nginx/client_temp 1 2
    
  3. Apache 及 Nginx 的上傳限制都需要透過配置檔案來更改。

    接著 Apache 的上傳限制通常以三個參數有關:

    # 這個參數限制單一 POST 請求的最大大小,也就是一個請求中可能有數個檔案,整個請求的大小不能超過這個值。
    post_max_size = 128M
    # 這個設定的是單一檔案的最大值。
    upload_max_filesize = 2M
    # 最後這個設定值是設定給 PHP 最多可使用的記憶體。
    memory_limit = 256M
    

    在某些文章中可能會提到 memory_limit 要大於 post_max_size,但是有第一點的前導知識之後, 你會發現實際上上傳這部分早就已經在執行 PHP 之前就完成了,以這個 256MB 為例,如果上傳 500MB 的檔案,也不會觸發錯誤, 在接下來的文章中我會實際證明。另外有關於 memory_limit 與 post_max_size 無關的說明, 在這篇文章中:Cannot upload files larger than 1GB in PHP under Apache even with ‘post_max_size’ and ‘upload_max_filesize’ set to 4096M 也有提到:

    Nope. You are correctly interpreting bad advice about memory_limit that would make you think that is the case when the reality is memory_limit has little to nothing to do with file upload size. memory_limit is purely about PHP process memory and has nothing to do with file transferring processes.

    而 Nginx 則採用 client_max_body_size 來設定。

    如果把上傳檔案、執行 PHP Script 到返回 Response 的過程畫成圖,大概就會長成這樣:

解決方法

要解鎖檔案上傳的限制,我們要解決幾個問題:

  1. PHP 對檔案上傳的限制。
  2. 暫存資料夾在物理上的容量限制。

PHP 對檔案上傳的限制

這個非常好解決,只要調整配置文件,讓他們對於檔案上傳的限制放寬就可以了,假設我們最大要給用戶上傳到 20GB,那麼我們就可以將 post_max_size 升到 20GB。

在 Apache 中我們可以這樣設定:

post_max_size = 20G
upload_max_filesize = 20G
memory_limit = 1024M

值得一提的是,在這邊同樣也把 memory_limit 調高了,這是因為作為一個上傳導向的應用程式, 每一個請求的分片都會相對較大,然後也有可能同時很多人上傳,因此提高 momery_limit 可以讓網站能負荷的使用者數量增加。

這樣我們就解鎖了 PHP 對檔案上傳的限制了。

temp_dir 物理上的容量限制

我們一般在 AWS 或 GCP 一些地方開了 instance 了不起容量就 50 ~ 100 GB 好一點的可以到 200GB, 但是以一個可以上傳巨大檔案的應用程式的伺服器而言, 200GB 肯定還是太小, 這時候我們就要使用到 AWS S3、Google Cloud Storage 或其他 Object Storage 來幫我們的忙了。

因為 S3 或是相容於 S3 的物件儲存系統是最常見的,因此我們就用 s3 系的物件儲存來做示範, 當然 Google Cloud Storage 也有這種功能,操作方法也完全一樣。

我們要使用的工具是這個:s3fs-fuse 這是一個可以將 s3 掛載到本地成為一個硬碟的軟體, 因為 S3 並沒有容量上限,而且單檔最大可以到 5TB,因此我們如果能夠將 s3 作為我們網站伺服器的暫存資料夾,那麼我們網站伺服器能接受的最大單檔大小就是 5TB 了。

範例

因為我沒有在使用 AWS 所以在這邊我就使用 Digital Ocean 的 Spaces 來做示範, 因為 Spaces 是一個 S3-compatible 的物件儲存,因此他的行為與操作方法與 S3 完全一模一樣。

然後我們採用的實體是 Digital Ocean 的 Droplet:1vCPU, 2GB RAM 以及 50GB SSD Disk

1. 安裝 s3fs

~$ sudo apt install s3fs
2. 取得 API Key 及 API Secret

首先要先取得 S3 的 API Key 及 API Secret,在這邊我一樣使用 Digital Ocean 來示範,你也可以使用 AWS 的 S3 來操作。

首先我們點選左欄最下方的 API,進入之後找到 Space access key。

點選 Generate New Key 之後就會取得 API Key 及 API Secret,然後我們先將其妥善保管好。

然後我們創建一個名為 .passwd-s3fs 的檔案,並將剛才得到的 API Key 及 API Secret 以格式:<API Key>:<API Secret> 寫在檔案中:

~$ touch .passwd-s3fs
~$ vim .passwd-s3fs

# .passwd-s3fs
3H3FNZ5BQECZFL4RCY5S:35IuzCObPHChaJtnW3FLhpSOHPmTZJpPWWP1joA4oS0

接著我們到我們主機中執行指令,在執行這個指令前,請先創建一個新的 Bucket 喔

~$ sudo mkdir -p /path/to/mount
~$ s3fs <bucket name> /path/to/mount -o passwd_file=./.passwd-s3fs -o url=https://nyc3.digitaloceanspaces.com/ -o use_path_request_style -o allow_other

# example
~$ sudo mkdir -p /bucket/floatflower
~$ s3fs floatflower /bucket/floatflower -o passwd_file=./.passwd-s3fs -o url=https://nyc3.digitaloceanspaces.com/ -o use_path_request_style -o allow_other

在這裡要注意的部分有三個:

  1. bucket name 要置換成你的 bucket 的名稱
  2. /path/to/mount 是你要掛載的路徑
  3. url 是 Space 的 Endpoint,你可以從 Space 的 Settings 中查看。

掛載完成之後,你就可以嘗試在這裏面放一個檔案:

~$ cd /bucket/floatflower
~$ touch test.txt

然後回到 Digital Ocean 的 Space 頁面中,如果看到 test.txt 已經出現,就代表已經掛載完成了!

要卸載可以使用

fusermount -u /path/to/mount

如:fusermount -u /bucket/floatflower

這樣,這個路徑 /bucket/floatflower就已經是一個直接寫入掛載了 Object Storage 的資料夾了,接著我們將 Apache 配置文件中的 sys_temp_dir 指定為:/bucket/floatflower

# php.ini
sys_temp_dir = /bucket/floatflower

# 重啟 Apache 伺服器
~$ sudo service apache2 restart

這樣我們就完成了。

實測

接著我用 Symfony 寫了一個超小型上傳介面 來測試,測試之前為了要展示效果,我們將設定如下的配置:

# 為了證明這個值與上傳時間無關
max_execution_time = 10
post_max_size = 20G
upload_max_filesize = 20G
# 為了證明上傳的大小與記憶體大小無關
memory_limit = 256M

接著我們開始上傳,上傳的檔案有 1.6 GB。

首先你會發現過了 10 秒之後,上傳並沒有因為 timeout 而停止,這是因為上傳是由 webserver 處理的,這時候還尚未執行 PHP Script。

在上傳的期間偷看一下 /bucket/floatflower 中的檔案就會發現,暫存檔案已經被寫入在這裡,並且也可以從 Digital Ocean 中看到暫存的檔案。

如果 memory_limit 會影響上傳檔案的大小的話,那麼 memory_limit 被設定為 256MB 的情況下, 理論上上傳到 16% ~ 18% 左右就會上傳停止了,但是並沒有,如同前面所述,只要 sys_temp_dir 不要裝滿,就可以不斷的接受上傳。

而如上圖所示,整個上傳動作耗時 780 秒,上傳 1.6GB 的檔案,按照我們的配置也沒有觸發錯誤,代表我們成功的讓小的主機也可以接受大檔案的上傳了。

結論

因為現在大部分的部署方法都是開啟大量的小主機來分散流量,代表我們不可能每一台主機的配置都非常非常的高, 透過將 S3 掛載到本地作為暫存資料夾就可以讓解除我們對檔案上傳大小的限制(S3的單檔大小為5TB)。另外,因為上傳檔案完畢之後,檔案就已經直接在 S3 上面了, 我們就可以透過 S3 SDK 直接在 Bucket 中或兩個 Bucket 之間做移動,不用再另外花時間再從伺服器端上傳至 S3 中,能夠讓應用程式上傳的速度更快,也更好管理檔案。