阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

学习DB2数据库

目录


1. 数据库基础命令

#1. 切换到数据库安装用户
su - db2inst1

#2. 连接到数据库
db2 connect to <数据库> #本地
db2 connect to <数据库> user <用户名> using <密码> #远端

#3. 强制所有应用断开数据库连接
db2 force application all

#4. 备份/还原整个数据库
db2 backup db <数据库>
db2 restore db <数据库>

#5. 查看所有数据库连接
db2 list application

#6. 启动/关闭数据库
db2start/db2stop

#7. 执行sql
db2 -tvf *.sql #中间出现错误不会断开
db2 -txvf *.sql #中间出现错误会断开,并提示错误

#8获取日志路径
db2 get db cfg for bpfdb3 #获取实例日志路径
db2 get dbm cfg | grep DIAGPATH #获取诊断日志路径

2. 数据库结构/数据导入导出

db2结构导出

db2lookup -d <数据库> -u <用户> -o <导出脚本.sql>

注意:导出的结构中可能有源数据库中自定义的表空间,如果导入目标数据库中没有相应的表空间就会报错,为了保持迁移表的一致性,在执行sql的时候使用db2 -txvf *.sql,中间如果出现错误会断开,需要把所有的错误都解决了。

db2move数据导出导入

#导出数据
db2move <数据库> export -l lobs

#导入数据
db2move <数据库> import -l lobs

注意:这里的-l参数指的是lobpaths,而不是日志。如果有lob相关的字段,需要指定路径将lob数据单独放在指定路径下。在数据导入的时候同样要指定lob路径,这样lob字段的数据才能够导入。

db2move命令参数参考相关文档:https://www.ibm.com/docs/en/db2/10.5?topic=commands-db2move-database-movement-tool

3. 数据库表空间

表空间创建示例:

db2 "create regular tablespace  tablespace1 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace1' 5g) bufferpool bp32k"
db2 "create regular tablespace  tablespace2 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace2' 10g) bufferpool bp32k"
db2 "create regular tablespace  tablespace3 pagesize 32k managed by database using(file '/usr/yixiayizi/tablespace3' 2g) bufferpool bp32k"

4. 参考链接

🔲 ☆

tcpdump抓包学习Nginx(反向代理),学完不怵nginx了,还总想跃跃欲试!(Nginx使用、原理完整版手册)

0. 前话

俄罗斯年轻程序员Igor Sysoev为了解决所谓C10K problem,也就是以前的Web Server不能支持超10k并发请求的问题,在2002年开启了新的Web Server的开发。

Nginx2004年在2-clause BSD证书下发布于众,根据2021年3月Web Server的调查,Nginx持有35.3%的市场占有率,为4.196亿网站提供服务。

感谢DigitalOcean公司的NGINXConfig项目,提供了很多写好的Nginx模板供下载,这样就可以在不理解Nginx配置的情况下复制粘贴配置Nginx。

这里不是说复制粘贴是不对的,而是如果只复制粘贴并不理解的话,迟早会出问题。所以,你必须理解Nginx的配置,通过学习本文,你能够:

  • 理解工具生成或者别人配置的Nginx。
  • 从0到1配置Web服务器、反向代理服务器和负载均衡服务器。
  • 优化Nginx获取最大性能。

学习本文需要有一定的Linux基础,会执行例如lscat等Linux命令,还需要你对前后端有一定的了解,不过这些对前端或者后端程序员都很容易。

1. Nginx基本介绍

Nginx是一个高性能的Web服务器,着眼于高性能、高并发和低资源消耗。尽管Nginx作为一个Web服务器被大家所熟知,它另外的一个核心功能是反向代理。

Nginx不是市场上唯一的Web服务器,它最大的竞争对手Apache HTTP Server(httpd)在1995年就发布了。人们在选择Nginx作为Web服务器时候,基于下面两点考虑:

  • 支持更高的并发。
  • 用更少的硬件资源提供静态文件服务。

Nginx和Apache谁更好的争论没有意义,如果想了解更多Nginx和Apache的区别可以参考Justin Ellingwood文章

关于Nginx对请求处理的新特点,引用Justin的文章解释如下:

Nginx在Apache之后出现,更多认识到网站业务扩大之后面临的并发性问题,所以从一开始就设计为异步、非阻塞和事件驱动连接处理的算法。

Nginx工作时候会设定worker进程(worker process),每一个worker进程都能够处理数千个连接。worker进程通过fast looping的机制来不断轮询处理事件。将具体处理请求的工作和连接解耦能够让每一个worker进程仅当新的事件触发的时候将其与一个连接关联。

Nginx基本工作原理图:

Nginx之所以能够在低资源消耗的情况下高性能提供静态文件服务,是因为它没有内置动态编程语言处理器。当一个静态文件请求到达后,Nginx就是简单的响应请求文件,并没有做什么额外的处理。

这不是说Nginx不能够整合动态编程语言处理器,它可以将请求任务代理到独立的进程上,例如PHP-FPMNode.js或者Python。一旦第三方进程处理完请求,再将响应代理回客户端,工作如图:

2. 怎么安装nginx

Nginx的安装网上示例很多,这里以Ubuntu为例:

#更新源
sudo apt update && sudo apt upgrade -y

#安装
sudo apt install nginx -y

这种方式安装Nginx成功之后,Nginx会注册为systemd系统服务,查看服务:

sudo systemctl status nginx

#如果没有注册为systemd服务,可以用service查看试下
sudo service nginx status

Nginx的配置文件经常放在/etc/nginx目录中,默认的配置端口是80,如果启动成功,可以访问得到页面:

恭喜!Nginx安装成功了!

3. Nginx配置文件管理

Nginx为静态或者动态文件提供服务,具体怎么样提供服务是由配置文件设置的。

Nginx的配置文件以.conf结尾,常常位于/etc/nginx目录中。访问/etc/nginx目录:

cd /etc/nginx

ls -lh

# drwxr-xr-x 2 root root 4.0K Apr 21  2020 conf.d
# -rw-r--r-- 1 root root 1.1K Feb  4  2019 fastcgi.conf
# -rw-r--r-- 1 root root 1007 Feb  4  2019 fastcgi_params
# -rw-r--r-- 1 root root 2.8K Feb  4  2019 koi-utf
# -rw-r--r-- 1 root root 2.2K Feb  4  2019 koi-win
# -rw-r--r-- 1 root root 3.9K Feb  4  2019 mime.types
# drwxr-xr-x 2 root root 4.0K Apr 21  2020 modules-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 modules-enabled
# -rw-r--r-- 1 root root 1.5K Feb  4  2019 nginx.conf
# -rw-r--r-- 1 root root  180 Feb  4  2019 proxy_params
# -rw-r--r-- 1 root root  636 Feb  4  2019 scgi_params
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-available
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 sites-enabled
# drwxr-xr-x 2 root root 4.0K Apr 17 14:42 snippets
# -rw-r--r-- 1 root root  664 Feb  4  2019 uwsgi_params
# -rw-r--r-- 1 root root 3.0K Feb  4  2019

该目录中的/etc/nginx/nginx.conf就是Nginx的主配置文件。如果你打开这个配置文件,会发现很多内容,不要害怕,本文就是一点一点的要学会它。

在进行配置文件修改的时候,不建议直接修改/etc/nginx/nginx.conf,可以将之备份之后再修改:

#重命名文件
sudo mv nginx.conf nginx.conf.backup

#新建配置文件
sudo touch nginx.conf

4. Nginx配置为一个基本的Web Server

这一部分,将会从零一步步学习Nginx配置文件的书写,目的是了解Nginx配置文件的基本语法和基本概念。

4.1 写第一个配置文件

vim /etc/nginx/nginx.conf打开配置文件并更新内容:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Bonjour, mon ami!\n";
        #配置重定向
        #return 302 https://www.baidu.com$request_uri;
    }

}

重启Nginx并访问,你会得到如下信息:

curl -i http://127.0.0.1

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 19 Feb 2022 08:31:59 GMT
Content-Type: text/plain
Content-Length: 21
Connection: keep-alive

Bonjour, mon ami!

4.2 校验、重载Nginx配置文件

Nginx的配置文件是否正确可以通过-t参数校验:

sudo nginx -t

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

如果有相关的语法错误,上述命令输出结果会有相关提示。

如果你想改变Nginx的相关状态,例如重启、重载等,可以有三种办法。一是通过-s(signal)参数向Nginx发送信号;二是使用系统服务管理工具systemd或者service等;三是使用kill命令对Linux进程操作。

向Nginx发送信号

nginx信号:nginx -s reload|quit|stop|reopen,分别表示重载配置文件、优雅停止Nginx、无条件停止Nginx和重新打开log文件。

所谓的“优雅停止”Nginx,是指处理完目前的请求再停止;而“无条件停止”Nginx,相当于kill -9,进程直接被杀死。

系统服务管理Nginx

#使用systemctl
sudo systemctl start|restart|stop nginx

#或者使用service
sudo service nginx start|restart|stop

kill命令杀死进程并手动启动

#杀死主进程及各子进程
sudo kill -TERM $MASTER_PID

#指定配置文件启动Nginx
sudo /usr/sbin/nginx -c /etc/nginx/nginx.conf

4.3 理解Nginx配置文件中的Directives和Contexts

Nginx的配置文件虽然看起来只是简单的配置文本,但它是包含语法的。实际上配置文件中的内容都是DirectivesDirectives分为两种:

  • Simple Directives
  • Block Directives

Simple Directives:包含名称和空格,以分号(;)结尾。例如listenreturn等。

Block Directives:包裹在{}中,{}Simple Directives组成,称之为Contexts

Nginx配置中核心的Contexts

  • events{}:总体配置nginx如何处理请求,只能在配置文件中出现一次。
  • http{}:配置nginx如何处理http或者https请求,只能在配置文件中出现一次。
  • server{}:内嵌在http{}中,用来配置一个独立主机上指定的虚拟主机。http{}可以配置多个server{},表示多个虚拟主机。
  • main:上述3个Contexts之外的配置都在该Contex上。

在主机上设置不同的虚拟主机(多个server{}、相同server_name),监听不同的端口(listen不同):

http {
    server {
        listen 80;
        server_name localhost;

        return 200 "hello from port 80!\n";
    }


    server {
        listen 8080;
        server_name localhost;

        return 200 "hello from port 8080!\n";
    }
}

不同的虚拟主机,监听同一个端口(多个server{}、不同server_name),监听同一个端口(listen相同):

这种情况必须用域名,Nginx会将请求头中Host信息取出来和服务端配置server_name做匹配,匹配到哪个就就进入到那个处理块中。

http {
    server {
        listen 8088;
        server_name library.test;

        return 200 "your local library!\n";
    }


    server {
        listen 8088;
        server_name librarian.library.test;

        return 200 "welcome dear librarian!\n";
    }
}

当访问不同的域名时,会返回不同的结果:

curl -i http://library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:02:20 GMT
Content-Type: application/octet-stream
Content-Length: 21
Connection: keep-alive

your local library !

curl -i http://librarian.library.test:8088

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:04:26 GMT
Content-Type: application/octet-stream
Content-Length: 24
Connection: keep-alive

welcome dear librarian!

这样能成功的提前是指定的域名解析到同一个IP,或者在本地的hosts文件中配置好域名进行本地测试:

xx.19.146.188 library.test librarian.library.test

注意,这里return这个Directive后面跟两个参数,一个是状态码,一个是返回的文本信息,文本信息要用引号引起来。

4.4 使用Nginx作为静态文件服务器

更新Nginx配置文件如下:

events {

}

http {

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

这里对Nginx默认的展示页面做了修改,在文件/usr/share/nginx/html/assets/mystyle.css写入p {background: red;}并在html文件中引入该css,这样正常情况段落的背景会变成红色。

访问页面,展示的是index.html,但是段落的背景色没有生效。debug一下css文件:

curl -i http://fengmengzhao.hypc:8088/assets/mystyle.css

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 20 Feb 2022 08:43:58 GMT
Content-Type: text/plain
Content-Length: 27
Last-Modified: Sun, 20 Feb 2022 08:38:54 GMT
Connection: keep-alive
ETag: "6211fe1e-1b"
Accept-Ranges: bytes

p {
    background: red;
}

注意,这里响应头信息Content-Typetext/plain,而不是text/css。也就是说Nginx将css文件做为一个普通的文本提供服务,而没有当做stylesheet,浏览器自然就不会渲染样式。

本文会在本地hosts文件增加域名解析,所以会在示例中看到对域名请求。在操作本文示例时,要根据自己环境对ip(域名)或者端口做相应修改。

4.5 Nginx中处理静态文件类型解析

实际上这里涉及到Nginx对静态文件类型解析的处理,默认不进行任何设置情况下,Nginx认为文本文件的类型是text/plain

修改配置文件如下:

events {

}

http {

    types {
        text/html html;
        text/css css;
    }

    server {

        listen 8088;
        server_name localhost;

        root /usr/share/nginx/html;
    }
}

重新访问页面,样式正常,mystyle.css文件的responseContent-Typetext/css

这里在http{}中引入了types{},通过文件的后缀映射文件的类型。需要注意,如果没有types{},nginx会认为.html文件的类型是text/html,但是一旦引入types{},nginx只会解析定义的类型映射。所以这里引入types{}后,不能只定义css的类型映射,同样要显式定义html的类型映射,否则nginx会将html解析为普通文本文件。

4.6 Nginx子配置引入

手动在http{}中增加types{}来映射文件类型对于小项目还可以,对大型项目来说手动配置就太繁琐了,Nginx提供了默认的解析映射(常常在/etc/nginx/mime.types文件中),可以通过include语法将子配置引入配置文件中。

修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
    }

}

重启Nginx,自定义的css文件能够正常展示。

5. Nginx的动态路由

上面的示例非常简单,访问root定义目录下的文件,存在就返回,不存在就返回默认404页面。

接下来学习Nginx的location动态路由用法,包括重定向、重写和try_files Directive。

所谓的动态路径就是用户访问的路径到达Nginx后,Nginx如何匹配访问内容。

Location Matches

修改配置文件,如下:

events {

}

http {

    server {

        #设置默认的Content-Type text/html,否则将以流的方式下载
        default_type text/html;
        #设置字符编码为utf-8,否则页面会乱码
        charset utf-8;

        listen 80;
        server_name localhost;
        #前缀匹配,示例:http://fengmengzhao.hypc:8088/agatha----
        location /agatha {
            return 200 "前缀匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #完全匹配,示例:http://fengmengzhao.hypc:8088/agatha
        location = /agatha {
            return 200 "完全匹配-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,默认大小写敏感,示例:http://fengmengzhao.hypc:8088/agatha01234
        #正则匹配的优先级要高于前缀匹配,低于优先前缀匹配
        location ~ /agatha[0-9]{
            return 200 "正则匹配,大小写敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #正则匹配,大小写不敏感,示例:http://fengmengzhao.hypc:8088/AGatHa01234
        location ~* /agatha[0-9]{
            return 200 "正则匹配,大小写不敏感-Miss Marple.\nHercule Poirot.\n";
        }
        #优先前缀匹配,示例:http://fengmengzhao.hypc:8088/Agatha01234
        #在前缀匹配前加^~即可转化为优先前缀匹配
        location ^~ /Agatha {
            return 200 "优先前缀匹配-Miss Marple.\nHercule Poirot.\n";
        } 
    }
}

匹配规则总结:

匹配 关键字
完全 =
优先前缀 ^~
正则 ~或者~*
前缀 None

如果一个请求满足多个配置的匹配,正则匹配的优先级大于前缀匹配,而优先前缀匹配的优先级大于正则匹配,完全匹配优先级最高。

nginx中的变量(Variables

设置变量:

set $<variable_name> <variable_value>;

# set name "Farhan"
# set age 25
# set is_working true*

变量类型:

  • String
  • Integer
  • Boolean

除了自定义变量外,nginx有内置的变量,参考https://nginx.org/en/docs/varindex.html

例如,如下配置中使用内置变量:

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "Host - $host\ - $uri\nArgs - $args\n";
    }

}

# curl http://localhost/user?name=Farhan

# Host - localhost
# URI - /user
# Args - name=Farhan

上面使用了$host$uri$args内置变量,分别表示主机名、请求相对路径和请求参数。变量可以作为值赋值给自定义变量,例如:

events {

}

http {

    server {

        listen 80;
        server_name localhost;
        
        set $name $arg_name; # $arg_<query string name>

        return 200 "Name - $name\n";
    }

}

上面出现了$arg_*内置变量,使用$arg_<query string name>可以获取$args变量中指定的query string

重定向(Redirects)和重写(Rewrites

nginx中的重定向和其他平台上见到的重定向一样,response返回3xx的状态码和location头信息。如果是在浏览器中访问,浏览器会自动重新发起location指定的请求,地址栏url也会发生改变。

重定向示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        location = /index_page {
                return 307 https://fengmengzhao.github.io;
        }

        location = /about_page {
                return 307 https://fengmengzhao.github.io/about;
        }
    }
}

#curl -I http://localhost/about_page

HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:47:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 180
Connection: keep-alive
Location: https://fengmengzhao.github.io/about

重写(Rewrites)和重定向不一样,重写内部转发了请求,地址栏不会发生改变。示例如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        rewrite /image /assets/generate.png;
    }
}

#curl -i http://localhost/image

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 21 Feb 2022 11:56:42 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Accept-Ranges: bytes

Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.

如果在浏览器上访问http://fengmengzhao.hypc:8088/image,即可展示图片。

try_files尝试多个文件

try_files示例:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files /assets/xxx.jpg /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

示例查找/assets/xxx.jpg文件,如果不存在就查找/not_found路径。

try_files常常和$uri内置变量一起使用:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;

        try_files $uri /not_found;
        #当访问http://localhost返回404
        #这里表示,当访问$uri文件不存在时,尝试$uri/作为一个目录访问
        #try_files $uri $uri/ /not_found;

        location /not_found {
                return 404 "sadly, you've hit a brick wall buddy!\n";
        }
    }
}

6. Nginx的日志

日志位置(常常在/var/log/nginx):

ls -lh /var/log/nginx/

# -rw-r----- 1 www-data adm     0 Apr 25 07:34 access.log
# -rw-r----- 1 www-data adm     0 Apr 25 07:34 error.log

删除日志文件并reopen Nginx:

# delete the old files
sudo rm /var/log/nginx/access.log /var/log/nginx/error.log

# create new files
sudo touch /var/log/nginx/access.log /var/log/nginx/error.log

# reopen the log files
sudo nginx -s reopen

这里如果采用上面删除文件后再创建文件的方法清空日志,就需要nginx -s reopen重载Nginx,否则新的日志文件不会被写入日志,因为Nginx的输出流指向还是之前删除的日志文件。实际上这里想清空日志文件可以采用echo "" > /var/log/nginx/access.log的方法,这样就不用reopen Nginx了。

访问Nginx并查看日志:

curl -I http://localhost

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 08:35:59 GMT
# Content-Type: text/html
# Content-Length: 960
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-3c0"
# Accept-Ranges: bytes

sudo cat /var/log/nginx/access.log 

# 192.168.20.20 - - [25/Apr/2021:08:35:59 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.68.0"

默认情况下,任何访问的日志都会记录在access.log文件中,也可以通过access_log Directive来自定义路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
        
        location / {
            #日志会在默认配置日志文件输出
            return 200 "this will be logged to the default file.\n";
        }
        
        location = /admin {
            #日志会输出在/var/logs/nginx/admin.log文件中
            access_log /var/logs/nginx/admin.log;
            
            return 200 "this will be logged in a separate file.\n";
        }
        
        location = /no_logging {
            #禁止日志输出
            access_log off;
            
            return 200 "this will not be logged.\n";
        }
    }
}

location{}中可以自定义access.log的路径,也可以用access_log off来关闭log输出。

同样,error_log也可以自定义Nginx的error.log路径:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log;
        #return后面只能跟两个参数,这里是为了让Nginx报错,输出错误日志
        return 200 "..." "...";
    }

}

使用nginx -s reload重载Nginx:

sudo nginx -s reload

# nginx: [emerg] invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

访问错误日志文件,有同样的错误信息:

sudo cat /var/log/nginx/error.log 

# 2021/04/25 08:35:45 [notice] 4169#4169: signal process started
# 2021/04/25 10:03:18 [emerg] 8434#8434: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:14

Nginx error日志信息是有级别的:

  • debug:能帮忙排查哪里出错了。
  • info:可以了解但是不必要的信息。
  • notice:比info更值得了解的信息,但不知道也没什么。
  • warn:意料之外的事情发生了,哪里出问题了,但还能工作。
  • error:什么失败了的信息。
  • crit:严重问题,急需解决。
  • alert:迫在眉睫。
  • emerg:系统不稳定,十万火急。

默认情况下,Nginx记录所有级别的Error信息,可以通过error_log第二个参数覆写。如果要设置最低级别的日志输出为warn,更新配置文件如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;
	
        error_log /var/log/error.log warn;

        return 200 "..." "...";
    }

}

重载Nginx并查看日志:

cat /var/log/nginx/error.log

# 2021/04/25 11:27:02 [emerg] 12769#12769: invalid number of arguments in "return" directive in /etc/nginx/nginx.conf:16

这里可以看到,没有输出之前的[notice]日志了。

7. Nginx作为反向代理服务器

7.1 什么是反向代理?

所谓的反向代理,首先是一种代理,是客户端和服务端之外的第三方。把正向代理(Forward proxy)和反向代理(Reverse proxy)比较起来看就很容易理解。

正向代理一般代理的是客户端,用户(客户端)是知道代理存在(一般是客户端配置的)。客户端对目标服务的请求会经由代理转发并将目标服务响应返回给客户端。常见的VPN代理、浏览器(设置)代理、Git(设置)代理和Fiddler抓包软件等都是正向代理。

本文中所述的“目标服务”、“被代理的上游服务”、“被代理的服务”、“服务端”均指代proxy_pass配置的被代理的服务。“代理服务”、“代理服务的服务端”指代的是Nginx提供的代理服务。

正向代理示意图:

反向代理一般代理的是服务端,客户端直接和代理服务打交道(如果有反向代理的话),而对被代理的服务一无所知。客户端请求到达代理服务之后,代理服务再将请求转发到被代理的服务并将响应返回给客户端。

反向代理示意图:

上面二图,可以理解蓝色背景的服务是相互知晓的。

Nginx作为反向代理时,处在客户端和服务端之间。客户端发送请求到Nginx(反向代理),Nginx将请求发送给服务端。一旦服务端处理完请求,会将结果返回给Nginx,Nginx再将结果返回给客户端。在这整个过程中,客户端并不知道实际上谁处理了请求(真正的处理请求并产生响应,而不是代理)。

7.2 反向代理基本原理

笔者刚接触反向代理的时候,感觉这是一个很神奇的事情。进行简单的配置就能将第三方的网站代理到自己的主机上吗?

实际上,不尽然。有些网站能够将主页代理过来,但功能不能完全使用;有些代理过来样式、图片等加载会出问题。只有理解了个中原理,才能够解释各种各样的情况。

所谓的反向代理就是将客户端发送来的请求转发给实际处理请求服务端(proxy_pass指定的服务端),服务端响应之后,再将响应代理回客户端。

既然是代理,就不仅仅简单的只做转发,在代理收到客户端请求后,准备转发到指定代理服务端之前,会对请求的header信息进行重写,例如重写规则如下(反向代理header重写章节会对规则做详细介绍):

  1. 值为空的header不会进行转发;headerkey中包含有_下划线的不会进行转发。
  2. 默认改写HostConnection两个header,分别为:Host: $proxy_hostConnection: close

如果代理服务器只是转发,还要什么代理?就像生活中的代理一样,会提供增值服务,什么事情都帮你搞定。

反向代理就是将客户端的请求,重写header信息之后,在代理服务的服务端转发请求到被代理服务,被代理服务处理请求将响应返回给代理服务,代理服务进而转发响应回客户端。

代理服务转发的请求是代理服务端重新发起,因此在客户端的浏览器或者Fiddler工具进行网络抓包是抓不到的。要看具体的代理发起网络请求需要用Wireshark工具抓包代理服务器对应的网卡。

别理解复杂了,就是客户端<--->代理服务<--->被代理服务。Nginx的反向代理默认不会改变响应的内容,被代理服务响应页面的绝对引用(/assets/image/abc.jpg)、相对引用(assets/image/abc.jpg)或者图床引用(https://image.com/image/abc.jpg)代理回客户端的时候不会发生改变。这些引用在客户端解析html时候会重新发起请求,如果请求指向了代理服务,会同样进行请求<--->代理服务<--->被代理服务这个流程。

--->表示请求,<---表示响应。

有些时候代理之后之所以情况变得复杂,是因为被代理服务存在重定向或者权鉴的约束产生的,而代理的过程就是请求<--->代理服务<--->被代理服务这么简单,并且不会改变被代理服务的响应内容。

7.3 反向代理基本配置

看一个简单的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 80;
        server_name localhost;

        location / {
                proxy_pass "https://bbs.tianya.cn/";
        }
    }
}

代理后页面如下:

因为是http反向代理了https,运营商竟然还在右下角插入了广告(https://bbs.tianya.cn/不会被插入广告)。

proxy_pass能够简单的将客户端请求转发给第三方服务端并反向代理响应结果返回给客户端。

这只是简单的代理,如果你要反向代理一个接口并且使用WebSocket,那么就要覆写header信息:

#WebSocket需要http/1.1,默认是http/1.0
proxy_http_version 1.1;
#覆写header Upgrade为$http_upgrade的值,该值为Nginx获取客户端请求过来的Upgrade头信息值
proxy_set_header Upgrade $http_upgrade;
#覆写header Connection为'upgrade'
proxy_set_header Connection 'upgrade';

7.4 Nginx反向代理地址匹配规则

客户端发送给Nginx的请求,究竟Nginx会怎样拼接到proxy_pass指定的上游服务呢?Nginx有一定的规则:

  1. 如果proxy_pass代理的上游服务是域名加端口(没有端口时默认端口为80或者443),那么客户端请求的代理路径会直接拼到上游服务地址上。示例,proxy_pass http://redis.cn就只是对域名(和端口)的代理。
  2. 如果proxy_pass代理的上游服务有请求路径,那么客户端请求的代理路径将会是把客户端请求路径裁剪掉匹配路径后再拼到上游服务地址上。示例,proxy_pass http://redis.cn/或者proxy_pass http://redis.cn/commands是有请求路径的代理。

上面1、2分别定义为“情况1”和“情况2”,下面中有引用。

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn;
        }

        #location /redis {
            #情况1,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/redis/commands
        #   proxy_pass http://redis.cn;
        #}

        location /redis {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn//commands
            proxy_pass http://redis.cn/;
        }

        location /redis/ {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis/commands --> http://redis.cn/commands
            proxy_pass http://redis.cn/;
        }

        #location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands --> http://redis.cn/commands
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://redis.cn/commands/keys.html
        #   proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commandskeys.html
        #    proxy_pass http://redis.cn/commands;
        #}

        #location /redis-commands/ {
             #情况2,客户端路径和代理路径映射:
        #    #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands/keys.html
        #    proxy_pass http://redis.cn/commands/;
        #}

        location /redis-commands {
            #情况2,客户端路径和代理路径映射:
            #http://fengmengzhao.hypc:8088/redis-commands/keys.html --> http://fengmengzhao.hypc:8088/commands//keys.html
            proxy_pass http://redis.cn/commands/;
        }

    }
}

总结客户端请求和代理端转发请求的对应关系,如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/ http://redis.cn    
/redis http://redis.cn /redis /redis
/ http://redis.cn/   /
/ http://redis.cn/ / /
/redis http://redis.cn/ /redis /
/redis http://redis.cn/ /redis/commands //commands
/redis/ http://redis.cn/ /redis /
/redis/ http://redis.cn/ /redis/commands /commands
/redis-commands http://redis.cn/commands /redis-commands /commands
/redis-commands http://redis.cn/commands /redis-commands/keys.html /commands/keys.html
/redis-commands/ http://redis.cn/commands /redis-commands /commands
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html
/redis-commands http://redis.cn/commands/ /redis-commands /commands/
/redis-commands http://redis.cn/commands/ /redis-commands/keys.html /commands//keys.html
/redis-commands/ http://redis.cn/commands/ /redis-commands /commands/
/redis-commands/ http://redis.cn/commands/ /redis-commands/keys.html /commands/keys.html

表格中为空表示只有域名+端口的访问,没有请求路径。

代理后的请求在客户端看不到网络请求,可以用tcpdump抓包代理服务所在主机的网卡生成.cap文件,并在Wireshark中查看具体请求。

tcpdump监听命令:

#xx.19.146.188是Nginx代理IP;121.42.46.75是被代理上游服务IP,也就是redis.cn域名的解析IP
#ech0是xx.19.146.188使用的网卡IP
sudo tcpdump -i eth0 tcp port 8088 and host xx.19.146.188 or host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-2.cap

启动后,访问代理服务,数据包经过网卡eth0就会被捕捉到。将nginx-2.cap文件在Wireshark中打开即可查看具体网络包。

以下表请求为demo,抓包获取代理请求。

请求如下:

匹配路径 proxy_pass 客户端请求 代理后请求
/redis-commands/ http://redis.cn/commands /redis-commands/keys.html /commandskeys.html

抓取请求包如图:

7.5 反向代理header重写

Nginx在服务端代理的请求和客户端发的请求不是完全相同的,主要的不同在于请求的header信息,Nginx会对客户端发过来的请求的header进行修改,规则如下:

  1. Nginx删除空值的header。Nginx这样做是因为空值的Header发送服务端也没有意义,当然利用这一点,如果想让代理不发送某个header信息,可以在配置中用proxy_set_header覆写header值为空。
  2. Nginx默认header的名称中如果包含_下划线是无效header。这个行为也可以通过配置文件中设置underscores_in_headers on来开启,否则任何含有_header信息都不会被代理到目标上游服务。
  3. 代理的Host头信息会被覆写为变量$proxy_host,该变量是被代理上游服务的IP(或域名)加端口,其值在proxy_pass中定义。
  4. 代理的Connection头信息会被覆写为”close”,该请求头告诉被代理上游服务,一旦服务端响应代理请求,该连接就会被关闭,不会被持久化(persistent)。

第3点的Host头信息覆写在Nginx的反向代理中是比较重要的,Nginx定义不同的变量代表不同的值:

  • $proxy_host:上面提过了,是默认反向代理覆写的header,其值是proxy_pass定义的上游服务IP和端口。
  • $http_host:是Nginx获取客户端请求的Host头。Nginx使用$http_作为前缀加上客户端header名称的小写,并将-符号用_替换拼接后就代表客户端实际请求的头信息。
  • $Host:常常和$http_host一样,但是会将http_host转化为小写(域名情况)并去除端口。如果http_host不存在或者是空的情况,$host的值等于Nginx配置中server_name的值。

Nginx可以通过proxy_set_header来覆写客户端发送过来请求的header再转发。除了上面说的Host头比较重要,经常用到的header还有:

  • X-Forwarded-Proto:配置值$schema。告诉上游被代理服务,原始的客户端请求是http还是https
  • X-Real-IP:配置值$remote_addr。告诉代理服务客户端的IP地址,辅助代理服务做出某种决定或者日志输出。
  • X-Forwarded-For:配置值$proxy_add_x_forwarded_for。包含请求经过每一次代理的IP。

7.6 反向代理试试,tcpdump抓包解析,探个中究竟

笔者也一直在理解这个Hosthttp请求中的作用,正常当一个http请求发送之后,tcp连接已经指定了IP和端口,那还需要Host头信息做什么呢?

首先,MDN Web DocsHost头的说明:

所有HTTP/1.1 请求报文中必须包含一个Host头字段。对于缺少Host头或者含有超过一个Host头的HTTP/1.1 请求,可能会收到400(Bad Request)状态码。

那Nginx反向代理默认对Host头覆写为$proxy_host的作用是什么,如果改写为其他会怎么样?用tcpdump工具抓包一探究竟。

看示例,反向代理http://redis.cn,配置如下(情况一):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
        }

}

最普通的反向代理设置,没有进行任何header覆写。用tcpdump工具监控网卡:

#先用ping或者nslookup找到redis.cn的IP,这里找到是121.42.46.75
#这里 host 121.42.46.75,代表过滤指定IP的包。不过滤的话包会很多,不太好看
#-c 100 捕捉到100个包,会自动退出并生产文件
#需要将cap文件Wireshark中打开
sudo tcpdump -i eth0 host 121.42.46.75 -c 100 -n -vvv -w /opt/nginx-redis-1.cap

这时候访问http://fengmengzhao.hypc:8088/,代理页面很正常:

Nginx服务端的tcpdump包也抓到了:

用Wireshark查看包请求:

修改Nginx配置proxy_set_header Host $http_host(情况二):

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location / {
            proxy_pass http://redis.cn;
            proxy_set_header Host $http_host;
        }

}

访问http://fengmengzhao.hypc:8088/,代理页面:

这是什么页面?如果直接用redis.cn的IP地址http://121.42.46.75访问,得到同样的页面。为什么?

看看抓到的包情况:

tcpdump抓包来看,该响应是正常从服务端响应的。那为何不同的Host头返回的页面会不同呢?

情况二设置proxy_set_header $http_host之后Nginx代理请求的Host为客户端请求的Host(fengmengzhao.hypc:8088),而情况一的Host为上游被代理服务的Host(redis.cn)。可能在redis.cn该域名对应的主机121.42.46.75不止提供一个80端口的服务。

这种在一个主机上提供多个域名服务(端口相同)称之为虚拟主机。理解Nginx配置文件中的Directives和Contexts章节中提到的Nginx可以设置不同域名同一端口的虚拟主机就可以实现这种情况。另外,Apache也支持配置不同域名的虚拟主机。这两种情况,归根结底都是在请求到达服务端后,服务端会获取请求中的Host头信息并匹配到不同的虚拟服务。

所以,Nginx反向代理中对Host头信息的覆写要看上游被代理服务是否有特殊需要到该信息。如果没有特殊实现上需要,默认的proxy_host就可以;如果是特殊的实现机制,就要小心对待。

这里的特殊需要是例如上面虚拟主机那种情况,Host头信息在HTTP/1.1中是必须带的。

7.7 反向代理处理相对路径问题

基于上面讲解的对反向代理的理解,我们处理一下实际工作中遇到的问题,增加对Nginx反向代理的认识。

假设被代理的上游服务是一个简单的静态页面(http://127.0.0.1:80),页面中引用了两个相同的图片,分别是绝对引用/assets/generate.png和相对引用assets/generate.png。我们进行如下的反向代理配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }

}

这时候,访问http://fengmengzhao.hypc:8088/static会发现其中绝对引用(/assets/generate.png)的图片加载失败,通过浏览器网络查看,其客户端加载的请求是http://fengmengzhao.hypc:8088/assets/generate.png。该请求在我们的配置中会默认寻找root匹配(一般默认是/usr/share/nginx/html路径),找不到对应的资源。

实际上不管是绝对应用还是相对应用我们想让客户端的请求都是http://fengmengzhao.hypc:8088/static/assets/generate.png,这里可以看到,如果采用上面的代理方式,并且上游服务有绝对路径的引用,就会出现加载异常的情况。示例:

这里我们也可以看出来,Nginx反向代理默认对响应的内容是不会修改的,目标服务中相对路径或者绝对路径的引用反向代理之后返回给客户端的跟直接访问目标服务端响应是一样的。

怎么样解决呢,有如下方案:

1). 如果目标上游服务可以修改,可以将所有的绝对路径的引用改为相对路径引用。一级目录静态文件引用/assets/generate.png要改为./assets/generate.png或者assets/generate.png;二级目录静态文件引用要改为../xxx/assets/generate.png。总之,页面上绝对路径的引用要改为相对路径的引用。

2). 可以将不能正常代理的图片添加代理,如下配置:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            proxy_pass http://127.0.0.1/;
        }
        
        location /assets/ {
            proxy_pass http://127.0.0.1/assets/;
        }

}

这样绝对引用http://fengmengzhao.hypc:8088/assets/generate.png就能够代理到http://127.0.0.1/assets/generate.png,就能够正常加载图片了。

3). 放弃子目录的方案,用独立域名就没问题了,配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name static.fengmengzhao.hypc;

        location / {
            proxy_pass http://127.0.0.1/;
        }

}

这样访问http://static.fengmengzhao.hypc:8088就能够成功代理http://127.0.0.1了。

4). Nginx重写目标服务端响应内容

文中强调过多次,Nginx反向代理默认是不会修改目标服务端响应内容的。但Nginx也支持对响应内容进行修改,需要开启Nginx的ngx_http_sub_module

可以通过nginx -V查看是否包含http_sub_module就知道当前Nginx是否有ngx_http_sub_module模块。

开启ngx_http_sub_module模块后,修改配置如下:

events {

}

http {

    include /etc/nginx/mime.types;

    server {
        listen 8088;
        server_name localhost;

        location /static/ {
            sub_filter 'src="/assets/' 'src="./assets/';
            sub_filter_once off;
            proxy_pass http://127.0.0.1/;
        }
        
}

通过上面的任意方法,可以获取正确的代理响应:

这里要注意一个点,当你的访问路径是http://fengmengzhao.hypc:8088/static(情况一),其响应html中有引用assets/generate.png,对该generate.png的请求路径是:http://fengmengzhao.hypc:8088/assets/gnerate.png。而当你的访问路径是http://fengmengzhao.hypc:8088/static/(情况二),其响应html同样引用assets/generate.png,对图片的请求会变为:http://fengmengzhao.hypc:8088/static/assets/generate.png。情况二访问路径和情况一的区别是URI的最后有没有跟/,如果有/结尾的话,认为当前访问是一个目录,所以其相对引用就从当前地址栏中的路径开始;如果没有/结尾的话,认为当前访问是一个文件,其相对路径就是文件所在的路径,也就是URI往前数有出现/那个层级,在这里就是根目录,所以情况一虽然是相对引用,但是请求路径还是从根目录开始。

8. Nginx作为一个负载均衡服务器

学习完反向代理,就很容易理解基于反向代理做进一步的负载均衡了。

配置示例:

events {

}

http {

    upstream backend_servers {
        server localhost:3001;
        server localhost:3002;
        server localhost:3003;
    }

    server {

        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://backend_servers;
        }
    }
}

upstream{}可以包含多个服务并且作为一个上游服务被引用。

测试负载均衡:

while sleep 0.5; do curl http://localhost; done

# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.
# response from server - 3.
# response from server - 1.
# response from server - 2.

9. 优化Nginx性能

本文介绍三个方面优化Nginx的性能:根据主机参数调优Worker Processes及Worker Connections配置、缓存静态文件和响应数据压缩。

9.1 怎么设置工作进程数(Worker Processes)和工作连接数(Worker Connections

文章开始的时候已经提到过,Nginx会设置Worker进程并在进程间进行切换,能够同时并发处理“成千上万”个请求。可以通过status命令查看Worker进程数:

sudo systemctl status nginx

# ● nginx.service - A high performance web server and a reverse proxy server
#      Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)
#      Active: active (running) since Sun 2021-04-25 08:33:11 UTC; 5h 54min ago
#        Docs: man:nginx(8)
#     Process: 22610 ExecReload=/usr/sbin/nginx -g daemon on; master_process on; -s reload (code=exited, status=0/SUCCESS)
#    Main PID: 3904 (nginx)
#       Tasks: 3 (limit: 1136)
#      Memory: 3.7M
#      CGroup: /system.slice/nginx.service
#              ├─ 3904 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
#              ├─22611 nginx: worker process
#              └─22612 nginx: worker process

#也可以通过ps查看进程
#能够看到master进程是各个Worker进程的父进程
ps -ef | grep nginx

这里可以看到有1个master进程和2个Worker进程。Worker进程数在Nginx中很容易配置:

#一般情况,主机有多少核,就设置Worker进程的个数为多少
worker_processes 2;
#根据主机cpu核心数的不同自动设置Worker进程的个数
#worker_processes auto;

events {

}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

假设说主机有4个核心,worker_processes如果配置为4,表示每一个Worker理论上能够利用100%的cpu。worker_processes如果配置为8,表示一个Worker理论上能够利用50%的cpu,意味着当主机cpu满负荷运转时Worker每运行1分钟就需要等待一分钟。所以,worker_processes不是配置的越大越好,数量如果超出主机cpu核心数,就会有时间浪费在操作系统级别对进程的调度。

可以很方便的通过nproc命令查看主机的cpu核心数:

nproc

# 4

worker_processes auto配置会根据主机cpu核心数的不同自动设置Worker进程的个数。如果你的主机只用来运行Nginx,可以这样配置;如果主机上还有其他服务部署,要斟酌合理分配资源。

worker_connections表示一个Worker进程能够处理的最大连接数,该参数跟主机cpu core个数和一个core能打开的文件个数有关(该值可以通过命令ulimit -n查询)。

ulimit -n

# 1024

worker_connections设置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    server {

        listen 80;
        server_name localhost;

        return 200 "worker processes and worker connections configuration!\n";
    }
}

注意,这里本文中第一次使用到events这个Context。

9.2 怎样缓存静态文件

不管使用Nginx提供什么样的服务,总是有一些静态文件(js或者css等)是不经常发生改变的,可以将它们缓存起来提高Nginx的性能。Nginx对静态文件的缓存配置非常方便:

worker_processes auto;

events {
    worker_connections 1024;
}

http {

    include /env/nginx/mime.types;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        #正则匹配,大小写不敏感
        #以.css或者.js或者.jpg结尾的匹配
        location ~* \.(css|js|jpg|png)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            #1M代表一个月
            expires 1M;
        }
    }
}

像之前反向代理设置中的proxy_set_header给可以给代理到后端的请求增加header一样,使用add_header可以给response增加header

Cache-Control头信息设置为public,是在告诉client该请求内容可以被缓存。PragmaCache-Control的old version。

Vary头信息设置为Accept-Encoding,后续详解。

expires directive表示Nginx缓存响应的时间,可以帮助很方便设置响应Expires头信息,其值可以是1M(1 month)、10m/10 minutes或者24h/24 hours等。

Cache-Control告诉客户端,该response在服务端缓存,客户端可以以任意的形式缓存。另外根据Nginx的expires设置的缓存时间,增加Cache-Control: max-age=2592000,这里Cache-Control: max-age代表该response在max-age时间内不会刷新。2592000单位是秒,等于expire设置的1M(一个月,30x24x3600=2592000)。

重启Nginx之后,测试请求的响应信息:

curl -I http://fengmengzhao.hypc:8088/assets/generate.png

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Tue, 01 Mar 2022 05:04:17 GMT
Content-Type: image/png
Content-Length: 144082
Last-Modified: Sun, 20 Feb 2022 08:35:21 GMT
Connection: keep-alive
ETag: "6211fd49-232d2"
Expires: Thu, 31 Mar 2022 05:04:17 GMT #注意这个时间和下面的比较
Cache-Control: max-age=2592000
Cache-Control: public
Pragma: public
Vary: Accept-Encoding
Accept-Ranges: bytes

这里可以看到,response中已经增加了Cache-Control头信息,说明配置已经生效。至于Nginx服务端有没有缓存响应,可以用tcpdump抓包看一看,这里不再演示。

需要注意的是,如果在浏览器上访问http://fengmengzhao.hypc:8088/assets/generate.png,第一次返回的是200状态码,表示是服务端成功返回。第二次返回的是304状态码,表示浏览器根据第一次response头信息Cache-Control: public的指示,第二次访问的时候,直接使用客户端缓存。也可以通过F12打开控制台,勾选Network --> Disable Cache选项,这样浏览器端就不使用缓存。

9.3 怎样压缩响应(response)

压缩配置:

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /env/nginx/mime.types;
    #开启gzip,默认只对html进行压缩
    gzip on;
    #不是设置的越大越好,一般设置为1-4
    gzip_comp_level 3;
    #对css和js文件进行压缩
    gzip_types text/css text/javascript;

    server {

        listen 80;
        server_name localhost;

        root /usr/share/nginx/html;
        
        location ~* \.(css|js|jpg)$ {
            access_log off;
            
            add_header Cache-Control public;
            add_header Pragma public;
            add_header Vary Accept-Encoding;
            expires 1M;
        }
    }
}

默认nginx会对html文件进行gzip压缩,如果要对其他类型文件压缩,需要设置gzip_types text/css text/javascript;

gzip_comp_level不是设置的越大越好,一般设置为1-4。

服务端设置gzip之后,要想真正的压缩传输到客户端,客户端需要增加header信息"Accept-Encoding: gzip"才能完成服务端到客户端的压缩传输。

客户端请求没有"Accept-Encoding: gzip"的示例:

curl -I http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:30:32 GMT
# Content-Type: text/css
# Content-Length: 46887
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: "608529d5-b727"
# Expires: Tue, 25 May 2021 16:30:32 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Accept-Ranges: bytes

客户端请求设置”Accept-Encoding: gzip”的示例:

curl -I -H "Accept-Encoding: gzip" http://localhost/mini.min.css

# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
# Date: Sun, 25 Apr 2021 16:31:38 GMT
# Content-Type: text/css
# Last-Modified: Sun, 25 Apr 2021 08:35:33 GMT
# Connection: keep-alive
# ETag: W/"608529d5-b727"
# Expires: Tue, 25 May 2021 16:31:38 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public
# Pragma: public
# Vary: Accept-Encoding
# Content-Encoding: gzip

注意,这里response的header中有Vary: Accept-Encoding信息,该头信息告诉客户端,根据客户端设置的Accept-Encoding头信息的不同,服务端响应会发生变化。

对比压缩前后传输内容的大小:

cd ~
mkdir compression-test && cd compression-test

curl http://localhost/mini.min.css > uncompressed.css

curl -H "Accept-Encoding: gzip" http://localhost/mini.min.css > compressed.css

ls -lh

# -rw-rw-r-- 1 vagrant vagrant 9.1K Apr 25 16:35 compressed.css
# -rw-rw-r-- 1 vagrant vagrant  46K Apr 25 16:35 uncompressed.css

没压缩的版本大小是46k,而压缩后的版本大小是9.1k

10. 理解Nginx整个配置文件

完整nginx配置文件:

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;

	##
	# Logging Settings
	##

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

	##
	# Gzip Settings
	##

	gzip on;

	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

	##
		# Virtual Host Configs
	##

	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;
}


#mail {
#	# See sample authentication script at:
#	# http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
# 
#	# auth_http localhost/auth.php;
#	# pop3_capabilities "TOP" "USER";
#	# imap_capabilities "IMAP4rev1" "UIDPLUS";
# 
#	server {
#		listen     localhost:110;
#		protocol   pop3;
#		proxy      on;
#	}
# 
#	server {
#		listen     localhost:143;
#		protocol   imap;
#		proxy      on;
#	}
#}

上文中已经讲解过的配置,不再做重复说明。

user www-data;设置Nginx进程的用户,这里会涉及到权限问题,如果用户为www-data读取没有权限的目录,就不能正常的提供服务,这时候查看Nginx的error日志,就会报权限相关的错。

pid /run/nginx.pid;设置nginx进程的process id。

include /etc/nginx/modules-enabled/*.conf;设置include指定目录中任何.conf结尾的配置文件。该目录用来加载nginx的动态模块(本文中并没有涉及)。

http{}下,有基本的优化设置,如下:

  • sendfile on;:禁止静态文件buffering。
  • tcp_nopush on;:允许在一个响应包中发送头信息。
  • tcp_nodelay on;:静态文件快传中禁用Nagle’s Algorithm

keepalive_timeout设置http connection的连接时间。types_hash_maxsize设置Hash map的大小。

SSL的配置在本文中不做讲解。

mail Context可以将Nginx配置为一个邮件服务端,本文仅讨论Nginx作为web服务端,所以不做说明。

重点看一下如下配置:

##
# Virtual Host Configs
##

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;

该配置表示Nginx会加载/etc/nginx/conf.d//etc/nginx/sites-enabled/目录内匹配的配置。这样,一般认为这两个目录就是放置Nginx配置的最好的选择,实际上并不是。

有另外一个目录/etc/nginx/sites-available/,该目录用来存放Nginx的虚拟主机(也就是server{}块)配置。/etc/nginx/sites-enabled/目录用来存放符号链接指向目录/etc/nginx/sites-available/中配置。例如:

ln -lh /etc/nginx/sites-enabled/

# lrwxrwxrwx 1 root root 34 Apr 25 08:33 default -> /etc/nginx/sites-available/default

这样通过符号链接的方式可以激活或者禁用/etc/nginx/sites-available/目录中的配置。符号链接unlink`和创建的命令如下:

#删除符号链接,用rm也可以
sudo unlink /etc/nginx/sites-enabled/default

#创建符号链接,第一个参数是被链接的文件,第二个参数是创建符号链接的路径
#也就是,链接某个文件到某个符号链接上
sudo ln -s /etc/nginx/sites-available/nginx-handbook.conf /etc/nginx/sites-enabled/nginx-handbook 

引用

后话

本文大部分内容参考https://www.freecodecamp.org/news/the-nginx-handbook/文章翻译整理,第7. Nginx作为反向代理服务器章重点加入笔者的理解。


本书完

🔲 ☆

bug现场谜之总不能告诉客户你要按F12(打开控制台)吧?(跨域详解)

目录


1. bug现场情况

现场两套系统,集成同一个单点登录。其中一个系统跳转到另外一个系统时浏览器会刷新两次。

奇怪的是打开F12,问题就不能复现。

2. 尝试破案

打开控制台问题就解决了?真是奇怪!可能是控制台打开后,静态文件在浏览器端不再缓存造成的。

打开F12禁止控制台Network --> Disable cache设置,果然问题能够复现,前端js的请求确实是缓存的。

初步判断两次刷新原因:前端js缓存,发送异步权限数据请求接口时没有权限(第一次请求刷新),然后重定向单点登录服务获取service ticket,重新登录后,再次请求权限数据接口(第二次请求刷新),页面成功展示。

笔者对浏览器的行为不熟,这里只是猜测。

笔者系统单点登录实现的CAS接口,所以应用session过期或者失效后需要从新从单点服务处获取service ticket票据。

浏览器刷新两次fiddler抓包如图:

第一次异步请求后,由于没有权限,302重定向访问单点登录服务。这里控制台会提示跨域请求,跨域在跨域详解部分详细介绍。

前端明确说了,不是前端的问题,解决不了。

笔者公司的前端就是硬气。

在后台处理,后台是springboot项目,增加配置:

spring:
  resources:
    cache:
      cachecontrol:
        max-age: 0

前端文件不会缓存,问题解决。

禁用缓存后,不会出现地址栏刷新两次现象,fiddler抓包如图:

问题解决了,笔者对那个跨域的报错产生了兴趣。之前也看过不少跨域的文章,始终对跨域云里雾里。春节找出收藏的跨域文章,好好研读了一下,有所获,赶紧借此文分享出来。

3. 跨域详解

web开发,工作中肯定接触过如下浏览器控制台报错:

No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

请求的跨域资源responseheader中没有Access-Control-Allow-Origin信息。

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://example.com/

浏览器同源策略禁止读取跨域资源。

Access to fetch at ‘https://example.com’ from origin ‘http://localhost:3000’ has been blocked by CORS policy.

在域http://localhost:3000下访问域https://example.com资源被禁止。

了解后面的内容后,就会明白这些报错的真正含义,也就能处理跨域问题了。

3.1 何谓同域(同源)?

  1. 相同协议(http、https)
  2. 相同主机名/ip(127.0.0.1、localhost、google.com)
  3. 相同端口(80、443、8080)

满足三个条件是同源,否则就是不同的域。

注意:localhost主机名虽然在网络层最终会解析为127.0.0.1,但是对于浏览器同源策略来说,localhost127.0.0.1是不同的主机名,二者不同则为跨域。

3.2 何谓跨域(跨域怎么发生?)

1989诞生的World Wide Web最初的html只有纯文本。

世界上第一个web页面,只包含纯文本和超链接。

1993年引入<img>标签,在html渲染时允许加载图片资源,这样纯文本中就可以展示图片。像这样在html中允许加载子资源(subresource)的tag还有:

  • <ifame>
  • <link>
  • <form>
  • <audio>
  • <video>
  • <script>

诚如上面html标签语义,所谓的“子资源(subresource)”就是例如表单、文件、音视频、脚本等外部资源。

当一个域中包含有上面taghtml渲染时,就会加载subresource,当这个subresource和当前域不同源时,跨域请求就发生了。例如,在一个域中xmlhttprequestajax)请求另外一个域的接口时就是跨域请求。

3.3 跨域有什么安全问题?

一个域中加载另一个域的文件、音视频、脚本等subresource时,大部分情况不会产生什么安全问题,但是有一些情况如果不做限制,就存在安全隐患。

例如一个域中提供了基于cookie/session权鉴的发送邮件接口,该域允许任何域对该接口的跨域请求。那么恶意网站有可能在获取有效cookie后任意调用发送邮件接口攻击网站。

可能有同学疑问:发送邮件接口如果需要权鉴认证才能成功调用,别人没有认证信息如何能成功调用呢?

实际上用户在浏览器端完成登录后,用户信息就存储在浏览器端(cookie),这时打开恶意网站就有可能被恶意脚本携带用户信息完成攻击。

3.4 何谓浏览器同源策略(same-origin policy)

既然跨域请求有安全的问题,浏览器端就做了相关限制,称之为“浏览器同源策略”。

同源策略阻止读取跨域请求得到的资源。

这是广义的一个定义,实际上浏览器针对不同subresource有不同的限制策略,下面有做详细说明。

同源策略在1995年网景浏览器2.02中引入,最开始是为了保护跨域DOM而设计的。

跨域请求有三种形式:

  1. 跨域写(Cross-origin writes)
  2. 跨域内嵌(Cross-origin embeds)
  3. 跨域读(Cross-origin reads)

同源策略的规则定义如下:

  • <ifame>:跨域内嵌允许(需要合适的X-Frame-Options)。
  • <link>:跨域内嵌允许(需要合适的Content-Type)。
  • <form>:跨域写允许。
  • <audio>:跨域内嵌允许。
  • <video>:跨域内嵌允许。
  • <script>:跨域内嵌允许,某些api的调用可能会被禁止(例如ajax跨域调用)。
  • <img>:跨域内嵌允许,通过JavaScript跨域读或者在<canvas>中加载被禁止。

3.5 何谓CORS(跨域资源共享)?

浏览器的同源策略能解决很多安全的问题,但是其限制也带来了不便。

CORS(Cross-origin resource sharing)跨域资源共享就是来放宽浏览器同源策略的严格限制,便于某些场景的使用。

同域请求,如图:

跨域请求,如图:

图中涉及到preflight请求下面详解。

3.6 “简单”和“复杂”跨域请求生命历程

这里重点讲述ajax跨域请求(使用浏览器内置fetch()函数)时,其请求过程和解决办法。

一个域中ajax跨域请求另一个域的接口时,该请求的生命历程是由客户端和被请求资源服务端共同决定的。客户端的行为是浏览器同源策略指定的,被请求资源服务端行为由资源提供者具体实现提供。具体来说:

所谓的请求“生命历程”是指:该请求从浏览器发起,到服务端响应,再到浏览器读取响应结果并展示这个过程。

如果是“简单”的ajax跨域请求,那么浏览器会放行该请求,如果服务端没有包含Access-Control-Allow-Originheader信息,则浏览器会限制对请求到资源reponse的读取。

如果是“复杂”的ajax跨域请求,那么浏览器会先自行触发一个preflight请求,根据服务端的相应header信息决定是否放行客户端请求。

这里所谓的“简单”和“复杂”请求是相关规范定义的,一个“复杂”请求要至少满足如下其中一个条件:

  1. GETPOST或者HEAD请求。
  2. 请求头信息包含除AcceptAccept-Language或者Content-Language外的头信息。
  3. 请求Content-Type的值不是application/x-www-form-urlencodedmultipart/form-data或者text/plain

2.中说的头信息不包括浏览器自动给请求加入的header信息,例如origin

接下来用Crystal启动http接口服务,看看不同跨域请求的生命历程:

Crystal安装参考官方文档,脚本basic_greet.cr为:

require "kemal"

port = 4000

get "/" do
  "Hello world!"
end

get "/greet" do
  "Hey!"
end

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  "Hello, #{name}!"
end

post "/greet_str" do |env|
  name = env.params
  "Hello, 成功了!"
end

Kemal.config.port = port
Kemal.run

使用命令sudo crystal run src/basic_greet.cr启动接口服务。

0). 同域下请求

http://xx.22.27.215:4000/greet接口域下发送“简单”的ajax请求,如图:

同域下请求,一切正常,接口能发起成功并且浏览器能读取响应接口数据。

不同浏览器控制台实现方式不大相同(但实现的规范是一样的),这里以FireFox浏览器为测试浏览器。

1). “简单”的post跨域请求

天涯bbs论坛域下发送“简单”的ajax请求,如图:

接口能发起成功。但是,如上图控制台报错,浏览器同源策略禁止读取远端资源,提示CORS header ‘Access-Control-Allow-Origin’ missing,也就是说响应头信息中缺少Access-Control-Allow-Origin信息。

这里之所以是“简单”请求,是因为Content-Typetext/plain,参考上面“复杂”请求规则,不满足任意一个。

这里所以找一个http服务,是因为Crystal接口是http的,如果在https域下调用,浏览器会直接禁止https域下请求http资源。

2). “复杂”的post跨域写入

天涯bbs论坛域下发送“复杂”的ajax请求。

控制台报错如图:

网络抓包如图:

图中1.preflight请求,请求方法为OPTIONS。服务端目前没有实现OPTIONS方法实现,提示404 Not Found

图中2.为真正的POST请求,因为1.preflight请求没有获得同源策略规定的头信息,所以2.的真正POST请求被浏览器级别blocked

注意图中2.OPTIONS请求是浏览器发起的,浏览器会带上一些header信息,比如:originAccess-Control-Reqest-MethodAccess-Control-Reqest-Headers

这种情况下的请求生命历程为:先行的preflight请求404 Not Found(“身先死”),真正的POST请求没有发起成功(“出师未捷”)。也就是所谓的:“出师未捷身先死”。

那,“复杂”的跨域请求preflight要求怎样的实现呢,才能满足浏览器CORS协议的要求呢?

浏览器在发送preflight后会寻找响应中的2个header

  • Access-Control-Allow-MethodsCORS协议允许的请求方法,例如GETPOST等。
  • Access-Control-Allow-HeadersCORS协议允许的请求header,例如Content-Type等。

针对“复杂”请求的生命历程来说,上面2个header必须匹配客户端实际请求信息,否则客户端实际请求可能会被浏览器级别blocked。说白了,服务端允许发送什么样方法的请求、什么样的头信息,客户端才能够成功发送。

preflight的响应信息还可以返回2个header,告诉客户端某些信息:

  • Access-Control-Max-Age:设置preflight请求能够缓存的秒数(默认值是5)。超过设置时间,“复杂”请求发起时浏览器会重新发起preflight;在设置时间内,不再发起preflight请求(使用缓存的preflight请求)。
  • Access-Control-Allow-Credentials:设置客户端实际请求是否能携带用户信息(例如cookie)。

如果服务端没有返回上面2个header信息,不影响请求的生命历程。

也就是说,根据CORS协议,preflight请求响应头信息中要明确返回客户端实际请求的方法(通过响应头信息Access-Control-Allow-Methods值)和头信息(通过响应头信息Access-Control-Allow-Headers值),这样浏览器才会同意发送客户端实际请求。而preflight请求响应头Access-Control-Max-Age可以指定preflight请求缓存的时间,默认就是5秒钟;preflight响应头Access-Control-Allow-Credentials告诉客户端,客户端实际请求能够能携带用户信息,否则不能携带。

这里对客户端实际请求进行了代码块标注,是为了强调该请求避免和preflight请求混为一谈。当一个“复杂”的跨域请求发起的时候,首先,浏览器会发送一个preflight请求,“试探”一下服务端是否允许该跨域请求,如果允许,浏览器才允许该“复杂”请求(也就是这里所谓的客户端实际请求)紧随preflight请求之后发起,否则就会被浏览器blocked

那,按照要求实现下preflight请求吧。

修改basic_greet.cr,增加OPTIONS实现:

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
end

重启接口服务,控制台重新请求,如图:

图中通过Status 200 OK可以看出preflight请求是成功的,但是下面控制台报错:响应头信息中缺少Access-Control-Allow-Origin信息。也就是说,preflight请求是成功了,CORS协议要求必须存在的preflight请求响应头信息也存在,但是由于Access-Control-Allow-Origin头信息的缺失,浏览器同源策略限制读取请求响应内容。

修改basic_greet.cr,响应信息头增加env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"

options "/greet" do |env|
  # Allow `POST /greet`...
  env.response.headers["Access-Control-Allow-Methods"] = "POST"
  # ...with `Content-type` header in the request...
  env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
  # ...from https://www.google.com origin.
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
end

重新请求:

preflight请求成功,如图:

控制台还是有客户端实际请求报错,不过这个错误就很熟悉了:

响应头信息中缺少Access-Control-Allow-Origin信息被浏览器禁止读取响应内容。接口中增加响应header信息:

post "/greet" do |env|
  name = env.params.json["name"].as(String)
  env.response.headers["Access-Control-Allow-Origin"] = "http://bbs.tianya.cn"
  "Hello, #{name}!"
end

重新请求:

“复杂”请求的preflight客户端实际请求都成功了,实现了“复杂”请求的跨域资源共享。

“复杂”请求整个生命历程,概括如图:

4. 总结

  • 看似玄学的问题,其背后都有一定的原因。能不能准确的识别出来取决于对问题涉及的相关知识广度和深度的了解,抱着好奇心多了解多涉猎能增加知识的广度,抱着探索的意志剖析技术点及其源头能深挖知识的深度。
  • 成熟的程序员不是任何知识都懂,而是当遇到问题涉及自己不懂的地方时,能迅速识别出盲区并学习掌握。
  • 跨域总结:由于浏览器跨域请求存在安全隐患,所以浏览器制定了同源策略进行跨域请求等行为的限制(一定程度)。跨域资源共享基于一定的规则放宽了同源策略严格限制,使得不同域之间数据交互更加方便。跨域的问题一旦产生(前端后完全分离项目尤其常见),需要前后端共同努力解决。一个接口是否允许被跨域请求是由服务端接口头信息告诉浏览器的,而客户端请求参数的设置,尤其涉及到cookie信息携带等配置需要客户端了解个中原理才能完成。

引用

更新记录

  • 2022-02-07 18:10 首次提交文章到冯兄话吉
  • 2022-02-08 18:27 微信公众号“冯兄画戟”文章发表前重读、优化、勘误。
  • 2022-02-09 12:40 掘金专栏发表前重读、优化、勘误。

相关文章推荐

🔲 ☆

bug现场谜之困在“init”方法上的那些时间!

目录


1. bug现场情况

现场将在Tomcat 8.5中运行的war包迁移到jetty 9.4.19上,启动容器后报错:

org.springframework.context.ApplicationContextException: Failed to start bean 'stompWebSocketHandlerMapping'; nested exception is java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:176) ~[spring-context-4.2.3.RELEASE.jar:4.2.3.RELEASE]
    ...
    ...

NoSuchMethodError应该是看到后最有头绪一个错误了:“在加载到JVM的对应类中找不到当前调用的方法”。

如果编译环境中对应类没有对应的方法,是不能编译成功的(集成开发环境会报错)。如果编译成功后部署时候报错NoSuchMethodError,说明运行时和编译时依赖的类不一致。

这里说的“编译时依赖”指的是:构建工具在编译时CLASSPATH中依赖的class;“运行时依赖”指的是:JVM实例运行时加载到JVM中的class。对于同一个class loader,只会成功加载一次class

上面的异常堆栈显示:类org.eclipse.jetty.websocket.server.WebSocketServerFactory没有构造方法WebSocketServerFactory(javax.servlet.ServletContext)。那就看一看运行时依赖的类有没有对应的构造方法吧。

现场的情况下,只能用javap命令,但是首先你要找到这个类是从哪个jar包加载的,如何根据类找到加载的jar包路径在接下来的尝试破案做进一步说明。

javap -cp lib/websocket/websocket-server-9.4.41.v20210516.jar org.eclipse.jetty.websocket.server.WebSocketServerFactory

注意这里如果使用 javap -cp lib/websocket/* xxxx这样指定classpath*配置的方式无效,但是对于javac命令是有效的。

这不是存在WebSocketServerFactory(ServletContext context)构造方法吗???

后来笔者又尝试了多种途径确认这个构造方法是存在的,但是却报错NoSuchMethodError,网上一大堆找“java.lang.nosuchmethoderror but method exists”,无果。因为网上说的最后都证明确实没有对应的方法。

但本案发现场的情况是它有啊!现场变得诡异起来了!难道笔者找到了一个超级bug?直觉告诉我100%不会,一定是自己哪块错了。

2. 尝试破案

回顾一下案发现场的情况,报错java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V,可是通过javap工具反编译明明有构造方法WebSocketServerFactory(javax.servlet.ServletContext)啊!

一般NoSuchMethodError异常有两种情况:

  1. classpth中该方法的类在多个jar包中,而JVM加载的jar包的那个类没有该方法。
  2. 只有一个jar包,jar包中的类没有该方法。

这两种情况归根结底是JVM运行时加载的类中确实缺失了方法。但是上面遇到的问题查找加载类是存在报错的构造方法的。

如果JVMclasspth中有多个包存在同一个class,到底JVM会加载哪个包中的class是平台相关的(Linux系统和Windows系统上可能加载的不是同一个jar包)。需要注意:JVMclasspth下的jar包中load对应的class文件,这跟jar包的命名没有关系。

可以通过以下方法根据报错信息定位加载的jar包:

1). JVM使用参数-verbose:class,这个参数能够输出加载classjar包绝对路径。

2). 使用java代码:

Class<?> clazz = null;
try {
     clazz = Class.forName("org.eclipse.jetty.websocket.server.WebSocketServerFactory");
} catch (ClassNotFoundException e) {
     e.printStackTrace();
}
CodeSource cs = clazz.getProtectionDomain().getCodeSource();
String location = cs.getLocation().getPath();
System.out.println(location);

3). 使用linux命令:

for file in *.jar; do
  echo $file;
  jar tvf $file |grep WebSocketServerFactory
done

在允许重启系统或者启动的JVM中设置了--verbose:class参数的话“1)”方法是最方便的,可以直接在日志中查找对用的类。

不允许重启JVM的话可以采用方法“2)”,但是要指定正确的classpath,否则加载不到对应的类。查找classpath可以从jvm对应的进程中查找。

对于springboot框架打成的jar包,一般依赖都打进在jar包中了;对于severlet容器使用的war包,依赖除了WEB-INF/lib外还包括容器安装目录下的lib包;对于普通的jar包,依赖可能定义在了MANIFEST元文件中(更多关于MANIFEST内容可以参考:https://fengmengzhao.github.io/2021/12/18/bug-scene-of-old-jar-classpath-mystery.html

如果想查找指定目录的哪个jar包含有某个class,可以使用“3)”方法,列出jar中包含的文件清单并查找匹配。

为什么要费劲找到报错类是从哪个jar包中加载的呢?一来jar包一般能提供版本相关的信息;二来javap命令是需要指定jar包作为classpath才能成功反编译。

使用javap命令反编译,语法如下:

#这种方式是指定类信息和类所在的jar包为classpath反编译
javap [-verbose] -cp /some/path/to/lib/xxx.jar com.xx.SomeClass

#这种方式是将class文件从jar中解压,直接反编译class文件
mkdir dir
cd dir
jar xvf ../SomeClass-belong-to.jar
javap [-verbose] com/xx/SomeClass.class

javap-verbose参数展示class文件的详细编译信息,如果只想判断是是否有某个方法,可以不加-verbose参数。

通过上面的方法确认本示例的情况:明明方法存在啊,为什么NoSuchMethodError,百思不得其解!

3. 真相浮出水面

怎么办呢?问题总是要解决的。

在开发环境上准备调试代码,突然意识到报错中的init是不是一个普通方法啊?

赶快看看反编译的代码发现确实没有init普通方法,只有init构造方法。问题就出在这里,查了一下发现jetty9.3升级到9.4的时候对WebSocketServerFactoryinit普通方法改为构造方法

这是笔者的一个知识误区,以为WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V是一个构造方法,实际上如果是构造方法报错是长这样的:

10:24:09.590 [main] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.NoSuchMethodError: org.springframework.boot.builder.SpringApplicationBuilder.<init>([Ljava/lang/Object;)V

普通方法和构造方法实际上就是.init().<init>()的区别。

这里报错中.<init>([Ljava/lang/Object;)V.表示是一个方法的调用;<init>表示构造方法的调用;[表示一个数组;Ljava/lang/Object;表示java.lang.object对象;V表示返回类型是void。实际上就是SpringApplicationBuilder(java.lang.Object...)的构造方法,方法的参数是java.lang.Object数组。这种写法和class文件的内部表示是一致的。jvm更多内部实现 类型表示参考:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html

对于一个程序员来说,异常的的堆栈信息是司空见惯的,也就懒得深究其中的一些玄机,果然“报应不爽”!出来混,迟早要还的。

4. 总结

  1. 很多看似玄学的bug解释不了,最后原因总是归结为“知识的盲区”。很多知识不必懂的很深入,但是基本的东西要了解,此时“不求甚解”,彼时“这是玄学?”。
  2. 有些时候会无意识的想当然一些结论(比如本示例中.init()方法自然认为是构造方法)。没办法十分敲定的东西,要多查一查,多一份思路。
  3. 排查问题,针对一个思路要充满信心,即使这个思路不能解决问题,至少也要能得出这条思路的结论。不能急躁、粗心、盲目尝试。思路窄了,就停下来,明天再尝试,避免进入死胡同。

更新记录

  • 2022-01-24 16:10 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-26 15:20 掘金专栏发表前重读、优化、勘误
🔲 ☆

bug现场谜之超级权限的root用户也存在“创建文件失败”的时候?

目录


1. bug现场情况

现场ETL抽数报错“创建文件失败”,无法将数据通过达梦dmfldr工具导入数据库中。

ETL的实现是,首先通过sql查询将数据库数据导出为dat文本文件,实际上就是一个csv文件,用分隔符$将每一列数据隔开,用换行符\r\n将每一行隔开;然后程序中调用shell命令借助于数据库load工具(本例中达梦数据load工具为dmfldrPostgreSQL数据load可以使用psql工具)将文本csv数据导入目标数据库。比如对于PostgreSQL数据库:

目标表target_table为:

学号 || 姓名 || 年龄 || 得分

导出的csv文件student_score.dat为:

set client_encoding to 'UTF8'
COPY student_score from stdin WITH DELIMITER '$' ESCAPE E'\\' CSV;
00001$冯兄_01$30$75
00002$冯兄_02$31$85
00003$冯兄_03$32$95
00004$冯兄_04$33$65
00005$冯兄_05$34$55

shell命令导入csv文件到表中:

psql -h $HOST -p $PORT -d $DB -U $DB_USER -f /path/to/student_score.dat

正常如上面那样使用psql客户端导入数据是需要输入密码的,可以使用免密的方式,如在客户端程序所在的主机的~/.pgpass中增加:$HOST:$DB:$DB_USER:$DB_PASSWD

本例中的达梦数据库也一样,生成的dat文件后,程序执行shell命令,通过dmfldr工具将数据导入目标表。

通过看日志,发现本例中是生成了dat文件,只是在导入的时候报错“创建文件失败”:

Caused by: xxxxException: xxxx错误:创建文件失败
    at xxxx.Execute.execute(Execute.java:239)

2. 尝试破案

由于报错摸不到头脑,先上后台用命令尝试是否成功导入:

为什么说“报错摸不到头脑”?因为程序是以root用户运行的,不存在权限问题,并且shell命令导入数据所需要的csv数据文件和参数ctrl文件都已经生成了,为什么会“创建文件失败”?

达梦dmfldr工具导入csv数据的方式如下:

./dmfldr userid=$DB_USER/$DB_PASSWD@$HOST:$PORT control=\'/path/to/test.ctrl\'

如果DB_PASSWD中含有特殊字符,可以使用'"$DB_PASSWD"'方法逃逸特殊字符。

test.ctrl文件示例:

OPTIONS (
DIRECT = false
rows = 50000
skip = 0
ERRORS = 0
)
LOAD DATA
INFILE '/path/to/*.dat' STR X '0D0A'
APPEND
INTO TABLE $TABLE
FIELDS '$'

更多达梦dmfldr工具使用参考官方文档:https://eco.dameng.com/docs/zh-cn/pm/getting-started-dmfldr.html

报错“文件少列”,意思是用$隔开的列的个数和目标表列的个数不匹配,但实际上经过确认csv文件数据列的个数和目标的数据列个数是相同的。

通过增加列(不是说少列吗,那就就是增加$)、删除字段数据等多种方法反复尝试,最后确认导入csv不能成功的原因是字符编码的问题,默认dmfldr使用的GBK编码,但是csv文件是UTF-8编码,用GBK解码UTF-8文件就出现各种奇怪的报错。

期间会报各种错误,如“字符串被截断”、“数据格式不正确”等。

dmfldr工具默认使用GBK编码没法改变,想着是不是可以更改系统使用字符编码为GBK,让导出的文件跟随系统编码为GBK,这样应该就能够导入了。

尝试修改当前终端系统字符集:

#查看本地字符集
locale

#查看所有本地支持的字符集
locale -a

#更改字符集,要选择locale -a展示支持的字符集
export LAGNG=zh_CN.gbk

实际上这里称“字符集”为“字符编码”更为准确,理解字符集与字符编码区别,参考文章:https://fengmengzhao.github.io/2015/07/30/computer-character-coding-styles.html

修改后,重启系统,发现系统界面、日志到处是乱码,导出的文件编码还是UTF-8编码,实际上说明导出文件的编码不会随运行系统字符集改变而改变,这也是开发的规范。

只能联系产品的研发修改代码了吗?

3. 真相浮出水面

第二天将前一天的验证结论又确认了一遍,本地使用命令导入数据时,字符编码存在问题。在ctrl文件中加入参数CHARACTER_CODE = 'UTF-8'之后数据能正确导入。

就要联系产品提bug的时候,突然想到不是有传说中的Arthas存在吗?可以做到在线反编译、修改代码、重新编译并重新加载类。上Arthas!玩一玩。

Arthas的安装不再赘述,文档很清楚。

使用命令启动并连接Arthas

#启动Arthas
#注意替换$PID,$PID是运行的JVM进程pid,通过命令ps -ef |grep xxx 获取
nohup java -Xbootclasspath/a:/opt/jdk-1.8/lib/tools.jar -jar ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -pid $PID -target-ip 127.0.0.1 -telnet-port 9658 -http-port 9563 -core ~/.arthas/lib/3.5.4/arthas/arthas-core.jar -agent ~/.arthas/lib/3.5.4/arthas/arthas-agent.jar &

#确认Arthas是否启动成功,上面设置telnet的端口号为9658,该端口可以修改
netstat -nalp |grep 9658

#连接Arthas
telnet 127.0.0.1 9658 

注意:用命令行启动Arthas进程后,立即用命令ps -ef |grep arthas能看到一个进程,说明Arthas在启动中,过一会儿进程消失,说明Arthas已经启动成功或者失败。如果成功的话,使用netstat -anlp |grep $PID能看到Arthas启动是指定的telnet监听端口。找不到指定的telnet监听端口说明没有启动成功,需要查看~/log/arthas/arthas.log日志文件。

使用Arthas修改代码并重新编译:

#根据报错日志,找到报错类Execute
sc *Execute

#反编译运行class文件为源代码
jad --source-only xxxx.Execute > /tmp/Execute.java

#修改源代码
#ctrl文件中增加字符编码设置:CHARACTER_CODE = 'UTF-8'

#查找该类的类加载器hash值
sc -d *Execute |grep classLoaderHash

#在线编译修改
mc -c $CLASSLOADER_HASH /tmp/Execute.java

#重新热加载class
redefine -c $CLASSLOADER_HASH /tmp/xxxx/Execute.class

重新执行ETL程序,发现还是报同样的错。后台查看ctrl文件内已加上了UTF-8字符编码的设置,手动执行dmfldr收入导入,能够导入成功。

可为什么还报错呢?这时候意识到可能程序执行的shell命令和笔者后台执行的命令不一致Arthas不是能wath参数吗?走一波:

#查看方法入参、类成员信息、返回信息、异常信息
#params是参数 target是当前类成员信息 returnObj是方法返回值 throwExp是抛出异常信息
#-x 2 表示递归层级 -e 表示异常时抛出
watch xxxx.Execute exec "{params, target, returnObj, throwExp}" -e -x 2

Arthas还可以使用OGNL表达式,例如:watch xxx.FileDAO TransString @org.apache.commons.io.IOUtils@toByteArray(params[0].getBinaryStream()) -b -e -x 2,这里@OGNL调用类静态成员或者方法的写法。

arthas执行静态方法、属性

#调用静态属性
ognl '@全路径类目@静态属性名'

#ognl执行静态方法
ognl '@全路径类目@静态方法名("参数")'

#ognl参数的使用
ognl '#value1=@com.shirc.arthasexample.ognl.OgnlTest@getPerson("src",18), #value2=@com.shirc.arthasexample.ognl.OgnlTest@setPerson(#value1) ,{#value1,#value2}' -x 2

更多OGNL用法请参考:https://commons.apache.org/proper/commons-ognl/language-guide.html

重新执行程序,控制台得到程序完整的执行command是:

/path/to/dmfldr userid=$DB_USER/'"DB_PASSWD"'@$HOST:$PORT control=\'/path/to/*.ctrl\' character_code=\'utf-8\' log=\'/path/logs/dmfldrLog/fldr.log.2022-01-05\' badfile=\'/path/logs/dmfldrLog/fldr.bad.2022-01-05\'

复制命令,手动在后台执行以下,报错“创建文件失败”,和日志中的报错一致。知道问题是哪里了,应该就是日志文件创建的时候缺少目录,造成不能创建日志文件报错。

手动创建日志文件目录:mkdir -p /path/dmfldrLog,重新执行导入命令,执行成功。重新执行ETL抽数程序,也成功,破案了!

4. 总结

  1. 实际上在笔者自己后台执行dmfldr命令的时候就走偏了,手动执行的命令和程序执行的命令不一致,结果自己的命令出新的bug以为就是问题所在,方向没找对,陷得更深了。
  2. 第一时间应该要用Arthas,当时现场环境只有JRE,笔者懒了,也付出了代价。
  3. 报错日志(本例中是“创建文件失败”,最后排查实际上问题就是一个日志文件路径目录不存在,造成dmfldr不能创建日志文件)很重要,查bug的时候多联系报错信息,能有助于查错不跑偏方向。
  4. 如果在不知道代码的情况下,Arthas真是一个利器,能极大提高排查问题的效率。大神总说工具不重要,实际上大神对工具都运用自如了,才说不重要。Arthas工具值得Java程序员好好学习。
  5. 不能先入为主,看到“创建文件失败”,认为以root启动的程序就能够创建文件成功,本例中是用root身份执行了dmfldr命令,关键是命令中带有绝对路径的日志路径,由于路径目录不存在,dmfldr工具就报错了。关键不在于是否是root的问题,而是dmfldr在没有目录的情况下不会自动创建目录。
  6. 实际上这里ETL程序通过shell调用第三方程序,要考虑周全第三方程序可能的报错,否则就会出现类似bug

更新记录

  • 2022-01-07 18:16 “冯兄画戟”微信公众号文章发表前重读、优化、勘误
  • 2022-01-20 10:13 增加arthas启动判断内容
  • 2022-01-21 22:35 掘金专栏发表前重读、优化、勘误

相关文章推荐

❌