Go 项目部署到 VPS,和 Node、Python 是完全不同的体验:编译出来就是一个静态二进制文件,服务器上不需要装任何运行时。
这意味着部署可以简单到"本地编译好,scp 一个文件上去"。但也正因为简单,很多 Go 项目在服务器上翻车的原因都很隐蔽 —— 本地跑得好好的,传上去报 Exec format error;文件明明在,却说找不到;内存慢慢涨到被内核杀掉。
这篇按 Ubuntu 24.04 走一遍完整流程:交叉编译的正确参数、静态资源怎么处理、systemd 托管、GOMEMLIMIT 调优、优雅退出、反向代理和不停机发布。
好处很明显:
- 服务器不需要 Go 工具链。不用像 Node 那样纠结运行时版本,也不用像 Python 那样建虚拟环境。
- 没有依赖漂移。二进制里包含了一切,三个月后重新部署不会因为某个依赖发了新版本就跑不起来。
- 冷启动快、单机内存占用可控,小内存 VPS 上比 Node/Python 更从容。
- 可以交叉编译 —— 在你的 Mac 或 Windows 上直接编出 Linux 二进制。
坑也集中在这几点:
- 静态资源默认不打进二进制。模板、CSS、前端构建产物都要单独处理,否则程序起来了但页面 500。
- CGO 开着会引入动态链接,换台机器可能因为 glibc 版本不同直接跑不起来。
- GOMEMLIMIT 不设,小内存机器容易被 OOM 杀掉。
- 优雅退出要自己写,不写就是每次部署都掐断在线请求。
核心原则:生产服务器上不需要装 Go。本地或者 CI 编好,只把二进制传上去。
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-trimpath \
-ldflags="-s -w -X main.version=$(git rev-parse --short HEAD)" \
-o dist/myapp ./cmd/myapp
每个参数都值得说清楚:
CGO_ENABLED=0 —— 关掉 CGO,产出纯静态二进制。开着 CGO 的话,编出来的程序会动态链接系统的 glibc;你在 Ubuntu 24.04 上编的,拿到 Debian 13 或者 Alpine 上可能因为 glibc 版本不同直接起不来。这是"本地好好的,服务器上跑不起来"最常见的原因。
GOOS=linux GOARCH=amd64 —— 交叉编译。本地是 macOS 还是 Windows 都一样,只要指定目标平台。目标架构对照:
| 机器架构 | GOARCH |
|---|---|
| x86_64 / amd64 VPS | amd64 |
| ARM VPS | arm64 |
不确定自己机器是哪种,SSH 上去敲 uname -m:返回 x86_64 就是 amd64,返回 aarch64 就是 arm64。买机器时怎么分清架构见 ARM VPS 值不值得买。
-trimpath —— 去掉二进制里记录的本地绝对路径。不光是体积问题,构建路径会暴露你本机的目录结构,而且影响构建的可复现性。
-s -w —— 去掉符号表和 DWARF 调试信息,二进制通常能小 20-30%。代价是 panic 时的堆栈信息会少一些。
-X main.version=... —— 把 git 版本号编进程序。上线出问题时,你能直接确认线上跑的是哪个 commit,不用靠猜。
var version = "dev"
func main() {
log.Printf("starting myapp version=%s", version)
// ...
}
如果确实需要 CGO(比如用了 sqlite3),就别指望交叉编译了,老老实实在目标架构上编,或者用 musl 工具链产出静态链接的版本。绝大多数 Web 项目关掉 CGO 就够。
Go 的二进制里不包含你的模板、CSS、图片和前端构建产物。两种处理方式。
import (
"embed"
"html/template"
"net/http"
)
//go:embed templates/*.html static/*
var assets embed.FS
func main() {
tpl := template.Must(template.ParseFS(assets, "templates/*.html"))
http.Handle("/static/", http.FileServer(http.FS(assets)))
_ = tpl
http.ListenAndServe("127.0.0.1:3000", nil)
}
好处是真正的单文件部署 —— 回滚就是换个二进制,不用管资源文件同步。
//go:embed 有两个容易踩的规则:
- 路径不能出现
../,只能嵌入当前目录或子目录的内容。 - 默认忽略以
_和.开头的文件。要包含它们得用//go:embed all:static。
前端用 React / Vue / Astro 构建出来的产物,交给 Nginx 直接返回比走 Go 的 http.FileServer 更快,也不占 Go 进程的内存。
| go:embed | 外部文件 + Nginx | |
|---|---|---|
| 部署复杂度 | 最低,一个文件 | 要同步两处 |
| 静态文件性能 | 走 Go 进程 | Nginx 直接返回,更快 |
| 前端改版 | 要重新编译整个二进制 | 只传静态文件 |
| 二进制体积 | 变大 | 不变 |
经验做法:模板用 go:embed 打进二进制(改了必须重编,保证一致性),前端构建产物交给 Nginx(更新频繁,体积大)。
Go 程序前台运行、自己处理信号,天然适合 systemd 的 Type=simple。
/etc/systemd/system/myapp.service:
[Unit]
Description=Go App
After=network.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
Environment=GOMEMLIMIT=400MiB
ExecStart=/srv/app/current/myapp
Restart=always
RestartSec=3
# 优雅退出:给 15 秒处理完在途请求
KillSignal=SIGTERM
TimeoutStopSec=15
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
# 资源与安全限制
MemoryMax=512M
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/app/shared
[Install]
WantedBy=multi-user.target
三个必须注意的点:
WorkingDirectory很关键。如果程序里用了相对路径(./config.yaml、./templates),工作目录不对就是"文件明明在却说找不到"。更稳的做法是代码里一律用绝对路径,或者用go:embed。EnvironmentFile指向shared/.env,不要放在 release 目录里,否则每次发布都会丢。ProtectSystem=strict会把整个文件系统挂成只读,要写的路径(日志、上传目录、.env)必须显式列进ReadWritePaths,漏了会以Read-only file system启动失败。
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
这是 Go 在 VPS 上最值得花时间的地方。
Go 的 GC 默认按 GOGC=100 工作 —— 堆内存涨到上次回收后的两倍才触发下一轮 GC。在小内存机器上,这个策略会让内存一路冲到接近上限才回收,然后被内核的 OOM Killer 直接杀掉,你连堆栈都看不到。
GOMEMLIMIT(Go 1.19 引入)给运行时一个软上限,让它提前、更积极地做 GC:
Environment=GOMEMLIMIT=400MiB
设置原则:比 cgroup 的内存上限小 20-30%。 比如 MemoryMax=512M 配 GOMEMLIMIT=400MiB —— 留出的那部分给堆外内存(goroutine 栈、runtime 结构、mmap 的文件)。
要理解一个关键区别:GOMEMLIMIT 是软限制,不是硬限制。 它不会阻止内存增长,只是让 GC 更早介入。真正的硬天花板还是 cgroup(systemd 的 MemoryMax 或 Docker 的 --memory)。两个一起配才是完整的。
想看清楚内存到底花在哪,把 pprof 挂上:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
然后用 SSH 隧道连过去看 http://localhost:6060/debug/pprof/heap。注意这个端口只能监听 127.0.0.1,绝不能暴露到公网 —— pprof 能拿到完整的内存和 goroutine 信息。
以前有个经典问题:宿主机 8 核,容器限制 1 核,Go 会按 8 核来调度 goroutine,结果一堆 goroutine 抢一个核,延迟抖动很明显,得手动设 GOMAXPROCS=1。
Go 1.25 起这个行为改了 —— 在 Linux 上,运行时会读取进程所属 cgroup 的 CPU 带宽限制,如果比逻辑 CPU 数少,就按较小的那个来设 GOMAXPROCS;而且 CPU 数或 cgroup 限制变化时会周期性地重新调整。
所以现在容器里不需要再手动设 GOMAXPROCS 了。要关掉这个行为用 GODEBUG=containermaxprocs=0。如果你之前因为这个问题在 Dockerfile 里写死了 GOMAXPROCS,现在可以删掉,但要注意升级 Go 版本时确认一下运行时行为。
版本上要注意:Go 只维护最近的两个大版本。截至 2026 年 9 月,1.27.1 是最新稳定版,1.26.8 仍在维护,而 1.25 已经在 2026 年 8 月 19 日停止支持了。生产项目用 1.27,或者至少 1.26。
systemctl restart 会给进程发 SIGTERM。如果你的程序没处理这个信号,进程立刻死掉,正在处理的请求全部中断 —— 用户看到的是"502"或者"连接被重置"。
srv := &http.Server{
Addr: "127.0.0.1:3000",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("forced shutdown:", err)
}
log.Println("server exited")
四个要点:
srv.Shutdown(ctx)会停止接受新连接,等已经在处理的请求完成。- 超时时间要小于 systemd 的
TimeoutStopSec(上面配的 15 秒),否则你还在优雅关闭,systemd 已经等不及发 SIGKILL 了。 - 监听
SIGTERM而不是只监听SIGINT—— systemd 停服务发的是 SIGTERM。 http.Server的默认值是没有超时的。不设ReadTimeout/WriteTimeout,慢连接会一直占着 goroutine 和连接,累积起来就是内存泄漏的样子。这个坑很多人踩到线上才发现。
Go 程序监听 127.0.0.1:3000,Nginx 对外提供 80/443。这样程序不需要 root 权限,也不用自己处理 TLS。
upstream goapp {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 20m;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
location / {
proxy_pass http://goapp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
# WebSocket / SSE(Go 里很常见)
location /ws/ {
proxy_pass http://goapp;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_buffering off;
}
}
sudo nginx -t && sudo systemctl reload nginx
HTTPS 直接用 certbot 加上去,或者干脆用 Caddy 代替整段配置(两行搞定,自动签证书),写法见 Caddy 反向代理完全指南。
Go 这边别忘了 X-Forwarded-For:不传的话,程序里 r.RemoteAddr 永远是 127.0.0.1,按 IP 做的限流会全站互相误伤。
Go 的部署优势在这里体现得最清楚 —— 不需要在服务器上装依赖、建环境、跑构建。
#!/usr/bin/env bash
set -euo pipefail
HOST=deploy@your-vps
RELEASE=$(date +%Y%m%d-%H%M%S)
TARGET=/srv/app/releases/$RELEASE
# 1. 本地编译
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath \
-ldflags="-s -w -X main.version=$(git rev-parse --short HEAD)" \
-o /tmp/myapp ./cmd/myapp
# 2. 传输
ssh "$HOST" "mkdir -p $TARGET"
scp /tmp/myapp "$HOST:$TARGET/myapp"
# 3. 切换并重启
ssh "$HOST" "chmod +x $TARGET/myapp && \
ln -sfn $TARGET /srv/app/current && \
sudo systemctl restart myapp"
# 4. 清理旧版本,保留最近 5 个
ssh "$HOST" "ls -1dt /srv/app/releases/* | tail -n +6 | xargs -r rm -rf"
set -euo pipefail 别省 —— 编译失败但脚本继续跑,会把一个旧二进制或者空目录切成 current,线上直接 502。
配 GitHub Actions 自动化更省事,构建放 CI、只传产物,做法见 GitHub Actions 自托管 Runner。
cannot execute binary file: Exec format error
架构不对。编的是 amd64,机器是 arm64(或者反过来)。uname -m 确认目标架构,重新编译。
No such file or directory,但文件明明在
九成是动态链接的锅。CGO 开着编出来的二进制依赖 glibc,目标机器上版本不匹配时会报这个误导性的错误。确认一下:
file ./myapp # 看是 statically linked 还是 dynamically linked
ldd ./myapp # 动态链接的话,看依赖什么
用 CGO_ENABLED=0 重编基本能解决。
permission denied
两种可能:二进制没有执行权限(chmod +x myapp),或者 systemd 的 ProtectSystem=strict 挡住了它要写的路径。看 journalctl -u myapp 里的具体报错。
模板或配置文件找不到
WorkingDirectory 不对,或者资源没打进二进制。用 go:embed 最省心,否则把路径写成绝对路径。
内存一直涨,然后被 OOM 杀掉
先配 GOMEMLIMIT(见上文),再用 pprof 确认是不是 goroutine 泄漏 —— 常见于 HTTP 请求体没 Close()、time.Ticker 没 Stop() 这类。临时续命可以加 Swap,但治不了泄漏,见 低内存怎么用 ZRAM 和 Swap 优化。
时区不对(时间差 8 小时)
精简镜像里没有 tzdata。装一下,或者编译时用 -tags timetzdata 把时区数据直接打进二进制:
go build -tags timetzdata -o dist/myapp ./cmd/myapp
502 Bad Gateway
Nginx 连不上后端。先 systemctl status myapp,再看是不是监听在 localhost 而不是 127.0.0.1(IPv6/IPv4 解析差异)。完整排查见 502 / 504 Bad Gateway 怎么办。
端口被占用
sudo ss -lntp 'sport = :3000'。常见于上次部署的进程没退干净,见 VPS 端口被占用怎么办。
日志的整体排查思路见 VPS 日志怎么看。
Go 的静态二进制让容器化的收益变小了 —— FROM scratch 能做出不到 20MB 的镜像,看着很优雅,但如果没有别的服务要一起编排,直接用 systemd 更简单。
| 直接 systemd | Docker | |
|---|---|---|
| 部署产物 | 一个二进制 | 一个镜像 |
| 隔离强度 | 一般(靠 systemd 加固) | 强 |
| 镜像大小 | — | scratch 基础可做到 <20MB |
| 多服务编排 | 手动 | compose 更顺手 |
| 排查链路 | 短 | 多一层要看 |
判断标准很简单:只有一个 Go 服务,用 systemd。要和数据库、缓存、队列一起编排,用 Docker。 容器路线的资源限制怎么配,见 VPS Docker Compose 生产环境怎么配。
选机器时,Go 服务对 CPU 单核性能比较敏感(GC 和调度都在一个进程里),但内存需求不大。个人项目用 Racknerd 这类年付机器就够;要按小时计费、随时升配,Vultr 更灵活;国内访问要求高的选 DMIT 的 CN2 GIA。配置怎么定见 VPS 配置怎么选。
- VPS 部署 Node.js 项目上线 — 同一个站还有 Node 服务
- VPS 部署 Python 项目上线 — Django / FastAPI 的对应做法
- VPS 配置怎么选 — Go 服务的机器怎么定
- Docker 部署实战 — 想改成容器化部署
- VPS 备份恢复演练怎么做 — 二进制好回滚,数据不行
