主頁 >  其他 > 【Pod Terminating原因追蹤系列之一】containerd中被漏掉的runc錯誤資訊

【Pod Terminating原因追蹤系列之一】containerd中被漏掉的runc錯誤資訊

2020-09-18 07:40:35 其他

前一段時間發現有一些containerd集群出現了Pod卡在Terminating的問題,經過一系列的排查發現是containerd對底層例外處理的問題,最后雖然通過一個短小的PR修復了這個bug,但是找到bug的程序和對問題的反思還是值得和大家分享的,

本文中會借由排查bug的程序來分析kubelet洗掉Pod的呼叫鏈,這樣不僅僅可以了解containerd的bug,還可以借此了解更多Pod洗掉不掉的原因,在文章的最后會對問題進行反思,來探討OCI出現的問題,

一個洗掉不掉的Pod

可能大家都會遇到這種問題,就是集群中有那么幾個Pod無論如何也洗掉不掉,看起來和下圖一樣,當然可有很多可能導致Pod卡在Terminating的原因,比如mount目錄被占用、dockerd卡死了或鏡像中有“i”屬性的檔案,因為節點上復雜的組件(docker、containerd、cri、runc)和過長的呼叫鏈,導致很難瞬間定位出現問題的位置,所以一般遇到此類問題都會通過日志、Pod的資訊和容器的狀態來逐步縮小排查范圍,

1

當然首先看下集群的資訊,發現沒有使用docker而直接用的cri和containerd,直接使用containerd照比使用docker會有更短的呼叫鏈和更強的魯棒性,照比使用docker應該更穩定才對(比如經常出現的docker和containerd資料不一致的問題在這里就不會出現),接下來當然是查看kubelet日志,如下(只保留了核心部分),從這條日志中可以發現貌似是kubelet呼叫cri介面,最終呼叫runc去洗掉容器時報錯導致洗掉失敗,

$ journalctl -u kubelet
Feb 01 11:37:27 VM_74_45_centos kubelet[687]: E0201 11:37:27.241794     687 pod_workers.go:190] Error syncing pod 18c3d965-38cc-11ea-9c1d-6e3e7be2a462 ("advertise-api-bql7q_prod(18c3d965-38cc-11ea-9c1d-6e3e7be2a462)"), skipping: error killing pod: [failed to "KillContainer" for "memcache" with KillContainerError: "rpc error: code = Unknown desc = failed to kill container \"55d04f7c957e81fcf487b0dd71a4e50fe138165303cf6e96053751fd6770172c\": unknown error after kill: runc did not terminate sucessfully: container \"55d04f7c957e81fcf487b0dd71a4e50fe138165303cf6e96053751fd6770172c\" does not exist\n: unknown"

接下來我們打算分析下容器當前的狀態,簡單介紹下,containerd中用container來表示容器、用task來表示容器的運行狀態,創建一個容器相當于創建container,而把容器運行起來相當于創建一個task并把task狀態置為Running,當然停掉容器就相當于把task的狀態設定為Stopped,通過ctr命令看下containerd中container和task的狀態,容器55d04f對應的container和task都還在、task狀態是STOPPED,接下來查看containerd日志,我們節選了一小部分,發現了如下現象,第一條日志是stop容器55d04f時做umount失敗,接下來都是kill容器55d04f時發現container不存在,

error="failed to handle container TaskExit event: failed to stop container: failed rootfs umount: failed to unmount target /run/containerd/io.containerd.runtime.v1.linux/k8s.io/55d04f.../rootfs: device or resource busy: unknown"
error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"
error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"
error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"

當然得到這些資訊直徑訓認為排查方向是:

  • 為何rootfs會被占用,只要找出來是誰在占用rootfs就可以解決問題了
  • 既然umount報錯,我們是否可以使用lazy umount
  • 反正之后containerd還會重試,再后來的重試中是否可以正確洗掉容器

第一個選項直接被排除了,看起來占用rootfs的行程并不是長期存在,等發現問題登錄到節點上排查時行程已經不在了,如果不是常駐行程問題就變得麻煩了,可能是某個周期執行的監控組件,也可能是用戶的某個日志收集容器某次收集時間較長在rootfs上多停留了一會,

處于懶惰的本能,我們先嘗試下第二個方案,剛剛我們說過容器在containerd中被定義為containertask,查看容器資訊時發現task并沒有被刪掉,于是我們直接在containerd的代碼中找到了umount容器rootfs的代碼,如下(為了閱讀體驗,已經簡化):

func (p *Init) delete(ctx context.Context) error {
    err := p.runtime.Delete(ctx, p.id, nil)
  // ...
    if err2 := mount.UnmountAll(p.Rootfs, 0); err2 != nil {
        log.G(ctx).WithError(err2).Warn("failed to cleanup rootfs mount")
        if err == nil {
            err = errors.Wrap(err2, "failed rootfs umount")
        }
    }
    return err
}
func unmount(target string, flags int) error {
    for i := 0; i < 50; i++ {
        if err := unix.Unmount(target, flags); err != nil {
            switch err {
            case unix.EBUSY:
                time.Sleep(50 * time.Millisecond)
                continue
            default:
                return err
            }
        }
        return nil
    }
    return errors.Wrapf(unix.EBUSY, "failed to unmount target %s", target)
}

containerd創建容器時會創建一個containerd-shim行程來管理創建出來的容器,原本containerd對容器行程的操作就轉化成了containerd對shim的RPC呼叫;而呼叫runc來操作容器的作業自然就會交給shim來做,這樣最大的好處就是可以方便的實作live-restore能力,也就是即使containerd重啟也不會影響到容器行程,

上面代碼中的 delete函式就是由containerd-shim呼叫的,函式中主要作業有兩個:呼叫runc delete刪掉容器、呼叫umount卸載掉容器的rootfs,containerd日志中第一次device busy導致的umount失敗就是在這里產生的,當然在umount函式中還是有個短暫的重試的,看來社區還是考慮到了偶爾可能會出現rootfs被占用的情況(懷疑是容器行程還沒來的急被回收,但在某些場景下,可能這個重試的時間還有點短),

這里要注意unmount的flags是0,查看docker代碼,發現docker在umount時加了MNT_DETACH,在簡單地修改了shim的代碼后,在節點上測驗,果然添加了MNT_DETACH以后就不會出現device busy了,于是自信的向社區提了PR,結果得到的回復卻是:

What typically happens in cases like this is you there is a mount marked as private that gets copied into a new mount namespace.
A new mount namespace is created for every container, for systemd services that have MountPropagation or PrivateTmp defined, and these types of things.
When those namespaces are created they get a copy of the root namespace, anything that has a private mount cannot be unmounted until all the namespaces are shut down.
Mounts get marked private depending on the propagation defined on their root mount or if explicitly set.... so for example if you have /var/foo mounted and /var is mounted with mount private propagation, /var/foo will inherit the private propagation.

In this case MNT_DETACH only detaches the mount and hides very real problems. Even if you remove the mountpoint the data will not be freed until (possibly?) a reboot or all other namespaces with copies of that mount in them are shut down.

大概意思就是如果你用了MNT_DETACH,會有一些真正的問題被藏起來,(這里有待測驗,我覺得社區里這個人回復的思路有問題),

看起來我們只能排查下為什么重試時還會失敗了,節點上執行洗掉Pod的流程還是比較長的,很難簡單通過幾個舉例直接說明問題,所以接下來分析下kubelet從cri到OCI洗掉容器的流程,

kubelet如何洗掉Pod中的容器

對于kubelet的分析就要從大名鼎鼎的SyncPod開始分析了,在SyncPod開始時會計算podContainerChanges,接下來整個流程都是根據podContainerChanges的情況來執行對容器的操作,我們假設change就是KillPod,而kubelet執行KillPod會先通過創建多個goroutine并發執行StopContainers,等到所有Containers都洗掉成功后再洗掉Pod的Sandbox,具體呼叫流程如下:

2

圖中用紅色標記的StopContainer其實就是最終呼叫了cri介面(container runtime interface),比如以下是兩個和洗掉容器相關的兩個cri介面,Kubernetes要求每種容器運行時都要實作cri介面,docker通過docker-shim實作了cri介面;而container通過cri插件實作了cri介面,兩者并沒區別,比如運行時是containerd時,對cri的呼叫就會通過containerd-shim最終在容器上產生影響,

// StopContainer stops a running container with a grace period (i.e., timeout).
// This call is idempotent, and must not return an error if the container has
// already been stopped.
// TODO: what must the runtime do after the grace period is reached?
StopContainer(context.Context, *StopContainerRequest) (*StopContainerResponse, error)
// RemoveContainer removes the container. If the container is running, the
// container must be forcibly removed.
// This call is idempotent, and must not return an error if the container has
// already been removed.
RemoveContainer(context.Context, *RemoveContainerRequest) (*RemoveContainerResponse, error)

當請求到了cri后,剩下的任務就都交給了containerd和containerd-cri,cri以插件的方式運行在containerd中,本質和containerd是同一個行程,因此可以通過containerd提供的client直接通過函式呼叫containerd提供的service,正常情況下整個呼叫鏈如下圖所示,

另外,cri插件中存在一個eventloop專門處理從containerd中獲取的event,比如當容器洗掉后,會收到TaskExit事件,這是cri會做清理作業;比如當容器oom時,會收到OOMKill事件,cri除了清理還會更新Reason,接下來我們了解下整個洗掉流程

  1. 當kubelet呼叫cri的StopContainer介面后,cri會呼叫containerd的task.Kill介面(這里的task就是containerd中用來表示容器運行狀態的模塊),containerd收到請求后會呼叫containerd-shim的kill介面,而containerd-shim會通過命令列工具runc來kill掉容器行程,runc雖然不是守護行程,但是也有部分資料會被持久化到檔案系統中,執行runc kill后,不只會給容器行程發送信號,同時還會修改runc的持久化資料,另外,當容器行程被干掉后,會被父行程shim回收掉,
  2. shim成功干掉容器后,會給cri發送TaskExit的事件,當cri收到事件后會呼叫containerd的task.Delete介面,這個介面會先通過shim清理runc保留的容器持久化資料和容器運行時所用的rootfs,當兩者都被清理后,shim留著也沒用了,這時干脆直接發信號kill掉shim,并清理掉containerd保存的task資訊,這時containerd中和容器狀態相關的資訊就都消失了,當然containerd中的container還完好無損,
  3. 哪怕代碼中不存在bug,這么長的呼叫鏈也可能會遇到系統問題,eventLoop呼叫task.Delete如果回傳錯誤會把當前的event放到一個backoff佇列,等過一段時間拿出來重試,這樣就保證哪怕當前對一個容器的操作失敗了,過段時間還可以重試,

回到之前的問題上,可能有些聰明的同學通過上面的流程圖和分析之前的日志就可以猜到答案了,沒猜到也沒關系,現在和大家一起分析下,還記的當時containerd的日志分成兩部分么,首先是執行umount報錯device busy,之后反復出現unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist",這兩部分和我們上面說的“delete task時清理rootfs,如果失敗了會隔段時間進行重試”這個表述很接近,我們再把呼叫的流程圖畫的更細點,這下應該就可以在圖中找到答案了,

當容器被kill掉之后還一切正常,cri收到了容器退出的信號,呼叫containerd的task.Delete()時,可以注意到,這里多了個withKill選項(上面的流程中其實也有,只不過被省略掉了),添加這個選項會在呼叫shim的Delete介面之前再次呼叫Kill,這樣可以防止Delete了正在運行的容器,算是“悲觀”的決定,

第一次task Delete的流程中,一切運行的都很順暢,runc kill掉一個已經掛掉的容器也沒什么問題,直到umount容器的rootfs,發現rootfs被占用了,而且在umount的50次重試中占用rootfs的行程并沒有退出,shim只好通過containerd向cri回傳一個錯誤,cri會在之后的一段時間里重新嘗試處理剛剛的這個event,

在接下來重試 task Delete中,會和第一次執行一樣,都會在delete之前執行kill,但由于第一次runc delete成功的洗掉了runc所持久化的容器資訊,重試時執行runc kill會報錯container does not exist,不巧的是shim和containerd并沒有特別處理這個錯誤資訊,而是直接回傳給了cri,這就導致了cri洗掉容器會失敗,并且再也無法umount容器的rootfs了,cri中的容器無法被刪掉,自然發起洗掉流程的syncPod也會出現問題,這樣最終就導致了Pod卡在了Terminating,

最終修復與反思

當然這里的修復也很簡單,只需要在呼叫runc kill后添加特殊判斷就可以了,具體修復的pr見https://github.com/containerd/containerd/pull/4214,目前已經合并到主干,并且回溯到1.2的版本中了,很多時候發現問題遠比修復問題要復雜的多,雖然最終修復bug的代碼很簡單,但是整個為了發現bug,我們用了好幾天時間來分析梳理整個流程,簡單看下錯誤處理的代碼,這里的error就是呼叫runc出現錯誤的回傳結果,

if strings.Contains(err.Error(), "os: process already finished") ||
        strings.Contains(err.Error(), "container not running") ||        
        strings.Contains(strings.ToLower(err.Error()), "no such process") ||
        err == unix.ESRCH {
        return errors.Wrapf(errdefs.ErrNotFound, "process already finished")
    } else if strings.Contains(err.Error(), "does not exist") {
    // we add code here !
        return errors.Wrapf(errdefs.ErrNotFound, "no such container")
    }
    return errors.Wrapf(err, "unknown error after kill")
}

顯而易見這坨代碼存在問題:

containerd-shim原本目的就是支持各種OCI工具,但是卻把runc的錯誤處理資訊寫死在呼叫OCI的路徑上,這樣最終可能導致shim只能為runc服務,而不好適配其他的OCI,比如完善containerd測驗時就會發現這坨代碼對crun并不work(crun是用純c語言實作的OCI工具),不可能在containerd中適配每一種OCI工具,所以問題還是出現在制定OCI規范時沒考慮到錯誤處理的情況,同樣我們也和OCI社區提了issue,

【騰訊云原生】云說新品、云研新術、云游新活、云賞資訊,掃碼關注同名公眾號,及時獲取更多干貨!!

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/71941.html

標籤:其他

上一篇:使用snpe-tensorflow-to-dlc轉換mobilenetssd失敗

下一篇:nodejs嘗試創建WEB服務器無法訪問localhost

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more