服务器买回来第一件事不是装应用,而是把地基打好。

本文按实际顺序,完整走一遍一台 4 核 8G 的 Ubuntu 24.04 服务器初始化流程:从磁盘分区、Swap 配置,到 Docker 安装、多用户创建与 sudo 授权,最后用 docker-compose 部署 Nginx 并配置 HTTPS 通配符证书。

本文所有命令均以 root 或 sudo 权限执行,文中域名统一使用占位符,替换成你自己的即可。

一、基础信息确认

先明确本文的环境参数,后续步骤都基于此:

项目 配置
操作系统 Ubuntu 24.04 LTS
CPU / 内存 4 核 8G
Swap 8G(文件方式)
数据盘 500G,分区后挂载到 /opt
Docker apt 仓库自带 docker.io
容器编排 docker compose(插件形式)
SSH 端口 2342
时区 Asia/Shanghai

查看当前环境:

1
2
3
4
5
6
7
8
9
# 系统版本
cat /etc/os-release | grep PRETTY_NAME

# CPU 核数与内存
nproc
free -h

# 当前磁盘情况
lsblk -f

二、配置 8G Swap

物理内存 8G 对于跑 Docker 的服务器偏紧,开 8G Swap 可以缓解内存不足导致进程被 OOM Killer 杀掉的风险。

1. 检查现有 Swap

1
2
free -h
swapon --show

如果已有 swap 且大小合适,可跳过本节。

2. 创建 Swap 文件

1
2
3
4
5
# 创建 8G 的 swap 文件
fallocate -l 8G /swapfile

# 或者用 dd(fallocate 在某些文件系统上会有问题,dd 更稳妥)
# dd if=/dev/zero of=/swapfile bs=1M count=8192 status=progress

3. 设置权限并格式化

1
2
3
4
5
# 权限必须是 600,否则其他用户可读写,有安全隐患
chmod 600 /swapfile

# 格式化为 swap 格式
mkswap /swapfile

4. 启用并设置开机自动挂载

1
2
3
4
5
6
# 立即启用
swapon /swapfile

# 验证
swapon --show
free -h

写入 /etc/fstab 实现开机自动挂载:

1
2
3
4
5
6
7
8
# 先备份
cp /etc/fstab /etc/fstab.bak

# 追加一行(>> 是追加,不要用 > 否则会清空原文件)
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# 验证配置正确性,有语法错误不会启动
mount -a

5. 调整 Swap 使用策略

默认 vm.swappiness=60,意味着内存用到 60% 就开始换页。服务器上通常希望优先用物理内存,内存不足了再 swap,所以调低:

1
2
3
4
5
6
7
8
9
cat > /etc/sysctl.d/99-swap.conf << 'EOF'
vm.swappiness=10
vm.vfs_cache_pressure=50
EOF

sysctl -p /etc/sysctl.d/99-swap.conf

# 验证
sysctl vm.swappiness

参数说明

  • vm.swappiness=10:降低换页倾向。设成 0 会完全禁用 swap,内存紧张时进程直接被 OOM Kill,反而更危险。
  • vm.vfs_cache_pressure=50:默认 100 表示缓存回收积极。调低可让 inode/dentry 缓存保留更久,减少频繁的磁盘 IO。

三、500G 硬盘分区与挂载

1. 查看磁盘设备名

1
lsblk -f

输出示例:

1
2
3
4
5
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
vda 252:0 0 50G 0 disk
├─vda1 252:1 0 50G 0 part /
└─vda2 252:2 0 498G 0 part
vdb 253:0 0 500G 0 disk

vdb 就是新增的 500G 数据盘。注意:实际设备名可能是 /dev/vdb、/dev/sdb 或 /dev/nvme1n1,务必按自己的实际情况确认,误格式化系统盘是灾难性操作。

2. 用 fdisk 创建分区

1
fdisk /dev/vdb

进入交互界面后的操作序列:

1
2
3
4
5
6
7
8
9
10
11
Command (m for help): n          # 新建分区
Partition number (1-4, default 1): 1
First sector (2048-1048575999, default 2048): # 直接回车,用默认值
Last sector, +size or +size% or +sizeT: +1G # 划 1G 给 /boot

Command (m for help): n # 再新建一个分区
Partition number (1-4, default 2): 2
First sector (2048-1048575999, default 2048): # 直接回车
Last sector, +size or +size% or +sizeT: +499G # 剩下全给数据分区

Command (m for help): w # 写入分区表并退出

fdisk 交互要点:看到 (default X) 直接回车接受默认值即可;分区类型保持默认的 Linux 类型;最后必须敲 w 才会生效,敲 q 是放弃。

查看分区结果:

1
fdisk -l /dev/vdb

3. 格式化分区

1
2
3
4
5
# /boot 分区
mkfs.ext4 /dev/vdb1

# 数据分区
mkfs.ext4 /dev/vdb2

4. 挂载数据分区到 /opt

1
2
3
4
5
6
7
8
# 创建挂载点
mkdir -p /opt

# 挂载
mount /dev/vdb2 /opt

# 验证
df -h /opt

5. 开机自动挂载

先获取分区的 UUID:

1
blkid /dev/vdb2

输出类似:

1
/dev/vdb2: UUID="a1b2c3d4-e5f6-7890-abcd-ef1234567890" TYPE="ext4"

写入 /etc/fstab:

1
2
3
4
5
6
7
8
9
10
cp /etc/fstab /etc/fstab.bak

# 用 UUID 而非设备名,避免重启后设备名变化导致挂载失败
echo 'UUID=a1b2c3d4-e5f6-7890-abcd-ef1234567890 /opt ext4 defaults,noatime 0 2' >> /etc/fstab

# 验证语法
mount -a

# 确认已挂载
df -h /opt

为什么要用 UUID:设备名(/dev/vdb)在重启或热插拔后可能变化,UUID 是分区唯一标识,不会变。用 UUID 挂载是生产环境的标准做法。

6. 确认 /boot 分区用途

上面的 /boot 分区是独立划出的,用于存放内核引导文件。查看当前系统的 /boot 使用情况:

1
df -h /boot

如果系统根分区 / 所在磁盘还很大(且根分区本身就是独立分区),可以不单独划 /boot——把所有空间给 /opt 即可。保留上面这个划分是为了结构清晰、便于后续重装系统时保留数据。

四、安装 Docker

1. 卸载可能存在的旧版本

1
2
3
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
apt-get remove -y $pkg
done

2. 安装 Docker

Ubuntu 24.04 官方仓库自带 docker.io,直接装即可:

1
2
apt-get update
apt-get install -y docker.io docker-compose-v2

验证:

1
2
docker --version
docker compose version

关于 docker-compose-v2:这是 Docker Compose V2 的独立包(插件形式),命令是 docker compose(中间有空格),不是旧版的 docker-compose。Ubuntu 24.04 仓库已同时提供两种,建议统一用 V2。

3. 配置日志轮转,防止磁盘被撑爆

Docker 默认不限制日志大小,跑久了 /var/lib/docker/containers/ 会涨到几十上百 G。生产环境必配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mkdir -p /etc/docker

cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
EOF

# 重启生效
systemctl restart docker
systemctl enable docker

# 验证配置
docker info | grep -A3 "Logging Driver"

单个容器日志上限 100M × 3 个文件 = 300M。容器多的话可以调大 max-size,但务必设置上限,别让日志变成磁盘杀手。

4. 国内镜像加速(可选但强烈建议)

服务器在国内拉取 Docker Hub 镜像会很慢甚至超时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "3"
},
"storage-driver": "overlay2",
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://dockerproxy.com",
"https://docker.1panel.live"
]
}
EOF

systemctl restart docker
docker info | grep -A2 "Registry Mirrors"

镜像加速站的可用性会变化,上面几个是目前较稳定的。如果失效了,去阿里云容器镜像服务控制台申请专属加速地址(每账号一个,更可靠)。

5. 普通用户免 sudo 使用 Docker(可选)

把用户加入 docker 组后,该用户就能直接执行 docker 命令,无需 sudo:

1
usermod -aG docker zhangsan

安全提醒:docker 组等同于 root 权限——用户可以通过挂载宿主机根目录来提权。只把你信任的人加进去。

五、创建用户并授予 sudo 权限

生产服务器不建议日常用 root 操作,给每个同事建独立账号,便于审计和权限隔离。

1. 创建用户

1
2
3
4
# 语法:useradd -m -s /bin/bash -c "备注" 用户名
useradd -m -s /bin/bash -c "张三" zhangsan
useradd -m -s /bin/bash -c "李四" lisi
useradd -m -s /bin/bash -c "王五" wangwu

参数说明:

  • -m 创建家目录(/home/zhangsan)
  • -s /bin/bash 指定登录 shell
  • -c "张三" 备注信息,chfn 可修改

2. 设置密码

1
2
3
passwd zhangsan
passwd lisi
passwd wangwu

交互式输入密码,输入时不显示字符,属正常现象。

3. 授予 sudo 权限

1
2
3
# 推荐:创建独立 sudoers 文件,不要直接改 /etc/sudoers
# 用编辑器可避免 sudoers 语法错误导致 sudo 整体失效
visudo -f /etc/sudoers.d/opsusers

在打开的文件中加入(用户名按实际情况调整):

1
2
# 运维用户组,%表示该组内所有用户
%opsusers ALL=(ALL:ALL) ALL

或者直接给单个用户:

1
2
3
zhangsan ALL=(ALL:ALL) ALL
lisi ALL=(ALL:ALL) ALL
wangwu ALL=(ALL:ALL) ALL

保存退出(:wq)。

4. 创建运维用户组并加入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 创建组
groupadd opsusers

# 把用户加入组
usermod -aG opsusers zhangsan
usermod -aG opsusers lisi
usermod -aG opsusers wangwu

# 验证
id zhangsan
id lisi
id wangwu

# 确认组变更生效(用户需要重新登录才完全生效)
groups zhangsan

为什么要用组而不是逐个用户授权:以后增减运维人员只需改组,不用动 sudoers 文件,权限管理清晰得多。

5. 验证 sudo 可用

1
2
3
4
su - zhangsan
sudo whoami # 应输出 root
sudo -l # 查看该用户可执行的命令
exit

六、设置时区

1
2
3
4
5
# 设置时区(交互式选择 Asia/Shanghai)
timedatectl set-timezone Asia/Shanghai

# 验证
timedatectl

输出应包含:

1
2
Local time:  Sun 2026-11-08 19:10:00 CST
Time zone: Asia/Shanghai (CST, +0800)

Ubuntu 默认已经使用 systemd 的 timedatectl 管理时区,不需要再改 /etc/localtime 或安装 tzdata。

检查时间同步:

1
2
3
4
timedatectl show -p NTPSynchronized
# 输出应为 yes

systemctl status systemd-timesyncd

如果未同步:

1
2
apt-get install -y systemd-timesyncd
systemctl enable --now systemd-timesyncd

时区对日志排查至关重要:服务器 UTC 时间 + 本地北京时间差 8 小时,排查故障时看日志会对不上时间,这点在之前的《CentOS 错误:Local Time 与实际相差 8 小时》里已经踩过一次。

七、修改 SSH 端口为 2342

1. 修改配置文件

1
2
3
4
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak

# 确认 Port 配置
grep -n "^#*Port" /etc/ssh/sshd_config

如果存在多个 Port 配置,只保留一个非注释的,否则 SSH 会同时监听多个端口。

编辑配置:

1
vim /etc/ssh/sshd_config

确保只有一行生效:

1
Port 2342

同时建议检查并调整这几项:

1
2
3
PermitRootLogin prohibit-password      # 禁止 root 密码登录,密钥登录仍可用
MaxAuthTries 3
LoginGraceTime 30

2. 校验配置语法

这一步千万别跳过——配置写错会导致 SSH 服务起不来,只能进控制台救。

1
sshd -t

没有任何输出 = 配置正确。有报错就按提示改。

3. 防火墙放行新端口

1
2
3
4
5
6
7
# ufw 已启用时
ufw allow 2342/tcp
ufw status

# 或用 firewalld
# firewall-cmd --permanent --add-port=2342/tcp
# firewall-cmd --reload

顺序很重要:如果服务器启用了防火墙,必须先放行新端口,再重启 SSH,否则会把自己关在门外。

4. 重启 SSH 并验证

1
2
systemctl restart sshd
systemctl status sshd

不要关闭当前这个 SSH 窗口! 另外开一个新终端测试:

1
ssh -p 2342 zhangsan@服务器IP

新窗口能连上,才可以关闭旧窗口。

5. 配置防火墙只开放必要端口

1
2
3
4
5
6
7
# 只允许 SSH 和 HTTP/HTTPS
ufw default deny incoming
ufw default allow outgoing
ufw allow 2342/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

6. 防止忘记密码导致锁死(可选但推荐)

1
2
3
# 临时开第二个端口,等新端口验证通过后再关闭
echo "Port 2222" >> /etc/ssh/sshd_config
sshd -t && systemctl restart sshd

保留 2222 端口几天,确认 2342 稳定可用后再删掉这行。

八、使用 docker-compose 部署整套应用栈

本文以一个 SpringBoot 项目为例,用一份 compose 文件把 Nginx、MySQL、Redis 和应用编排在一起。

1. 规划目录结构

所有服务统一放在 /opt 下,按服务分子目录:

1
2
mkdir -p /opt/{nginx/{conf,html,logs,certs},mysql/{data,conf,init},redis/{data,conf},app}
cd /opt

最终结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/opt/
├── docker-compose.yml # 统一编排所有服务
├── .env # 密码等敏感配置(不提交到 Git)
├── nginx/
│ ├── conf/default.conf # Nginx 配置
│ ├── html/ # 静态文件
│ ├── logs/ # 访问/错误日志
│ └── certs/ # TLS 证书
├── mysql/
│ ├── data/ # 数据持久化
│ ├── conf/my.cnf # MySQL 配置
│ └── init/init.sql # 初始化 SQL
├── redis/
│ ├── data/ # 持久化
│ └── conf/redis.conf # Redis 配置
└── app/
├── Dockerfile # 基于 JDK17 的应用镜像
└── app.jar

为什么放在 /opt:上一节刚挂载的 500G 数据盘就挂在这里。数据库数据目录必须放在数据盘上,否则系统盘会被撑爆,且容器重建时数据会丢。

2. 准备证书

你已自行申请通配符证书,需要三个文件:

1
2
3
4
nginx/certs/
├── fullchain.pem # 完整证书链(服务器上必须用这个,不是 crt)
├── privkey.pem # 私钥
└── cert.pem # 可选,部分工具需要

拷贝到服务器(在你本地机器执行):

1
scp -P 2342 fullchain.pem privkey.pem root@服务器IP:/opt/nginx/certs/

必须用 fullchain.pem:只上传 cert.pem(不含中间证书)会导致部分浏览器和移动端报证书错误——这是自签证书场景下最常见的坑。

设置权限(私钥必须是 600,容器内 Nginx 以非 root 用户运行时读不到):

1
2
chmod 600 /opt/nginx/certs/privkey.pem
chmod 644 /opt/nginx/certs/fullchain.pem

证书续期:通配符证书一般有效期较长(通常 1 年),到期前需要重新签发替换。到期时替换文件后执行 docker compose restart nginx 即可生效。

3. 准备环境变量文件

密码不要直接写在 compose 文件里。单独建一个 .env,所有服务通过变量引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat > /opt/.env << 'EOF'
# ===== MySQL =====
MYSQL_ROOT_PASSWORD=Root@2026#Secure
MYSQL_DATABASE=app_db
MYSQL_USER=app
MYSQL_PASSWORD=App@2026#Secure

# ===== Redis =====
REDIS_PASSWORD=Redis@2026#Secure

# ===== SpringBoot 应用 =====
APP_PORT=8080
TZ=Asia/Shanghai
EOF

# 权限设为 600,只有 root 可读
chmod 600 /opt/.env

为什么用 .env:密码集中管理、compose 文件可以进 Git 而不泄露密码、docker compose 会自动读取同目录下的 .env。

密码里的特殊字符:.env 中 # 后面会被当成注释,$ 会被当成变量引用。所以密码里尽量避免使用这两个字符,或用引号包裹。本文示例用了 #,实际使用时建议改成不含特殊字符的强密码,或者用双引号包裹整行。

4. 编写 docker-compose.yml

这是整套栈的核心。所有服务加入同一个自定义网络 app-net:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
services:
# ==================== Nginx ====================
nginx:
image: nginx:1.27-alpine
container_name: nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
- ./nginx/logs:/var/log/nginx
- ./nginx/certs:/etc/nginx/certs:ro
networks:
- app-net
depends_on:
- app
environment:
TZ: ${TZ}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3

# ==================== MySQL ====================
mysql:
image: mysql:8.0
container_name: mysql
restart: always
ports:
- "19873:3306"
environment:
TZ: ${TZ}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_general_ci
- --default-authentication-plugin=mysql_native_password
- --lower_case_table_names=1
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
- ./mysql/init:/docker-entrypoint-initdb.d:ro
networks:
- app-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s

# ==================== Redis ====================
redis:
image: redis:7-alpine
container_name: redis
restart: always
ports:
- "8971:6379"
command: redis-server /usr/local/etc/redis/redis.conf --requirepass ${REDIS_PASSWORD}
volumes:
- ./redis/data:/data
- ./redis/conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5

# ==================== SpringBoot 应用 ====================
app:
build:
context: ./app
dockerfile: Dockerfile
image: app-service:latest
container_name: app
restart: always
ports:
- "${APP_PORT}:8080"
environment:
TZ: ${TZ}
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-net

networks:
app-net:
name: app-net
driver: bridge

5. 关键设计说明

端口映射对照表

服务 容器内端口 宿主机端口 说明
Nginx 80 / 443 80 / 443 对外暴露,承载 HTTPS
MySQL 3306 19873 避开默认端口,减少扫描
Redis 6379 8971 同上
应用 8080 8080(可改) 建议只内网访问,由 Nginx 反代

改端口的意义:把数据库端口从默认的 3306/6379 改掉,可以过滤掉互联网上相当一部分自动化扫描。安全上不能只依赖这个——真正的防线是密码强度 + 防火墙只放行必要端口 + 不对公网暴露。

服务名即主机名

同一网络内,应用连数据库不需要写 IP,直接用服务名:

1
2
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/app_db    # 不是 172.x.x.x
SPRING_DATA_REDIS_HOST: redis

这是 Docker 内置 DNS 提供的服务发现能力。写 IP 的容器重启后 IP 会变,用服务名则始终稳定。

健康检查与启动顺序

depends_on 配合 condition: service_healthy,能保证 MySQL、Redis 真正就绪后应用才启动:

1
2
3
depends_on:
mysql:
condition: service_healthy

只有 depends_on 而没有健康检查时,容器只是”启动了”,MySQL 还在初始化数据库,此时应用启动会因为连接失败而崩溃。

6. 配置 MySQL

创建配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cat > /opt/mysql/conf/my.cnf << 'EOF'
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_general_ci
default-time-zone = '+08:00'

# 最大连接数
max_connections = 500
max_connections_error = 1000

# InnoDB 缓冲池(内存 8G 时设 2G 比较稳妥)
innodb_buffer_pool_size = 2G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_file_per_table = 1

# 慢查询日志
slow_query_log = 1
long_query_time = 2

# 关闭 DNS 解析(容器环境 IP 会变)
skip_name_resolve = 1

[client]
default-character-set = utf8mb4
EOF

skip_name_resolve=1 在容器里很重要:MySQL 默认会做反向 DNS 解析,主机名解析慢时会导致连接堆积。

初始化 SQL(首次启动自动执行):

1
2
3
4
5
6
7
cat > /opt/mysql/init/init.sql << 'EOF'
-- 创建业务数据库(环境变量 MYSQL_DATABASE 已自动创建,此处放额外 DDL)
USE app_db;

-- 授权应用账号(限制来源 IP 为应用所在网段更安全)
-- FLUSH PRIVILEGES;
EOF

7. 配置 Redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cat > /opt/redis/conf/redis.conf << 'EOF'
# 监听所有网卡(容器内需要)
bind 0.0.0.0
protected-mode yes

# 端口
port 6379

# 密码(compose 中已通过 --requirepass 传入,此处留空避免冲突)
# requirepass xxx

# 持久化
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
dir /data

# 内存限制(8G 机器留足空间给 MySQL 和应用)
maxmemory 2gb
maxmemory-policy allkeys-lru

# 慢日志
slowlog-log-slower-than 10000
slowlog-max-len 128
EOF

密码只在一处配置:compose 里通过 --requirepass ${REDIS_PASSWORD} 传入。这样 .env 是唯一的事实来源,不会出现配置文件和 compose 两处密码不一致导致连不上。

8. 编写应用 Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cat > /opt/app/Dockerfile << 'EOF'
# JDK17 基础镜像
FROM eclipse-temurin:17-jre-jammy

LABEL maintainer="0x01@dosbat.com"

WORKDIR /app

# 时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 复制 jar 包
COPY app.jar /app/app.jar

EXPOSE 8080

# JVM 参数:限制堆内存,配合容器内存限制
ENTRYPOINT ["java", \
"-Djava.security.egd=file:/dev/./urandom", \
"-Xms512m", "-Xmx1024m", \
"-XX:+UseG1GC", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "/app/app.jar"]
EOF

把构建好的 jar 包放进去:

1
2
3
# 在项目根目录执行
mvn clean package -DskipTests
cp target/your-app.jar /opt/app/app.jar

为什么用 -jre 而不是 -jdk:JRE 只包含运行环境,镜像体积小得多。eclipse-temurin:17-jre-jammy 约 200MB,-jdk 变体接近 500MB。除非容器里需要编译,否则一律用 JRE。

JVM 内存要和容器限制匹配:如果给容器加了 mem_limit: 2g,那 -Xmx 不要超过它,否则 JVM 感知不到 cgroup 限制,实际用超了会被 OOM Kill。设置 MaxRAMPercentage 让 JVM 自动按比例分配更省心。

给应用容器加内存限制(在 compose 的 app 服务下补充):

1
2
3
4
5
deploy:
resources:
limits:
memory: 2g
cpus: "2.0"

9. 编写 Nginx 反向代理配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
cat > /opt/nginx/conf/default.conf << 'EOF'
server {
listen 80;
server_name example.com *.example.com;

location /.well-known/acme-challenge/ {
root /var/www/html;
}

location / {
return 301 https://$host$request_uri;
}
}

server {
listen 443 ssl;
http2 on;
server_name example.com *.example.com;

ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;

access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;

client_max_body_size 20m;

# 反向代理到 SpringBoot 应用
location / {
proxy_pass http://app:8080;
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_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

# 文件上传大小限制
client_max_body_size 20m;
proxy_buffering off;
}

location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
EOF

注意 proxy_pass 用服务名 app:8080 而不是 IP。Nginx 和应用在同一个网络,服务名解析由 Docker DNS 完成,容器重建换 IP 后配置依然有效。

X-Forwarded-Proto 必须传,否则应用生成的跳转链接、重置密码链接会变成 http,在 https 站点下会触发混合内容问题。

10. 启动整套服务

1
2
3
4
5
6
7
8
9
10
cd /opt

# 校验 compose 文件语法(会展开 .env 变量)
docker compose config

# 构建并启动
docker compose up -d --build

# 查看状态
docker compose ps

启动顺序是自动的:compose 会先启动 MySQL、Redis,等健康检查通过后再启动应用,最后启动 Nginx。

查看日志:

1
2
3
4
5
6
7
8
# 全局
docker compose logs -f

# 只看应用
docker compose logs -f app

# 只看 MySQL 初始化过程
docker compose logs mysql | head -50

11. 验证部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. 检查所有容器状态(应为 healthy / running)
docker compose ps

# 2. 测试 MySQL(宿主机 19873 端口)
mysql -h 127.0.0.1 -P 19873 -u root -p
# 密码填 MYSQL_ROOT_PASSWORD

# 3. 测试 Redis(宿主机 8971 端口)
redis-cli -h 127.0.0.1 -p 8971 -a 'Redis@2026#Secure' ping
# 输出 PONG

# 4. 测试应用
curl -I http://127.0.0.1:8080

# 5. 测试 Nginx
curl -I http://localhost
curl -k -I https://localhost

# 6. 容器内用服务名互连(验证网络和密码)
docker compose exec app sh -c 'echo > /dev/tcp/redis/6379 && echo "redis reachable"'
docker compose exec mysql mysqladmin ping -h 127.0.0.1 -p"${MYSQL_ROOT_PASSWORD}"

12. 容器内网络连通性验证

1
2
3
4
5
6
7
8
9
10
# 查看网络
docker network inspect app-net

# 进入应用容器测试 DNS 解析
docker compose exec app getent hosts mysql
docker compose exec app getent hosts redis

# 测试端口连通
docker compose exec app nc -zv mysql 3306
docker compose exec app nc -zv redis 6379

用 getent hosts mysql 能返回 IP,说明 Docker 内嵌 DNS 工作正常。如果返回空,说明服务没加入同一个网络,或服务名拼写错误。

13. 常用运维命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cd /opt

# 查看状态
docker compose ps

# 查看日志
docker compose logs -f app

# 重启单个服务
docker compose restart app

# 只重建应用(改了代码或 Dockerfile)
docker compose up -d --build app

# 修改 Nginx 配置后平滑重载
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload

# 停止并删除容器(数据卷保留在宿主机)
docker compose down

# 连数据卷一起删除(危险,会丢数据)
# docker compose down -v

14. 数据备份与恢复

MySQL 数据在宿主机 ./mysql/data,但直接拷贝数据目录是不安全的(可能拷到写入中的状态)。用逻辑备份:

1
2
3
4
5
6
7
# 备份
docker compose exec mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" \
--all-databases --single-transaction --routines --triggers \
> /opt/backup/mysql_$(date +%Y%m%d_%H%M%S).sql

# 恢复
docker compose exec -T mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" < backup.sql

定时备份脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat > /opt/backup_mysql.sh << 'EOF'
#!/bin/bash
BACKUP_DIR=/opt/backup
mkdir -p $BACKUP_DIR
cd /opt
source .env
docker compose exec -T mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" \
--all-databases --single-transaction --routines --triggers \
> $BACKUP_DIR/mysql_$(date +%Y%m%d_%H%M%S).sql
# 只保留最近 7 天的备份
find $BACKUP_DIR -name "mysql_*.sql" -mtime +7 -delete
EOF

chmod +x /opt/backup_mysql.sh

# 加入定时任务(每天 3 点)
crontab -e
# 0 3 * * * /opt/backup_mysql.sh >> /var/log/backup_mysql.log 2>&1

Redis 持久化已通过 appendonly yes 开启,RDB 和 AOF 都在 ./redis/data。

15. 修改密码的正确方式

密码写在 .env 里,改完只需:

1
2
3
cd /opt
vim .env # 修改对应密码
docker compose up -d --force-recreate mysql redis app

⚠️ 改 MySQL 密码有个坑:环境变量 MYSQL_ROOT_PASSWORD 只在首次初始化时生效。数据库已经存在后,改这个变量不会改变现有密码,会导致应用连不上。

正确做法是进入容器执行 SQL 修改:

1
docker compose exec mysql mysql -u root -p'旧密码'
1
2
3
ALTER USER 'root'@'%' IDENTIFIED BY '新密码';
ALTER USER 'app'@'%' IDENTIFIED BY '新密码';
FLUSH PRIVILEGES;

然后再更新 .env 并重建应用容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./html:/usr/share/nginx/html:ro
- ./logs:/var/log/nginx
- ./certs:/etc/nginx/certs:ro
networks:
- nginx-net
environment:
TZ: Asia/Shanghai
# 健康检查:容器异常时自动标记,便于排查
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3


## 九、服务器安全加固清单

上线前逐项检查:

```bash
# 1. SSH 端口已改
grep "^Port" /etc/ssh/sshd_config

# 2. root 密码登录已禁用
grep "PermitRootLogin" /etc/ssh/sshd_config

# 3. 防火墙已启用且只开放必要端口
ufw status verbose

# 4. 防火墙已设为默认拒绝
ufw status | grep "Default"

# 5. Swap 已生效
swapon --show

# 6. 时区正确
timedatectl | grep "Time zone"

# 7. Docker 日志已限制大小
docker info | grep -A3 "Logging Driver"

# 8. 磁盘挂载正常
df -h | grep -E "/opt|/$"

# 9. 应用栈容器状态(应为 running/healthy)
cd /opt && docker compose ps

# 10. 容器网络互通
docker compose exec app getent hosts mysql
docker compose exec app getent hosts redis

# 11. 数据库端口未直接暴露公网(防火墙只放行 SSH/HTTP/HTTPS)
ufw status | grep -E "19873|8971"

第 11 项:MySQL(19873)和 Redis(8971)的端口映射主要是为了本地调试方便。生产环境建议不对公网开放这两个端口,通过防火墙只允许内网 IP 访问:

1
2
3
# 只允许内网访问数据库端口
ufw allow from 192.168.0.0/16 to any port 19873 proto tcp
ufw allow from 192.168.0.0/16 to any port 8971 proto tcp

其他建议:

  • 定期更新系统:apt-get update && apt-get upgrade -y
  • 开启自动安全更新:apt-get install -y unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades
  • 配置 SSH 密钥登录:禁用密码登录(PasswordAuthentication no),改用密钥
  • 定期备份:/etc 目录、/opt 下的 compose 配置和证书
  • Docker 数据目录迁移:如果 /var/lib/docker 占用了系统盘,考虑迁移到 /opt

十、完整初始化脚本

把前面的核心步骤整合成一个脚本,新服务器可以一把梭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/bin/bash
set -e

# ============ 参数配置 ============
SWAP_SIZE=8G
DATA_DISK=/dev/vdb
DATA_UUID=""
SSH_PORT=2342
OPS_USERS="zhangsan lisi wangwu"

echo "=========================================="
echo " Ubuntu 24.04 服务器初始化脚本"
echo "=========================================="

# ============ 1. 系统更新 ============
echo "[1/8] 更新系统"
apt-get update
apt-get upgrade -y

# ============ 2. 时区 ============
echo "[2/8] 设置时区"
timedatectl set-timezone Asia/Shanghai

# ============ 3. Swap ============
echo "[3/8] 配置 ${SWAP_SIZE} Swap"
if ! swapon --show | grep -q /swapfile; then
fallocate -l ${SWAP_SIZE} /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
cat > /etc/sysctl.d/99-swap.conf << 'EOF'
vm.swappiness=10
vm.vfs_cache_pressure=50
EOF
sysctl -p /etc/sysctl.d/99-swap.conf > /dev/null

# ============ 4. 磁盘分区 ============
echo "[4/8] 配置数据盘 ${DATA_DISK}"
if [ -n "${DATA_UUID}" ]; then
mkdir -p /opt
grep -q "${DATA_UUID}" /etc/fstab || echo "UUID=${DATA_UUID} /opt ext4 defaults,noatime 0 2" >> /etc/fstab
mount -a
fi

# ============ 5. Docker ============
echo "[5/8] 安装 Docker"
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
apt-get remove -y $pkg 2>/dev/null || true
done
apt-get install -y docker.io docker-compose-v2
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "100m", "max-file": "3" },
"storage-driver": "overlay2"
}
EOF
systemctl restart docker
systemctl enable docker

# ============ 6. 用户与 sudo ============
echo "[6/8] 创建运维用户"
groupadd -f opsusers
for u in ${OPS_USERS}; do
id ${u} > /dev/null 2>&1 || useradd -m -s /bin/bash ${u}
usermod -aG opsusers ${u}
usermod -aG docker ${u}
done
echo '%opsusers ALL=(ALL:ALL) ALL' > /etc/sudoers.d/opsusers
chmod 440 /etc/sudoers.d/opsusers
visudo -c

# ============ 7. SSH 端口 ============
echo "[7/8] 修改 SSH 端口为 ${SSH_PORT}"
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sed -i "s/^#*Port .*/Port ${SSH_PORT}/" /etc/ssh/sshd_config
sed -i 's/^#*PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sshd -t
systemctl restart sshd
ufw allow ${SSH_PORT}/tcp 2>/dev/null || true
ufw allow 80/tcp 2>/dev/null || true
ufw allow 443/tcp 2>/dev/null || true

echo "[8/8] 完成"
echo "=========================================="
echo " 请务必另开终端验证 SSH 连接:"
echo " ssh -p ${SSH_PORT} zhangsan@$(hostname -I | awk '{print $1}')"
echo " 验证成功后再关闭当前会话!"
echo "=========================================="

⚠️ 执行前必看

  1. DATA_UUID 需要你填入 blkid 查到的实际 UUID,留空则跳过磁盘挂载
  2. 务必另开一个终端验证 SSH 能连上,再关闭当前会话
  3. 脚本用 set -e,任何一步失败会立即停止
  4. 磁盘分区(fdisk)因涉及交互,脚本中未自动化,建议手动执行

十一、常见问题

Docker 容器无法访问网络

1
2
3
4
5
# 检查 iptables 规则是否被 ufw 重置
iptables -L -n | grep DOCKER

# 重启 Docker 重建规则
systemctl restart docker

修改 SSH 端口后连不上

大概率是防火墙没放行,或者 sshd_config 里有多个 Port 配置。只能通过云厂商的 VNC/控制台登录修复:

1
2
3
4
5
6
# 检查是否有多个 Port
grep -n "^Port" /etc/ssh/sshd_config

# 检查防火墙
ufw status
iptables -L -n | grep 2342

swap 创建失败

1
2
3
4
5
# 文件系统不支持 fallocate 时改用 dd
dd if=/dev/zero of=/swapfile bs=1M count=8192 status=progress
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

nginx 容器起不来看日志

1
docker compose logs nginx

常见原因:

  • 证书路径不对 → 确认 ./certs/ 下文件名与配置一致
  • 配置语法错误 → docker compose exec nginx nginx -t
  • 端口被占用 → ss -lntp | grep :80

磁盘挂载后容量不对

1
2
3
4
5
6
# 检查是否格式化过(新盘必须 mkfs 一次)
blkid /dev/vdb2

# 重新挂载
umount /opt && mount /dev/vdb2 /opt
df -h /opt

应用启动报 Connection refused(连不上 MySQL/Redis)

按顺序排查:

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 容器是否都起来了
docker compose ps

# 2. 服务名能否解析(最常见问题)
docker compose exec app getent hosts mysql
# 返回空 → 服务名写错,或没加入同一网络

# 3. 端口是否通
docker compose exec app nc -zv mysql 3306

# 4. 密码是否正确(环境变量没生效)
docker compose exec mysql mysql -u app -p'${MYSQL_PASSWORD}' -e "select 1"

90% 的情况是第 2 步:应用容器和数据库不在同一个网络,或者 depends_on 写的是服务名但实际容器名不同。

MySQL 改了环境变量密码却不生效

1
2
3
# 环境变量只在首次初始化时生效,数据库存在后改它没用
# 正确做法:进容器改 SQL
docker compose exec mysql mysql -u root -p'旧密码'
1
2
3
ALTER USER 'root'@'%' IDENTIFIED BY '新密码';
ALTER USER 'app'@'%' IDENTIFIED BY '新密码';
FLUSH PRIVILEGES;

Redis 报 NOAUTH Authentication required

密码没传进去。检查 compose 里 Redis 的 command:

1
command: redis-server /usr/local/etc/redis/redis.conf --requirepass ${REDIS_PASSWORD}

验证变量是否展开:

1
docker compose config | grep requirepass

如果配置了 redis.conf 里又有 requirepass,且两个值不一致,就会连不上。密码只在一处配置,compose 里传参是最不容易出错的方式。

应用内存溢出被 OOM Kill

1
2
3
4
5
6
# 确认是不是被 OOM 杀的
docker inspect app --format '{{.State.OOMKilled}}'
dmesg -T | grep -i "killed process"

# 查看容器实际限制
docker compose exec app java -XX:+PrintFlagsFinal -version | grep MaxHeapSize

核心原则:JVM 的 -Xmx 不要超过容器的 mem_limit。容器限制 2G 时,-Xmx 设 1G~1.5G 更稳,或者用 -XX:MaxRAMPercentage=75.0 让 JVM 自动感知。

Nginx 502 Bad Gateway

1
2
3
4
5
6
7
8
# 1. 应用容器是否在跑
docker compose ps app

# 2. 应用是否监听 8080
docker compose exec app sh -c "netstat -tlnp | grep 8080"

# 3. 应用日志有没有异常
docker compose logs --tail=50 app

502 说明 Nginx 活着但后端没响应。最常见是应用还在启动(SpringBoot 启动慢)或启动失败。

应用启动慢,Nginx 报 502

SpringBoot 冷启动可能要几十秒。调整 Nginx 超时和重试:

1
2
3
4
5
6
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

# 后端不可用时先返回 502 而不是卡住
proxy_next_upstream error timeout http_502 http_503;

同时给应用配置健康检查,让 Nginx 能感知就绪状态:

1
2
3
4
5
6
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s

数据卷权限问题(MySQL 起不来)

1
2
3
4
5
6
# 看日志
docker compose logs mysql | tail -20

# 常见原因:宿主机目录权限不对
# MySQL 容器内以 mysql 用户运行,需要目录属主匹配
chown -R 999:999 /opt/mysql/data

compose 文件里密码被覆盖

1
2
3
4
5
# 检查变量是否正确展开
docker compose config

# 确认 .env 文件在 compose 文件同级目录
ls -la /opt/.env

.env 必须在 docker-compose.yml 同级目录才会被自动加载。放在子目录里不会生效。

总结

一台服务器从裸机到能跑生产服务,完整链路是:

磁盘分区 → Swap → 系统更新 → 时区 → Docker → 用户与权限 → SSH 加固 → 容器化部署 → 安全检查

每个环节都有对应的坑:

环节 关键点
Swap 权限必须 600;vm.swappiness 调低到 10
磁盘 fdisk 交互要点;用 UUID 挂载;先 mkfs 再挂载
Docker 必须限制日志大小;国内建议配镜像加速
用户 用组统一授权 sudo;visudo 避免语法错误
SSH 先验证新端口再关旧窗口;改端口前先放行防火墙
Nginx 用 fullchain.pem 而非 cert.pem;配置改完先 nginx -t
MySQL 改密码要走 SQL,环境变量只在首次初始化生效
Redis 密码只在一处配置,避免 compose 与 conf 不一致
应用 -Xmx 不能超过容器内存限制;JRE 而非 JDK 镜像

把这套流程固化下来,之后每上一台新机器,半小时内就能完成基础环境搭建,业务部署就是后面的事了。

整套栈的网络拓扑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                     公网
│
┌────▼────┐
│ Nginx │ 80 / 443 (HTTPS)
└────┬────┘
│ proxy_pass http://app:8080
┌────────────────┼────────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼────┐
│ app │ │ MySQL │ │ Redis │
│ JDK17 │ │ 19873 │ │ 8971 │
│ :8080 │────▶│ :3306 │ │ :6379 │
└─────────┘ └───────────┘ └─────────┘
│ │ │
└────────────────┴────────────────┘
app-net 网络
(服务名即主机名,免 IP)

记住这条原则:容器间用服务名互连(mysql:3306),只有 Nginx 对外暴露 80/443,数据库端口尽量只在内网可达。


相关阅读