后后臺(tái)設(shè)置保存500:從非法URL臟數(shù)據(jù)到完整排查修復(fù)指南)
GitLab版本升級(jí)這件事做得多了你會(huì)形成一種條件反射升級(jí)不可怕升級(jí)后的第一個(gè)午休才可怕。這不上個(gè)月我們這套GitLab實(shí)例剛從12.3.9一路升到14.4.5整個(gè)遷移過(guò)程順利得像教科書結(jié)果升級(jí)完不到半小時(shí)管理員在后臺(tái)修改通用設(shè)置頁(yè)面上直接彈了個(gè)“Something went wrong on our end.”通用設(shè)置一點(diǎn)就報(bào)錯(cuò)而且誰(shuí)都沒改過(guò)數(shù)據(jù)庫(kù)嚇得我午覺都沒睡。這篇文章就把這次故障的完整鏈路寫清楚從現(xiàn)象到日志定位從根因到修復(fù)再到順藤摸瓜發(fā)現(xiàn)的一批同類隱患。如果你也正準(zhǔn)備做GitLab大版本升級(jí)或者升完級(jí)發(fā)現(xiàn)后臺(tái)設(shè)置點(diǎn)不動(dòng)、保存500這篇文章應(yīng)該能幫你省掉一整個(gè)下午的排查時(shí)間。1. 升級(jí)背景為什么從12.3.9到14.4.5需要格外謹(jǐn)慎1.1 跨三個(gè)大版本的升級(jí)路線GitLab的大版本升級(jí)和普通軟件不一樣不是把新版本包裝上去就完事。官方對(duì)升級(jí)路徑有明確要求跨大版本升級(jí)時(shí)必須先從當(dāng)前大版本升級(jí)到該大版本的最后一個(gè)小版本再進(jìn)入下一個(gè)大版本否則數(shù)據(jù)庫(kù)的schema遷移可能因?yàn)樘?jí)而直接失敗輕則升級(jí)中斷重則數(shù)據(jù)不一致。我們這套實(shí)例的原始版本是12.3.9目標(biāo)版本是14.4.5中間隔了12、13、14三個(gè)大版本。按官方建議的路徑實(shí)際執(zhí)行順序是# 第一步先升到12.x的最后一個(gè)版本 # 12.3.9 - 12.10.14 # 第二步跨入13.x大版本并且升到13.x的最后一個(gè)版本 # 12.10.14 - 13.12.15 # 第三步進(jìn)入14.x直接升到14.4.5 # 13.12.15 - 14.4.5這里有個(gè)容易忽略的點(diǎn)很多人在12.3.9的時(shí)候直接下載14.4.5的安裝包執(zhí)行完gitlab-ctl upgrade就以為完事了。實(shí)際上GitLab的升級(jí)腳本會(huì)在數(shù)據(jù)庫(kù)遷移階段檢查當(dāng)前schema版本跨度過(guò)大會(huì)直接拒絕執(zhí)行遷移或者更糟遷移執(zhí)行到一半報(bào)錯(cuò)整實(shí)例處于半升級(jí)狀態(tài)。我們當(dāng)時(shí)就是老老實(shí)實(shí)按三步走每步都做了備份每步升級(jí)完都跑一遍基礎(chǔ)驗(yàn)證整個(gè)過(guò)程倒是沒出幺蛾子。1.2 升級(jí)前的準(zhǔn)備動(dòng)作這次升級(jí)前我們做的準(zhǔn)備值得拿出來(lái)說(shuō)一下。首先是把生產(chǎn)環(huán)境的數(shù)據(jù)備份恢復(fù)到一套staging環(huán)境里在staging上完完整整走了一遍升級(jí)流程。這一步很重要因?yàn)镚itLab的升級(jí)腳本只負(fù)責(zé)遷移不負(fù)責(zé)驗(yàn)證業(yè)務(wù)數(shù)據(jù)的兼容性有些故障在升級(jí)過(guò)程中不報(bào)錯(cuò)要等升級(jí)后你真正去操作某些功能時(shí)才炸出來(lái)。備份命令這里也給一下注意不同版本命令可能不太一樣12.x時(shí)期還能用gitlab-rake gitlab:backup:create13.x之后新版本更建議直接用gitlab-backup# 升級(jí)前在每個(gè)版本節(jié)點(diǎn)都執(zhí)行一次 sudo gitlab-backup create sudo gitlab-ctl status # 確認(rèn)各服務(wù)狀態(tài)正常 sudo gitlab-ctl reconfigure sudo gitlab-rake gitlab:check升級(jí)完成之后我們按照慣例先驗(yàn)證了登錄、工程列表、CI跑一次流水線這些核心鏈路當(dāng)時(shí)覺得一切正常就去后臺(tái)改設(shè)置。結(jié)果恰恰是“改設(shè)置”這個(gè)動(dòng)作把藏了很久的存量數(shù)據(jù)問(wèn)題給捅出來(lái)了。2. 報(bào)錯(cuò)現(xiàn)場(chǎng)保存通用設(shè)置時(shí)頁(yè)面直接5002.1 操作路徑和第一反應(yīng)故障復(fù)現(xiàn)路徑非常清晰。管理員賬號(hào)登錄后進(jìn)入 Admin Area → Settings → General這是站點(diǎn)的通用設(shè)置頁(yè)。當(dāng)時(shí)只是想改一下站點(diǎn)描述順便檢查首頁(yè)URL字段結(jié)果點(diǎn)下 Save changes 按鈕之后頁(yè)面沒有出現(xiàn)熟悉的“保存成功”提示而是直接跳到了GitLab經(jīng)典的錯(cuò)誤頁(yè)。頁(yè)面提示是Something went wrong on our end.翻譯成人話就是你發(fā)起的請(qǐng)求在后端拋了一個(gè)未處理的異常500了。這個(gè)提示本身沒有任何排查價(jià)值它只是GitLab對(duì)生產(chǎn)環(huán)境用戶屏蔽錯(cuò)誤詳情的手段。真正有價(jià)值的信息在日志里。遇到這類問(wèn)題我的第一反應(yīng)是別去猜先去翻日志。GitLab的日志路徑比較固定恰好能直接看到本次請(qǐng)求的完整調(diào)用鏈。2.2 從日志里找到真正的異常我們的GitLab使用的是omnibus安裝方式日志都在/var/log/gitlab/gitlab-rails/目錄下。這次故障對(duì)應(yīng)的請(qǐng)求記錄在production_json.log里用tail拉出來(lái)的關(guān)鍵信息是這樣的{ method: PUT, path: /admin/application_settings/general, format: html, controller: Admin::ApplicationSettingsController, action: update, status: 500, error: ActiveRecord::RecordInvalid: Validation failed: Homepage URL is not a valid URL, duration: 1187, params: { application_setting: { site_description: ... } } }日志里的信息量已經(jīng)很大了我給幾個(gè)關(guān)鍵字段做個(gè)拆解字段含義這次故障中傳遞的信息methodHTTP方法PUT說(shuō)明是更新操作path請(qǐng)求路徑/admin/application_settings/general就是通用設(shè)置保存接口controller/action控制器與方法Admin::ApplicationSettingsController#update走的是后臺(tái)設(shè)置更新邏輯statusHTTP狀態(tài)碼500服務(wù)端異常error異常類型與消息ActiveRecord::RecordInvalid模型校驗(yàn)失敗具體是“Homepage URL is not a valid URL”params當(dāng)前提交的參數(shù)這里有個(gè)陷阱本次params里只提交了site_description壓根沒提交home_page_url看到ActiveRecord::RecordInvalid的時(shí)候我就知道問(wèn)題出在數(shù)據(jù)校驗(yàn)上而不是路由、權(quán)限或者別的雜七雜八的地方。但這里有個(gè)特別刁鉆的細(xì)節(jié)日志里params顯示這次請(qǐng)求只修改了site_description一個(gè)描述字段為什么會(huì)觸發(fā)home_page_url的校驗(yàn)錯(cuò)誤這就是本次故障最值得記錄的點(diǎn)你改的字段和報(bào)錯(cuò)的字段完全不是同一個(gè)。3. 排查鏈路從URL校驗(yàn)一路追到庫(kù)存臟數(shù)據(jù)3.1 在console里直接觸發(fā)全量校驗(yàn)要復(fù)現(xiàn)這個(gè)問(wèn)題不需要重新去頁(yè)面上點(diǎn)保存。在服務(wù)器上打開Rails控制臺(tái)直接對(duì)ApplicationSetting模型做一次保存操作sudo gitlab-rails consoles ApplicationSetting.current s.save!這就是最干凈的復(fù)現(xiàn)方式。上面這行代碼沒有做任何修改就是原封不動(dòng)地把當(dāng)前設(shè)置記錄重新保存一遍。在正常狀態(tài)它會(huì)安靜地返回true在這次故障環(huán)境下它直接拋出了和頁(yè)面上一模一樣的異常ActiveRecord::RecordInvalid: Validation failed: Homepage URL is not a valid URL這個(gè)復(fù)現(xiàn)結(jié)果說(shuō)明兩件事。第一問(wèn)題不在頁(yè)面不在前端也不在你本次修改的內(nèi)容上而是整條application_settings記錄本身帶著一個(gè)非法值。第二GitLab在保存任何設(shè)置時(shí)都會(huì)對(duì)這條記錄做一次全字段校驗(yàn)?zāi)呐履阒桓牧艘粋€(gè)描述字段它也會(huì)把這條記錄里的所有字段全部過(guò)一遍校驗(yàn)器。3.2 為什么“沒改過(guò)的字段”會(huì)擋住保存這里要順帶解釋一個(gè)Rails的機(jī)制方便和我一樣半路出家搞運(yùn)維的朋友理解。Rails的ActiveRecord模型在更新記錄時(shí)默認(rèn)會(huì)觸發(fā)valid?校驗(yàn)而模型上所有的validates聲明都會(huì)被執(zhí)行。換句話說(shuō)save!不僅僅是校驗(yàn)?zāi)阌凶兓淖侄味切r?yàn)整條記錄在當(dāng)前代碼規(guī)則下是否合法。GitLab升級(jí)到14.4.5之后ApplicationSetting模型里針對(duì)URL類字段的校驗(yàn)規(guī)則比12.3.9時(shí)嚴(yán)格得多。新版本的校驗(yàn)器要求URL必須帶http://或https://協(xié)議頭而且host部分必須能正常解析出來(lái)。如果你在舊版本里存了一個(gè)“看起來(lái)能用但不完全合法”的URL升級(jí)后新代碼一校驗(yàn)這個(gè)陳年老數(shù)據(jù)就會(huì)把整個(gè)保存操作給攔下來(lái)。3.3 從模型源碼和數(shù)據(jù)庫(kù)把舊值揪出來(lái)知道了原理排查就變得很簡(jiǎn)單。先用Rails的控制臺(tái)把當(dāng)前庫(kù)里home_page_url的值打印出來(lái)s ApplicationSetting.current puts s.home_page_url.inspect輸出結(jié)果如下//intranet.example.com看到這串值的時(shí)候我心里就有數(shù)了。這是個(gè)沒有協(xié)議頭的裸地址瀏覽器可能有自己的容錯(cuò)機(jī)制某些場(chǎng)景下能自動(dòng)補(bǔ)全協(xié)議但GitLab后臺(tái)的URL校驗(yàn)器不會(huì)慣著它。URI.parse解析這種值的時(shí)候scheme會(huì)被解析成nil直接不滿足“只允許http和https”的約束。為了交叉驗(yàn)證我還做了一步直接去模型源碼里翻了校驗(yàn)規(guī)則。omnibus安裝的GitLab源碼路徑在grep -rn home_page_url /opt/gitlab/embedded/service/gitlab-rails/app/models/相關(guān)的validates聲明寫得明明白白URL類型字段走的是GitLab的URL校驗(yàn)器schemes白名單只有[http, https]。數(shù)據(jù)庫(kù)這一側(cè)也用SQL確認(rèn)了一遍SELECT home_page_url, support_url, terms FROM application_settings;果然不止home_page_url連support_url都存著同樣格式的裸地址。這說(shuō)明當(dāng)時(shí)某位管理員在舊版本上手動(dòng)填這些字段的時(shí)候壓根沒填協(xié)議頭而12.3.9時(shí)代估計(jì)對(duì)這類字段沒卡那么嚴(yán)于是臟數(shù)據(jù)就這么被放了進(jìn)來(lái)一直躺到這次升級(jí)。4. 修復(fù)方案先把非法值改合法再動(dòng)后臺(tái)4.1 一次成功的console修復(fù)修復(fù)本身不復(fù)雜把非法值改成合法值就行。我們的場(chǎng)景里這些URL實(shí)際指向的是內(nèi)部辦公系統(tǒng)入口補(bǔ)上協(xié)議頭即可s ApplicationSetting.current s.home_page_url https://intranet.example.com s.support_url https://intranet.example.com/support s.save!保存成功后返回值是true不再拋校驗(yàn)異常。然后回到后臺(tái)頁(yè)面重新把站點(diǎn)描述改好點(diǎn)保存成功提示正常出現(xiàn)問(wèn)題解決。這里有一個(gè)建議遇到多個(gè)URL類字段都有問(wèn)題時(shí)別一個(gè)字段一個(gè)字段地改直接在console里逐個(gè)檢查交給同一個(gè)保存事務(wù)最后一次性save!避免中間狀態(tài)反復(fù)觸發(fā)校驗(yàn)失敗。4.2 為什么不建議用update_columns強(qiáng)行繞過(guò)很多人看到這種問(wèn)題第一反應(yīng)是找個(gè)辦法繞過(guò)校驗(yàn)。Rails確實(shí)提供了繞過(guò)校驗(yàn)的接口比如update_columns它可以直接寫數(shù)據(jù)庫(kù)不觸發(fā)任何校驗(yàn)和回調(diào)# 不推薦的做法 ApplicationSetting.current.update_columns(home_page_url: https://intranet.example.com)這條命令確實(shí)能把這次報(bào)錯(cuò)按下去但我不推薦這么做。原因很實(shí)際這次的根因不是某個(gè)字段寫錯(cuò)而是整條記錄攜帶了大量未經(jīng)驗(yàn)證的舊值。你用update_columns只修正了一個(gè)字段其他同樣不合格的字段還在那里躺著下次保存設(shè)置的時(shí)候還會(huì)以另一個(gè)報(bào)錯(cuò)形式跳出來(lái)。而且繞過(guò)校驗(yàn)意味著繞過(guò)了模型里的回調(diào)邏輯GitLab在新版本里可能依賴某些字段的值來(lái)驅(qū)動(dòng)其他功能比如出站請(qǐng)求限制、登錄頁(yè)定制等。你硬寫進(jìn)去的值如果不符合預(yù)期可能在更隱蔽的地方造成狀態(tài)不一致。正確的做法是像我們一樣把所有不合法字段一次性修正讓整條記錄能通過(guò)新規(guī)則的校驗(yàn)這才是干凈的狀態(tài)。4.3 修復(fù)后的驗(yàn)證和同類字段排查修復(fù)完成后我沒有直接關(guān)掉console而是順手做了一輪同類字段排查。所有像URL、協(xié)議、枚舉值這類強(qiáng)約束字段都可能存在類似的舊數(shù)據(jù)問(wèn)題。建議在console里把設(shè)置記錄的所有字段過(guò)一遍特別是這些常見字段s ApplicationSetting.current %i[ home_page_url support_url terms help_page_url after_sign_out_path redirect_http_to_https_host ].each do |field| value s.public_send(field) puts #{field}: #{value.inspect} if value.present? end檢查完URL字段之后再完整跑一次gitlab-rails gitlab:check確認(rèn)整體健康。這次檢查確實(shí)又發(fā)現(xiàn)了兩處不合法URL一并修掉之后后臺(tái)設(shè)置頁(yè)保存功能徹底恢復(fù)正常。5. 同一個(gè)坑循環(huán)上演以后升級(jí)要注意的幾類設(shè)置故障5.1 枚舉字段的合法值范圍變了URL校驗(yàn)問(wèn)題只是“升級(jí)后改設(shè)置報(bào)錯(cuò)”的其中一個(gè)子類。我查了一圈發(fā)現(xiàn)GitLab跨大版本升級(jí)后通用設(shè)置相關(guān)的報(bào)錯(cuò)里最常見的是枚舉字段值不在合法列表內(nèi)。典型的報(bào)錯(cuò)長(zhǎng)這樣ActiveRecord::RecordInvalid: Validation failed: Default project creation is not included in the list這種問(wèn)題的根源是不同大版本對(duì)某個(gè)枚舉字段的合法值定義發(fā)生了變化。12.x時(shí)代允許存的某個(gè)舊值到了14.x版本的模型中已經(jīng)不在合法列表里了。而數(shù)據(jù)庫(kù)里存的還是老值一旦保存整個(gè)設(shè)置記錄校驗(yàn)器就會(huì)直接拒絕。排查這類問(wèn)題時(shí)可以直接在console里看模型對(duì)這個(gè)字段的校驗(yàn)規(guī)則不用去翻源碼文件ApplicationSetting.validators_on(:default_project_creation)這會(huì)打印出當(dāng)前版本針對(duì)這個(gè)字段定義的validator包括合法的可選值列表。然后對(duì)比數(shù)據(jù)庫(kù)里的實(shí)際值就能判斷到底是哪個(gè)舊值失效了改成新版本認(rèn)可的值即可。5.2 配置項(xiàng)存儲(chǔ)方式調(diào)整讓舊值悄悄失效還有一類故障更隱蔽它不報(bào)錯(cuò)但你的配置會(huì)“神秘消失”或者“改了不生效”。這類問(wèn)題通常不是校驗(yàn)規(guī)則變化而是配置項(xiàng)的存儲(chǔ)方式發(fā)生了變化。GitLab在多個(gè)大版本里對(duì)部分設(shè)置字段做過(guò)存儲(chǔ)結(jié)構(gòu)調(diào)整有些老字段從序列化數(shù)組改成了JSONB有些布爾值字段被合并拆分。升級(jí)后數(shù)據(jù)庫(kù)遷移按理應(yīng)該完成字段轉(zhuǎn)換但某些歷史數(shù)據(jù)在遷移前就不規(guī)范轉(zhuǎn)出來(lái)的結(jié)果就可能是空數(shù)組、空字符串或者一個(gè)詭異對(duì)象。表現(xiàn)到頁(yè)面上就是配置項(xiàng)明明還顯示著內(nèi)容但你一保存它就變成默認(rèn)值或者你改了一段配置怎么都不生效。遇到這類問(wèn)題建議先去查這個(gè)字段在目標(biāo)版本里是怎么定義的。最直接的辦法是看GitLab源碼里對(duì)應(yīng)的遷移文件grep -rn jsonb\|serialize\|default_project_creation /opt/gitlab/embedded/service/gitlab-rails/db/migrate/ | head -20通過(guò)遷移文件確認(rèn)字段在新版本里的類型和默認(rèn)值再回?cái)?shù)據(jù)庫(kù)里對(duì)比實(shí)際值。如果確實(shí)出現(xiàn)了轉(zhuǎn)換異常手工修正存量數(shù)據(jù)的格式就行了。5.3 把“設(shè)置體檢”并入升級(jí)流程這次故障之后我把“設(shè)置體檢”正式納入了升級(jí)后的驗(yàn)證清單。所謂體檢就是在升級(jí)完成、業(yè)務(wù)驗(yàn)證通過(guò)后先別急著去后臺(tái)改配置先在console里跑一次全量校驗(yàn)# 升級(jí)后的第一道關(guān)卡 s ApplicationSetting.current s.save! puts settings model save OK這條命令會(huì)觸發(fā)整條設(shè)置記錄的全字段校驗(yàn)?zāi)茉谧疃虝r(shí)間內(nèi)發(fā)現(xiàn)所有因新舊規(guī)則差異導(dǎo)致的臟數(shù)據(jù)問(wèn)題。如果這條命令直接過(guò)了那后臺(tái)設(shè)置頁(yè)基本不會(huì)出類似的幺蛾子如果它報(bào)錯(cuò)了那就按本次文章里的思路把報(bào)錯(cuò)字段逐個(gè)修正直到能干凈地保存。除了設(shè)置模型本身的校驗(yàn)我還會(huì)手動(dòng)驗(yàn)證幾個(gè)對(duì)版本升級(jí)最敏感的功能創(chuàng)建新工程、觸發(fā)一次CI流水線、驗(yàn)證Webhook回調(diào)等。原因是GitLab 14.x對(duì)出站請(qǐng)求的本地網(wǎng)絡(luò)訪問(wèn)限制變化也很大這些功能都可能因?yàn)榕f配置而出現(xiàn)“升級(jí)前正常、升級(jí)后失效”的情況。這些驗(yàn)證看著不起眼但都比“等到用戶報(bào)障再排查”省心得多。最后再分享一點(diǎn)這次的經(jīng)驗(yàn)這次故障從頭到尾折騰了小半天但真正定位到根因也就花了不到半小時(shí)?;仡^復(fù)盤最值得記住的一句話是升級(jí)后修改設(shè)置報(bào)錯(cuò)問(wèn)題大概率不是設(shè)置本身而是這條設(shè)置記錄里的歷史數(shù)據(jù)無(wú)法通過(guò)新版本的規(guī)則校驗(yàn)。我從這次經(jīng)歷里總結(jié)出兩條可復(fù)用的經(jīng)驗(yàn)。第一GitLab升級(jí)前不要只備份數(shù)據(jù)和配置文件最好把a(bǔ)pplication_settings表里的核心字段導(dǎo)出來(lái)和升級(jí)后在staging環(huán)境里的同字段做一次對(duì)比提前發(fā)現(xiàn)那些在新版本里已經(jīng)非法的值。第二升級(jí)完成后不要著急做配置變更先在console里主動(dòng)對(duì)設(shè)置模型跑一次save!這一步花不了一分鐘卻能把你從“后臺(tái)設(shè)置一點(diǎn)就500”的尷尬中提前拉出來(lái)。如果你現(xiàn)在正好也卡在類似的報(bào)錯(cuò)上別急著重建實(shí)例或回滾數(shù)據(jù)庫(kù)先把異常日志里的字段名找出來(lái)再在console里打印那條記錄的對(duì)應(yīng)值大概率一分鐘就能定位到問(wèn)題所在。